@asteby/metacore-runtime-react 45.0.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 (177) hide show
  1. package/CHANGELOG.md +29 -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/approval-events.d.ts +16 -0
  9. package/dist/approval-events.d.ts.map +1 -0
  10. package/dist/approval-events.js +27 -0
  11. package/dist/approval-gate.d.ts +39 -0
  12. package/dist/approval-gate.d.ts.map +1 -0
  13. package/dist/approval-gate.js +110 -0
  14. package/dist/approval-pin-dialog.d.ts +25 -0
  15. package/dist/approval-pin-dialog.d.ts.map +1 -0
  16. package/dist/approval-pin-dialog.js +66 -0
  17. package/dist/business/catalog.d.ts +16 -0
  18. package/dist/business/catalog.d.ts.map +1 -0
  19. package/dist/business/catalog.js +50 -0
  20. package/dist/business/customer-picker.d.ts +39 -0
  21. package/dist/business/customer-picker.d.ts.map +1 -0
  22. package/dist/business/customer-picker.js +51 -0
  23. package/dist/business/feedback.d.ts +72 -0
  24. package/dist/business/feedback.d.ts.map +1 -0
  25. package/dist/business/feedback.js +52 -0
  26. package/dist/business/field-errors.d.ts +26 -0
  27. package/dist/business/field-errors.d.ts.map +1 -0
  28. package/dist/business/field-errors.js +59 -0
  29. package/dist/business/format.d.ts +49 -0
  30. package/dist/business/format.d.ts.map +1 -0
  31. package/dist/business/format.js +128 -0
  32. package/dist/business/index.d.ts +16 -0
  33. package/dist/business/index.d.ts.map +1 -0
  34. package/dist/business/index.js +15 -0
  35. package/dist/business/line-items-editor.d.ts +25 -0
  36. package/dist/business/line-items-editor.d.ts.map +1 -0
  37. package/dist/business/line-items-editor.js +36 -0
  38. package/dist/business/line-items.d.ts +69 -0
  39. package/dist/business/line-items.d.ts.map +1 -0
  40. package/dist/business/line-items.js +133 -0
  41. package/dist/business/payment-capture.d.ts +21 -0
  42. package/dist/business/payment-capture.d.ts.map +1 -0
  43. package/dist/business/payment-capture.js +24 -0
  44. package/dist/business/payment.d.ts +61 -0
  45. package/dist/business/payment.d.ts.map +1 -0
  46. package/dist/business/payment.js +74 -0
  47. package/dist/business/product-picker.d.ts +24 -0
  48. package/dist/business/product-picker.d.ts.map +1 -0
  49. package/dist/business/product-picker.js +48 -0
  50. package/dist/business/product-search.d.ts +70 -0
  51. package/dist/business/product-search.d.ts.map +1 -0
  52. package/dist/business/product-search.js +51 -0
  53. package/dist/business/refund-destination.d.ts +19 -0
  54. package/dist/business/refund-destination.d.ts.map +1 -0
  55. package/dist/business/refund-destination.js +31 -0
  56. package/dist/business/refund.d.ts +29 -0
  57. package/dist/business/refund.d.ts.map +1 -0
  58. package/dist/business/refund.js +44 -0
  59. package/dist/business/return-wizard.d.ts +23 -0
  60. package/dist/business/return-wizard.d.ts.map +1 -0
  61. package/dist/business/return-wizard.js +59 -0
  62. package/dist/business/return.d.ts +90 -0
  63. package/dist/business/return.d.ts.map +1 -0
  64. package/dist/business/return.js +112 -0
  65. package/dist/business/use-async-search.d.ts +10 -0
  66. package/dist/business/use-async-search.d.ts.map +1 -0
  67. package/dist/business/use-async-search.js +38 -0
  68. package/dist/document/action-bar.d.ts +12 -0
  69. package/dist/document/action-bar.d.ts.map +1 -0
  70. package/dist/document/action-bar.js +39 -0
  71. package/dist/document/cancel-with-reason.d.ts +47 -0
  72. package/dist/document/cancel-with-reason.d.ts.map +1 -0
  73. package/dist/document/cancel-with-reason.js +52 -0
  74. package/dist/document/document-header.d.ts +41 -0
  75. package/dist/document/document-header.d.ts.map +1 -0
  76. package/dist/document/document-header.js +30 -0
  77. package/dist/document/document-page.d.ts +37 -0
  78. package/dist/document/document-page.d.ts.map +1 -0
  79. package/dist/document/document-page.js +137 -0
  80. package/dist/document/document-timeline.d.ts +20 -0
  81. package/dist/document/document-timeline.d.ts.map +1 -0
  82. package/dist/document/document-timeline.js +26 -0
  83. package/dist/document/evaluate.d.ts +31 -0
  84. package/dist/document/evaluate.d.ts.map +1 -0
  85. package/dist/document/evaluate.js +170 -0
  86. package/dist/document/format.d.ts +9 -0
  87. package/dist/document/format.d.ts.map +1 -0
  88. package/dist/document/format.js +52 -0
  89. package/dist/document/index.d.ts +13 -0
  90. package/dist/document/index.d.ts.map +1 -0
  91. package/dist/document/index.js +12 -0
  92. package/dist/document/registry.d.ts +20 -0
  93. package/dist/document/registry.d.ts.map +1 -0
  94. package/dist/document/registry.js +20 -0
  95. package/dist/document/smart-buttons.d.ts +16 -0
  96. package/dist/document/smart-buttons.d.ts.map +1 -0
  97. package/dist/document/smart-buttons.js +16 -0
  98. package/dist/document/status-badge.d.ts +16 -0
  99. package/dist/document/status-badge.d.ts.map +1 -0
  100. package/dist/document/status-badge.js +23 -0
  101. package/dist/document/types.d.ts +196 -0
  102. package/dist/document/types.d.ts.map +1 -0
  103. package/dist/document/types.js +1 -0
  104. package/dist/document/use-document-sources.d.ts +14 -0
  105. package/dist/document/use-document-sources.d.ts.map +1 -0
  106. package/dist/document/use-document-sources.js +72 -0
  107. package/dist/dynamic-columns.d.ts.map +1 -1
  108. package/dist/dynamic-columns.js +3 -1
  109. package/dist/dynamic-relation.d.ts +4 -0
  110. package/dist/dynamic-relation.d.ts.map +1 -1
  111. package/dist/dynamic-relation.js +10 -1
  112. package/dist/dynamic-row-actions.d.ts.map +1 -1
  113. package/dist/dynamic-row-actions.js +14 -2
  114. package/dist/dynamic-table.d.ts.map +1 -1
  115. package/dist/dynamic-table.js +28 -3
  116. package/dist/index.d.ts +5 -0
  117. package/dist/index.d.ts.map +1 -1
  118. package/dist/index.js +6 -0
  119. package/dist/reason-prompt.d.ts +59 -0
  120. package/dist/reason-prompt.d.ts.map +1 -0
  121. package/dist/reason-prompt.js +111 -0
  122. package/dist/server-error.d.ts.map +1 -1
  123. package/dist/server-error.js +19 -0
  124. package/dist/use-addon-settings.d.ts +17 -3
  125. package/dist/use-addon-settings.d.ts.map +1 -1
  126. package/dist/use-addon-settings.js +20 -8
  127. package/package.json +1 -1
  128. package/src/__tests__/approval-gate.test.tsx +134 -0
  129. package/src/__tests__/prefill-from-record.test.ts +69 -0
  130. package/src/__tests__/reason-prompt.test.tsx +121 -0
  131. package/src/__tests__/use-addon-settings.test.ts +16 -1
  132. package/src/action-modal-dispatcher.tsx +145 -3
  133. package/src/activity-diff.tsx +10 -1
  134. package/src/approval-events.ts +39 -0
  135. package/src/approval-gate.tsx +180 -0
  136. package/src/approval-pin-dialog.tsx +162 -0
  137. package/src/business/business.test.ts +210 -0
  138. package/src/business/catalog.ts +68 -0
  139. package/src/business/customer-picker.tsx +201 -0
  140. package/src/business/feedback.tsx +141 -0
  141. package/src/business/field-errors.ts +92 -0
  142. package/src/business/format.ts +151 -0
  143. package/src/business/index.ts +15 -0
  144. package/src/business/line-items-editor.tsx +259 -0
  145. package/src/business/line-items.ts +190 -0
  146. package/src/business/payment-capture.tsx +158 -0
  147. package/src/business/payment.ts +125 -0
  148. package/src/business/product-picker.tsx +171 -0
  149. package/src/business/product-search.ts +108 -0
  150. package/src/business/refund-destination.tsx +121 -0
  151. package/src/business/refund.ts +72 -0
  152. package/src/business/return-wizard.tsx +223 -0
  153. package/src/business/return.ts +181 -0
  154. package/src/business/use-async-search.ts +43 -0
  155. package/src/document/__tests__/document-page.test.tsx +107 -0
  156. package/src/document/__tests__/evaluate.test.ts +140 -0
  157. package/src/document/action-bar.tsx +140 -0
  158. package/src/document/cancel-with-reason.tsx +214 -0
  159. package/src/document/document-header.tsx +140 -0
  160. package/src/document/document-page.tsx +268 -0
  161. package/src/document/document-timeline.tsx +66 -0
  162. package/src/document/evaluate.ts +180 -0
  163. package/src/document/format.ts +59 -0
  164. package/src/document/index.ts +28 -0
  165. package/src/document/registry.ts +39 -0
  166. package/src/document/smart-buttons.tsx +54 -0
  167. package/src/document/status-badge.tsx +44 -0
  168. package/src/document/types.ts +203 -0
  169. package/src/document/use-document-sources.ts +91 -0
  170. package/src/dynamic-columns.tsx +5 -1
  171. package/src/dynamic-relation.tsx +14 -1
  172. package/src/dynamic-row-actions.tsx +13 -1
  173. package/src/dynamic-table.tsx +22 -2
  174. package/src/index.ts +33 -0
  175. package/src/reason-prompt.tsx +247 -0
  176. package/src/server-error.ts +20 -0
  177. package/src/use-addon-settings.ts +26 -7
