@asteby/metacore-runtime-react 48.0.0 → 49.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -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 +57 -10
- 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 +65 -17
- 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/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.map +1 -1
- package/dist/document-form-dialog.js +7 -4
- 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 +12 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +3 -3
- 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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- 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 +32 -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-dialog.test.tsx +40 -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 +58 -16
- 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 +72 -18
- package/src/business/line-items.ts +16 -1
- 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 +8 -4
- package/src/dynamic-columns.tsx +13 -57
- package/src/dynamic-crud-page.tsx +11 -2
- package/src/dynamic-row-actions.tsx +12 -2
- package/src/dynamic-table.tsx +3 -0
- package/src/index.ts +25 -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 +28 -0
|
@@ -11,6 +11,7 @@ import type { Allocation, AllocationIssue, OpenDocument } from '../primitives/al
|
|
|
11
11
|
import type {
|
|
12
12
|
ActionFieldDef,
|
|
13
13
|
DocumentFormLines,
|
|
14
|
+
DocumentFormSource,
|
|
14
15
|
DocumentFormOpenDocuments,
|
|
15
16
|
DocumentFormsManifest,
|
|
16
17
|
DocumentFormType,
|
|
@@ -24,8 +25,10 @@ const ADVANCED_SECTIONS = new Set(['fiscal', 'advanced'])
|
|
|
24
25
|
export type EditorLinesConfig = DocumentFormLines & { field: string }
|
|
25
26
|
|
|
26
27
|
/** true si el tipo se abre en el DocumentEditor (`layout: "editor"`). */
|
|
27
|
-
export function isEditorLayout(type: Pick<DocumentFormType, 'layout'> | null | undefined): boolean {
|
|
28
|
-
|
|
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
|
}
|
|
@@ -38,6 +38,8 @@ import {
|
|
|
38
38
|
editorLinesConfig,
|
|
39
39
|
linesFromSource,
|
|
40
40
|
localIssues,
|
|
41
|
+
sourceLoadSummary,
|
|
42
|
+
sourceTracksRemaining,
|
|
41
43
|
partyDefaults,
|
|
42
44
|
partySummaryRows,
|
|
43
45
|
splitEditorFields,
|
|
@@ -142,6 +144,10 @@ export function DocumentEditor({
|
|
|
142
144
|
const [attempted, setAttempted] = useState(false)
|
|
143
145
|
const [sourceKey, setSourceKey] = useState<string>(initialSource?.key ?? '')
|
|
144
146
|
const [sourceId, setSourceId] = useState<string>(initialSource?.id ?? '')
|
|
147
|
+
// Fuente de la que salieron los renglones: su `line_link_field` viaja en cada
|
|
148
|
+
// renglón para que el servidor descuente lo ya facturado/devuelto y valide.
|
|
149
|
+
const [loadedSource, setLoadedSource] = useState<DocumentFormSource | null>(null)
|
|
150
|
+
const [sourceNote, setSourceNote] = useState<string | undefined>()
|
|
145
151
|
const relCache = useRef(new Map<string, Promise<any[]>>())
|
|
146
152
|
|
|
147
153
|
const taxRate = defaultTaxRate ?? orgTaxRate
|
|
@@ -273,10 +279,13 @@ export function DocumentEditor({
|
|
|
273
279
|
const p: Record<string, unknown> = { ...(type.defaults ?? {}), ...header }
|
|
274
280
|
if (forms.type_field && !type.submit_action) p[forms.type_field] = type.value ?? type.key
|
|
275
281
|
if (lineCfg) {
|
|
276
|
-
p[lineCfg.field] =
|
|
282
|
+
p[lineCfg.field] =
|
|
283
|
+
isAllocation && openCfg
|
|
284
|
+
? allocationPayload(allocation.allocations, openCfg)
|
|
285
|
+
: serializeLineItems(lines, { sourceLineField: loadedSource?.line_link_field })
|
|
277
286
|
}
|
|
278
287
|
return p
|
|
279
|
-
}, [type, header, forms.type_field, lineCfg, isAllocation, openCfg, allocation, lines])
|
|
288
|
+
}, [type, header, forms.type_field, lineCfg, isAllocation, openCfg, allocation, lines, loadedSource])
|
|
280
289
|
|
|
281
290
|
const baseUrl = type.endpoint ?? endpoint ?? `/dynamic/${model}`
|
|
282
291
|
|
|
@@ -304,27 +313,72 @@ export function DocumentEditor({
|
|
|
304
313
|
}
|
|
305
314
|
|
|
306
315
|
// ---- Cargar desde… --------------------------------------------------------
|
|
316
|
+
/**
|
|
317
|
+
* Renglones del origen. Con lo pendiente declarado (`line_link_field`,
|
|
318
|
+
* `remaining_qty_field` o `remaining_endpoint`) los sirve el host ya con
|
|
319
|
+
* `remaining_quantity` (cantidad − lo ya facturado/devuelto, calculado en el
|
|
320
|
+
* servidor); si el host aún no tiene `source-lines` (404) o la fuente no lo
|
|
321
|
+
* declara, se leen de la relación como antes.
|
|
322
|
+
*/
|
|
323
|
+
const fetchSourceRows = useCallback(
|
|
324
|
+
async (src: DocumentFormSource, id: string): Promise<unknown[]> => {
|
|
325
|
+
if (src.remaining_endpoint) {
|
|
326
|
+
const res = await api.get(src.remaining_endpoint, { params: { id } })
|
|
327
|
+
return res?.data?.data ?? []
|
|
328
|
+
}
|
|
329
|
+
if (sourceTracksRemaining(src)) {
|
|
330
|
+
try {
|
|
331
|
+
const res = await api.get(`${baseUrl}/source-lines`, {
|
|
332
|
+
params: { source: src.key, id, ...(draftId ? { exclude: draftId } : {}) },
|
|
333
|
+
})
|
|
334
|
+
return res?.data?.data ?? []
|
|
335
|
+
} catch (err: any) {
|
|
336
|
+
if (err?.response?.status !== 404) throw err
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
let rels = relCache.current.get(src.model)
|
|
340
|
+
if (!rels) {
|
|
341
|
+
rels = api
|
|
342
|
+
.get(`/metadata/table/${src.model}`)
|
|
343
|
+
.then((r: any) => r?.data?.data?.relations ?? r?.data?.relations ?? [])
|
|
344
|
+
relCache.current.set(src.model, rels)
|
|
345
|
+
}
|
|
346
|
+
const call = prefillFromFieldRelationRequest(
|
|
347
|
+
{ fieldKey: lineCfg?.field ?? 'lines', refModel: src.model, refId: id, spec: { $prefillFromRecord: src.lines } },
|
|
348
|
+
await rels,
|
|
349
|
+
)
|
|
350
|
+
if (!call) throw new Error(`${src.model}: falta la relación «${src.lines}»`)
|
|
351
|
+
const res = await api.get(call.endpoint, { params: call.params })
|
|
352
|
+
return res?.data?.data ?? []
|
|
353
|
+
},
|
|
354
|
+
[api, baseUrl, draftId, lineCfg?.field],
|
|
355
|
+
)
|
|
356
|
+
|
|
307
357
|
const loadFromSource = useCallback(
|
|
308
358
|
async (src: DocumentFormSource, id: string) => {
|
|
309
359
|
if (!lineCfg || !id) return
|
|
310
360
|
setBusy('source')
|
|
311
361
|
try {
|
|
312
362
|
if (!isAllocation) {
|
|
313
|
-
|
|
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 }))
|
|
363
|
+
const rows = await fetchSourceRows(src, id)
|
|
364
|
+
const loaded = linesFromSource(rows, src.map, { kind, discountMode: lineCfg.discount_mode })
|
|
365
|
+
const { covered } = sourceLoadSummary(rows)
|
|
366
|
+
setLines(loaded)
|
|
367
|
+
setLoadedSource(src)
|
|
327
368
|
setLinesFromSourceDoc(kind === 'credit')
|
|
369
|
+
setSourceNote(
|
|
370
|
+
loaded.length === 0 && covered > 0
|
|
371
|
+
? t('documentEditor.source_all_covered', {
|
|
372
|
+
defaultValue: 'Todo el documento ya está registrado en otros documentos; no queda nada pendiente.',
|
|
373
|
+
})
|
|
374
|
+
: covered > 0
|
|
375
|
+
? t('documentEditor.source_loaded_partial', {
|
|
376
|
+
defaultValue: '{{count}} renglones con lo pendiente · {{covered}} ya cubiertos se omitieron',
|
|
377
|
+
count: loaded.length,
|
|
378
|
+
covered,
|
|
379
|
+
})
|
|
380
|
+
: t('documentEditor.source_loaded', { defaultValue: '{{count}} renglones cargados', count: loaded.length }),
|
|
381
|
+
)
|
|
328
382
|
}
|
|
329
383
|
if (src.header || src.link_field) {
|
|
330
384
|
const h = await api.get(`/data/${src.model}`, { params: { f_id: `eq:${id}`, per_page: 1 } })
|
|
@@ -345,7 +399,7 @@ export function DocumentEditor({
|
|
|
345
399
|
setBusy(null)
|
|
346
400
|
}
|
|
347
401
|
},
|
|
348
|
-
[api, lineCfg, isAllocation, kind, t],
|
|
402
|
+
[api, lineCfg, isAllocation, kind, t, fetchSourceRows],
|
|
349
403
|
)
|
|
350
404
|
|
|
351
405
|
// Prefill al abrir desde un documento origen (acción de fila «Crear NC», «Facturar venta»…).
|
|
@@ -538,7 +592,7 @@ export function DocumentEditor({
|
|
|
538
592
|
<EditorSection
|
|
539
593
|
slot="load-from-source"
|
|
540
594
|
title={t('documentEditor.load_from', { defaultValue: 'Cargar desde…' })}
|
|
541
|
-
hint={busy === 'source' ? t('documentEditor.loading_source', { defaultValue: 'Cargando…' }) :
|
|
595
|
+
hint={busy === 'source' ? t('documentEditor.loading_source', { defaultValue: 'Cargando…' }) : sourceNote}
|
|
542
596
|
>
|
|
543
597
|
<div className="flex flex-wrap items-center gap-2">
|
|
544
598
|
{sources.length > 1 && (
|
|
@@ -32,6 +32,12 @@ export interface LineItem {
|
|
|
32
32
|
cost?: number
|
|
33
33
|
/** Tope de cantidad (NC: facturado − acreditado; devolución: vendido − devuelto). */
|
|
34
34
|
max_quantity?: number
|
|
35
|
+
/**
|
|
36
|
+
* Id del renglón del documento origen («crear desde»). Viaja en la columna
|
|
37
|
+
* `line_link_field` de la fuente para que el servidor descuente y valide lo
|
|
38
|
+
* pendiente; un renglón libre no lo lleva.
|
|
39
|
+
*/
|
|
40
|
+
source_line_id?: string
|
|
35
41
|
technician_id?: string
|
|
36
42
|
lot?: string
|
|
37
43
|
dot?: string
|
|
@@ -203,7 +209,10 @@ export function validateLineItems(lines: LineItem[], policy: LineItemsPolicy = {
|
|
|
203
209
|
* Forma que se manda al backend: números reales (nunca `""`), sin claves de UI,
|
|
204
210
|
* opcionales vacíos omitidos y `position` 1-based conservando el orden.
|
|
205
211
|
*/
|
|
206
|
-
export function serializeLineItems(
|
|
212
|
+
export function serializeLineItems(
|
|
213
|
+
lines: LineItem[],
|
|
214
|
+
opts: { /** Columna que recibe `source_line_id` (la `line_link_field` de la fuente). */ sourceLineField?: string } = {},
|
|
215
|
+
): Array<Record<string, unknown>> {
|
|
207
216
|
return lines.map((l, i) => {
|
|
208
217
|
const out: Record<string, unknown> = {
|
|
209
218
|
position: i + 1,
|
|
@@ -222,6 +231,12 @@ export function serializeLineItems(lines: LineItem[]): Array<Record<string, unkn
|
|
|
222
231
|
if (typeof v === 'string' && v.trim()) out[k] = v.trim()
|
|
223
232
|
}
|
|
224
233
|
if (l.extensions && Object.keys(l.extensions).length > 0) out.fiscal_data = { ...l.extensions }
|
|
234
|
+
if (opts.sourceLineField && l.source_line_id) out[opts.sourceLineField] = l.source_line_id
|
|
235
|
+
// Importes ya calculados: un modelo de renglón sin tasa (solo
|
|
236
|
+
// `tax_amount`/`subtotal`) los guarda tal cual en vez de perder el IVA.
|
|
237
|
+
const a = computeLine(l)
|
|
238
|
+
out.subtotal = a.net
|
|
239
|
+
out.tax_amount = a.tax
|
|
225
240
|
}
|
|
226
241
|
return out
|
|
227
242
|
})
|
|
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'
|
|
|
8
8
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@asteby/metacore-ui/primitives'
|
|
9
9
|
import { cn } from '@asteby/metacore-ui/lib'
|
|
10
10
|
import { ActionBar } from './action-bar'
|
|
11
|
+
import { DocumentSecondaryBar } from './secondary-bar'
|
|
11
12
|
import { DocumentHeader, type HeaderBadge, type HeaderMetric } from './document-header'
|
|
12
13
|
import { SmartButtons, type SmartButtonItem } from './smart-buttons'
|
|
13
14
|
import { StatusBadge } from './status-badge'
|
|
@@ -175,6 +176,20 @@ export function DocumentPage(props: DocumentPageProps) {
|
|
|
175
176
|
</TabsContent>
|
|
176
177
|
))}
|
|
177
178
|
</Tabs>
|
|
179
|
+
|
|
180
|
+
{/* Compartir / imprimir / correo / PDF-XML y lo que aporten los
|
|
181
|
+
addons instalados: al pie, no compitiendo con la primaria. */}
|
|
182
|
+
<DocumentSecondaryBar
|
|
183
|
+
items={layout.footer.map((a) => ({
|
|
184
|
+
key: a.def.key,
|
|
185
|
+
label: a.def.label,
|
|
186
|
+
icon: a.def.icon,
|
|
187
|
+
blockedReason: a.blockedReason,
|
|
188
|
+
run: () => handleAction(a),
|
|
189
|
+
}))}
|
|
190
|
+
model={spec.model}
|
|
191
|
+
record={record}
|
|
192
|
+
/>
|
|
178
193
|
</div>
|
|
179
194
|
)
|
|
180
195
|
}
|
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
|
|
@@ -43,7 +43,7 @@ import { validateValues, bagHasErrors } from './validator'
|
|
|
43
43
|
import { clearFieldErrorTree } from './field-validation-ui'
|
|
44
44
|
import { emitRecordMutation } from './record-mutation-events'
|
|
45
45
|
import { isLineItemsField, resolveWidget, scopeValueFromFilterToken } from './dynamic-form-schema'
|
|
46
|
-
import { createCatalogProductSearch } from './business/catalog-product-search'
|
|
46
|
+
import { createCatalogProductSearch, withDefaultTaxRate } from './business/catalog-product-search'
|
|
47
47
|
import { DocumentEditor } from './business/document-editor'
|
|
48
48
|
import { editorLinesConfig, isEditorLayout } from './business/document-editor-model'
|
|
49
49
|
import type {
|
|
@@ -149,10 +149,14 @@ export function DocumentFormDialog({
|
|
|
149
149
|
const type = useMemo(() => types.find((x) => x.key === typeKey) ?? null, [types, typeKey])
|
|
150
150
|
// Sin buscador inyectado, el paso de renglones solo ofrecía «Renglón libre»
|
|
151
151
|
// y la línea entraba sin precio (DynamicCRUDPage no pasa searchProducts).
|
|
152
|
-
// Default: el catálogo de productos de la org.
|
|
152
|
+
// Default: el catálogo de productos de la org. El buscador del host también
|
|
153
|
+
// recibe el IVA de la org para los productos que no traen su tasa.
|
|
153
154
|
const taxRate = defaultTaxRate ?? orgTaxRate
|
|
154
155
|
const lineSearch = useMemo(
|
|
155
|
-
() =>
|
|
156
|
+
() =>
|
|
157
|
+
searchProducts
|
|
158
|
+
? withDefaultTaxRate(searchProducts, taxRate)
|
|
159
|
+
: createCatalogProductSearch(api, { model: productModel, defaultTaxRate: taxRate }),
|
|
156
160
|
[searchProducts, api, productModel, taxRate],
|
|
157
161
|
)
|
|
158
162
|
const lineCfg = type ? editorLinesConfig(type, forms) : undefined
|
|
@@ -278,7 +282,7 @@ export function DocumentFormDialog({
|
|
|
278
282
|
|
|
279
283
|
// layout "editor": una sola pantalla por secciones (DocumentEditor). Sin él
|
|
280
284
|
// sigue el wizard de siempre (retrocompatible).
|
|
281
|
-
if (open && type && step !== 'type' && isEditorLayout(type)) {
|
|
285
|
+
if (open && type && step !== 'type' && (isEditorLayout(type) || !!initialSource)) {
|
|
282
286
|
return (
|
|
283
287
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
284
288
|
<DialogContent
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -17,7 +17,7 @@ import { ColumnDef } from '@tanstack/react-table'
|
|
|
17
17
|
import { format, type Locale } from 'date-fns'
|
|
18
18
|
import { isCalendarDayValue, parseCalendarDate } from './calendar-date'
|
|
19
19
|
import { es, enUS } from 'date-fns/locale'
|
|
20
|
-
import { Calendar, Check, Copy, Mail, Minus
|
|
20
|
+
import { Calendar, Check, Copy, Mail, Minus } from 'lucide-react'
|
|
21
21
|
import {
|
|
22
22
|
Avatar,
|
|
23
23
|
AvatarFallback,
|
|
@@ -25,10 +25,6 @@ import {
|
|
|
25
25
|
Badge,
|
|
26
26
|
Button,
|
|
27
27
|
Checkbox,
|
|
28
|
-
DropdownMenu,
|
|
29
|
-
DropdownMenuContent,
|
|
30
|
-
DropdownMenuItem,
|
|
31
|
-
DropdownMenuTrigger,
|
|
32
28
|
InitialsAvatar,
|
|
33
29
|
} from '@asteby/metacore-ui'
|
|
34
30
|
import {
|
|
@@ -57,7 +53,7 @@ import { DynamicIcon, isLucideIconName } from './dynamic-icon'
|
|
|
57
53
|
import { CollectionCell } from './collection-cell'
|
|
58
54
|
import { isNilUuid, normalizeNilUuid } from './nil-uuid'
|
|
59
55
|
import { useOptionsResolver } from './use-options-resolver'
|
|
60
|
-
import {
|
|
56
|
+
import { RowActionsMenu } from './row-actions-menu'
|
|
61
57
|
import type { TableMetadata, ColumnDefinition } from './types'
|
|
62
58
|
import { isColumnVisibleInTable } from './column-visibility'
|
|
63
59
|
import type {
|
|
@@ -208,35 +204,9 @@ const CodeCell: React.FC<{ text: string; maxLength?: number }> = ({ text, maxLen
|
|
|
208
204
|
)
|
|
209
205
|
}
|
|
210
206
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
* `onAction`, whose shared handler (useDynamicRowActions) opens the
|
|
215
|
-
* requires-addon dialog instead of executing.
|
|
216
|
-
*/
|
|
217
|
-
export function RowActionMenuItem({
|
|
218
|
-
action,
|
|
219
|
-
label,
|
|
220
|
-
onSelect,
|
|
221
|
-
}: {
|
|
222
|
-
action: any
|
|
223
|
-
label: React.ReactNode
|
|
224
|
-
onSelect: (e: React.MouseEvent) => void
|
|
225
|
-
}) {
|
|
226
|
-
const requirement = resolveRequiresAddon(action)
|
|
227
|
-
const requiresAddonLabel = useRequiresAddonLabel()
|
|
228
|
-
return (
|
|
229
|
-
<DropdownMenuItem
|
|
230
|
-
onClick={onSelect}
|
|
231
|
-
title={requirement ? requiresAddonLabel(requirement) : undefined}
|
|
232
|
-
data-requires-addon={requirement?.key}
|
|
233
|
-
>
|
|
234
|
-
<DynamicIcon name={action.icon || 'Zap'} className="mr-2 h-4 w-4" />
|
|
235
|
-
{label}
|
|
236
|
-
{requirement && <RequiresAddonLock requirement={requirement} />}
|
|
237
|
-
</DropdownMenuItem>
|
|
238
|
-
)
|
|
239
|
-
}
|
|
207
|
+
// RowActionMenuItem vive en row-actions-menu.tsx (junto al menú que lo usa);
|
|
208
|
+
// se re-exporta aquí por compatibilidad (dynamic-kanban, hosts).
|
|
209
|
+
export { RowActionMenuItem } from './row-actions-menu'
|
|
240
210
|
|
|
241
211
|
/**
|
|
242
212
|
* Lifecycle column used by `requiresState`: prefer `status` (workshop, vehicles,
|
|
@@ -1571,28 +1541,14 @@ export function makeDefaultGetDynamicColumns(
|
|
|
1571
1541
|
maxSize: 80,
|
|
1572
1542
|
meta: {},
|
|
1573
1543
|
cell: ({ row }) => (
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
<DropdownMenuContent align="end">
|
|
1583
|
-
{resolvedActions
|
|
1584
|
-
.filter((action) => isRowActionVisible(action, row.original, lifecycleStageField(metadata)))
|
|
1585
|
-
.map((action) => (
|
|
1586
|
-
<RowActionMenuItem
|
|
1587
|
-
key={action.key}
|
|
1588
|
-
action={action}
|
|
1589
|
-
label={translateMetadataLabel(action.label, t)}
|
|
1590
|
-
onSelect={() => onAction && onAction(action.key, row.original)}
|
|
1591
|
-
/>
|
|
1592
|
-
))}
|
|
1593
|
-
</DropdownMenuContent>
|
|
1594
|
-
</DropdownMenu>
|
|
1595
|
-
</div>
|
|
1544
|
+
// Primaria destacada, principales y un «Más…» con las
|
|
1545
|
+
// secundarias (compartir/imprimir/correo/chat + las que
|
|
1546
|
+
// aporten addons instalados). Ver row-actions-menu.tsx.
|
|
1547
|
+
<RowActionsMenu
|
|
1548
|
+
actions={resolvedActions.filter((action) => isRowActionVisible(action, row.original, lifecycleStageField(metadata)))}
|
|
1549
|
+
row={row.original}
|
|
1550
|
+
onAction={onAction}
|
|
1551
|
+
/>
|
|
1596
1552
|
),
|
|
1597
1553
|
})
|
|
1598
1554
|
}
|
|
@@ -102,6 +102,12 @@ export interface DynamicCRUDPageProps {
|
|
|
102
102
|
* formulario para él.
|
|
103
103
|
*/
|
|
104
104
|
filter?: Record<string, string>
|
|
105
|
+
/**
|
|
106
|
+
* Abre el alta guiada al montar («crear desde»): tipo de documento y origen
|
|
107
|
+
* a precargar (`sources[].key` + id), p. ej. leídos de la URL
|
|
108
|
+
* `?create=<tipo>&from=<fuente>&from_id=<id>`. Solo con document_forms.
|
|
109
|
+
*/
|
|
110
|
+
initialCreate?: { type?: string; source?: { key: string; id: string } }
|
|
105
111
|
}
|
|
106
112
|
|
|
107
113
|
/**
|
|
@@ -126,6 +132,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
|
|
|
126
132
|
onChange,
|
|
127
133
|
documentForms,
|
|
128
134
|
filter,
|
|
135
|
+
initialCreate,
|
|
129
136
|
} = props
|
|
130
137
|
|
|
131
138
|
const strings = { ...defaultStrings, ...(i18n ?? {}) }
|
|
@@ -137,7 +144,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
|
|
|
137
144
|
|
|
138
145
|
const [metadata, setMetadata] = useState<TableMetadata | null>(cachedMeta ?? null)
|
|
139
146
|
const [refreshKey, setRefreshKey] = useState(0)
|
|
140
|
-
const [openCreate, setOpenCreate] = useState(
|
|
147
|
+
const [openCreate, setOpenCreate] = useState(!!initialCreate)
|
|
141
148
|
const [openExport, setOpenExport] = useState(false)
|
|
142
149
|
const [openImport, setOpenImport] = useState(false)
|
|
143
150
|
|
|
@@ -308,13 +315,15 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
|
|
|
308
315
|
model={model}
|
|
309
316
|
endpoint={dataEndpoint}
|
|
310
317
|
forms={guidedForms}
|
|
318
|
+
initialType={initialCreate?.type}
|
|
319
|
+
initialSource={initialCreate?.source}
|
|
311
320
|
onSaved={handleRefresh}
|
|
312
321
|
/>
|
|
313
322
|
)}
|
|
314
323
|
|
|
315
324
|
{showCreate && !guidedForms && (
|
|
316
325
|
<DynamicRecordDialog
|
|
317
|
-
open={openCreate}
|
|
326
|
+
open={openCreate && !initialCreate?.source}
|
|
318
327
|
onOpenChange={setOpenCreate}
|
|
319
328
|
mode='create'
|
|
320
329
|
model={model}
|