@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.
- package/CHANGELOG.md +29 -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/approval-events.d.ts +16 -0
- package/dist/approval-events.d.ts.map +1 -0
- package/dist/approval-events.js +27 -0
- package/dist/approval-gate.d.ts +39 -0
- package/dist/approval-gate.d.ts.map +1 -0
- package/dist/approval-gate.js +110 -0
- package/dist/approval-pin-dialog.d.ts +25 -0
- package/dist/approval-pin-dialog.d.ts.map +1 -0
- package/dist/approval-pin-dialog.js +66 -0
- package/dist/business/catalog.d.ts +16 -0
- package/dist/business/catalog.d.ts.map +1 -0
- package/dist/business/catalog.js +50 -0
- package/dist/business/customer-picker.d.ts +39 -0
- package/dist/business/customer-picker.d.ts.map +1 -0
- package/dist/business/customer-picker.js +51 -0
- package/dist/business/feedback.d.ts +72 -0
- package/dist/business/feedback.d.ts.map +1 -0
- package/dist/business/feedback.js +52 -0
- package/dist/business/field-errors.d.ts +26 -0
- package/dist/business/field-errors.d.ts.map +1 -0
- package/dist/business/field-errors.js +59 -0
- package/dist/business/format.d.ts +49 -0
- package/dist/business/format.d.ts.map +1 -0
- package/dist/business/format.js +128 -0
- package/dist/business/index.d.ts +16 -0
- package/dist/business/index.d.ts.map +1 -0
- package/dist/business/index.js +15 -0
- package/dist/business/line-items-editor.d.ts +25 -0
- package/dist/business/line-items-editor.d.ts.map +1 -0
- package/dist/business/line-items-editor.js +36 -0
- package/dist/business/line-items.d.ts +69 -0
- package/dist/business/line-items.d.ts.map +1 -0
- package/dist/business/line-items.js +133 -0
- package/dist/business/payment-capture.d.ts +21 -0
- package/dist/business/payment-capture.d.ts.map +1 -0
- package/dist/business/payment-capture.js +24 -0
- package/dist/business/payment.d.ts +61 -0
- package/dist/business/payment.d.ts.map +1 -0
- package/dist/business/payment.js +74 -0
- package/dist/business/product-picker.d.ts +24 -0
- package/dist/business/product-picker.d.ts.map +1 -0
- package/dist/business/product-picker.js +48 -0
- package/dist/business/product-search.d.ts +70 -0
- package/dist/business/product-search.d.ts.map +1 -0
- package/dist/business/product-search.js +51 -0
- package/dist/business/refund-destination.d.ts +19 -0
- package/dist/business/refund-destination.d.ts.map +1 -0
- package/dist/business/refund-destination.js +31 -0
- package/dist/business/refund.d.ts +29 -0
- package/dist/business/refund.d.ts.map +1 -0
- package/dist/business/refund.js +44 -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/business/use-async-search.d.ts +10 -0
- package/dist/business/use-async-search.d.ts.map +1 -0
- package/dist/business/use-async-search.js +38 -0
- package/dist/document/action-bar.d.ts +12 -0
- package/dist/document/action-bar.d.ts.map +1 -0
- package/dist/document/action-bar.js +39 -0
- package/dist/document/cancel-with-reason.d.ts +47 -0
- package/dist/document/cancel-with-reason.d.ts.map +1 -0
- package/dist/document/cancel-with-reason.js +52 -0
- package/dist/document/document-header.d.ts +41 -0
- package/dist/document/document-header.d.ts.map +1 -0
- package/dist/document/document-header.js +30 -0
- package/dist/document/document-page.d.ts +37 -0
- package/dist/document/document-page.d.ts.map +1 -0
- package/dist/document/document-page.js +137 -0
- package/dist/document/document-timeline.d.ts +20 -0
- package/dist/document/document-timeline.d.ts.map +1 -0
- package/dist/document/document-timeline.js +26 -0
- package/dist/document/evaluate.d.ts +31 -0
- package/dist/document/evaluate.d.ts.map +1 -0
- package/dist/document/evaluate.js +170 -0
- package/dist/document/format.d.ts +9 -0
- package/dist/document/format.d.ts.map +1 -0
- package/dist/document/format.js +52 -0
- package/dist/document/index.d.ts +13 -0
- package/dist/document/index.d.ts.map +1 -0
- package/dist/document/index.js +12 -0
- package/dist/document/registry.d.ts +20 -0
- package/dist/document/registry.d.ts.map +1 -0
- package/dist/document/registry.js +20 -0
- package/dist/document/smart-buttons.d.ts +16 -0
- package/dist/document/smart-buttons.d.ts.map +1 -0
- package/dist/document/smart-buttons.js +16 -0
- package/dist/document/status-badge.d.ts +16 -0
- package/dist/document/status-badge.d.ts.map +1 -0
- package/dist/document/status-badge.js +23 -0
- package/dist/document/types.d.ts +196 -0
- package/dist/document/types.d.ts.map +1 -0
- package/dist/document/types.js +1 -0
- package/dist/document/use-document-sources.d.ts +14 -0
- package/dist/document/use-document-sources.d.ts.map +1 -0
- package/dist/document/use-document-sources.js +72 -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 +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -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/server-error.d.ts.map +1 -1
- package/dist/server-error.js +19 -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__/approval-gate.test.tsx +134 -0
- 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/approval-events.ts +39 -0
- package/src/approval-gate.tsx +180 -0
- package/src/approval-pin-dialog.tsx +162 -0
- package/src/business/business.test.ts +210 -0
- package/src/business/catalog.ts +68 -0
- package/src/business/customer-picker.tsx +201 -0
- package/src/business/feedback.tsx +141 -0
- package/src/business/field-errors.ts +92 -0
- package/src/business/format.ts +151 -0
- package/src/business/index.ts +15 -0
- package/src/business/line-items-editor.tsx +259 -0
- package/src/business/line-items.ts +190 -0
- package/src/business/payment-capture.tsx +158 -0
- package/src/business/payment.ts +125 -0
- package/src/business/product-picker.tsx +171 -0
- package/src/business/product-search.ts +108 -0
- package/src/business/refund-destination.tsx +121 -0
- package/src/business/refund.ts +72 -0
- package/src/business/return-wizard.tsx +223 -0
- package/src/business/return.ts +181 -0
- package/src/business/use-async-search.ts +43 -0
- package/src/document/__tests__/document-page.test.tsx +107 -0
- package/src/document/__tests__/evaluate.test.ts +140 -0
- package/src/document/action-bar.tsx +140 -0
- package/src/document/cancel-with-reason.tsx +214 -0
- package/src/document/document-header.tsx +140 -0
- package/src/document/document-page.tsx +268 -0
- package/src/document/document-timeline.tsx +66 -0
- package/src/document/evaluate.ts +180 -0
- package/src/document/format.ts +59 -0
- package/src/document/index.ts +28 -0
- package/src/document/registry.ts +39 -0
- package/src/document/smart-buttons.tsx +54 -0
- package/src/document/status-badge.tsx +44 -0
- package/src/document/types.ts +203 -0
- package/src/document/use-document-sources.ts +91 -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 +33 -0
- package/src/reason-prompt.tsx +247 -0
- package/src/server-error.ts +20 -0
- 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":"
|
|
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"}
|
package/dist/server-error.js
CHANGED
|
@@ -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
|
|
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
|
@@ -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
|
+
})
|