package/dist/index.js CHANGED
@@ -41,6 +41,9 @@ export { AddonLayoutProvider, useAddonLayout, useAddonLayoutControl, useDeclareA
41
41
  export * from './slot';
42
42
  export * from './capability-gate';
43
43
  export { PermissionsProvider, useCan, usePermissionsActive, makeCan, capabilityForActionKey, modelCapability, gateTableMetadata, resolveRowActions, } from './permissions-context';
44
+ export { ApprovalPinDialog, approvalErrorMessage, } from './approval-pin-dialog';
45
+ export { ApprovalGateProvider, useApprovalGate, approvalCapability, approvalRequiredInfo, approvalGateAvailable, APPROVAL_PARKED_EVENT, APPROVAL_DECIDED_EVENT, } from './approval-gate';
46
+ export { ReasonPromptDialog, useReasonPrompt, reasonRequiredInfo, DEFAULT_REASON_MIN, } from './reason-prompt';
44
47
  export { useDynamicRowActions, } from './dynamic-row-actions';
45
48
  export { resolveRequiresAddon, setAddonInstallHandler, getAddonInstallHandler, requiresAddonName, useRequiresAddonLabel, RequiresAddonLock, RequiresAddonDialog, } from './requires-addon';
46
49
  export { ColorPickerField, DEFAULT_ROLE_COLOR, normalizeHex } from './color-picker-field';
@@ -48,6 +51,7 @@ export { PermissionsManager, moduleActionCapability, moduleCapabilities, granted
48
51
  export { applySidebarLayout, collectFolderKeys, folderAccessCapability, coreRefKey, addonModelRefKey, addonUrlRefKey, urlRefKey, leafRefFor, normalizeNavPath, slugifyFolderKey, isValidFolderKey, layoutTreeFromNavGroups, } from './apply-sidebar-layout';
49
52
  export { SidebarLayoutManager, } from './sidebar-layout-manager';
50
53
  export * from './org-runtime-context';
54
+ export * from './business';
51
55
  export * from './org-runtime-provider';
52
56
  export * from './navigation-builder';
53
57
  export * from './i18n-provider';
@@ -99,3 +103,5 @@ export { WidgetCard, DeltaChip, WidgetEmpty, WidgetError, } from './widgets/widg
99
103
  export { formatWidgetValue, formatAxisTick, formatDelta, accentClasses, paletteColor, CHART_PALETTE, } from './widgets/widget-format';
100
104
  export { AssistInterview, AssistCardView } from './assist-interview';
101
105
  export { useAttributeClasses, resolveAttributeClasses, resetAttributeClassCache, } from './attribute-classes';
106
+ // Página de documento (benchmark §5/§6): cabecera, badges, acciones por estado, botones inteligentes.
107
+ export * from './document';
@@ -0,0 +1,59 @@
1
+ import React from 'react';
2
+ /** What the server told us about the missing reason. */
3
+ export interface ReasonRequiredInfo {
4
+ /** Minimum trimmed length the policy demands (default 3). */
5
+ min: number;
6
+ /** True when a reason WAS sent but was too short. */
7
+ tooShort: boolean;
8
+ }
9
+ export declare const DEFAULT_REASON_MIN = 3;
10
+ /**
11
+ * Detects the kernel's mandatory-reason refusal in an axios error (or a bare
12
+ * response body): HTTP 422 with `errors.reason[0].code` of `required` | `min`.
13
+ * Returns null for any other error.
14
+ */
15
+ export declare function reasonRequiredInfo(err: unknown): ReasonRequiredInfo | null;
16
+ export interface ReasonPromptDialogProps {
17
+ open: boolean;
18
+ title: string;
19
+ description?: React.ReactNode;
20
+ /** Minimum trimmed length; the submit button stays disabled below it. */
21
+ min?: number;
22
+ /** Label of the confirm button (default "Continuar"). */
23
+ confirmLabel?: string;
24
+ /** Style the confirm button as destructive (delete / cancel). */
25
+ destructive?: boolean;
26
+ /** Inline error from a previous attempt. */
27
+ error?: string | null;
28
+ busy?: boolean;
29
+ onSubmit: (reason: string) => void;
30
+ onCancel: () => void;
31
+ }
32
+ export declare function ReasonPromptDialog({ open, title, description, min, confirmLabel, destructive, error, busy, onSubmit, onCancel, }: ReasonPromptDialogProps): React.JSX.Element;
33
+ export interface RunWithReasonOptions<T> {
34
+ /** Dialog headline, e.g. "Eliminar registro". */
35
+ title: string;
36
+ description?: React.ReactNode;
37
+ confirmLabel?: string;
38
+ destructive?: boolean;
39
+ /**
40
+ * Performs the request. Called first WITHOUT a reason; when the server
41
+ * demands one it is called again with the operator's reason. Throw (axios
42
+ * style) on failure.
43
+ */
44
+ request: (reason?: string) => Promise<T>;
45
+ /** A reason already collected (bulk operations reuse one for every row). */
46
+ reason?: string;
47
+ }
48
+ export interface ReasonPromptApi {
49
+ /**
50
+ * Resolves the request's result, or `undefined` when the operator cancelled
51
+ * the reason prompt. Any error that is NOT a reason refusal is rethrown.
52
+ */
53
+ run: <T>(opts: RunWithReasonOptions<T>) => Promise<T | undefined>;
54
+ /** Render once near the caller: the dialog the helper opens. */
55
+ dialog: React.ReactNode;
56
+ }
57
+ /** See the module comment. Render `dialog` somewhere in the caller's tree. */
58
+ export declare function useReasonPrompt(): ReasonPromptApi;
59
+ //# sourceMappingURL=reason-prompt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reason-prompt.d.ts","sourceRoot":"","sources":["../src/reason-prompt.tsx"],"names":[],"mappings":"AAYA,OAAO,KAAmD,MAAM,OAAO,CAAA;AAcvE,wDAAwD;AACxD,MAAM,WAAW,kBAAkB;IAC/B,6DAA6D;IAC7D,GAAG,EAAE,MAAM,CAAA;IACX,qDAAqD;IACrD,QAAQ,EAAE,OAAO,CAAA;CACpB;AAED,eAAO,MAAM,kBAAkB,IAAI,CAAA;AAEnC;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,kBAAkB,GAAG,IAAI,CAS1E;AAED,MAAM,WAAW,uBAAuB;IACpC,IAAI,EAAE,OAAO,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC7B,yEAAyE;IACzE,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,yDAAyD;IACzD,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,iEAAiE;IACjE,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,4CAA4C;IAC5C,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC,QAAQ,EAAE,MAAM,IAAI,CAAA;CACvB;AAED,wBAAgB,kBAAkB,CAAC,EAC/B,IAAI,EACJ,KAAK,EACL,WAAW,EACX,GAAwB,EACxB,YAAY,EACZ,WAAkB,EAClB,KAAK,EACL,IAAI,EACJ,QAAQ,EACR,QAAQ,GACX,EAAE,uBAAuB,qBAoDzB;AAED,MAAM,WAAW,oBAAoB,CAAC,CAAC;IACnC,iDAAiD;IACjD,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC7B,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB;;;;OAIG;IACH,OAAO,EAAE,CAAC,MAAM,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CAAA;IACxC,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,eAAe;IAC5B;;;OAGG;IACH,GAAG,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,oBAAoB,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAA;IACjE,gEAAgE;IAChE,MAAM,EAAE,KAAK,CAAC,SAAS,CAAA;CAC1B;AAUD,8EAA8E;AAC9E,wBAAgB,eAAe,IAAI,eAAe,CA6EjD"}
@@ -0,0 +1,111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Mandatory reason on destructive operations (PER-4).
3
+ //
4
+ // A manifest model declares `reason_required` (delete and/or actions such as
5
+ // cancel). The kernel then answers a DELETE / action WITHOUT a reason with
6
+ // 422 `errors.reason: [{code: "required" | "min", params: {min}}]`. Instead of
7
+ // every screen carrying its own "motivo" prompt, `useReasonPrompt().run()` wraps
8
+ // the request: it tries it, and when the server says a reason is needed it asks
9
+ // once (a dialog) and retries with the reason attached. Screens that already know
10
+ // they need one can pass `reason` up front through the same helper.
11
+ //
12
+ // The reason is stamped on the canonical event by the kernel, so the activity log
13
+ // (RecordHistory) shows who removed/cancelled what, when, and why.
14
+ import { useCallback, useEffect, useRef, useState } from 'react';
15
+ import { useTranslation } from 'react-i18next';
16
+ import { Loader2 } from 'lucide-react';
17
+ import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Textarea, } from '@asteby/metacore-ui/primitives';
18
+ export const DEFAULT_REASON_MIN = 3;
19
+ /**
20
+ * Detects the kernel's mandatory-reason refusal in an axios error (or a bare
21
+ * response body): HTTP 422 with `errors.reason[0].code` of `required` | `min`.
22
+ * Returns null for any other error.
23
+ */
24
+ export function reasonRequiredInfo(err) {
25
+ const data = err?.response?.data ?? err;
26
+ const issues = data?.errors?.reason;
27
+ if (!Array.isArray(issues) || issues.length === 0)
28
+ return null;
29
+ const first = issues[0];
30
+ const code = typeof first === 'string' ? first : first?.code;
31
+ if (code !== 'required' && code !== 'min')
32
+ return null;
33
+ const min = typeof first === 'object' && typeof first?.params?.min === 'number' ? first.params.min : DEFAULT_REASON_MIN;
34
+ return { min, tooShort: code === 'min' };
35
+ }
36
+ export function ReasonPromptDialog({ open, title, description, min = DEFAULT_REASON_MIN, confirmLabel, destructive = true, error, busy, onSubmit, onCancel, }) {
37
+ const { t } = useTranslation();
38
+ const [reason, setReason] = useState('');
39
+ useEffect(() => {
40
+ if (open)
41
+ setReason('');
42
+ }, [open]);
43
+ const ready = reason.trim().length >= min;
44
+ return (_jsx(Dialog, { open: open, onOpenChange: (o) => !o && !busy && onCancel(), children: _jsxs(DialogContent, { className: "sm:max-w-md", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: title }), description ? _jsx(DialogDescription, { children: description }) : null] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "mc-reason", className: "text-sm font-medium", children: t('reason.label', { defaultValue: 'Motivo' }) }), _jsx(Textarea, { id: "mc-reason", autoFocus: true, rows: 3, value: reason, onChange: (e) => setReason(e.target.value), placeholder: t('reason.placeholder', { defaultValue: 'Explica por qué. Queda en la bitácora.' }) }), error ? _jsx("p", { className: "text-destructive text-sm", children: error }) : null, !error && reason.length > 0 && !ready ? (_jsx("p", { className: "text-muted-foreground text-xs", children: t('reason.min', { min, defaultValue: 'Escribe al menos {{min}} caracteres.' }) })) : null] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: onCancel, disabled: busy, children: t('common.cancel', { defaultValue: 'Cancelar' }) }), _jsxs(Button, { variant: destructive ? 'destructive' : 'default', disabled: !ready || busy, onClick: () => onSubmit(reason.trim()), children: [busy ? _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }) : null, confirmLabel ?? t('reason.confirm', { defaultValue: 'Continuar' })] })] })] }) }));
45
+ }
46
+ /** See the module comment. Render `dialog` somewhere in the caller's tree. */
47
+ export function useReasonPrompt() {
48
+ const { t } = useTranslation();
49
+ const [pending, setPending] = useState(null);
50
+ const [busy, setBusy] = useState(false);
51
+ const pendingRef = useRef(null);
52
+ pendingRef.current = pending;
53
+ const run = useCallback((opts) => {
54
+ return new Promise((resolve, reject) => {
55
+ opts
56
+ .request(opts.reason)
57
+ .then((v) => resolve(v))
58
+ .catch((err) => {
59
+ const info = reasonRequiredInfo(err);
60
+ if (!info)
61
+ return reject(err);
62
+ setPending({
63
+ opts: opts,
64
+ info,
65
+ resolve: resolve,
66
+ reject,
67
+ error: null,
68
+ });
69
+ });
70
+ });
71
+ }, []);
72
+ const submit = async (reason) => {
73
+ const p = pendingRef.current;
74
+ if (!p)
75
+ return;
76
+ setBusy(true);
77
+ try {
78
+ const v = await p.opts.request(reason);
79
+ setPending(null);
80
+ p.resolve(v);
81
+ }
82
+ catch (err) {
83
+ const info = reasonRequiredInfo(err);
84
+ if (info) {
85
+ // Server still not satisfied (a longer minimum): stay open, say why.
86
+ setPending({
87
+ ...p,
88
+ info,
89
+ error: t('reason.min', {
90
+ min: info.min,
91
+ defaultValue: 'Escribe al menos {{min}} caracteres.',
92
+ }),
93
+ });
94
+ }
95
+ else {
96
+ setPending(null);
97
+ p.reject(err);
98
+ }
99
+ }
100
+ finally {
101
+ setBusy(false);
102
+ }
103
+ };
104
+ const cancel = () => {
105
+ const p = pendingRef.current;
106
+ setPending(null);
107
+ p?.resolve(undefined);
108
+ };
109
+ const dialog = (_jsx(ReasonPromptDialog, { open: pending !== null, title: pending?.opts.title ?? '', description: pending?.opts.description, min: pending?.info.min, confirmLabel: pending?.opts.confirmLabel, destructive: pending?.opts.destructive, error: pending?.error, busy: busy, onSubmit: submit, onCancel: cancel }));
110
+ return { run, dialog };
111
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"server-error.d.ts","sourceRoot":"","sources":["../src/server-error.ts"],"names":[],"mappings":"AAcA,kFAAkF;AAClF,MAAM,WAAW,cAAc;IAC3B,gFAAgF;IAChF,KAAK,EAAE,MAAM,CAAA;IACb;;gDAE4C;IAC5C,WAAW,CAAC,EAAE,MAAM,CAAA;CACvB;AA8BD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,GAAG,cAAc,CAuBtF;AAED;;gFAEgF;AAChF,MAAM,MAAM,SAAS,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,KAAK,MAAM,CAAA;AAUvG;8EAC8E;AAC9E,MAAM,WAAW,UAAU;IACvB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAC5B,OAAO,CAAC,EAAE,MAAM,CAAA;CACnB;AA6BD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,UAAU,EAAE,CAAC,GAAG,SAAS,CAazF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAC9B,KAAK,EAAE,UAAU,EACjB,KAAK,EAAE,MAAM,EACb,CAAC,EAAE,SAAS,EACZ,QAAQ,CAAC,EAAE,MAAM,GAClB,MAAM,CAMR;AAED;6EAC6E;AAC7E,wBAAgB,qBAAqB,CACjC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,EAAE,CAAC,EACjC,CAAC,EAAE,SAAS,EACZ,IAAI,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAC9D,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAOxB;AAUD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAC9B,IAAI,EAAE,OAAO,EACb,IAAI,CAAC,EAAE;IAAE,CAAC,CAAC,EAAE,SAAS,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GAC/C,IAAI,CAUN;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC5B,GAAG,EAAE,OAAO,EACZ,IAAI,CAAC,EAAE;IAAE,CAAC,CAAC,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAChG,IAAI,CA4BN"}
1
+ {"version":3,"file":"server-error.d.ts","sourceRoot":"","sources":["../src/server-error.ts"],"names":[],"mappings":"AAeA,kFAAkF;AAClF,MAAM,WAAW,cAAc;IAC3B,gFAAgF;IAChF,KAAK,EAAE,MAAM,CAAA;IACb;;gDAE4C;IAC5C,WAAW,CAAC,EAAE,MAAM,CAAA;CACvB;AA8BD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,GAAG,cAAc,CAuBtF;AAED;;gFAEgF;AAChF,MAAM,MAAM,SAAS,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,KAAK,MAAM,CAAA;AAUvG;8EAC8E;AAC9E,MAAM,WAAW,UAAU;IACvB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAC5B,OAAO,CAAC,EAAE,MAAM,CAAA;CACnB;AA6BD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,UAAU,EAAE,CAAC,GAAG,SAAS,CAazF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAC9B,KAAK,EAAE,UAAU,EACjB,KAAK,EAAE,MAAM,EACb,CAAC,EAAE,SAAS,EACZ,QAAQ,CAAC,EAAE,MAAM,GAClB,MAAM,CAMR;AAED;6EAC6E;AAC7E,wBAAgB,qBAAqB,CACjC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,EAAE,CAAC,EACjC,CAAC,EAAE,SAAS,EACZ,IAAI,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAC9D,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAOxB;AAUD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAC9B,IAAI,EAAE,OAAO,EACb,IAAI,CAAC,EAAE;IAAE,CAAC,CAAC,EAAE,SAAS,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GAC/C,IAAI,CAUN;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC5B,GAAG,EAAE,OAAO,EACZ,IAAI,CAAC,EAAE;IAAE,CAAC,CAAC,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAChG,IAAI,CA+CN"}
@@ -11,6 +11,7 @@
11
11
  // the toast description, in ONE place so every call site behaves identically.
