@asteby/metacore-runtime-react 45.2.0 → 45.3.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 +12 -0
- package/dist/action-modal-dispatcher.d.ts +29 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +115 -6
- package/dist/activity-diff.d.ts +2 -0
- package/dist/activity-diff.d.ts.map +1 -1
- package/dist/activity-diff.js +2 -2
- package/dist/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +8 -0
- package/dist/business/index.d.ts +2 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +2 -0
- package/dist/business/refund-destination.js +1 -1
- package/dist/business/refund.d.ts +1 -1
- package/dist/business/refund.d.ts.map +1 -1
- package/dist/business/refund.js +1 -0
- package/dist/business/return-wizard.d.ts +23 -0
- package/dist/business/return-wizard.d.ts.map +1 -0
- package/dist/business/return-wizard.js +59 -0
- package/dist/business/return.d.ts +90 -0
- package/dist/business/return.d.ts.map +1 -0
- package/dist/business/return.js +112 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +3 -1
- package/dist/dynamic-relation.d.ts +4 -0
- package/dist/dynamic-relation.d.ts.map +1 -1
- package/dist/dynamic-relation.js +10 -1
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +14 -2
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +28 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/reason-prompt.d.ts +59 -0
- package/dist/reason-prompt.d.ts.map +1 -0
- package/dist/reason-prompt.js +111 -0
- package/dist/use-addon-settings.d.ts +17 -3
- package/dist/use-addon-settings.d.ts.map +1 -1
- package/dist/use-addon-settings.js +20 -8
- package/package.json +1 -1
- package/src/__tests__/prefill-from-record.test.ts +69 -0
- package/src/__tests__/reason-prompt.test.tsx +121 -0
- package/src/__tests__/use-addon-settings.test.ts +16 -1
- package/src/action-modal-dispatcher.tsx +145 -3
- package/src/activity-diff.tsx +10 -1
- package/src/business/business.test.ts +46 -0
- package/src/business/catalog.ts +8 -0
- package/src/business/index.ts +2 -0
- package/src/business/refund-destination.tsx +2 -2
- package/src/business/refund.ts +2 -1
- package/src/business/return-wizard.tsx +223 -0
- package/src/business/return.ts +181 -0
- package/src/dynamic-columns.tsx +5 -1
- package/src/dynamic-relation.tsx +14 -1
- package/src/dynamic-row-actions.tsx +13 -1
- package/src/dynamic-table.tsx +22 -2
- package/src/index.ts +10 -0
- package/src/reason-prompt.tsx +247 -0
- package/src/use-addon-settings.ts +26 -7
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
/** Stable react-query key for an addon's per-org settings. */
|
|
2
|
-
export declare function addonSettingsKey(addonKey: string): readonly ["addon-settings", string];
|
|
1
|
+
/** Stable react-query key for an addon's per-org settings (per branch when given). */
|
|
2
|
+
export declare function addonSettingsKey(addonKey: string, branchId?: string | null): readonly ["addon-settings", string, string] | readonly ["addon-settings", string];
|
|
3
|
+
/**
|
|
4
|
+
* Settings URL. With a branch, the host answers the settings the addon runs
|
|
5
|
+
* with there: org values with each `scope: branch` key replaced by the branch's
|
|
6
|
+
* override (POS-1). Without one, the org values (legacy behaviour).
|
|
7
|
+
*/
|
|
8
|
+
export declare function addonSettingsUrl(addonKey: string, branchId?: string | null): string;
|
|
3
9
|
/**
|
|
4
10
|
* Merge saved org values over the caller's defaults so a never-saved setting
|
|
5
11
|
* falls back to its default. Pure + exported for unit testing. Only keys that
|
|
@@ -19,6 +25,12 @@ export interface UseAddonSettingsOptions<T> {
|
|
|
19
25
|
staleTime?: number;
|
|
20
26
|
/** Defer fetching (e.g. until the addon key is known). */
|
|
21
27
|
enabled?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Read the settings as one branch sees them (manifest `scope: branch` keys
|
|
30
|
+
* resolve to the branch override, else the org value). Omit for org values.
|
|
31
|
+
* A terminal passes its active branch here.
|
|
32
|
+
*/
|
|
33
|
+
branchId?: string | null;
|
|
22
34
|
}
|
|
23
35
|
export interface UseAddonSettingsResult<T> {
|
|
24
36
|
/** Saved org values merged over `defaults`. Never null. */
|
|
@@ -52,5 +64,7 @@ export declare function useAddonSettings<T extends Record<string, unknown> = Rec
|
|
|
52
64
|
* const update = useUpdateAddonSettings('pos')
|
|
53
65
|
* update.mutate({ allowNegativeStock: true })
|
|
54
66
|
*/
|
|
55
|
-
export declare function useUpdateAddonSettings<T extends Record<string, unknown> = Record<string, unknown>>(addonKey: string
|
|
67
|
+
export declare function useUpdateAddonSettings<T extends Record<string, unknown> = Record<string, unknown>>(addonKey: string,
|
|
68
|
+
/** Write branch overrides (only `scope: branch` keys; `null` clears one) instead of org values. */
|
|
69
|
+
branchId?: string | null): import("@tanstack/react-query").UseMutationResult<Partial<T>, Error, Partial<T>, unknown>;
|
|
56
70
|
//# sourceMappingURL=use-addon-settings.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-addon-settings.d.ts","sourceRoot":"","sources":["../src/use-addon-settings.ts"],"names":[],"mappings":"AA6BA,
|
|
1
|
+
{"version":3,"file":"use-addon-settings.d.ts","sourceRoot":"","sources":["../src/use-addon-settings.ts"],"names":[],"mappings":"AA6BA,sFAAsF;AACtF,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,qFAE1E;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAGnF;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChE,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,SAAS,EAChC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,SAAS,GACtC,CAAC,CAQH;AAED,MAAM,WAAW,uBAAuB,CAAC,CAAC;IACtC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;IACrB,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,0DAA0D;IAC1D,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC3B;AAED,MAAM,WAAW,sBAAsB,CAAC,CAAC;IACrC,2DAA2D;IAC3D,QAAQ,EAAE,CAAC,CAAA;IACX,SAAS,EAAE,OAAO,CAAA;IAClB,KAAK,EAAE,OAAO,CAAA;IACd,OAAO,EAAE,MAAM,IAAI,CAAA;CACtB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxF,QAAQ,EAAE,MAAM,EAChB,IAAI,GAAE,uBAAuB,CAAC,CAAC,CAAM,GACtC,sBAAsB,CAAC,CAAC,CAAC,CA8B3B;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9F,QAAQ,EAAE,MAAM;AAChB,mGAAmG;AACnG,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,6FAiB3B"}
|
|
@@ -22,9 +22,18 @@
|
|
|
22
22
|
import { useMemo } from 'react';
|
|
23
23
|
import { useMutation, useQuery, useQueryClient, } from '@tanstack/react-query';
|
|
24
24
|
import { useApi } from './api-context';
|
|
25
|
-
/** Stable react-query key for an addon's per-org settings. */
|
|
26
|
-
export function addonSettingsKey(addonKey) {
|
|
27
|
-
return ['addon-settings', addonKey];
|
|
25
|
+
/** Stable react-query key for an addon's per-org settings (per branch when given). */
|
|
26
|
+
export function addonSettingsKey(addonKey, branchId) {
|
|
27
|
+
return branchId ? ['addon-settings', addonKey, branchId] : ['addon-settings', addonKey];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Settings URL. With a branch, the host answers the settings the addon runs
|
|
31
|
+
* with there: org values with each `scope: branch` key replaced by the branch's
|
|
32
|
+
* override (POS-1). Without one, the org values (legacy behaviour).
|
|
33
|
+
*/
|
|
34
|
+
export function addonSettingsUrl(addonKey, branchId) {
|
|
35
|
+
const base = `/api/addons/${addonKey}/settings`;
|
|
36
|
+
return branchId ? `${base}?branch_id=${encodeURIComponent(branchId)}` : base;
|
|
28
37
|
}
|
|
29
38
|
/**
|
|
30
39
|
* Merge saved org values over the caller's defaults so a never-saved setting
|
|
@@ -59,11 +68,11 @@ export function mergeAddonSettings(defaults, stored) {
|
|
|
59
68
|
*/
|
|
60
69
|
export function useAddonSettings(addonKey, opts = {}) {
|
|
61
70
|
const api = useApi();
|
|
62
|
-
const { defaults, staleTime, enabled } = opts;
|
|
71
|
+
const { defaults, staleTime, enabled, branchId } = opts;
|
|
63
72
|
const query = useQuery({
|
|
64
|
-
queryKey: addonSettingsKey(addonKey),
|
|
73
|
+
queryKey: addonSettingsKey(addonKey, branchId),
|
|
65
74
|
queryFn: async () => {
|
|
66
|
-
const res = await api.get(
|
|
75
|
+
const res = await api.get(addonSettingsUrl(addonKey, branchId));
|
|
67
76
|
const body = res.data;
|
|
68
77
|
// Host envelope: { success, data: { <key>: <value> } }.
|
|
69
78
|
const values = body?.success ? body.data : body;
|
|
@@ -92,17 +101,20 @@ export function useAddonSettings(addonKey, opts = {}) {
|
|
|
92
101
|
* const update = useUpdateAddonSettings('pos')
|
|
93
102
|
* update.mutate({ allowNegativeStock: true })
|
|
94
103
|
*/
|
|
95
|
-
export function useUpdateAddonSettings(addonKey
|
|
104
|
+
export function useUpdateAddonSettings(addonKey,
|
|
105
|
+
/** Write branch overrides (only `scope: branch` keys; `null` clears one) instead of org values. */
|
|
106
|
+
branchId) {
|
|
96
107
|
const api = useApi();
|
|
97
108
|
const qc = useQueryClient();
|
|
98
109
|
return useMutation({
|
|
99
110
|
mutationFn: async (values) => {
|
|
100
|
-
const res = await api.put(
|
|
111
|
+
const res = await api.put(addonSettingsUrl(addonKey, branchId), values);
|
|
101
112
|
const body = res.data;
|
|
102
113
|
const saved = body?.success ? body.data : body;
|
|
103
114
|
return (saved ?? values);
|
|
104
115
|
},
|
|
105
116
|
onSuccess: () => {
|
|
117
|
+
// Prefix match: refreshes the org read and every branch read.
|
|
106
118
|
void qc.invalidateQueries({ queryKey: addonSettingsKey(addonKey) });
|
|
107
119
|
},
|
|
108
120
|
});
|
package/package.json
CHANGED
|
@@ -144,3 +144,72 @@ describe('applyPrefillLock', () => {
|
|
|
144
144
|
expect(applyPrefillLock(field)).toBe(field)
|
|
145
145
|
})
|
|
146
146
|
})
|
|
147
|
+
|
|
148
|
+
// ---- fromField: rows seeded from a document picked in a sibling field --------
|
|
149
|
+
|
|
150
|
+
import { prefillFromFieldRequests, prefillFromFieldRelationRequest } from '../action-modal-dispatcher'
|
|
151
|
+
|
|
152
|
+
const receiptFields = (): ActionFieldDef[] => [
|
|
153
|
+
{ key: 'purchase_order_id', label: 'OC', type: 'dynamic_select', ref: 'PurchaseOrder' } as ActionFieldDef,
|
|
154
|
+
receiveField({
|
|
155
|
+
key: 'items',
|
|
156
|
+
default: {
|
|
157
|
+
$prefillFromRecord: 'items',
|
|
158
|
+
fromField: 'purchase_order_id',
|
|
159
|
+
map: { product_id: 'product_variant_id', ordered: 'qty', received_so_far: 'qty_received' },
|
|
160
|
+
remaining: { target: 'qty_received', of: 'qty', minus: 'qty_received' },
|
|
161
|
+
},
|
|
162
|
+
} as Partial<ActionFieldDef>),
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
describe('prefillFromFieldRequests', () => {
|
|
166
|
+
it('lee el id del campo hermano y el modelo de su ref', () => {
|
|
167
|
+
const reqs = prefillFromFieldRequests(receiptFields(), { purchase_order_id: 'po-1' })
|
|
168
|
+
expect(reqs).toHaveLength(1)
|
|
169
|
+
expect(reqs[0]).toMatchObject({ fieldKey: 'items', refModel: 'PurchaseOrder', refId: 'po-1' })
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('desenvuelve {value,id} y devuelve refId vacío sin selección', () => {
|
|
173
|
+
expect(prefillFromFieldRequests(receiptFields(), { purchase_order_id: { id: 'po-2', label: 'OC-2' } })[0].refId).toBe('po-2')
|
|
174
|
+
expect(prefillFromFieldRequests(receiptFields(), { purchase_order_id: '' })[0].refId).toBe('')
|
|
175
|
+
expect(prefillFromFieldRequests(receiptFields(), {})[0].refId).toBe('')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('ignora specs sin fromField o cuyo campo hermano no tiene ref', () => {
|
|
179
|
+
const noFrom = receiveField({ default: { $prefillFromRecord: 'items' } } as Partial<ActionFieldDef>)
|
|
180
|
+
expect(prefillFromFieldRequests([noFrom], {})).toEqual([])
|
|
181
|
+
const fields = receiptFields()
|
|
182
|
+
;(fields[0] as any).ref = undefined
|
|
183
|
+
expect(prefillFromFieldRequests(fields, { purchase_order_id: 'x' })).toEqual([])
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('prefillFromFieldRelationRequest', () => {
|
|
188
|
+
const rels = [
|
|
189
|
+
{ name: 'goods_receipts', kind: 'one_to_many', through: 'GoodsReceipt', foreign_key: 'purchase_order_id' },
|
|
190
|
+
{ name: 'items', kind: 'one_to_many', through: 'PurchaseOrderItem', foreign_key: 'purchase_order_id' },
|
|
191
|
+
]
|
|
192
|
+
it('arma el request de hijos por la relación de la OC elegida', () => {
|
|
193
|
+
const [req] = prefillFromFieldRequests(receiptFields(), { purchase_order_id: 'po-1' })
|
|
194
|
+
expect(prefillFromFieldRelationRequest(req, rels)).toEqual({
|
|
195
|
+
endpoint: '/data/PurchaseOrderItem',
|
|
196
|
+
params: { f_purchase_order_id: 'eq:po-1', per_page: 200 },
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
it('null sin selección o si la relación no existe', () => {
|
|
200
|
+
const [empty] = prefillFromFieldRequests(receiptFields(), {})
|
|
201
|
+
expect(prefillFromFieldRelationRequest(empty, rels)).toBeNull()
|
|
202
|
+
const [req] = prefillFromFieldRequests(receiptFields(), { purchase_order_id: 'po-1' })
|
|
203
|
+
expect(prefillFromFieldRelationRequest(req, [])).toBeNull()
|
|
204
|
+
})
|
|
205
|
+
it('las filas resultantes descartan lo ya recibido por completo (remaining)', () => {
|
|
206
|
+
const [req] = prefillFromFieldRequests(receiptFields(), { purchase_order_id: 'po-1' })
|
|
207
|
+
const rows = buildPrefillRows(req.spec, {
|
|
208
|
+
items: [
|
|
209
|
+
{ product_variant_id: 'a', qty: 10, qty_received: 4 },
|
|
210
|
+
{ product_variant_id: 'b', qty: 5, qty_received: 5 },
|
|
211
|
+
],
|
|
212
|
+
})
|
|
213
|
+
expect(rows).toEqual([{ product_id: 'a', ordered: 10, received_so_far: 4, qty_received: 6 }])
|
|
214
|
+
})
|
|
215
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// Mandatory reason (PER-4): the request is tried bare; a 422 errors.reason makes
|
|
4
|
+
// the hook ask once and retry with the reason; cancel = nothing ran.
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
7
|
+
|
|
8
|
+
vi.mock('react-i18next', () => ({
|
|
9
|
+
useTranslation: () => ({
|
|
10
|
+
t: (k: string, opts?: { defaultValue?: string; min?: number }) =>
|
|
11
|
+
(opts?.defaultValue ?? k).replace('{{min}}', String(opts?.min ?? '')),
|
|
12
|
+
}),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
import { reasonRequiredInfo, useReasonPrompt } from '../reason-prompt'
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
cleanup()
|
|
19
|
+
vi.clearAllMocks()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const refusal = (code: 'required' | 'min', min?: number) => ({
|
|
23
|
+
response: { status: 422, data: { success: false, errors: { reason: [{ code, params: min ? { min } : undefined }] } } },
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('reasonRequiredInfo', () => {
|
|
27
|
+
it('reads required and min refusals', () => {
|
|
28
|
+
expect(reasonRequiredInfo(refusal('required'))).toEqual({ min: 3, tooShort: false })
|
|
29
|
+
expect(reasonRequiredInfo(refusal('min', 8))).toEqual({ min: 8, tooShort: true })
|
|
30
|
+
})
|
|
31
|
+
it('ignores every other error', () => {
|
|
32
|
+
expect(reasonRequiredInfo(new Error('boom'))).toBeNull()
|
|
33
|
+
expect(reasonRequiredInfo({ response: { data: { errors: { name: [{ code: 'required' }] } } } })).toBeNull()
|
|
34
|
+
expect(reasonRequiredInfo({ response: { data: { errors: { reason: [{ code: 'other' }] } } } })).toBeNull()
|
|
35
|
+
expect(reasonRequiredInfo(undefined)).toBeNull()
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
let result: unknown = 'unset'
|
|
40
|
+
function Probe({ request }: { request: (r?: string) => Promise<string> }) {
|
|
41
|
+
const { run, dialog } = useReasonPrompt()
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
<button onClick={async () => { result = await run({ title: 'Eliminar', request }) }}>go</button>
|
|
45
|
+
{dialog}
|
|
46
|
+
</>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('useReasonPrompt', () => {
|
|
51
|
+
it('does not prompt when the server needs no reason', async () => {
|
|
52
|
+
result = 'unset'
|
|
53
|
+
const request = vi.fn().mockResolvedValue('ok')
|
|
54
|
+
render(<Probe request={request} />)
|
|
55
|
+
fireEvent.click(screen.getByText('go'))
|
|
56
|
+
await waitFor(() => expect(result).toBe('ok'))
|
|
57
|
+
expect(request).toHaveBeenCalledTimes(1)
|
|
58
|
+
expect(request).toHaveBeenCalledWith(undefined)
|
|
59
|
+
expect(screen.queryByLabelText('Motivo')).toBeNull()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('asks once on a required refusal and retries with the trimmed reason', async () => {
|
|
63
|
+
result = 'unset'
|
|
64
|
+
const request = vi.fn().mockRejectedValueOnce(refusal('required')).mockResolvedValueOnce('done')
|
|
65
|
+
render(<Probe request={request} />)
|
|
66
|
+
fireEvent.click(screen.getByText('go'))
|
|
67
|
+
const box = await screen.findByLabelText('Motivo')
|
|
68
|
+
const confirm = screen.getByText('Continuar').closest('button') as HTMLButtonElement
|
|
69
|
+
expect(confirm.disabled).toBe(true)
|
|
70
|
+
fireEvent.change(box, { target: { value: ' captura duplicada ' } })
|
|
71
|
+
expect(confirm.disabled).toBe(false)
|
|
72
|
+
fireEvent.click(confirm)
|
|
73
|
+
await waitFor(() => expect(result).toBe('done'))
|
|
74
|
+
expect(request).toHaveBeenNthCalledWith(2, 'captura duplicada')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('stays open with the inline hint when the server wants a longer reason', async () => {
|
|
78
|
+
result = 'unset'
|
|
79
|
+
const request = vi
|
|
80
|
+
.fn()
|
|
81
|
+
.mockRejectedValueOnce(refusal('required'))
|
|
82
|
+
.mockRejectedValueOnce(refusal('min', 10))
|
|
83
|
+
.mockResolvedValueOnce('done')
|
|
84
|
+
render(<Probe request={request} />)
|
|
85
|
+
fireEvent.click(screen.getByText('go'))
|
|
86
|
+
fireEvent.change(await screen.findByLabelText('Motivo'), { target: { value: 'abc' } })
|
|
87
|
+
fireEvent.click(screen.getByText('Continuar'))
|
|
88
|
+
await screen.findByText('Escribe al menos 10 caracteres.')
|
|
89
|
+
fireEvent.change(screen.getByLabelText('Motivo'), { target: { value: 'un motivo bastante largo' } })
|
|
90
|
+
fireEvent.click(screen.getByText('Continuar'))
|
|
91
|
+
await waitFor(() => expect(result).toBe('done'))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('resolves undefined when the operator cancels', async () => {
|
|
95
|
+
result = 'unset'
|
|
96
|
+
const request = vi.fn().mockRejectedValueOnce(refusal('required'))
|
|
97
|
+
render(<Probe request={request} />)
|
|
98
|
+
fireEvent.click(screen.getByText('go'))
|
|
99
|
+
await screen.findByLabelText('Motivo')
|
|
100
|
+
fireEvent.click(screen.getByText('Cancelar'))
|
|
101
|
+
await waitFor(() => expect(result).toBeUndefined())
|
|
102
|
+
expect(request).toHaveBeenCalledTimes(1)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('rethrows a non-reason error', async () => {
|
|
106
|
+
const request = vi.fn().mockRejectedValue(new Error('network'))
|
|
107
|
+
let caught: unknown
|
|
108
|
+
function P2() {
|
|
109
|
+
const { run, dialog } = useReasonPrompt()
|
|
110
|
+
return (
|
|
111
|
+
<>
|
|
112
|
+
<button onClick={() => run({ title: 'x', request }).catch((e) => { caught = e })}>go</button>
|
|
113
|
+
{dialog}
|
|
114
|
+
</>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
render(<P2 />)
|
|
118
|
+
fireEvent.click(screen.getByText('go'))
|
|
119
|
+
await waitFor(() => expect((caught as Error)?.message).toBe('network'))
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// itself is a thin react-query wrapper over `useApi`; the load-bearing logic
|
|
5
5
|
// is this pure merge, so that's what we test directly.
|
|
6
6
|
import { describe, it, expect } from 'vitest'
|
|
7
|
-
import { mergeAddonSettings, addonSettingsKey } from '../use-addon-settings'
|
|
7
|
+
import { mergeAddonSettings, addonSettingsKey, addonSettingsUrl } from '../use-addon-settings'
|
|
8
8
|
|
|
9
9
|
interface PosSettings extends Record<string, unknown> {
|
|
10
10
|
allowNegativeStock: boolean
|
|
@@ -72,3 +72,18 @@ describe('addonSettingsKey', () => {
|
|
|
72
72
|
expect(addonSettingsKey('inventory')).toEqual(['addon-settings', 'inventory'])
|
|
73
73
|
})
|
|
74
74
|
})
|
|
75
|
+
|
|
76
|
+
describe('branch-scoped settings (POS-1)', () => {
|
|
77
|
+
it('keys and urls carry the branch only when given', () => {
|
|
78
|
+
expect(addonSettingsKey('pos', 'b1')).toEqual(['addon-settings', 'pos', 'b1'])
|
|
79
|
+
expect(addonSettingsKey('pos', null)).toEqual(['addon-settings', 'pos'])
|
|
80
|
+
expect(addonSettingsUrl('pos')).toBe('/api/addons/pos/settings')
|
|
81
|
+
expect(addonSettingsUrl('pos', 'b 1')).toBe('/api/addons/pos/settings?branch_id=b%201')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('an org-key prefix matches every branch read (invalidation)', () => {
|
|
85
|
+
const org = addonSettingsKey('pos')
|
|
86
|
+
const branch = addonSettingsKey('pos', 'b1')
|
|
87
|
+
expect(branch.slice(0, org.length)).toEqual([...org])
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
//
|
|
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
|
-
import { useState, useEffect, useMemo, useSyncExternalStore } from 'react'
|
|
17
|
+
import { useState, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
|
|
18
18
|
import { useTranslation } from 'react-i18next'
|
|
19
19
|
import {
|
|
20
20
|
Dialog,
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
} from './field-validation-ui'
|
|
58
58
|
import { validationCatalog } from './validation-catalog'
|
|
59
59
|
import { useApi } from './api-context'
|
|
60
|
+
import { useReasonPrompt } from './reason-prompt'
|
|
60
61
|
import { buildRelationFilterParams } from './dynamic-relation-helpers'
|
|
61
62
|
import { DynamicIcon } from './dynamic-icon'
|
|
62
63
|
import { DynamicLineItems } from './dynamic-line-items'
|
|
@@ -112,6 +113,16 @@ export interface PrefillSpec {
|
|
|
112
113
|
* flow (which carries no prefill spec); only the prefilled action locks them.
|
|
113
114
|
*/
|
|
114
115
|
lock?: string[]
|
|
116
|
+
/**
|
|
117
|
+
* Key of a SIBLING action field (a dynamic_select with a `ref`) whose selected
|
|
118
|
+
* value is the id of the document the rows are seeded from. Without it the
|
|
119
|
+
* rows come from the record the action was opened on (row actions); with it
|
|
120
|
+
* a `create`-placed action — which has no record — seeds its grid as soon as
|
|
121
|
+
* the user picks the source document (e.g. a goods receipt picking its
|
|
122
|
+
* purchase order). `$prefillFromRecord` then names a one_to_many relation of
|
|
123
|
+
* the REFERENCED model. Clearing the selection empties the grid.
|
|
124
|
+
*/
|
|
125
|
+
fromField?: string
|
|
115
126
|
}
|
|
116
127
|
|
|
117
128
|
export function isPrefillSpec(v: unknown): v is PrefillSpec {
|
|
@@ -222,6 +233,64 @@ export function prefillRelationRequests(
|
|
|
222
233
|
return out
|
|
223
234
|
}
|
|
224
235
|
|
|
236
|
+
// ---- prefill lines from a document picked in a sibling field ----------------
|
|
237
|
+
//
|
|
238
|
+
// A create-placed action has no record, so `$prefillFromRecord` alone can only
|
|
239
|
+
// seed an empty grid. With `fromField` the spec follows a sibling ref field:
|
|
240
|
+
// when the user selects the source document the modal loads its relation rows
|
|
241
|
+
// and seeds the grid (see PrefillSpec.fromField).
|
|
242
|
+
|
|
243
|
+
export interface PrefillFromFieldRequest {
|
|
244
|
+
fieldKey: string
|
|
245
|
+
spec: PrefillSpec
|
|
246
|
+
/** Model key the sibling field references (its `ref`). */
|
|
247
|
+
refModel: string
|
|
248
|
+
/** Selected document id; '' when nothing is selected (grid must clear). */
|
|
249
|
+
refId: string
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function prefillFromFieldRequests(
|
|
253
|
+
fields: ActionFieldDef[] | undefined,
|
|
254
|
+
formData: Record<string, any>,
|
|
255
|
+
): PrefillFromFieldRequest[] {
|
|
256
|
+
const out: PrefillFromFieldRequest[] = []
|
|
257
|
+
if (!fields) return out
|
|
258
|
+
for (const field of fields) {
|
|
259
|
+
if (!isLineItemsField(field)) continue
|
|
260
|
+
const spec = lineItemsDefault(field)
|
|
261
|
+
if (!isPrefillSpec(spec) || !spec.fromField) continue
|
|
262
|
+
const source = fields.find((f) => f.key === spec.fromField)
|
|
263
|
+
const refModel = source ? getFieldRef(source) : undefined
|
|
264
|
+
if (!refModel) continue
|
|
265
|
+
const raw = unwrapRecordScalar(formData?.[spec.fromField])
|
|
266
|
+
const refId = raw === undefined || raw === null ? '' : String(raw)
|
|
267
|
+
out.push({ fieldKey: field.key, spec, refModel, refId })
|
|
268
|
+
}
|
|
269
|
+
return out
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// prefillFromFieldRelationRequest resolves the child-rows request for a
|
|
273
|
+
// selected document, from the referenced model's declared relations. Null when
|
|
274
|
+
// the relation is missing or not a plain one_to_many.
|
|
275
|
+
export function prefillFromFieldRelationRequest(
|
|
276
|
+
req: PrefillFromFieldRequest,
|
|
277
|
+
relations: Array<{ name?: string; kind?: string; through?: string; foreign_key?: string; scope?: Record<string, unknown> }>,
|
|
278
|
+
): { endpoint: string; params: Record<string, string | number> } | null {
|
|
279
|
+
if (!req.refId) return null
|
|
280
|
+
const rel = (relations || []).find(
|
|
281
|
+
(r) => r?.name === req.spec.$prefillFromRecord && (r.kind ?? 'one_to_many') === 'one_to_many',
|
|
282
|
+
)
|
|
283
|
+
if (!rel?.through || !rel.foreign_key) return null
|
|
284
|
+
const scope: Record<string, string> = {}
|
|
285
|
+
for (const [k, v] of Object.entries(rel.scope ?? {})) {
|
|
286
|
+
if (v !== undefined && v !== null) scope[k] = String(v)
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
endpoint: `/data/${rel.through}`,
|
|
290
|
+
params: { ...buildRelationFilterParams(rel.foreign_key, req.refId, scope), per_page: 200 },
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
225
294
|
// ---- scalar prefill from the acted-on record --------------------------------
|
|
226
295
|
//
|
|
227
296
|
// Row actions (stamp / refactura / cancel-with-reason) should open with the
|
|
@@ -728,6 +797,9 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
|
|
|
728
797
|
const { t, i18n } = useTranslation()
|
|
729
798
|
const api = useApi()
|
|
730
799
|
const [executing, setExecuting] = useState(false)
|
|
800
|
+
// Actions listed in the model's `reason_required.actions` (cancel, void…)
|
|
801
|
+
// answer 422 errors.reason without a motive; the prompt asks and retries.
|
|
802
|
+
const reasonPrompt = useReasonPrompt()
|
|
731
803
|
// `action.label` is an addon-contributed i18n key; its locale bundle loads
|
|
732
804
|
// asynchronously, so translate at render (defaultValue keeps an already
|
|
733
805
|
// localized label unchanged).
|
|
@@ -737,7 +809,13 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
|
|
|
737
809
|
setExecuting(true)
|
|
738
810
|
try {
|
|
739
811
|
const url = buildActionUrl(endpoint, model, record.id, action.key)
|
|
740
|
-
const res = await
|
|
812
|
+
const res = await reasonPrompt.run({
|
|
813
|
+
title: label,
|
|
814
|
+
description: t('actions.reason_desc', { defaultValue: 'Esta acción pide el motivo. Queda en la bitácora del documento.' }),
|
|
815
|
+
request: (reason) => api.post(url, reason ? { reason } : {}),
|
|
816
|
+
})
|
|
817
|
+
// Operator cancelled the reason prompt: nothing ran.
|
|
818
|
+
if (!res) return
|
|
741
819
|
if (res.data.success) {
|
|
742
820
|
toastServerSuccess(res.data, { t })
|
|
743
821
|
onOpenChange(false)
|
|
@@ -753,6 +831,8 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
|
|
|
753
831
|
}
|
|
754
832
|
|
|
755
833
|
return (
|
|
834
|
+
<>
|
|
835
|
+
{reasonPrompt.dialog}
|
|
756
836
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
757
837
|
<AlertDialogContent>
|
|
758
838
|
<AlertDialogHeader>
|
|
@@ -778,6 +858,7 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
|
|
|
778
858
|
</AlertDialogFooter>
|
|
779
859
|
</AlertDialogContent>
|
|
780
860
|
</AlertDialog>
|
|
861
|
+
</>
|
|
781
862
|
)
|
|
782
863
|
}
|
|
783
864
|
|
|
@@ -788,6 +869,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
788
869
|
// defaultValue keeps an already-localized string unchanged.
|
|
789
870
|
const tl = (s: string) => t(s, { defaultValue: s })
|
|
790
871
|
const api = useApi()
|
|
872
|
+
const reasonPrompt = useReasonPrompt()
|
|
791
873
|
const branchGate = useBranchCreateGate()
|
|
792
874
|
const [formData, setFormData] = useState<Record<string, any>>({})
|
|
793
875
|
const [executing, setExecuting] = useState(false)
|
|
@@ -850,6 +932,60 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
850
932
|
}
|
|
851
933
|
}, [open, action.fields, record, relations, api])
|
|
852
934
|
|
|
935
|
+
// Lines seeded from a document picked in a sibling field (PrefillSpec.fromField).
|
|
936
|
+
// Re-runs only when the picked id changes; each pick loads the referenced
|
|
937
|
+
// model's relations (cached per model for the life of the modal) and then
|
|
938
|
+
// the child rows.
|
|
939
|
+
const fromFieldReqs = useMemo(
|
|
940
|
+
() => prefillFromFieldRequests(action.fields, formData),
|
|
941
|
+
[action.fields, formData],
|
|
942
|
+
)
|
|
943
|
+
const fromFieldSig = fromFieldReqs.map((r) => `${r.fieldKey}=${r.refModel}:${r.refId}`).join('|')
|
|
944
|
+
const refRelationsCache = useRef<Map<string, Promise<any[]>>>(new Map())
|
|
945
|
+
useEffect(() => {
|
|
946
|
+
if (!open || fromFieldReqs.length === 0) return
|
|
947
|
+
let cancelled = false
|
|
948
|
+
for (const req of fromFieldReqs) {
|
|
949
|
+
if (!req.refId) {
|
|
950
|
+
setFormData((prev: Record<string, any>) =>
|
|
951
|
+
Array.isArray(prev[req.fieldKey]) && prev[req.fieldKey].length === 0
|
|
952
|
+
? prev
|
|
953
|
+
: { ...prev, [req.fieldKey]: [] },
|
|
954
|
+
)
|
|
955
|
+
continue
|
|
956
|
+
}
|
|
957
|
+
let relsPromise = refRelationsCache.current.get(req.refModel)
|
|
958
|
+
if (!relsPromise) {
|
|
959
|
+
relsPromise = api
|
|
960
|
+
.get(`/metadata/table/${req.refModel}`)
|
|
961
|
+
.then((res: any) => {
|
|
962
|
+
const rels = res?.data?.relations ?? res?.data?.data?.relations ?? []
|
|
963
|
+
return Array.isArray(rels) ? rels : []
|
|
964
|
+
})
|
|
965
|
+
.catch(() => [] as any[])
|
|
966
|
+
refRelationsCache.current.set(req.refModel, relsPromise)
|
|
967
|
+
}
|
|
968
|
+
relsPromise
|
|
969
|
+
.then((rels) => {
|
|
970
|
+
const call = prefillFromFieldRelationRequest(req, rels)
|
|
971
|
+
if (!call) return
|
|
972
|
+
return api.get(call.endpoint, { params: call.params }).then((res: any) => {
|
|
973
|
+
if (cancelled) return
|
|
974
|
+
const rows = res?.data?.data ?? res?.data ?? []
|
|
975
|
+
if (!Array.isArray(rows)) return
|
|
976
|
+
const built = buildPrefillRows(req.spec, { [req.spec.$prefillFromRecord]: rows })
|
|
977
|
+
setFormData((prev: Record<string, any>) => ({ ...prev, [req.fieldKey]: built }))
|
|
978
|
+
})
|
|
979
|
+
})
|
|
980
|
+
.catch(() => {})
|
|
981
|
+
}
|
|
982
|
+
return () => {
|
|
983
|
+
cancelled = true
|
|
984
|
+
}
|
|
985
|
+
// fromFieldSig captures every dependency of fromFieldReqs that matters.
|
|
986
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
987
|
+
}, [open, fromFieldSig, api])
|
|
988
|
+
|
|
853
989
|
const updateField = (key: string, value: any) => {
|
|
854
990
|
setFormData((prev: Record<string, any>) => ({ ...prev, [key]: value }))
|
|
855
991
|
setFieldErrors((prev) => clearFieldErrorTree(prev, key))
|
|
@@ -914,7 +1050,12 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
914
1050
|
if (branchId) payload = { ...payload, branch_id: branchId }
|
|
915
1051
|
}
|
|
916
1052
|
const url = buildActionUrl(endpoint, model, record?.id, action.key)
|
|
917
|
-
const res = await
|
|
1053
|
+
const res = await reasonPrompt.run({
|
|
1054
|
+
title: tl(action.label),
|
|
1055
|
+
description: t('actions.reason_desc', { defaultValue: 'Esta acción pide el motivo. Queda en la bitácora del documento.' }),
|
|
1056
|
+
request: (reason) => api.post(url, reason ? { ...payload, reason } : payload),
|
|
1057
|
+
})
|
|
1058
|
+
if (!res) return
|
|
918
1059
|
if (res.data.success) {
|
|
919
1060
|
toastServerSuccess(res.data, { t })
|
|
920
1061
|
onOpenChange(false)
|
|
@@ -1022,6 +1163,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
1022
1163
|
{tl(action.label)}
|
|
1023
1164
|
</Button>
|
|
1024
1165
|
</DialogFooter>
|
|
1166
|
+
{reasonPrompt.dialog}
|
|
1025
1167
|
</DialogContent>
|
|
1026
1168
|
</Dialog>
|
|
1027
1169
|
)
|
package/src/activity-diff.tsx
CHANGED
|
@@ -50,6 +50,8 @@ export interface ActivityEvent {
|
|
|
50
50
|
*/
|
|
51
51
|
changes?: Record<string, { from: unknown; to: unknown }> | null
|
|
52
52
|
summary?: string | null
|
|
53
|
+
/** Motive the operator stated for a delete / reason_required action (PER-4). */
|
|
54
|
+
reason?: string | null
|
|
53
55
|
occurred_at: string
|
|
54
56
|
}
|
|
55
57
|
|
|
@@ -204,7 +206,7 @@ export const ActivityDiff: React.FC<ActivityDiffProps> = ({
|
|
|
204
206
|
|
|
205
207
|
const variantBadge = VARIANT_BADGE[variant] ?? VARIANT_BADGE.other
|
|
206
208
|
|
|
207
|
-
if (allKeys.length === 0 && !event.summary) {
|
|
209
|
+
if (allKeys.length === 0 && !event.summary && !event.reason) {
|
|
208
210
|
return (
|
|
209
211
|
<div className={cn('text-sm text-muted-foreground italic py-1', className)}>
|
|
210
212
|
Sin campos registrados.
|
|
@@ -235,6 +237,13 @@ export const ActivityDiff: React.FC<ActivityDiffProps> = ({
|
|
|
235
237
|
)}
|
|
236
238
|
</div>
|
|
237
239
|
|
|
240
|
+
{/* Mandatory-reason motive (delete / cancel with reason_required) */}
|
|
241
|
+
{event.reason && (
|
|
242
|
+
<p className="text-sm" data-testid="activity-reason">
|
|
243
|
+
<span className="font-medium">Motivo:</span> {event.reason}
|
|
244
|
+
</p>
|
|
245
|
+
)}
|
|
246
|
+
|
|
238
247
|
{/* Summary line (if backend provided one) */}
|
|
239
248
|
{event.summary && (
|
|
240
249
|
<p className="text-sm text-muted-foreground italic">{event.summary}</p>
|
|
@@ -162,3 +162,49 @@ describe('product search', () => {
|
|
|
162
162
|
expect(line).toMatchObject({ product_id: 'v1', unit_price: 1200, tax_rate: 0.16, available: 3, description: 'Llanta X · 205/55R16' })
|
|
163
163
|
})
|
|
164
164
|
})
|
|
165
|
+
|
|
166
|
+
import { canTransitionRma, computeReturnTotals, creditNoteRelation, returnSteps, serializeReturn, validateReturnChoices, type ReturnableLine } from './return'
|
|
167
|
+
|
|
168
|
+
describe('return', () => {
|
|
169
|
+
const lines: ReturnableLine[] = [
|
|
170
|
+
{ key: 'a', product_id: 'p1', description: 'Llanta', sold: 4, alreadyReturned: 1, unit_price: 1000, tax_rate: 0.16 },
|
|
171
|
+
{ key: 'b', description: 'Servicio', sold: 1, unit_price: 200 },
|
|
172
|
+
]
|
|
173
|
+
const ch = (q: number) => ({ a: { quantity: q, condition: 'sellable' as const, destination: 'stock' as const } })
|
|
174
|
+
it('tope por devolver = vendido − ya devuelto', () => {
|
|
175
|
+
expect(validateReturnChoices(lines, ch(4), 'x').errors['a.quantity']).toBeTruthy()
|
|
176
|
+
expect(validateReturnChoices(lines, ch(3), 'x').valid).toBe(true)
|
|
177
|
+
})
|
|
178
|
+
it('exige renglón y motivo; destino coherente con la condición', () => {
|
|
179
|
+
expect(validateReturnChoices(lines, {}, 'x').valid).toBe(false)
|
|
180
|
+
expect(validateReturnChoices(lines, ch(1), '').valid).toBe(false)
|
|
181
|
+
const bad = { a: { quantity: 1, condition: 'sellable' as const, destination: 'scrap' as const } }
|
|
182
|
+
expect(validateReturnChoices(lines, bad, 'x').errors['a.destination']).toBeTruthy()
|
|
183
|
+
})
|
|
184
|
+
it('totales con IVA y relación 03/01', () => {
|
|
185
|
+
expect(computeReturnTotals(lines, ch(2)).total).toBe(2320)
|
|
186
|
+
expect(creditNoteRelation(lines, ch(2))).toBe('03')
|
|
187
|
+
expect(creditNoteRelation(lines, { b: { quantity: 1, condition: 'sellable', destination: 'stock' } })).toBe('01')
|
|
188
|
+
})
|
|
189
|
+
it('sin almacén, Recibir es un paso; con almacén no', () => {
|
|
190
|
+
expect(returnSteps({ warehouseConnected: false })).toContain('receive')
|
|
191
|
+
expect(returnSteps({ warehouseConnected: true })).not.toContain('receive')
|
|
192
|
+
})
|
|
193
|
+
it('serializeReturn omite renglones en 0 y añade NC solo si hay reembolso a NC', () => {
|
|
194
|
+
const p = serializeReturn({ lines, choices: ch(2), reason: ' defecto ', received: true, refund: [{ kind: 'credit_note', amount: 2320 }] })
|
|
195
|
+
expect(p.lines).toHaveLength(1)
|
|
196
|
+
expect(p.reason).toBe('defecto')
|
|
197
|
+
expect(p.credit_note).toEqual({ relation: '03' })
|
|
198
|
+
expect(serializeReturn({ lines, choices: ch(2), reason: 'x', received: true, refund: [{ kind: 'cash', amount: 2320 }] }).credit_note).toBeUndefined()
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe('rma states', () => {
|
|
203
|
+
it('flujo feliz y motivo obligatorio al rechazar/cancelar', () => {
|
|
204
|
+
expect(canTransitionRma('draft', 'authorized').ok).toBe(true)
|
|
205
|
+
expect(canTransitionRma('received', 'settled').ok).toBe(true)
|
|
206
|
+
expect(canTransitionRma('authorized', 'rejected').ok).toBe(false)
|
|
207
|
+
expect(canTransitionRma('authorized', 'rejected', 'no aplica').ok).toBe(true)
|
|
208
|
+
expect(canTransitionRma('closed', 'draft').ok).toBe(false)
|
|
209
|
+
})
|
|
210
|
+
})
|
package/src/business/catalog.ts
CHANGED
|
@@ -57,4 +57,12 @@ export const BUSINESS_COMPONENTS: readonly BusinessComponentSpec[] = [
|
|
|
57
57
|
usedBy: ['ReturnWizard', 'Cancelación de apartado', 'Anticipos'],
|
|
58
58
|
helpers: ['validateRefund', 'singleDestination', 'DEFAULT_REFUND_DESTINATIONS'],
|
|
59
59
|
},
|
|
60
|
+
{
|
|
61
|
+
name: 'ReturnWizard',
|
|
62
|
+
value: 'lines: ReturnableLine[]',
|
|
63
|
+
events: ['onSubmit', 'onCancel'],
|
|
64
|
+
permissions: [],
|
|
65
|
+
usedBy: ['POS', 'Pedido', 'Factura', 'RMA', 'OT'],
|
|
66
|
+
helpers: ['computeReturnTotals', 'validateReturnChoices', 'creditNoteRelation', 'returnSteps', 'serializeReturn'],
|
|
67
|
+
},
|
|
60
68
|
] as const
|