@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
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Mandatory reason on destructive operations (PER-4).
|
|
2
|
+
//
|
|
3
|
+
// A manifest model declares `reason_required` (delete and/or actions such as
|
|
4
|
+
// cancel). The kernel then answers a DELETE / action WITHOUT a reason with
|
|
5
|
+
// 422 `errors.reason: [{code: "required" | "min", params: {min}}]`. Instead of
|
|
6
|
+
// every screen carrying its own "motivo" prompt, `useReasonPrompt().run()` wraps
|
|
7
|
+
// the request: it tries it, and when the server says a reason is needed it asks
|
|
8
|
+
// once (a dialog) and retries with the reason attached. Screens that already know
|
|
9
|
+
// they need one can pass `reason` up front through the same helper.
|
|
10
|
+
//
|
|
11
|
+
// The reason is stamped on the canonical event by the kernel, so the activity log
|
|
12
|
+
// (RecordHistory) shows who removed/cancelled what, when, and why.
|
|
13
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
14
|
+
import { useTranslation } from 'react-i18next'
|
|
15
|
+
import { Loader2 } from 'lucide-react'
|
|
16
|
+
import {
|
|
17
|
+
Button,
|
|
18
|
+
Dialog,
|
|
19
|
+
DialogContent,
|
|
20
|
+
DialogDescription,
|
|
21
|
+
DialogFooter,
|
|
22
|
+
DialogHeader,
|
|
23
|
+
DialogTitle,
|
|
24
|
+
Textarea,
|
|
25
|
+
} from '@asteby/metacore-ui/primitives'
|
|
26
|
+
|
|
27
|
+
/** What the server told us about the missing reason. */
|
|
28
|
+
export interface ReasonRequiredInfo {
|
|
29
|
+
/** Minimum trimmed length the policy demands (default 3). */
|
|
30
|
+
min: number
|
|
31
|
+
/** True when a reason WAS sent but was too short. */
|
|
32
|
+
tooShort: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_REASON_MIN = 3
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Detects the kernel's mandatory-reason refusal in an axios error (or a bare
|
|
39
|
+
* response body): HTTP 422 with `errors.reason[0].code` of `required` | `min`.
|
|
40
|
+
* Returns null for any other error.
|
|
41
|
+
*/
|
|
42
|
+
export function reasonRequiredInfo(err: unknown): ReasonRequiredInfo | null {
|
|
43
|
+
const data = (err as { response?: { data?: unknown } } | undefined)?.response?.data ?? err
|
|
44
|
+
const issues = (data as { errors?: { reason?: unknown } } | undefined)?.errors?.reason
|
|
45
|
+
if (!Array.isArray(issues) || issues.length === 0) return null
|
|
46
|
+
const first = issues[0] as { code?: string; params?: { min?: number } } | string
|
|
47
|
+
const code = typeof first === 'string' ? first : first?.code
|
|
48
|
+
if (code !== 'required' && code !== 'min') return null
|
|
49
|
+
const min = typeof first === 'object' && typeof first?.params?.min === 'number' ? first.params.min : DEFAULT_REASON_MIN
|
|
50
|
+
return { min, tooShort: code === 'min' }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ReasonPromptDialogProps {
|
|
54
|
+
open: boolean
|
|
55
|
+
title: string
|
|
56
|
+
description?: React.ReactNode
|
|
57
|
+
/** Minimum trimmed length; the submit button stays disabled below it. */
|
|
58
|
+
min?: number
|
|
59
|
+
/** Label of the confirm button (default "Continuar"). */
|
|
60
|
+
confirmLabel?: string
|
|
61
|
+
/** Style the confirm button as destructive (delete / cancel). */
|
|
62
|
+
destructive?: boolean
|
|
63
|
+
/** Inline error from a previous attempt. */
|
|
64
|
+
error?: string | null
|
|
65
|
+
busy?: boolean
|
|
66
|
+
onSubmit: (reason: string) => void
|
|
67
|
+
onCancel: () => void
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function ReasonPromptDialog({
|
|
71
|
+
open,
|
|
72
|
+
title,
|
|
73
|
+
description,
|
|
74
|
+
min = DEFAULT_REASON_MIN,
|
|
75
|
+
confirmLabel,
|
|
76
|
+
destructive = true,
|
|
77
|
+
error,
|
|
78
|
+
busy,
|
|
79
|
+
onSubmit,
|
|
80
|
+
onCancel,
|
|
81
|
+
}: ReasonPromptDialogProps) {
|
|
82
|
+
const { t } = useTranslation()
|
|
83
|
+
const [reason, setReason] = useState('')
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (open) setReason('')
|
|
87
|
+
}, [open])
|
|
88
|
+
|
|
89
|
+
const ready = reason.trim().length >= min
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<Dialog open={open} onOpenChange={(o: boolean) => !o && !busy && onCancel()}>
|
|
93
|
+
<DialogContent className="sm:max-w-md">
|
|
94
|
+
<DialogHeader>
|
|
95
|
+
<DialogTitle>{title}</DialogTitle>
|
|
96
|
+
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
|
97
|
+
</DialogHeader>
|
|
98
|
+
<div className="space-y-2">
|
|
99
|
+
<label htmlFor="mc-reason" className="text-sm font-medium">
|
|
100
|
+
{t('reason.label', { defaultValue: 'Motivo' })}
|
|
101
|
+
</label>
|
|
102
|
+
<Textarea
|
|
103
|
+
id="mc-reason"
|
|
104
|
+
autoFocus
|
|
105
|
+
rows={3}
|
|
106
|
+
value={reason}
|
|
107
|
+
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setReason(e.target.value)}
|
|
108
|
+
placeholder={t('reason.placeholder', { defaultValue: 'Explica por qué. Queda en la bitácora.' })}
|
|
109
|
+
/>
|
|
110
|
+
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
|
111
|
+
{!error && reason.length > 0 && !ready ? (
|
|
112
|
+
<p className="text-muted-foreground text-xs">
|
|
113
|
+
{t('reason.min', { min, defaultValue: 'Escribe al menos {{min}} caracteres.' })}
|
|
114
|
+
</p>
|
|
115
|
+
) : null}
|
|
116
|
+
</div>
|
|
117
|
+
<DialogFooter>
|
|
118
|
+
<Button variant="outline" onClick={onCancel} disabled={busy}>
|
|
119
|
+
{t('common.cancel', { defaultValue: 'Cancelar' })}
|
|
120
|
+
</Button>
|
|
121
|
+
<Button
|
|
122
|
+
variant={destructive ? 'destructive' : 'default'}
|
|
123
|
+
disabled={!ready || busy}
|
|
124
|
+
onClick={() => onSubmit(reason.trim())}
|
|
125
|
+
>
|
|
126
|
+
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
|
127
|
+
{confirmLabel ?? t('reason.confirm', { defaultValue: 'Continuar' })}
|
|
128
|
+
</Button>
|
|
129
|
+
</DialogFooter>
|
|
130
|
+
</DialogContent>
|
|
131
|
+
</Dialog>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface RunWithReasonOptions<T> {
|
|
136
|
+
/** Dialog headline, e.g. "Eliminar registro". */
|
|
137
|
+
title: string
|
|
138
|
+
description?: React.ReactNode
|
|
139
|
+
confirmLabel?: string
|
|
140
|
+
destructive?: boolean
|
|
141
|
+
/**
|
|
142
|
+
* Performs the request. Called first WITHOUT a reason; when the server
|
|
143
|
+
* demands one it is called again with the operator's reason. Throw (axios
|
|
144
|
+
* style) on failure.
|
|
145
|
+
*/
|
|
146
|
+
request: (reason?: string) => Promise<T>
|
|
147
|
+
/** A reason already collected (bulk operations reuse one for every row). */
|
|
148
|
+
reason?: string
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface ReasonPromptApi {
|
|
152
|
+
/**
|
|
153
|
+
* Resolves the request's result, or `undefined` when the operator cancelled
|
|
154
|
+
* the reason prompt. Any error that is NOT a reason refusal is rethrown.
|
|
155
|
+
*/
|
|
156
|
+
run: <T>(opts: RunWithReasonOptions<T>) => Promise<T | undefined>
|
|
157
|
+
/** Render once near the caller: the dialog the helper opens. */
|
|
158
|
+
dialog: React.ReactNode
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface PendingReason {
|
|
162
|
+
opts: RunWithReasonOptions<unknown>
|
|
163
|
+
info: ReasonRequiredInfo
|
|
164
|
+
resolve: (v: unknown) => void
|
|
165
|
+
reject: (e: unknown) => void
|
|
166
|
+
error: string | null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** See the module comment. Render `dialog` somewhere in the caller's tree. */
|
|
170
|
+
export function useReasonPrompt(): ReasonPromptApi {
|
|
171
|
+
const { t } = useTranslation()
|
|
172
|
+
const [pending, setPending] = useState<PendingReason | null>(null)
|
|
173
|
+
const [busy, setBusy] = useState(false)
|
|
174
|
+
const pendingRef = useRef<PendingReason | null>(null)
|
|
175
|
+
pendingRef.current = pending
|
|
176
|
+
|
|
177
|
+
const run = useCallback(<T,>(opts: RunWithReasonOptions<T>): Promise<T | undefined> => {
|
|
178
|
+
return new Promise<T | undefined>((resolve, reject) => {
|
|
179
|
+
opts
|
|
180
|
+
.request(opts.reason)
|
|
181
|
+
.then((v) => resolve(v))
|
|
182
|
+
.catch((err) => {
|
|
183
|
+
const info = reasonRequiredInfo(err)
|
|
184
|
+
if (!info) return reject(err)
|
|
185
|
+
setPending({
|
|
186
|
+
opts: opts as RunWithReasonOptions<unknown>,
|
|
187
|
+
info,
|
|
188
|
+
resolve: resolve as (v: unknown) => void,
|
|
189
|
+
reject,
|
|
190
|
+
error: null,
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
}, [])
|
|
195
|
+
|
|
196
|
+
const submit = async (reason: string) => {
|
|
197
|
+
const p = pendingRef.current
|
|
198
|
+
if (!p) return
|
|
199
|
+
setBusy(true)
|
|
200
|
+
try {
|
|
201
|
+
const v = await p.opts.request(reason)
|
|
202
|
+
setPending(null)
|
|
203
|
+
p.resolve(v)
|
|
204
|
+
} catch (err) {
|
|
205
|
+
const info = reasonRequiredInfo(err)
|
|
206
|
+
if (info) {
|
|
207
|
+
// Server still not satisfied (a longer minimum): stay open, say why.
|
|
208
|
+
setPending({
|
|
209
|
+
...p,
|
|
210
|
+
info,
|
|
211
|
+
error: t('reason.min', {
|
|
212
|
+
min: info.min,
|
|
213
|
+
defaultValue: 'Escribe al menos {{min}} caracteres.',
|
|
214
|
+
}),
|
|
215
|
+
})
|
|
216
|
+
} else {
|
|
217
|
+
setPending(null)
|
|
218
|
+
p.reject(err)
|
|
219
|
+
}
|
|
220
|
+
} finally {
|
|
221
|
+
setBusy(false)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const cancel = () => {
|
|
226
|
+
const p = pendingRef.current
|
|
227
|
+
setPending(null)
|
|
228
|
+
p?.resolve(undefined)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const dialog = (
|
|
232
|
+
<ReasonPromptDialog
|
|
233
|
+
open={pending !== null}
|
|
234
|
+
title={pending?.opts.title ?? ''}
|
|
235
|
+
description={pending?.opts.description}
|
|
236
|
+
min={pending?.info.min}
|
|
237
|
+
confirmLabel={pending?.opts.confirmLabel}
|
|
238
|
+
destructive={pending?.opts.destructive}
|
|
239
|
+
error={pending?.error}
|
|
240
|
+
busy={busy}
|
|
241
|
+
onSubmit={submit}
|
|
242
|
+
onCancel={cancel}
|
|
243
|
+
/>
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return { run, dialog }
|
|
247
|
+
}
|
|
@@ -27,9 +27,19 @@ import {
|
|
|
27
27
|
} from '@tanstack/react-query'
|
|
28
28
|
import { useApi } from './api-context'
|
|
29
29
|
|
|
30
|
-
/** Stable react-query key for an addon's per-org settings. */
|
|
31
|
-
export function addonSettingsKey(addonKey: string) {
|
|
32
|
-
return ['addon-settings', addonKey] as const
|
|
30
|
+
/** Stable react-query key for an addon's per-org settings (per branch when given). */
|
|
31
|
+
export function addonSettingsKey(addonKey: string, branchId?: string | null) {
|
|
32
|
+
return branchId ? (['addon-settings', addonKey, branchId] as const) : (['addon-settings', addonKey] as const)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Settings URL. With a branch, the host answers the settings the addon runs
|
|
37
|
+
* with there: org values with each `scope: branch` key replaced by the branch's
|
|
38
|
+
* override (POS-1). Without one, the org values (legacy behaviour).
|
|
39
|
+
*/
|
|
40
|
+
export function addonSettingsUrl(addonKey: string, branchId?: string | null): string {
|
|
41
|
+
const base = `/api/addons/${addonKey}/settings`
|
|
42
|
+
return branchId ? `${base}?branch_id=${encodeURIComponent(branchId)}` : base
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
/**
|
|
@@ -63,6 +73,12 @@ export interface UseAddonSettingsOptions<T> {
|
|
|
63
73
|
staleTime?: number
|
|
64
74
|
/** Defer fetching (e.g. until the addon key is known). */
|
|
65
75
|
enabled?: boolean
|
|
76
|
+
/**
|
|
77
|
+
* Read the settings as one branch sees them (manifest `scope: branch` keys
|
|
78
|
+
* resolve to the branch override, else the org value). Omit for org values.
|
|
79
|
+
* A terminal passes its active branch here.
|
|
80
|
+
*/
|
|
81
|
+
branchId?: string | null
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
export interface UseAddonSettingsResult<T> {
|
|
@@ -92,12 +108,12 @@ export function useAddonSettings<T extends Record<string, unknown> = Record<stri
|
|
|
92
108
|
opts: UseAddonSettingsOptions<T> = {},
|
|
93
109
|
): UseAddonSettingsResult<T> {
|
|
94
110
|
const api = useApi()
|
|
95
|
-
const { defaults, staleTime, enabled } = opts
|
|
111
|
+
const { defaults, staleTime, enabled, branchId } = opts
|
|
96
112
|
|
|
97
113
|
const query = useQuery<Partial<T>>({
|
|
98
|
-
queryKey: addonSettingsKey(addonKey),
|
|
114
|
+
queryKey: addonSettingsKey(addonKey, branchId),
|
|
99
115
|
queryFn: async () => {
|
|
100
|
-
const res = await api.get(
|
|
116
|
+
const res = await api.get(addonSettingsUrl(addonKey, branchId))
|
|
101
117
|
const body = (res as { data: any }).data
|
|
102
118
|
// Host envelope: { success, data: { <key>: <value> } }.
|
|
103
119
|
const values = body?.success ? body.data : body
|
|
@@ -134,18 +150,21 @@ export function useAddonSettings<T extends Record<string, unknown> = Record<stri
|
|
|
134
150
|
*/
|
|
135
151
|
export function useUpdateAddonSettings<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
136
152
|
addonKey: string,
|
|
153
|
+
/** Write branch overrides (only `scope: branch` keys; `null` clears one) instead of org values. */
|
|
154
|
+
branchId?: string | null,
|
|
137
155
|
) {
|
|
138
156
|
const api = useApi()
|
|
139
157
|
const qc = useQueryClient()
|
|
140
158
|
|
|
141
159
|
return useMutation<Partial<T>, Error, Partial<T>>({
|
|
142
160
|
mutationFn: async (values) => {
|
|
143
|
-
const res = await api.put(
|
|
161
|
+
const res = await api.put(addonSettingsUrl(addonKey, branchId), values)
|
|
144
162
|
const body = (res as { data: any }).data
|
|
145
163
|
const saved = body?.success ? body.data : body
|
|
146
164
|
return (saved ?? values) as Partial<T>
|
|
147
165
|
},
|
|
148
166
|
onSuccess: () => {
|
|
167
|
+
// Prefix match: refreshes the org read and every branch read.
|
|
149
168
|
void qc.invalidateQueries({ queryKey: addonSettingsKey(addonKey) })
|
|
150
169
|
},
|
|
151
170
|
})
|