12
12
  import { toast } from 'sonner';
13
13
  import { validationCatalog, validationMessageKey } from './validation-catalog';
14
+ import { APPROVAL_PARKED_EVENT, approvalGateAvailable, approvalRequiredInfo } from './approval-events';
14
15
  function formatIssueEntry(v) {
15
16
  if (v == null)
16
17
  return '';
@@ -187,6 +188,24 @@ export function toastServerError(err, opts) {
187
188
  const t = opts?.t;
188
189
  const lang = opts?.language;
189
190
  const cat = validationCatalog(lang);
191
+ // A write parked for approval is not a failure: say so, and when an
192
+ // <ApprovalGateProvider> is mounted offer to approve it right now with a PIN.
193
+ const parked = approvalRequiredInfo(err);
194
+ if (parked) {
195
+ const title = parked.label || (t ? t('approvals.sent', { defaultValue: 'Enviado a aprobación' }) : 'Enviado a aprobación');
196
+ toast.info(title, {
197
+ description: parked.roles.length
198
+ ? (t ? t('approvals.waiting_for', { defaultValue: 'Lo decide: {{roles}}', roles: parked.roles.join(', ') }) : parked.roles.join(', '))
199
+ : undefined,
200
+ action: parked.requestId && approvalGateAvailable()
201
+ ? {
202
+ label: t ? t('approvals.approve_with_pin', { defaultValue: 'Aprobar con PIN' }) : 'Aprobar con PIN',
203
+ onClick: () => window.dispatchEvent(new CustomEvent(APPROVAL_PARKED_EVENT, { detail: parked })),
204
+ }
205
+ : undefined,
206
+ });
207
+ return;
208
+ }
190
209
  const map = extractFieldErrors(err);
191
210
  if (map) {
192
211
  const localized = t
@@ -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): import("@tanstack/react-query").UseMutationResult<Partial<T>, Error, Partial<T>, unknown>;
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,8DAA8D;AAC9D,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,uCAEhD;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;CACpB;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,6FAgBnB"}
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(`/api/addons/${addonKey}/settings`);
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(`/api/addons/${addonKey}/settings`, values);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "45.0.0",
3
+ "version": "45.3.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,134 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // ApprovalGate: inline PIN grants + parked-request approval, and the capability
4
+ // bypass that lets a supervisor skip their own prompt.
5
+ import { afterEach, describe, expect, it, vi } from 'vitest'
6
+ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
7
+
8
+ vi.mock('react-i18next', () => ({
9
+ useTranslation: () => ({
10
+ t: (_k: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? _k,
11
+ }),
12
+ }))
13
+ vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { info: vi.fn(), success: vi.fn(), error: vi.fn() }) }))
14
+
15
+ import { ApiProvider, type ApiClient } from '../api-context'
16
+ import { PermissionsProvider } from '../permissions-context'
17
+ import {
18
+ ApprovalGateProvider,
19
+ useApprovalGate,
20
+ approvalRequiredInfo,
21
+ APPROVAL_PARKED_EVENT,
22
+ type ApprovalGrant,
23
+ } from '../approval-gate'
24
+ import { toastServerError } from '../server-error'
25
+ import { toast } from 'sonner'
26
+
27
+ afterEach(() => {
28
+ cleanup()
29
+ vi.clearAllMocks()
30
+ })
31
+
32
+ function makeApi(post: ApiClient['post']): ApiClient {
33
+ return { get: vi.fn(), post, put: vi.fn(), delete: vi.fn() } as unknown as ApiClient
34
+ }
35
+
36
+ let lastGrant: ApprovalGrant | null | undefined
37
+ function Probe({ policy }: { policy: string }) {
38
+ const gate = useApprovalGate()
39
+ return (
40
+ <button
41
+ onClick={async () => {
42
+ lastGrant = await gate.requestApproval({ policy, label: 'Venta sin existencias', context: { qty: 3 } })
43
+ }}
44
+ >
45
+ ask
46
+ </button>
47
+ )
48
+ }
49
+
50
+ function mount(post: ApiClient['post'], caps: string[] = [], isAdmin = false) {
51
+ lastGrant = undefined
52
+ return render(
53
+ <ApiProvider client={makeApi(post)}>
54
+ <PermissionsProvider permissions={caps} isAdmin={isAdmin}>
55
+ <ApprovalGateProvider>
56
+ <Probe policy="oversell" />
57
+ </ApprovalGateProvider>
58
+ </PermissionsProvider>
59
+ </ApiProvider>,
60
+ )
61
+ }
62
+
63
+ function fill(reason: string, pin: string) {
64
+ fireEvent.change(screen.getByLabelText('Motivo'), { target: { value: reason } })
65
+ fireEvent.change(screen.getByLabelText('PIN del supervisor'), { target: { value: pin } })
66
+ }
67
+
68
+ describe('ApprovalGate', () => {
69
+ it('a holder of general.approve_<policy> passes without a prompt', async () => {
70
+ const post = vi.fn()
71
+ mount(post, ['general.approve_oversell'])
72
+ await act(async () => fireEvent.click(screen.getByText('ask')))
73
+ expect(lastGrant).toEqual({ id: null, policy: 'oversell', bypassed: true })
74
+ expect(post).not.toHaveBeenCalled()
75
+ expect(screen.queryByLabelText('PIN del supervisor')).toBeNull()
76
+ })
77
+
78
+ it('posts pin-grant with reason + context and resolves the audit id', async () => {
79
+ const post = vi.fn().mockResolvedValue({ data: { success: true, data: { id: 'audit-1' } } })
80
+ mount(post)
81
+ await act(async () => fireEvent.click(screen.getByText('ask')))
82
+ const submit = screen.getByText('Autorizar').closest('button')!
83
+ expect(submit.hasAttribute('disabled')).toBe(true) // reason + PIN required
84
+ fill('cliente esperando', '4321')
85
+ await act(async () => fireEvent.click(submit))
86
+ await waitFor(() => expect(lastGrant).toBeDefined())
87
+ expect(post).toHaveBeenCalledWith(
88
+ '/approvals/pin-grant',
89
+ expect.objectContaining({ policy: 'oversell', pin: '4321', reason: 'cliente esperando', context: { qty: 3 } }),
90
+ )
91
+ expect(lastGrant).toMatchObject({ id: 'audit-1', bypassed: false })
92
+ })
93
+
94
+ it('a wrong PIN keeps the dialog open with an inline message and nothing resolves', async () => {
95
+ const post = vi.fn().mockRejectedValue({ response: { data: { error: { code: 'approval_pin_invalid' } } } })
96
+ mount(post)
97
+ await act(async () => fireEvent.click(screen.getByText('ask')))
98
+ fill('r', '0000')
99
+ await act(async () => fireEvent.click(screen.getByText('Autorizar').closest('button')!))
100
+ expect(await screen.findByRole('alert')).toBeTruthy()
101
+ expect(lastGrant).toBeUndefined()
102
+ })
103
+
104
+ it('cancelling resolves null (fail closed)', async () => {
105
+ mount(vi.fn())
106
+ await act(async () => fireEvent.click(screen.getByText('ask')))
107
+ await act(async () => fireEvent.click(screen.getByText('Cancelar')))
108
+ await waitFor(() => expect(lastGrant).toBeNull())
109
+ })
110
+
111
+ it('a parked approval_required toast offers the PIN and approves through approve-pin', async () => {
112
+ const post = vi.fn().mockResolvedValue({ data: { success: true } })
113
+ mount(post)
114
+ const err = {
115
+ response: {
116
+ data: { success: false, error: { code: 'approval_required' }, meta: { approval_request_id: 'req-9', approval_label: 'Precio bajo el piso', approval_roles: ['gerente'] } },
117
+ },
118
+ }
119
+ expect(approvalRequiredInfo(err)).toMatchObject({ requestId: 'req-9', roles: ['gerente'] })
120
+ toastServerError(err)
121
+ const opts = (toast.info as unknown as ReturnType<typeof vi.fn>).mock.calls[0][1]
122
+ expect(toast.error).not.toHaveBeenCalled()
123
+ await act(async () => opts.action.onClick())
124
+ fill('autorizado', '1234')
125
+ await act(async () => fireEvent.click(screen.getByText('Autorizar').closest('button')!))
126
+ await waitFor(() => expect(post).toHaveBeenCalledWith('/approvals/req-9/approve-pin', { pin: '1234', reason: 'autorizado' }))
127
+ })
128
+
129
+ it('without a gate mounted the parked toast has no PIN action and the event is inert', () => {
130
+ toastServerError({ response: { data: { error: { code: 'approval_required' }, meta: { approval_request_id: 'x' } } } })
131
+ expect((toast.info as unknown as ReturnType<typeof vi.fn>).mock.calls[0][1].action).toBeUndefined()
132
+ window.dispatchEvent(new CustomEvent(APPROVAL_PARKED_EVENT, { detail: { requestId: 'x', roles: [] } }))
133
+ })
134
+ })
@@ -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
+ })