@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/action-modal-dispatcher.d.ts +29 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +115 -6
  5. package/dist/activity-diff.d.ts +2 -0
  6. package/dist/activity-diff.d.ts.map +1 -1
  7. package/dist/activity-diff.js +2 -2
  8. package/dist/business/catalog.d.ts.map +1 -1
  9. package/dist/business/catalog.js +8 -0
  10. package/dist/business/index.d.ts +2 -0
  11. package/dist/business/index.d.ts.map +1 -1
  12. package/dist/business/index.js +2 -0
  13. package/dist/business/refund-destination.js +1 -1
  14. package/dist/business/refund.d.ts +1 -1
  15. package/dist/business/refund.d.ts.map +1 -1
  16. package/dist/business/refund.js +1 -0
  17. package/dist/business/return-wizard.d.ts +23 -0
  18. package/dist/business/return-wizard.d.ts.map +1 -0
  19. package/dist/business/return-wizard.js +59 -0
  20. package/dist/business/return.d.ts +90 -0
  21. package/dist/business/return.d.ts.map +1 -0
  22. package/dist/business/return.js +112 -0
  23. package/dist/dynamic-columns.d.ts.map +1 -1
  24. package/dist/dynamic-columns.js +3 -1
  25. package/dist/dynamic-relation.d.ts +4 -0
  26. package/dist/dynamic-relation.d.ts.map +1 -1
  27. package/dist/dynamic-relation.js +10 -1
  28. package/dist/dynamic-row-actions.d.ts.map +1 -1
  29. package/dist/dynamic-row-actions.js +14 -2
  30. package/dist/dynamic-table.d.ts.map +1 -1
  31. package/dist/dynamic-table.js +28 -3
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -0
  35. package/dist/reason-prompt.d.ts +59 -0
  36. package/dist/reason-prompt.d.ts.map +1 -0
  37. package/dist/reason-prompt.js +111 -0
  38. package/dist/use-addon-settings.d.ts +17 -3
  39. package/dist/use-addon-settings.d.ts.map +1 -1
  40. package/dist/use-addon-settings.js +20 -8
  41. package/package.json +1 -1
  42. package/src/__tests__/prefill-from-record.test.ts +69 -0
  43. package/src/__tests__/reason-prompt.test.tsx +121 -0
  44. package/src/__tests__/use-addon-settings.test.ts +16 -1
  45. package/src/action-modal-dispatcher.tsx +145 -3
  46. package/src/activity-diff.tsx +10 -1
  47. package/src/business/business.test.ts +46 -0
  48. package/src/business/catalog.ts +8 -0
  49. package/src/business/index.ts +2 -0
  50. package/src/business/refund-destination.tsx +2 -2
  51. package/src/business/refund.ts +2 -1
  52. package/src/business/return-wizard.tsx +223 -0
  53. package/src/business/return.ts +181 -0
  54. package/src/dynamic-columns.tsx +5 -1
  55. package/src/dynamic-relation.tsx +14 -1
  56. package/src/dynamic-row-actions.tsx +13 -1
  57. package/src/dynamic-table.tsx +22 -2
  58. package/src/index.ts +10 -0
  59. package/src/reason-prompt.tsx +247 -0
  60. 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(`/api/addons/${addonKey}/settings`)
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(`/api/addons/${addonKey}/settings`, values)
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
  })