@asteby/metacore-runtime-react 47.0.0 → 48.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +24 -4
- package/dist/audit-info.d.ts +54 -0
- package/dist/audit-info.d.ts.map +1 -0
- package/dist/audit-info.js +142 -0
- package/dist/business/catalog-product-search.d.ts +25 -0
- package/dist/business/catalog-product-search.d.ts.map +1 -0
- package/dist/business/catalog-product-search.js +57 -0
- package/dist/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +2 -0
- package/dist/business/document-editor-model.d.ts +107 -0
- package/dist/business/document-editor-model.d.ts.map +1 -0
- package/dist/business/document-editor-model.js +279 -0
- package/dist/business/document-editor-panels.d.ts +64 -0
- package/dist/business/document-editor-panels.d.ts.map +1 -0
- package/dist/business/document-editor-panels.js +58 -0
- package/dist/business/document-editor.d.ts +27 -0
- package/dist/business/document-editor.d.ts.map +1 -0
- package/dist/business/document-editor.js +445 -0
- package/dist/business/document-lines.d.ts.map +1 -1
- package/dist/business/document-lines.js +5 -1
- package/dist/business/index.d.ts +5 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +5 -0
- package/dist/business/line-items.d.ts +7 -0
- package/dist/business/line-items.d.ts.map +1 -1
- package/dist/business/line-items.js +6 -1
- package/dist/business/payment-allocator.d.ts +19 -0
- package/dist/business/payment-allocator.d.ts.map +1 -0
- package/dist/business/payment-allocator.js +31 -0
- package/dist/business/product-search.d.ts +4 -0
- package/dist/business/product-search.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +19 -8
- package/dist/document-form-dialog.d.ts +31 -2
- package/dist/document-form-dialog.d.ts.map +1 -1
- package/dist/document-form-dialog.js +43 -11
- package/dist/dynamic-columns-helpers.d.ts +9 -0
- package/dist/dynamic-columns-helpers.d.ts.map +1 -1
- package/dist/dynamic-columns-helpers.js +19 -0
- package/dist/dynamic-columns.d.ts +8 -2
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +21 -5
- package/dist/dynamic-crud-page.d.ts +6 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +14 -5
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +15 -3
- package/dist/dynamic-form.d.ts +16 -1
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +81 -20
- package/dist/dynamic-kanban.d.ts +9 -3
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +39 -13
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +3 -0
- package/dist/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +2 -0
- package/dist/dynamic-relation-helpers.d.ts.map +1 -1
- package/dist/dynamic-relation-helpers.js +8 -0
- package/dist/dynamic-relation.js +1 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +4 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -2
- package/dist/installed-addons-context.d.ts +15 -0
- package/dist/installed-addons-context.d.ts.map +1 -0
- package/dist/installed-addons-context.js +39 -0
- package/dist/option-filter.d.ts +31 -0
- package/dist/option-filter.d.ts.map +1 -0
- package/dist/option-filter.js +49 -0
- package/dist/org-runtime-context.d.ts +8 -0
- package/dist/org-runtime-context.d.ts.map +1 -1
- package/dist/org-runtime-context.js +8 -0
- package/dist/org-runtime-provider.d.ts +3 -1
- package/dist/org-runtime-provider.d.ts.map +1 -1
- package/dist/org-runtime-provider.js +3 -3
- package/dist/primitives/aging.d.ts +34 -0
- package/dist/primitives/aging.d.ts.map +1 -0
- package/dist/primitives/aging.js +48 -0
- package/dist/primitives/allocation.d.ts +42 -0
- package/dist/primitives/allocation.d.ts.map +1 -0
- package/dist/primitives/allocation.js +59 -0
- package/dist/primitives/approvals.d.ts +71 -0
- package/dist/primitives/approvals.d.ts.map +1 -0
- package/dist/primitives/approvals.js +48 -0
- package/dist/primitives/contributions.d.ts +82 -0
- package/dist/primitives/contributions.d.ts.map +1 -0
- package/dist/primitives/contributions.js +126 -0
- package/dist/primitives/document-kinds.d.ts +25 -0
- package/dist/primitives/document-kinds.d.ts.map +1 -0
- package/dist/primitives/document-kinds.js +26 -0
- package/dist/primitives/index.d.ts +8 -0
- package/dist/primitives/index.d.ts.map +1 -0
- package/dist/primitives/index.js +23 -0
- package/dist/primitives/panels.d.ts +68 -0
- package/dist/primitives/panels.d.ts.map +1 -0
- package/dist/primitives/panels.js +3 -0
- package/dist/primitives/use-contributions.d.ts +15 -0
- package/dist/primitives/use-contributions.d.ts.map +1 -0
- package/dist/primitives/use-contributions.js +30 -0
- package/dist/record-prefill-bar.d.ts +11 -0
- package/dist/record-prefill-bar.d.ts.map +1 -0
- package/dist/record-prefill-bar.js +33 -0
- package/dist/types.d.ts +142 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-options-resolver.d.ts +8 -0
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +8 -2
- package/package.json +5 -5
- package/src/__tests__/action-modal-grid-layout.test.tsx +1 -1
- package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
- package/src/__tests__/audit-info.test.tsx +103 -0
- package/src/__tests__/catalog-product-search.test.ts +61 -0
- package/src/__tests__/dependent-options.test.tsx +12 -5
- package/src/__tests__/document-editor-model.test.ts +167 -0
- package/src/__tests__/document-editor.test.tsx +261 -0
- package/src/__tests__/document-form-dialog.test.tsx +118 -3
- package/src/__tests__/dynamic-form-select-edit.test.tsx +155 -0
- package/src/__tests__/dynamic-kanban.test.tsx +24 -3
- package/src/__tests__/dynamic-record-extension-label.test.tsx +88 -0
- package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +4 -0
- package/src/__tests__/installed-addons-context.test.tsx +50 -0
- package/src/__tests__/line-item-totals.test.ts +28 -0
- package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
- package/src/__tests__/option-filter.test.tsx +115 -0
- package/src/__tests__/primitives-federated.test.tsx +156 -0
- package/src/__tests__/primitives.test.ts +224 -0
- package/src/__tests__/record-prefill-bar.test.tsx +50 -0
- package/src/__tests__/structured-view-value.test.tsx +5 -1
- package/src/__tests__/wizard-action-modal.test.tsx +1 -1
- package/src/action-modal-dispatcher.tsx +47 -4
- package/src/audit-info.tsx +219 -0
- package/src/business/catalog-product-search.ts +87 -0
- package/src/business/catalog.ts +2 -0
- package/src/business/document-editor-model.ts +325 -0
- package/src/business/document-editor-panels.tsx +164 -0
- package/src/business/document-editor.tsx +727 -0
- package/src/business/document-lines.ts +5 -1
- package/src/business/index.ts +5 -0
- package/src/business/line-items.ts +15 -1
- package/src/business/payment-allocator.tsx +110 -0
- package/src/business/product-search.ts +4 -0
- package/src/dialogs/dynamic-record.tsx +42 -6
- package/src/document-form-dialog.tsx +92 -12
- package/src/dropdown-menu-entry.test.ts +23 -0
- package/src/dynamic-columns-helpers.ts +22 -0
- package/src/dynamic-columns.tsx +23 -6
- package/src/dynamic-crud-page.tsx +22 -3
- package/src/dynamic-form-schema.ts +15 -2
- package/src/dynamic-form.tsx +120 -39
- package/src/dynamic-kanban.tsx +41 -10
- package/src/dynamic-line-items.tsx +3 -0
- package/src/dynamic-multi-select-field.tsx +2 -0
- package/src/dynamic-relation-helpers.ts +9 -0
- package/src/dynamic-relation.tsx +7 -2
- package/src/dynamic-select-field.tsx +4 -0
- package/src/index.ts +34 -1
- package/src/installed-addons-context.tsx +58 -0
- package/src/option-filter.ts +88 -0
- package/src/org-runtime-context.ts +10 -0
- package/src/org-runtime-provider.tsx +7 -2
- package/src/primitives/aging.ts +76 -0
- package/src/primitives/allocation.ts +104 -0
- package/src/primitives/approvals.ts +103 -0
- package/src/primitives/contributions.ts +208 -0
- package/src/primitives/document-kinds.ts +62 -0
- package/src/primitives/index.ts +23 -0
- package/src/primitives/panels.ts +59 -0
- package/src/primitives/use-contributions.ts +42 -0
- package/src/record-prefill-bar.tsx +62 -0
- package/src/row-action-stage-gate.test.ts +33 -0
- package/src/types.ts +142 -0
- package/src/use-options-resolver.ts +19 -1
- package/vitest.config.ts +1 -0
- package/vitest.setup.ts +7 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { allocatePayment, validateAllocation, type OpenDocument } from '../primitives/allocation'
|
|
3
|
+
import { bucketFor, computeAging, daysOverdue } from '../primitives/aging'
|
|
4
|
+
import {
|
|
5
|
+
__resetContributions,
|
|
6
|
+
contributionsVersion,
|
|
7
|
+
federatedModalComponent,
|
|
8
|
+
registerDocumentContribution,
|
|
9
|
+
registerFederatedModal,
|
|
10
|
+
resolveContributions,
|
|
11
|
+
resolveFederatedModal,
|
|
12
|
+
slotIdFor,
|
|
13
|
+
subscribeContributions,
|
|
14
|
+
} from '../primitives/contributions'
|
|
15
|
+
import { approvalsClient, groupApprovals, parseJSONField, registerApprovalCategory, type ApprovalRequestDTO } from '../primitives/approvals'
|
|
16
|
+
import { CORE_DOCUMENT_KINDS, getDocumentKind, listDocumentKinds, registerDocumentKind } from '../primitives/document-kinds'
|
|
17
|
+
import { blockingChecks } from '../primitives/panels'
|
|
18
|
+
import { installedPredicate } from '../primitives/use-contributions'
|
|
19
|
+
import { slotRegistry } from '../slot'
|
|
20
|
+
import type { ApiClient } from '../api-context'
|
|
21
|
+
|
|
22
|
+
// Datos del mockup complemento-pago.html (Transportes del Bajío).
|
|
23
|
+
const docs: OpenDocument[] = [
|
|
24
|
+
{ id: 'a1060', number: 'A-1060', issued_at: '2026-09-10', due_at: '2026-10-10', total: 42270.4, balance: 42270.4, currency: 'MXN', payment_method: 'PPD' },
|
|
25
|
+
{ id: 'a1051', number: 'A-1051', issued_at: '2026-08-15', due_at: '2026-09-14', total: 18560, balance: 8500, currency: 'MXN', payment_method: 'PPD', installments_paid: 1 },
|
|
26
|
+
{ id: 'a1066', number: 'A-1066', issued_at: '2026-09-28', due_at: '2026-10-28', total: 23260, balance: 23260, currency: 'MXN', payment_method: 'PPD' },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
describe('allocatePayment', () => {
|
|
30
|
+
it('aplica primero lo más vencido y calcula parcialidad e insoluto', () => {
|
|
31
|
+
const r = allocatePayment(30000, docs)
|
|
32
|
+
expect(r.allocations).toEqual([
|
|
33
|
+
{ document_id: 'a1051', amount: 8500, balance_before: 8500, balance_after: 0, installment: 2 },
|
|
34
|
+
{ document_id: 'a1060', amount: 21500, balance_before: 42270.4, balance_after: 20770.4, installment: 1 },
|
|
35
|
+
])
|
|
36
|
+
expect(r.unapplied).toBe(0)
|
|
37
|
+
})
|
|
38
|
+
it('manual respeta montos y deja sin aplicar el resto', () => {
|
|
39
|
+
const r = allocatePayment(10000, docs, 'manual', { a1066: 4000 })
|
|
40
|
+
expect(r.applied).toBe(4000)
|
|
41
|
+
expect(r.unapplied).toBe(6000)
|
|
42
|
+
expect(validateAllocation(10000, 'MXN', docs, r.allocations).map(i => i.code)).toEqual(['unapplied'])
|
|
43
|
+
expect(validateAllocation(10000, 'MXN', docs, r.allocations, { allowUnapplied: true })[0].severity).toBe('warning')
|
|
44
|
+
})
|
|
45
|
+
it('detecta pago mayor al saldo y moneda distinta', () => {
|
|
46
|
+
const codes = validateAllocation(9000, 'USD', docs, [{ document_id: 'a1051', amount: 9000, balance_before: 8500, balance_after: -500, installment: 2 }]).map(i => i.code)
|
|
47
|
+
expect(codes).toContain('over_balance')
|
|
48
|
+
expect(codes).toContain('currency_mismatch')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('computeAging', () => {
|
|
53
|
+
it('tramos y vencido por cliente al corte', () => {
|
|
54
|
+
expect(daysOverdue('2026-09-14', '2026-10-05')).toBe(21)
|
|
55
|
+
const { rows, totals, total } = computeAging(docs.map(d => ({ party_id: 'tba', due_at: d.due_at ?? null, issued_at: d.issued_at, balance: d.balance })), '2026-10-05')
|
|
56
|
+
expect(totals.current).toBe(65530.4)
|
|
57
|
+
expect(totals.d1_30).toBe(8500)
|
|
58
|
+
expect(total).toBe(74030.4)
|
|
59
|
+
expect(rows[0].oldest_overdue_days).toBe(21)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('contribuciones federadas', () => {
|
|
64
|
+
afterEach(() => __resetContributions())
|
|
65
|
+
const ctx = { kind: 'invoice', model: 'customers.Invoice', values: {}, mode: 'create' as const }
|
|
66
|
+
it('se ocultan si el addon no está instalado y se ordenan por prioridad', () => {
|
|
67
|
+
const Comp = () => null
|
|
68
|
+
registerDocumentContribution({ id: 'fiscal_mexico.header.cfdi', kinds: ['invoice', 'credit_note'], region: 'header.fields', requiresAddon: 'fiscal_mexico', priority: 10, component: Comp })
|
|
69
|
+
registerDocumentContribution({ id: 'inventory.header.warehouse', kinds: '*', region: 'header.fields', requiresAddon: 'inventory', component: Comp })
|
|
70
|
+
expect(resolveContributions(ctx, 'header.fields', a => a === 'inventory').map(c => c.id)).toEqual(['inventory.header.warehouse'])
|
|
71
|
+
expect(resolveContributions(ctx, 'header.fields', () => true).map(c => c.id)).toEqual(['fiscal_mexico.header.cfdi', 'inventory.header.warehouse'])
|
|
72
|
+
})
|
|
73
|
+
it('el disposer la quita (desinstalación en caliente)', () => {
|
|
74
|
+
const off = registerDocumentContribution({ id: 'x', kinds: '*', region: 'side.panels', component: () => null })
|
|
75
|
+
off()
|
|
76
|
+
expect(resolveContributions(ctx, 'side.panels', () => true)).toEqual([])
|
|
77
|
+
})
|
|
78
|
+
it('modal federado sin addon → null (el dispatcher usa el formulario genérico)', () => {
|
|
79
|
+
registerFederatedModal({ key: 'fiscal_mexico.import_cfdi', addon: 'fiscal_mexico', load: async () => ({ default: () => null }) })
|
|
80
|
+
expect(resolveFederatedModal('fiscal_mexico.import_cfdi', () => false)).toBeNull()
|
|
81
|
+
expect(resolveFederatedModal('fiscal_mexico.import_cfdi', () => true)?.addon).toBe('fiscal_mexico')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('groupApprovals', () => {
|
|
86
|
+
it('agrupa por categoría registrada por cada addon', () => {
|
|
87
|
+
const off = registerApprovalCategory({ key: 'cfdi', label: 'Cancelación CFDI', match: r => r.action_key === 'cancel_fiscal' })
|
|
88
|
+
const base = { kind: 'action', status: 'pending' } as ApprovalRequestDTO
|
|
89
|
+
const g = groupApprovals([{ ...base, id: '1', action_key: 'cancel_fiscal' }, { ...base, id: '2', action_key: 'approve_adjustment' }])
|
|
90
|
+
expect(Object.keys(g).sort()).toEqual(['cfdi', 'other'])
|
|
91
|
+
off()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('allocatePayment (estrategias y bordes)', () => {
|
|
96
|
+
it('oldest_issued_first ordena por emisión', () => {
|
|
97
|
+
const r = allocatePayment(50000, docs, 'oldest_issued_first')
|
|
98
|
+
expect(r.allocations.map(a => a.document_id)).toEqual(['a1051', 'a1060'])
|
|
99
|
+
})
|
|
100
|
+
it('monto negativo o cero no aplica nada y validateAllocation lo marca', () => {
|
|
101
|
+
expect(allocatePayment(-5, docs)).toEqual({ allocations: [], applied: 0, unapplied: 0 })
|
|
102
|
+
expect(validateAllocation(0, 'MXN', docs, []).map(i => i.code)).toContain('non_positive')
|
|
103
|
+
})
|
|
104
|
+
it('pago mayor a todos los saldos deja el excedente sin aplicar', () => {
|
|
105
|
+
const r = allocatePayment(100000, docs)
|
|
106
|
+
expect(r.applied).toBe(74030.4)
|
|
107
|
+
expect(r.unapplied).toBe(25969.6)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('bucketFor', () => {
|
|
112
|
+
it('ubica los bordes de cada tramo', () => {
|
|
113
|
+
expect(bucketFor(-3)).toBe('current')
|
|
114
|
+
expect(bucketFor(0)).toBe('current')
|
|
115
|
+
expect(bucketFor(30)).toBe('d1_30')
|
|
116
|
+
expect(bucketFor(31)).toBe('d31_60')
|
|
117
|
+
expect(bucketFor(400)).toBe('d90_plus')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('contribuciones federadas (registro)', () => {
|
|
122
|
+
afterEach(() => __resetContributions())
|
|
123
|
+
const ctx = { kind: 'invoice', model: 'customers.Invoice', values: { total: 10 }, mode: 'edit' as const }
|
|
124
|
+
|
|
125
|
+
it('publica en slotRegistry por kind/region y el disposer la retira', () => {
|
|
126
|
+
const off = registerDocumentContribution({ id: 'a.body.x', kinds: ['invoice', 'quote'], region: 'body.sections', component: () => null })
|
|
127
|
+
expect(slotRegistry.get(slotIdFor('invoice', 'body.sections'))).toHaveLength(1)
|
|
128
|
+
expect(slotRegistry.get(slotIdFor('quote', 'body.sections'))).toHaveLength(1)
|
|
129
|
+
off()
|
|
130
|
+
off() // idempotente
|
|
131
|
+
expect(slotRegistry.get(slotIdFor('invoice', 'body.sections'))).toHaveLength(0)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('re-registrar el mismo id reemplaza; el disposer viejo no borra el nuevo', () => {
|
|
135
|
+
const offOld = registerDocumentContribution({ id: 'a.side.p', kinds: '*', region: 'side.panels', priority: 1, component: () => null })
|
|
136
|
+
registerDocumentContribution({ id: 'a.side.p', kinds: '*', region: 'side.panels', priority: 5, component: () => null })
|
|
137
|
+
expect(slotRegistry.get(slotIdFor('*', 'side.panels'))).toHaveLength(1)
|
|
138
|
+
offOld()
|
|
139
|
+
expect(resolveContributions(ctx, 'side.panels', () => true).map(c => c.priority)).toEqual([5])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('filtra por `when` y por kind; con `load` siempre expone un componente', () => {
|
|
143
|
+
registerDocumentContribution({ id: 'a.h.only_edit', kinds: ['invoice'], region: 'header.fields', when: c => c.mode === 'edit', load: async () => ({ default: () => null }) })
|
|
144
|
+
registerDocumentContribution({ id: 'a.h.quote', kinds: ['quote'], region: 'header.fields', component: () => null })
|
|
145
|
+
const [only] = resolveContributions(ctx, 'header.fields', () => true)
|
|
146
|
+
expect(only.id).toBe('a.h.only_edit')
|
|
147
|
+
expect(typeof only.component).toBe('function')
|
|
148
|
+
expect(resolveContributions({ ...ctx, mode: 'create' }, 'header.fields', () => true)).toEqual([])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('exige component o load', () => {
|
|
152
|
+
expect(() => registerDocumentContribution({ id: 'bad', kinds: '*', region: 'side.panels' })).toThrow(/component o load/)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('notifica a los suscriptores en register/dispose', () => {
|
|
156
|
+
const spy = vi.fn()
|
|
157
|
+
const unsub = subscribeContributions(spy)
|
|
158
|
+
const v0 = contributionsVersion()
|
|
159
|
+
const off = registerDocumentContribution({ id: 'n', kinds: '*', region: 'side.panels', component: () => null })
|
|
160
|
+
off()
|
|
161
|
+
expect(spy).toHaveBeenCalledTimes(2)
|
|
162
|
+
expect(contributionsVersion()).toBe(v0 + 2)
|
|
163
|
+
unsub()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('modal federado: no registrado → null; disposer lo retira; componente memoizado', () => {
|
|
167
|
+
expect(resolveFederatedModal('returns.settle_method', () => true)).toBeNull()
|
|
168
|
+
const m = { key: 'returns.settle_method', addon: 'returns', load: async () => ({ default: () => null }) }
|
|
169
|
+
const off = registerFederatedModal(m)
|
|
170
|
+
expect(resolveFederatedModal('returns.settle_method', () => true)).toBe(m)
|
|
171
|
+
expect(federatedModalComponent(m)).toBe(federatedModalComponent(m))
|
|
172
|
+
off()
|
|
173
|
+
expect(resolveFederatedModal('returns.settle_method', () => true)).toBeNull()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('installedPredicate: sin provider = instalado; con provider consulta el set', () => {
|
|
177
|
+
expect(installedPredicate(null)('lo_que_sea')).toBe(true)
|
|
178
|
+
const p = installedPredicate({ addons: new Set(['customers']), capabilities: new Set() })
|
|
179
|
+
expect(p('customers')).toBe(true)
|
|
180
|
+
expect(p('fiscal_mexico')).toBe(false)
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('tipos de documento', () => {
|
|
185
|
+
it('registerDocumentKind con disposer y filtrado por addon instalado', () => {
|
|
186
|
+
const offs = CORE_DOCUMENT_KINDS.map(registerDocumentKind)
|
|
187
|
+
expect(getDocumentKind('invoice')?.model).toBe('customers.Invoice')
|
|
188
|
+
expect(listDocumentKinds(a => a === 'purchases').map(k => k.key)).toEqual(['purchase_order', 'supplier_invoice', 'supplier_payment'])
|
|
189
|
+
offs.forEach(off => off())
|
|
190
|
+
expect(getDocumentKind('invoice')).toBeUndefined()
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe('paneles', () => {
|
|
195
|
+
it('blockingChecks: solo errores sin política de autorización', () => {
|
|
196
|
+
const checks = [
|
|
197
|
+
{ key: 'a', severity: 'error' as const, message: 'Falta RFC' },
|
|
198
|
+
{ key: 'b', severity: 'error' as const, message: 'Sin crédito', approval_policy: 'credit_override' },
|
|
199
|
+
{ key: 'c', severity: 'warning' as const, message: 'Sin correo' },
|
|
200
|
+
]
|
|
201
|
+
expect(blockingChecks(checks).map(c => c.key)).toEqual(['a'])
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('approvalsClient', () => {
|
|
206
|
+
it('arma la consulta y lee lista y conteo', async () => {
|
|
207
|
+
const get = vi.fn(async (url: string) =>
|
|
208
|
+
url.startsWith('/approvals/count') ? { data: { data: { count: 3 } } } : { data: { data: [{ id: '1' }], meta: { total: 1 } } },
|
|
209
|
+
)
|
|
210
|
+
const post = vi.fn(async () => ({ data: {} }))
|
|
211
|
+
const c = approvalsClient({ get, post } as unknown as ApiClient)
|
|
212
|
+
expect(await c.list({ status: 'pending', for_me: true, model: undefined })).toEqual({ items: [{ id: '1' }], total: 1 })
|
|
213
|
+
expect(get).toHaveBeenCalledWith('/approvals?status=pending&for_me=true')
|
|
214
|
+
expect(await c.count()).toBe(3)
|
|
215
|
+
await c.reject('9', 'No procede')
|
|
216
|
+
expect(post).toHaveBeenCalledWith('/approvals/9/reject', { reason: 'No procede' })
|
|
217
|
+
})
|
|
218
|
+
it('parseJSONField tolera string, objeto y basura', () => {
|
|
219
|
+
expect(parseJSONField('{"a":1}')).toEqual({ a: 1 })
|
|
220
|
+
expect(parseJSONField({ a: 2 })).toEqual({ a: 2 })
|
|
221
|
+
expect(parseJSONField('no-json')).toBeNull()
|
|
222
|
+
expect(parseJSONField('')).toBeNull()
|
|
223
|
+
})
|
|
224
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// RecordPrefillBar: pinta los ayudantes registrados para el modelo/modo, los
|
|
4
|
+
// filtra por permiso y solo deja pasar al formulario las claves que declara.
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
7
|
+
import { registerRecordPrefill, unregisterRecordPrefillsByOwner, type RecordPrefillProps } from '@asteby/metacore-sdk'
|
|
8
|
+
import { RecordPrefillBar } from '../record-prefill-bar'
|
|
9
|
+
import { PermissionsProvider } from '../permissions-context'
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
cleanup()
|
|
13
|
+
unregisterRecordPrefillsByOwner('fiscal_mexico')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
function CsfButton({ applyValues }: RecordPrefillProps) {
|
|
17
|
+
return (
|
|
18
|
+
<button type="button" onClick={() => applyValues({ tax_id: 'XAXX010101000', organization_id: 'hack' })}>
|
|
19
|
+
Cargar constancia
|
|
20
|
+
</button>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('RecordPrefillBar', () => {
|
|
25
|
+
it('aplica solo claves permitidas y aparece cuando el remote registra tarde', () => {
|
|
26
|
+
const onApply = vi.fn()
|
|
27
|
+
render(
|
|
28
|
+
<RecordPrefillBar model="customers.Customer" mode="create" values={{}} allowedKeys={new Set(['tax_id'])} onApply={onApply} />,
|
|
29
|
+
)
|
|
30
|
+
expect(screen.queryByText('Cargar constancia')).toBeNull()
|
|
31
|
+
act(() => {
|
|
32
|
+
registerRecordPrefill({ id: 'fiscal_mexico.csf', models: ['Customer'], component: CsfButton }, 'fiscal_mexico')
|
|
33
|
+
})
|
|
34
|
+
fireEvent.click(screen.getByText('Cargar constancia'))
|
|
35
|
+
expect(onApply).toHaveBeenCalledWith({ tax_id: 'XAXX010101000' })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('se oculta sin el permiso declarado', () => {
|
|
39
|
+
registerRecordPrefill(
|
|
40
|
+
{ id: 'fiscal_mexico.csf', models: ['Customer'], component: CsfButton, permission: 'customers.csf' },
|
|
41
|
+
'fiscal_mexico',
|
|
42
|
+
)
|
|
43
|
+
render(
|
|
44
|
+
<PermissionsProvider permissions={[]} isAdmin={false}>
|
|
45
|
+
<RecordPrefillBar model="Customer" mode="create" values={{}} allowedKeys={new Set(['tax_id'])} onApply={() => {}} />
|
|
46
|
+
</PermissionsProvider>,
|
|
47
|
+
)
|
|
48
|
+
expect(screen.queryByText('Cargar constancia')).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -82,7 +82,11 @@ describe('detail-view jsonb line-items (ViewValue → inline CollectionCell)', (
|
|
|
82
82
|
record={{}}
|
|
83
83
|
/>
|
|
84
84
|
)
|
|
85
|
-
|
|
85
|
+
// Keys are humanized (the addon-declared label is the localized source);
|
|
86
|
+
// each pair renders as a <dt>/<dd>, never "[object Object]".
|
|
87
|
+
expect(screen.getByText('Price').tagName).toBe('DT')
|
|
88
|
+
expect(screen.getByText('10').tagName).toBe('DD')
|
|
89
|
+
expect(screen.getByText('Quantity')).toBeTruthy()
|
|
86
90
|
expect(container.textContent).not.toContain('[object Object]')
|
|
87
91
|
})
|
|
88
92
|
|
|
@@ -14,7 +14,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
14
14
|
import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
15
15
|
|
|
16
16
|
vi.mock('react-i18next', () => ({
|
|
17
|
-
useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k }),
|
|
17
|
+
useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k, i18n: { language: 'es' } }),
|
|
18
18
|
}))
|
|
19
19
|
|
|
20
20
|
import { ActionModalDispatcher } from '../action-modal-dispatcher'
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// The host injects its axios-like client via <ApiProvider>; we no longer
|
|
16
16
|
// depend on a bundler alias to `@/lib/api`.
|
|
17
17
|
import { emitRecordMutation } from './record-mutation-events'
|
|
18
|
-
import { useState, useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
|
|
18
|
+
import { Suspense, useState, useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
|
|
19
19
|
import { useTranslation } from 'react-i18next'
|
|
20
20
|
import {
|
|
21
21
|
Dialog,
|
|
@@ -88,6 +88,9 @@ import {
|
|
|
88
88
|
subscribeActionComponents,
|
|
89
89
|
} from '@asteby/metacore-sdk'
|
|
90
90
|
import { requestFederatedAction } from './federated-action-loader'
|
|
91
|
+
import { useInstalledAddons } from './installed-addons-context'
|
|
92
|
+
import { federatedModalComponent, resolveFederatedModal } from './primitives/contributions'
|
|
93
|
+
import { installedPredicate, useContributionsVersion } from './primitives/use-contributions'
|
|
91
94
|
|
|
92
95
|
export type { ActionMetadata, ActionModalProps }
|
|
93
96
|
|
|
@@ -375,6 +378,15 @@ export function ActionModalDispatcher({
|
|
|
375
378
|
const readComponent = () => getActionComponent(model, action.key)
|
|
376
379
|
const CustomComponent = useSyncExternalStore(subscribeActionComponents, readComponent, readComponent)
|
|
377
380
|
|
|
381
|
+
// Modal federado declarativo (registerFederatedModal). Si el addon dueño
|
|
382
|
+
// del slug consta como NO instalado, el `modal` se ignora y se usa el
|
|
383
|
+
// formulario genérico de la acción de inmediato (sin esperar el timeout).
|
|
384
|
+
useContributionsVersion()
|
|
385
|
+
const installed = useInstalledAddons()
|
|
386
|
+
const slug = action.modal || ''
|
|
387
|
+
const federatedModal = slug ? resolveFederatedModal(slug, installedPredicate(installed)) : null
|
|
388
|
+
const modalAddonMissing = !!slug && !federatedModal && !!installed && !installed.addons.has(slug.split('.')[0])
|
|
389
|
+
|
|
378
390
|
if (CustomComponent) {
|
|
379
391
|
return (
|
|
380
392
|
<CustomComponent
|
|
@@ -389,10 +401,28 @@ export function ActionModalDispatcher({
|
|
|
389
401
|
)
|
|
390
402
|
}
|
|
391
403
|
|
|
404
|
+
if (federatedModal) {
|
|
405
|
+
const FederatedComponent = federatedModalComponent<ActionModalProps>(federatedModal)
|
|
406
|
+
return (
|
|
407
|
+
<Suspense fallback={<LoadingActionDialog open={open} onOpenChange={onOpenChange} action={action} />}>
|
|
408
|
+
<FederatedComponent
|
|
409
|
+
open={open}
|
|
410
|
+
onOpenChange={onOpenChange}
|
|
411
|
+
action={action}
|
|
412
|
+
model={model}
|
|
413
|
+
record={record}
|
|
414
|
+
endpoint={endpoint}
|
|
415
|
+
onSuccess={onSuccess}
|
|
416
|
+
/>
|
|
417
|
+
</Suspense>
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
|
|
392
421
|
// Declarative custom slot (`modal: "addon.action"`). Wait for the remote,
|
|
393
422
|
// then a hard error — confirm/fields generics look "fine" and hide a
|
|
394
|
-
// missing remote.
|
|
395
|
-
|
|
423
|
+
// missing remote. Exception: the slug's addon is known NOT installed — the
|
|
424
|
+
// remote will never come, so fall through to the generic form now.
|
|
425
|
+
if (action.modal && !modalAddonMissing) {
|
|
396
426
|
return (
|
|
397
427
|
<PendingCustomActionModal
|
|
398
428
|
open={open}
|
|
@@ -479,7 +509,6 @@ function PendingCustomActionModal({
|
|
|
479
509
|
action: ActionMetadata
|
|
480
510
|
model: string
|
|
481
511
|
}) {
|
|
482
|
-
const { t } = useTranslation()
|
|
483
512
|
const slug = action.modal || ''
|
|
484
513
|
const [timedOut, setTimedOut] = useState(false)
|
|
485
514
|
|
|
@@ -522,6 +551,20 @@ function PendingCustomActionModal({
|
|
|
522
551
|
return <MissingCustomActionModal open={open} onOpenChange={onOpenChange} action={action} model={model} />
|
|
523
552
|
}
|
|
524
553
|
|
|
554
|
+
return <LoadingActionDialog open={open} onOpenChange={onOpenChange} action={action} />
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Diálogo «Cargando…» mientras llega el componente federado de la acción. */
|
|
558
|
+
function LoadingActionDialog({
|
|
559
|
+
open,
|
|
560
|
+
onOpenChange,
|
|
561
|
+
action,
|
|
562
|
+
}: {
|
|
563
|
+
open: boolean
|
|
564
|
+
onOpenChange: (open: boolean) => void
|
|
565
|
+
action: ActionMetadata
|
|
566
|
+
}) {
|
|
567
|
+
const { t } = useTranslation()
|
|
525
568
|
return (
|
|
526
569
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
527
570
|
<DialogContent>
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* audit-info.tsx
|
|
3
|
+
*
|
|
4
|
+
* <AuditInfo> — discreet "when and who" footer for a record: created, modified
|
|
5
|
+
* and (if tombstoned) deleted, each with the actor and a relative + absolute
|
|
6
|
+
* date. Driven only by the kernel's `TableMetadata.audit` (column names) and
|
|
7
|
+
* the record row — manifests declare nothing.
|
|
8
|
+
*
|
|
9
|
+
* Actor resolution (no extra requests), in order:
|
|
10
|
+
* 1. the host's `resolveActor(id)` when it returns a name;
|
|
11
|
+
* 2. the record's expanded sibling (`created_by_id` → `row.created_by`,
|
|
12
|
+
* `{ label | name | title }` or a plain string);
|
|
13
|
+
* 3. the system actor id → "Sistema" / "System";
|
|
14
|
+
* 4. a short id (first 8 chars) — never a long uuid.
|
|
15
|
+
*
|
|
16
|
+
* Renders nothing when `audit` is absent or the record has none of its values.
|
|
17
|
+
*/
|
|
18
|
+
import * as React from 'react'
|
|
19
|
+
import { useTranslation } from 'react-i18next'
|
|
20
|
+
import { formatDistanceToNow } from 'date-fns'
|
|
21
|
+
import { es, enUS } from 'date-fns/locale'
|
|
22
|
+
import { ChevronDown, ChevronRight, History } from 'lucide-react'
|
|
23
|
+
import { cn } from '@asteby/metacore-ui/lib'
|
|
24
|
+
import { formatDateCell } from './dynamic-columns'
|
|
25
|
+
import { objectLabel } from './dynamic-relation-helpers'
|
|
26
|
+
import { isNilUuid } from './nil-uuid'
|
|
27
|
+
import { useTimeZone } from './org-runtime-context'
|
|
28
|
+
import type { AuditMeta } from './types'
|
|
29
|
+
|
|
30
|
+
/** The kernel's actor for unattended work (schedules, webhooks, connectors). */
|
|
31
|
+
export const SYSTEM_ACTOR_ID = '00000000-0000-0000-0000-000000000001'
|
|
32
|
+
|
|
33
|
+
const AUDIT_KEYS: (keyof AuditMeta)[] = [
|
|
34
|
+
'created_at',
|
|
35
|
+
'created_by',
|
|
36
|
+
'updated_at',
|
|
37
|
+
'updated_by',
|
|
38
|
+
'deleted_at',
|
|
39
|
+
'deleted_by',
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Defensive reader for the served `audit` block: keeps only known keys whose
|
|
44
|
+
* value is a non-empty string; returns `undefined` when nothing usable remains.
|
|
45
|
+
*/
|
|
46
|
+
export function readAuditMeta(raw: unknown): AuditMeta | undefined {
|
|
47
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
|
|
48
|
+
const src = raw as Record<string, unknown>
|
|
49
|
+
const out: AuditMeta = {}
|
|
50
|
+
for (const k of AUDIT_KEYS) {
|
|
51
|
+
const v = src[k]
|
|
52
|
+
if (typeof v === 'string' && v.trim() !== '') out[k] = v
|
|
53
|
+
}
|
|
54
|
+
return Object.keys(out).length ? out : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AuditActor {
|
|
58
|
+
/** Display text. */
|
|
59
|
+
name: string
|
|
60
|
+
/** True when the name is a real resolved label (not a short id). */
|
|
61
|
+
resolved: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolves the actor stored in `row[idColumn]` to something human. */
|
|
65
|
+
export function resolveAuditActor(
|
|
66
|
+
row: Record<string, unknown>,
|
|
67
|
+
idColumn: string | undefined,
|
|
68
|
+
opts: { resolveActor?: (id: string) => string | undefined; systemLabel: string },
|
|
69
|
+
): AuditActor | null {
|
|
70
|
+
if (!idColumn) return null
|
|
71
|
+
const raw = row[idColumn]
|
|
72
|
+
// The column itself may already be an expanded object.
|
|
73
|
+
const own = objectLabel(raw)
|
|
74
|
+
const id = typeof raw === 'string' ? raw.trim() : ''
|
|
75
|
+
if (!own && (!id || isNilUuid(id))) return null
|
|
76
|
+
if (id.toLowerCase() === SYSTEM_ACTOR_ID) return { name: opts.systemLabel, resolved: true }
|
|
77
|
+
if (id) {
|
|
78
|
+
const fromHost = opts.resolveActor?.(id)
|
|
79
|
+
if (fromHost) return { name: fromHost, resolved: true }
|
|
80
|
+
}
|
|
81
|
+
if (own) return { name: own, resolved: true }
|
|
82
|
+
const base = idColumn.endsWith('_id') ? idColumn.slice(0, -3) : idColumn
|
|
83
|
+
const sibling = base === idColumn ? undefined : row[base]
|
|
84
|
+
const siblingName =
|
|
85
|
+
objectLabel(sibling) ?? (typeof sibling === 'string' && sibling.trim() !== '' ? sibling : undefined)
|
|
86
|
+
if (siblingName) return { name: siblingName, resolved: true }
|
|
87
|
+
return { name: id.length > 8 ? id.slice(0, 8) : id, resolved: false }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const COPY = {
|
|
91
|
+
es: {
|
|
92
|
+
title: 'Historial del registro',
|
|
93
|
+
created: 'Creado por {{who}}',
|
|
94
|
+
updated: 'Modificado por {{who}}',
|
|
95
|
+
deleted: 'Eliminado por {{who}}',
|
|
96
|
+
createdAt: 'Creado',
|
|
97
|
+
updatedAt: 'Modificado',
|
|
98
|
+
deletedAt: 'Eliminado',
|
|
99
|
+
system: 'Sistema',
|
|
100
|
+
},
|
|
101
|
+
en: {
|
|
102
|
+
title: 'Record history',
|
|
103
|
+
created: 'Created by {{who}}',
|
|
104
|
+
updated: 'Modified by {{who}}',
|
|
105
|
+
deleted: 'Deleted by {{who}}',
|
|
106
|
+
createdAt: 'Created',
|
|
107
|
+
updatedAt: 'Modified',
|
|
108
|
+
deletedAt: 'Deleted',
|
|
109
|
+
system: 'System',
|
|
110
|
+
},
|
|
111
|
+
} as const
|
|
112
|
+
|
|
113
|
+
export interface AuditInfoProps {
|
|
114
|
+
/** The record row (keys are the column names `audit` points at). */
|
|
115
|
+
record: Record<string, unknown> | null | undefined
|
|
116
|
+
/** `TableMetadata.audit`. Absent → renders nothing. */
|
|
117
|
+
audit: AuditMeta | null | undefined
|
|
118
|
+
/** Optional host resolver uuid → display name (e.g. from a users cache). */
|
|
119
|
+
resolveActor?: (id: string) => string | undefined
|
|
120
|
+
/** IANA timezone; defaults to the org runtime timezone. */
|
|
121
|
+
timeZone?: string
|
|
122
|
+
/** BCP-47 locale; defaults to the i18n language. */
|
|
123
|
+
locale?: string
|
|
124
|
+
/** Start expanded. Default: collapsed. */
|
|
125
|
+
defaultOpen?: boolean
|
|
126
|
+
className?: string
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface Line {
|
|
130
|
+
key: string
|
|
131
|
+
text: string
|
|
132
|
+
absolute: string
|
|
133
|
+
title?: string
|
|
134
|
+
relative: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function AuditInfo({
|
|
138
|
+
record,
|
|
139
|
+
audit,
|
|
140
|
+
resolveActor,
|
|
141
|
+
timeZone,
|
|
142
|
+
locale,
|
|
143
|
+
defaultOpen = false,
|
|
144
|
+
className,
|
|
145
|
+
}: AuditInfoProps) {
|
|
146
|
+
const { t, i18n } = useTranslation()
|
|
147
|
+
const ctxTz = useTimeZone()
|
|
148
|
+
const [open, setOpen] = React.useState(defaultOpen)
|
|
149
|
+
|
|
150
|
+
const meta = readAuditMeta(audit)
|
|
151
|
+
if (!meta || !record) return null
|
|
152
|
+
|
|
153
|
+
const lang = (locale || i18n?.language || 'es').toLowerCase().startsWith('en') ? 'en' : 'es'
|
|
154
|
+
const copy = COPY[lang]
|
|
155
|
+
const dfLocale = lang === 'en' ? enUS : es
|
|
156
|
+
const tz = timeZone ?? ctxTz
|
|
157
|
+
const tt = (key: keyof typeof copy, vars?: Record<string, string>) =>
|
|
158
|
+
t(`audit.${key}`, { defaultValue: copy[key], ...vars }) as string
|
|
159
|
+
|
|
160
|
+
const build = (
|
|
161
|
+
key: string,
|
|
162
|
+
atCol: string | undefined,
|
|
163
|
+
byCol: string | undefined,
|
|
164
|
+
whoKey: 'created' | 'updated' | 'deleted',
|
|
165
|
+
whenKey: 'createdAt' | 'updatedAt' | 'deletedAt',
|
|
166
|
+
): Line | null => {
|
|
167
|
+
const f = atCol ? formatDateCell(record[atCol], undefined, dfLocale, tz) : null
|
|
168
|
+
if (!f) return null
|
|
169
|
+
const actor = resolveAuditActor(record, byCol, { resolveActor, systemLabel: tt('system') })
|
|
170
|
+
const date = new Date(record[atCol as string] as string)
|
|
171
|
+
const relative = formatDistanceToNow(date, { addSuffix: true, locale: dfLocale })
|
|
172
|
+
return {
|
|
173
|
+
key,
|
|
174
|
+
text: actor ? tt(whoKey, { who: actor.name }) : tt(whenKey),
|
|
175
|
+
absolute: f.display,
|
|
176
|
+
title: f.title,
|
|
177
|
+
relative,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const lines = [
|
|
182
|
+
build('created', meta.created_at, meta.created_by, 'created', 'createdAt'),
|
|
183
|
+
// Hide "modified" when it is the creation instant itself (never edited).
|
|
184
|
+
meta.updated_at && record[meta.updated_at] !== record[meta.created_at ?? '']
|
|
185
|
+
? build('updated', meta.updated_at, meta.updated_by, 'updated', 'updatedAt')
|
|
186
|
+
: null,
|
|
187
|
+
build('deleted', meta.deleted_at, meta.deleted_by, 'deleted', 'deletedAt'),
|
|
188
|
+
].filter((l): l is Line => l !== null)
|
|
189
|
+
|
|
190
|
+
if (lines.length === 0) return null
|
|
191
|
+
|
|
192
|
+
const Chevron = open ? ChevronDown : ChevronRight
|
|
193
|
+
return (
|
|
194
|
+
<div className={cn('text-xs text-muted-foreground', className)} data-testid="audit-info">
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
className="flex items-center gap-1.5 hover:text-foreground"
|
|
198
|
+
aria-expanded={open}
|
|
199
|
+
onClick={() => setOpen(o => !o)}
|
|
200
|
+
>
|
|
201
|
+
<Chevron className="h-3.5 w-3.5" />
|
|
202
|
+
<History className="h-3.5 w-3.5" />
|
|
203
|
+
<span>{tt('title')}</span>
|
|
204
|
+
</button>
|
|
205
|
+
{open && (
|
|
206
|
+
<ul className="mt-2 space-y-1 pl-6">
|
|
207
|
+
{lines.map(l => (
|
|
208
|
+
<li key={l.key} data-audit-line={l.key}>
|
|
209
|
+
<span>{l.text}</span>
|
|
210
|
+
{' · '}
|
|
211
|
+
<time title={l.title}>{l.absolute}</time>
|
|
212
|
+
<span className="opacity-70"> ({l.relative})</span>
|
|
213
|
+
</li>
|
|
214
|
+
))}
|
|
215
|
+
</ul>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
)
|
|
219
|
+
}
|