@asteby/metacore-runtime-react 46.3.0 → 47.0.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 +31 -0
- package/dist/action-modal-dispatcher.d.ts +10 -1
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +107 -19
- package/dist/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +24 -0
- package/dist/business/index.d.ts +3 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +3 -0
- package/dist/business/print-send-dialog.d.ts +27 -0
- package/dist/business/print-send-dialog.d.ts.map +1 -0
- package/dist/business/print-send-dialog.js +55 -0
- package/dist/business/relate-documents.d.ts +41 -0
- package/dist/business/relate-documents.d.ts.map +1 -0
- package/dist/business/relate-documents.js +42 -0
- package/dist/business/vehicle-picker.d.ts +36 -0
- package/dist/business/vehicle-picker.d.ts.map +1 -0
- package/dist/business/vehicle-picker.js +61 -0
- package/dist/cfdi-stamp-panel.d.ts +29 -0
- package/dist/cfdi-stamp-panel.d.ts.map +1 -0
- package/dist/cfdi-stamp-panel.js +53 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +5 -0
- package/dist/document-form-dialog.d.ts +23 -0
- package/dist/document-form-dialog.d.ts.map +1 -0
- package/dist/document-form-dialog.js +196 -0
- package/dist/dynamic-crud-page.d.ts +6 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +17 -7
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +3 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +6 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/list-primary-action.d.ts +31 -0
- package/dist/list-primary-action.d.ts.map +1 -0
- package/dist/list-primary-action.js +35 -0
- package/dist/model-action-toolbar.d.ts +7 -1
- package/dist/model-action-toolbar.d.ts.map +1 -1
- package/dist/model-action-toolbar.js +10 -3
- package/dist/model-extension-registry.d.ts +7 -0
- package/dist/model-extension-registry.d.ts.map +1 -1
- package/dist/nav-permissions.d.ts +19 -0
- package/dist/nav-permissions.d.ts.map +1 -0
- package/dist/nav-permissions.js +51 -0
- package/dist/navigation-builder.d.ts +6 -0
- package/dist/navigation-builder.d.ts.map +1 -1
- package/dist/navigation-builder.js +26 -1
- package/dist/permissions-manager.d.ts +9 -0
- package/dist/permissions-manager.d.ts.map +1 -1
- package/dist/permissions-manager.js +50 -17
- package/dist/print-document-button.d.ts.map +1 -1
- package/dist/print-document-button.js +9 -1
- package/dist/record-mutation-events.d.ts +14 -0
- package/dist/record-mutation-events.d.ts.map +1 -0
- package/dist/record-mutation-events.js +37 -0
- package/dist/types.d.ts +60 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-print-document.d.ts +15 -1
- package/dist/use-print-document.d.ts.map +1 -1
- package/dist/use-print-document.js +52 -1
- package/dist/use-record-mutation-tick.d.ts +6 -0
- package/dist/use-record-mutation-tick.d.ts.map +1 -0
- package/dist/use-record-mutation-tick.js +11 -0
- package/package.json +3 -3
- package/src/__tests__/action-modal-feedback.test.tsx +116 -0
- package/src/__tests__/business-pickers.test.tsx +157 -0
- package/src/__tests__/document-form-dialog.test.tsx +190 -0
- package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
- package/src/__tests__/list-primary-action.test.tsx +157 -0
- package/src/__tests__/nav-permissions.test.tsx +76 -0
- package/src/__tests__/permissions-manager.test.tsx +21 -4
- package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
- package/src/action-modal-dispatcher.tsx +124 -6
- package/src/business/catalog.ts +24 -0
- package/src/business/index.ts +3 -0
- package/src/business/print-send-dialog.tsx +175 -0
- package/src/business/relate-documents.tsx +182 -0
- package/src/business/vehicle-picker.tsx +188 -0
- package/src/cfdi-stamp-panel.tsx +146 -0
- package/src/dialogs/dynamic-record.tsx +5 -0
- package/src/document-form-dialog.tsx +357 -0
- package/src/dynamic-crud-page.tsx +36 -7
- package/src/dynamic-kanban.tsx +3 -1
- package/src/dynamic-table.tsx +6 -2
- package/src/index.ts +36 -0
- package/src/list-primary-action.ts +71 -0
- package/src/model-action-toolbar.tsx +18 -3
- package/src/model-extension-registry.tsx +7 -0
- package/src/nav-permissions.ts +58 -0
- package/src/navigation-builder.tsx +27 -1
- package/src/permissions-manager.tsx +39 -26
- package/src/print-document-button.tsx +9 -1
- package/src/record-mutation-events.ts +50 -0
- package/src/types.ts +63 -0
- package/src/use-print-document.ts +66 -3
- package/src/use-record-mutation-tick.ts +12 -0
package/src/dynamic-table.tsx
CHANGED
|
@@ -66,6 +66,7 @@ import { Inbox, Download, Upload, Trash2 } from 'lucide-react'
|
|
|
66
66
|
import { toast } from 'sonner'
|
|
67
67
|
import { Progress } from './dialogs/_primitives'
|
|
68
68
|
import { useMetadataCache } from './metadata-cache'
|
|
69
|
+
import { useRecordMutationTick } from './use-record-mutation-tick'
|
|
69
70
|
import { useApi, useCurrentBranch } from './api-context'
|
|
70
71
|
import { useReasonPrompt } from './reason-prompt'
|
|
71
72
|
import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
|
|
@@ -295,6 +296,9 @@ export function DynamicTable({
|
|
|
295
296
|
// OrgRuntimeProvider (without it, money fell back to USD).
|
|
296
297
|
const orgTimeZone = useTimeZone()
|
|
297
298
|
const orgCurrency = useCurrency()
|
|
299
|
+
// Bumps when a dialog/modal anywhere creates, edits or deletes a record of
|
|
300
|
+
// this model, so the list reloads without the caller wiring refreshTrigger.
|
|
301
|
+
const mutationTick = useRecordMutationTick(model)
|
|
298
302
|
const timeZone = timeZoneProp ?? orgTimeZone
|
|
299
303
|
const currency = currencyProp ?? orgCurrency
|
|
300
304
|
// The explicit `pagination` prop wins; the legacy `infiniteScroll` boolean
|
|
@@ -917,7 +921,7 @@ export function DynamicTable({
|
|
|
917
921
|
} finally {
|
|
918
922
|
setLoadingData(false)
|
|
919
923
|
}
|
|
920
|
-
}, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api, enableUrlSync, relationInclude])
|
|
924
|
+
}, [model, metadata, pagination, buildFilterParams, refreshTrigger, mutationTick, endpoint, currentBranch?.id, api, enableUrlSync, relationInclude])
|
|
921
925
|
|
|
922
926
|
// Columns whose metadata opts into a footer total (display_config.aggregate
|
|
923
927
|
// → styleConfig.aggregate). When empty, no footer row is rendered and no
|
|
@@ -1098,7 +1102,7 @@ export function DynamicTable({
|
|
|
1098
1102
|
// deps). Without it the comment above lied: infinite lists silently
|
|
1099
1103
|
// failed to reload after a create ("a veces no recarga la tabla").
|
|
1100
1104
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1101
|
-
}, [infiniteScroll, metadata, filterSignature, refreshTrigger])
|
|
1105
|
+
}, [infiniteScroll, metadata, filterSignature, refreshTrigger, mutationTick])
|
|
1102
1106
|
|
|
1103
1107
|
const handleRefresh = useCallback(() => {
|
|
1104
1108
|
// Infinite mode owns its own list: refresh reloads page 1 and drops the
|
package/src/index.ts
CHANGED
|
@@ -188,6 +188,42 @@ export {
|
|
|
188
188
|
type ModelActionToolbarProps,
|
|
189
189
|
type ActionPlacement,
|
|
190
190
|
} from './model-action-toolbar'
|
|
191
|
+
export {
|
|
192
|
+
resolveListPrimaryAction,
|
|
193
|
+
actionReplacesCreate,
|
|
194
|
+
type ListPrimaryActionInput,
|
|
195
|
+
type ListPrimaryActionResult,
|
|
196
|
+
type ListActionRef,
|
|
197
|
+
type CreateMode,
|
|
198
|
+
} from './list-primary-action'
|
|
199
|
+
export {
|
|
200
|
+
emitRecordMutation,
|
|
201
|
+
subscribeRecordMutations,
|
|
202
|
+
RECORD_MUTATION_EVENT,
|
|
203
|
+
type RecordMutationKind,
|
|
204
|
+
type RecordMutationDetail,
|
|
205
|
+
} from './record-mutation-events'
|
|
206
|
+
export {
|
|
207
|
+
DocumentFormDialog,
|
|
208
|
+
resolveDocumentForms,
|
|
209
|
+
type DocumentFormDialogProps,
|
|
210
|
+
} from './document-form-dialog'
|
|
211
|
+
export {
|
|
212
|
+
modelFromNavUrl,
|
|
213
|
+
capabilityForNavItem,
|
|
214
|
+
isNavItemAllowed,
|
|
215
|
+
useNavItemVisible,
|
|
216
|
+
type NavLeafLike,
|
|
217
|
+
} from './nav-permissions'
|
|
218
|
+
export {
|
|
219
|
+
CfdiStampPanel,
|
|
220
|
+
CfdiStampResultDialog,
|
|
221
|
+
extractStampResult,
|
|
222
|
+
type CfdiStampResult,
|
|
223
|
+
type CfdiStampPanelProps,
|
|
224
|
+
type CfdiStampResultDialogProps,
|
|
225
|
+
} from './cfdi-stamp-panel'
|
|
226
|
+
export { useRecordMutationTick } from './use-record-mutation-tick'
|
|
191
227
|
export * from './addon-loader'
|
|
192
228
|
export {
|
|
193
229
|
PURGE_ADDON_MESSAGE,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Una sola acción primaria de alta por pantalla (UX-1).
|
|
2
|
+
// Puro: DynamicCRUDPage y ModelActionToolbar lo usan, y el shell de ops
|
|
3
|
+
// (`/m/$model`) debe llamarlo también para no pintar un segundo «Crear».
|
|
4
|
+
|
|
5
|
+
export type CreateMode = 'generic' | 'hidden' | 'action'
|
|
6
|
+
|
|
7
|
+
export interface ListActionRef {
|
|
8
|
+
key: string
|
|
9
|
+
placement?: string
|
|
10
|
+
/** El kernel sirve snake_case. CamelCase se acepta por si el host ya lo normalizó. */
|
|
11
|
+
replaces_create?: boolean
|
|
12
|
+
replacesCreate?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ListPrimaryActionInput {
|
|
16
|
+
/** `metadata.enableCRUDActions`. Sin él no hay Crear genérico. */
|
|
17
|
+
enableCRUD?: boolean
|
|
18
|
+
/** `metadata.canCreate`. `false` oculta el genérico; `undefined` no cambia nada. */
|
|
19
|
+
canCreate?: boolean
|
|
20
|
+
hideCreate?: boolean
|
|
21
|
+
createMode?: CreateMode
|
|
22
|
+
/** Si apunta a una acción de toolbar, esa es la primaria y oculta el genérico. */
|
|
23
|
+
primaryActionKey?: string
|
|
24
|
+
actions?: ListActionRef[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ListPrimaryActionResult {
|
|
28
|
+
showGenericCreate: boolean
|
|
29
|
+
primaryAction?: ListActionRef
|
|
30
|
+
/** Acciones de toolbar que no son la primaria (siguen visibles, en secundario). */
|
|
31
|
+
secondaryActions: ListActionRef[]
|
|
32
|
+
/** Más de una acción reclama ser la primaria. Solo una se usa. */
|
|
33
|
+
conflict: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `placement: create`, o el flag declarativo para una acción que hoy es `table`. */
|
|
37
|
+
export function actionReplacesCreate(action: ListActionRef): boolean {
|
|
38
|
+
return action.placement === 'create' || action.replaces_create === true || action.replacesCreate === true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isToolbar(action: ListActionRef): boolean {
|
|
42
|
+
const placement = action.placement ?? 'row'
|
|
43
|
+
return placement === 'table' || placement === 'create' || actionReplacesCreate(action)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveListPrimaryAction(input: ListPrimaryActionInput): ListPrimaryActionResult {
|
|
47
|
+
const actions = input.actions ?? []
|
|
48
|
+
const claimants = actions.filter(actionReplacesCreate)
|
|
49
|
+
|
|
50
|
+
let primary: ListActionRef | undefined
|
|
51
|
+
if (input.primaryActionKey) {
|
|
52
|
+
const keyed = actions.find((a) => a.key === input.primaryActionKey)
|
|
53
|
+
if (keyed && isToolbar(keyed)) primary = keyed
|
|
54
|
+
}
|
|
55
|
+
if (!primary && claimants.length > 0) primary = claimants[0]
|
|
56
|
+
|
|
57
|
+
const hideGeneric =
|
|
58
|
+
input.hideCreate === true ||
|
|
59
|
+
input.canCreate === false ||
|
|
60
|
+
input.createMode === 'hidden' ||
|
|
61
|
+
input.createMode === 'action' ||
|
|
62
|
+
input.enableCRUD !== true ||
|
|
63
|
+
primary != null
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
showGenericCreate: !hideGeneric,
|
|
67
|
+
primaryAction: primary,
|
|
68
|
+
secondaryActions: actions.filter((a) => isToolbar(a) && a.key !== primary?.key),
|
|
69
|
+
conflict: claimants.length > 1,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
useRequiresAddonLabel,
|
|
33
33
|
} from './requires-addon'
|
|
34
34
|
import type { ActionDefinition, ActionMetadata, RequiresAddon, TableMetadata } from './types'
|
|
35
|
+
import { resolveListPrimaryAction } from './list-primary-action'
|
|
35
36
|
|
|
36
37
|
export type ActionPlacement = 'row' | 'table' | 'create'
|
|
37
38
|
|
|
@@ -58,6 +59,12 @@ export interface ModelActionToolbarProps {
|
|
|
58
59
|
* lazy load without blocking first paint of the list page.
|
|
59
60
|
*/
|
|
60
61
|
onActionIntent?: (action: ActionDefinition) => void
|
|
62
|
+
/**
|
|
63
|
+
* Acción que queda como único botón primario (`data-primary`). El resto de
|
|
64
|
+
* las que reclaman el alta bajan a `outline`. Lo resuelve igual
|
|
65
|
+
* `resolveListPrimaryAction`.
|
|
66
|
+
*/
|
|
67
|
+
primaryActionKey?: string
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
const DEFAULT_PLACEMENTS: ActionPlacement[] = ['table', 'create']
|
|
@@ -139,6 +146,7 @@ export function ModelActionToolbar({
|
|
|
139
146
|
onChange,
|
|
140
147
|
className,
|
|
141
148
|
onActionIntent,
|
|
149
|
+
primaryActionKey,
|
|
142
150
|
}: ModelActionToolbarProps) {
|
|
143
151
|
const { t } = useTranslation()
|
|
144
152
|
const all = useModelActions(model, placements, actions)
|
|
@@ -153,6 +161,11 @@ export function ModelActionToolbar({
|
|
|
153
161
|
const [missingAddon, setMissingAddon] = useState<RequiresAddon | null>(null)
|
|
154
162
|
const requiresAddonLabel = useRequiresAddonLabel()
|
|
155
163
|
const dataEndpoint = endpoint ?? `/data/${model}/me`
|
|
164
|
+
const primary = resolveListPrimaryAction({
|
|
165
|
+
enableCRUD: true,
|
|
166
|
+
primaryActionKey,
|
|
167
|
+
actions: surfaced,
|
|
168
|
+
})
|
|
156
169
|
|
|
157
170
|
if (surfaced.length === 0) return null
|
|
158
171
|
|
|
@@ -160,16 +173,18 @@ export function ModelActionToolbar({
|
|
|
160
173
|
<>
|
|
161
174
|
<div className={className ?? 'flex items-center gap-2'}>
|
|
162
175
|
{surfaced.map((a) => {
|
|
163
|
-
const
|
|
176
|
+
const isPrimary = primary.primaryAction?.key === a.key
|
|
177
|
+
const isCreate = isPrimary || (a.placement ?? 'row') === 'create'
|
|
164
178
|
// Locked while its optional addon is missing: still shown,
|
|
165
179
|
// but a click explains + offers install instead of running.
|
|
166
180
|
const requirement = resolveRequiresAddon(a)
|
|
167
181
|
return (
|
|
168
182
|
<Button
|
|
169
183
|
key={a.key}
|
|
170
|
-
variant={
|
|
184
|
+
variant={isPrimary ? 'default' : 'outline'}
|
|
185
|
+
data-primary={isPrimary ? 'true' : undefined}
|
|
171
186
|
data-aby-target={
|
|
172
|
-
|
|
187
|
+
isPrimary
|
|
173
188
|
? `model.${model}.create`
|
|
174
189
|
: `model.${model}.action.${a.key}`
|
|
175
190
|
}
|
|
@@ -24,6 +24,13 @@ export interface ModelExtension {
|
|
|
24
24
|
headerExtras?: React.ComponentType<ModelExtensionProps>
|
|
25
25
|
toolbarExtras?: React.ComponentType<ModelExtensionProps>
|
|
26
26
|
hideCreate?: boolean
|
|
27
|
+
/**
|
|
28
|
+
* `hidden` quita el Crear genérico. `action` también, porque la acción del
|
|
29
|
+
* módulo (ver `primaryActionKey`) es la alta. `generic` lo deja.
|
|
30
|
+
*/
|
|
31
|
+
createMode?: 'generic' | 'hidden' | 'action'
|
|
32
|
+
/** Acción de toolbar que queda como único botón primario y oculta el Crear genérico. */
|
|
33
|
+
primaryActionKey?: string
|
|
27
34
|
hideExport?: boolean
|
|
28
35
|
hideImport?: boolean
|
|
29
36
|
hideRefresh?: boolean
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// nav-permissions — the sidebar follows the role's EFFECTIVE permissions
|
|
2
|
+
// (PIT-044). Before, every installed addon's entries were listed for every
|
|
3
|
+
// role and only failed with a 403 after the click.
|
|
4
|
+
//
|
|
5
|
+
// Rules (same capability vocabulary as useCan/gateTableMetadata):
|
|
6
|
+
// - no <PermissionsProvider> mounted, or isAdmin / "*" -> nothing hidden
|
|
7
|
+
// (useCan() is always-true then, so the predicate is a no-op);
|
|
8
|
+
// - an entry with an explicit `requires` capability -> can(requires);
|
|
9
|
+
// - an entry pointing at a model screen (`/m/<model>`) -> can('<model>.index');
|
|
10
|
+
// - any other entry (custom pages, activity, settings…) -> kept. Those are
|
|
11
|
+
// gated by the backend / the host's own `requires`, never guessed here.
|
|
12
|
+
import { useCallback } from 'react'
|
|
13
|
+
import { modelCapability, useCan, usePermissionsActive, type CanFn } from './permissions-context'
|
|
14
|
+
|
|
15
|
+
/** Minimal structural shape of a sidebar leaf (matches `@asteby/metacore-ui` NavLinkItem). */
|
|
16
|
+
export interface NavLeafLike {
|
|
17
|
+
url: string
|
|
18
|
+
requires?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MODEL_URL_RE = /^\/(?:app\/)?m\/([^/?#]+)/i
|
|
22
|
+
|
|
23
|
+
/** Model key of a `/m/<model>` url, or undefined for any other route. */
|
|
24
|
+
export function modelFromNavUrl(url: string | undefined): string | undefined {
|
|
25
|
+
if (!url) return undefined
|
|
26
|
+
const m = MODEL_URL_RE.exec(url)
|
|
27
|
+
if (!m) return undefined
|
|
28
|
+
try {
|
|
29
|
+
return decodeURIComponent(m[1])
|
|
30
|
+
} catch {
|
|
31
|
+
return m[1]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Capability a leaf needs, or undefined when it is not gated by the SDK. */
|
|
36
|
+
export function capabilityForNavItem(item: NavLeafLike): string | undefined {
|
|
37
|
+
if (item.requires) return item.requires
|
|
38
|
+
const model = modelFromNavUrl(item.url)
|
|
39
|
+
return model ? modelCapability(model, 'index') : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Pure visibility predicate for a leaf given a capability checker. */
|
|
43
|
+
export function isNavItemAllowed(item: NavLeafLike, can: CanFn): boolean {
|
|
44
|
+
const cap = capabilityForNavItem(item)
|
|
45
|
+
return cap ? can(cap) : true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Hook for the host's sidebar: returns a predicate for `<AppSidebar
|
|
50
|
+
* isItemVisible>` / `filterNavGroups`, or `undefined` when permission gating
|
|
51
|
+
* is not active (nothing to filter).
|
|
52
|
+
*/
|
|
53
|
+
export function useNavItemVisible(): ((item: NavLeafLike) => boolean) | undefined {
|
|
54
|
+
const can = useCan()
|
|
55
|
+
const active = usePermissionsActive()
|
|
56
|
+
const predicate = useCallback((item: NavLeafLike) => isNavItemAllowed(item, can), [can])
|
|
57
|
+
return active ? predicate : undefined
|
|
58
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// NavigationBuilder — merges a host's base sidebar with `manifest.navigation`
|
|
2
2
|
// contributions from every loaded addon. Pure function + a React hook.
|
|
3
3
|
import { useMemo } from 'react'
|
|
4
|
+
import { useCan, type CanFn } from './permissions-context'
|
|
4
5
|
|
|
5
6
|
export interface NavItem {
|
|
6
7
|
key: string
|
|
@@ -59,8 +60,33 @@ export function mergeNavigation(base: NavItem[], contributions: AddonNavigationC
|
|
|
59
60
|
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Drops items whose `requires` capability the user lacks (PIT-044). A parent
|
|
65
|
+
* left without children is dropped too, unless it links somewhere itself.
|
|
66
|
+
*/
|
|
67
|
+
export function filterNavigationByCapability(items: NavItem[], can: CanFn): NavItem[] {
|
|
68
|
+
const out: NavItem[] = []
|
|
69
|
+
for (const item of items) {
|
|
70
|
+
if (item.requires && !can(item.requires)) continue
|
|
71
|
+
if (item.children) {
|
|
72
|
+
const children = filterNavigationByCapability(item.children, can)
|
|
73
|
+
if (children.length === 0 && item.children.length > 0 && !item.to) continue
|
|
74
|
+
out.push({ ...item, children })
|
|
75
|
+
} else {
|
|
76
|
+
out.push(item)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
62
82
|
export function useNavigation(base: NavItem[], contributions: AddonNavigationContribution[]): NavItem[] {
|
|
63
|
-
|
|
83
|
+
// useCan() allows everything without a <PermissionsProvider>, so hosts that
|
|
84
|
+
// never opt in keep the full tree.
|
|
85
|
+
const can = useCan()
|
|
86
|
+
return useMemo(
|
|
87
|
+
() => filterNavigationByCapability(mergeNavigation(base, contributions), can),
|
|
88
|
+
[base, contributions, can],
|
|
89
|
+
)
|
|
64
90
|
}
|
|
65
91
|
|
|
66
92
|
export interface NavigationBuilderProps {
|
|
@@ -73,7 +73,6 @@ import {
|
|
|
73
73
|
Popover,
|
|
74
74
|
PopoverContent,
|
|
75
75
|
PopoverTrigger,
|
|
76
|
-
Separator,
|
|
77
76
|
Skeleton,
|
|
78
77
|
Collapsible,
|
|
79
78
|
CollapsibleContent,
|
|
@@ -360,6 +359,31 @@ export function suggestRoleIcon(label: string): string {
|
|
|
360
359
|
return 'Shield'
|
|
361
360
|
}
|
|
362
361
|
|
|
362
|
+
/** Label of the synthetic module that hosts the general (non-model) flags. */
|
|
363
|
+
export const GENERAL_MODULE_LABEL = 'Permisos generales'
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The general flags (`general.work_after_hours`, …) as a synthetic module, so
|
|
367
|
+
* they are picked from the module combobox and edited in the same two-column
|
|
368
|
+
* action grid as any module's actions (#1021). Each flag keeps its full
|
|
369
|
+
* capability key through `PermissionActionDef.capability`.
|
|
370
|
+
*/
|
|
371
|
+
export function generalPermissionsModule(general: GeneralPermissionDef[]): PermissionModuleDef {
|
|
372
|
+
return {
|
|
373
|
+
key: 'general',
|
|
374
|
+
label: GENERAL_MODULE_LABEL,
|
|
375
|
+
icon: 'SlidersHorizontal',
|
|
376
|
+
kind: 'model',
|
|
377
|
+
actions: general.map((g) => ({
|
|
378
|
+
key: g.key.replace(/^general\./, ''),
|
|
379
|
+
label: g.label,
|
|
380
|
+
description: g.description,
|
|
381
|
+
capability: g.key,
|
|
382
|
+
kind: 'custom',
|
|
383
|
+
})),
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
363
387
|
/**
|
|
364
388
|
* Normalize whatever `loadModules` returned into the canonical grouped shape.
|
|
365
389
|
*
|
|
@@ -613,6 +637,15 @@ export function PermissionsManager({
|
|
|
613
637
|
|
|
614
638
|
const allModules = React.useMemo(() => (groups ? flattenGroups(groups) : []), [groups])
|
|
615
639
|
|
|
640
|
+
// What the module picker lists: the catalog groups plus a trailing synthetic
|
|
641
|
+
// group for the general flags. `allModules` / "Otorgar todo" keep using the
|
|
642
|
+
// plain catalog, so that button's scope does not change.
|
|
643
|
+
const pickerGroups = React.useMemo<ModuleGroup[] | null>(() => {
|
|
644
|
+
if (!groups) return groups
|
|
645
|
+
if (!general || general.length === 0) return groups
|
|
646
|
+
return [...groups, { title: '', modules: [generalPermissionsModule(general)] }]
|
|
647
|
+
}, [groups, general])
|
|
648
|
+
|
|
616
649
|
// ---- initial load: catalog + roles in parallel -------------------------
|
|
617
650
|
React.useEffect(() => {
|
|
618
651
|
let cancelled = false
|
|
@@ -670,8 +703,8 @@ export function PermissionsManager({
|
|
|
670
703
|
[roles, activeRoleId],
|
|
671
704
|
)
|
|
672
705
|
const activeModule = React.useMemo(
|
|
673
|
-
() => moduleAtEntry(
|
|
674
|
-
[
|
|
706
|
+
() => moduleAtEntry(pickerGroups, activeModuleKey)?.module ?? null,
|
|
707
|
+
[pickerGroups, activeModuleKey],
|
|
675
708
|
)
|
|
676
709
|
|
|
677
710
|
const dirty = baseline !== null && draft !== null && !capabilitySetsEqual(baseline, draft)
|
|
@@ -841,8 +874,8 @@ export function PermissionsManager({
|
|
|
841
874
|
const moduleTotal = activeModule?.actions.length ?? 0
|
|
842
875
|
const checksDisabled = !activeRole || !draft || loadingPerms || saving
|
|
843
876
|
const activeModuleGroupTitle = React.useMemo(
|
|
844
|
-
() => moduleAtEntry(
|
|
845
|
-
[
|
|
877
|
+
() => moduleAtEntry(pickerGroups, activeModuleKey)?.group.title ?? '',
|
|
878
|
+
[pickerGroups, activeModuleKey],
|
|
846
879
|
)
|
|
847
880
|
|
|
848
881
|
// ---- render --------------------------------------------------------------
|
|
@@ -1052,26 +1085,6 @@ export function PermissionsManager({
|
|
|
1052
1085
|
)}
|
|
1053
1086
|
</div>
|
|
1054
1087
|
|
|
1055
|
-
{(general?.length ?? 0) > 0 && (
|
|
1056
|
-
<>
|
|
1057
|
-
<Separator />
|
|
1058
|
-
<div>
|
|
1059
|
-
<h3 className="mb-2 text-sm font-semibold">Permisos Generales</h3>
|
|
1060
|
-
<div className="flex flex-col gap-2">
|
|
1061
|
-
{general!.map((g) => (
|
|
1062
|
-
<CapabilityCheck
|
|
1063
|
-
key={g.key}
|
|
1064
|
-
checked={draft?.has(g.key) ?? false}
|
|
1065
|
-
disabled={checksDisabled}
|
|
1066
|
-
onToggle={() => toggleCapability(g.key)}
|
|
1067
|
-
label={g.label}
|
|
1068
|
-
description={g.description}
|
|
1069
|
-
/>
|
|
1070
|
-
))}
|
|
1071
|
-
</div>
|
|
1072
|
-
</div>
|
|
1073
|
-
</>
|
|
1074
|
-
)}
|
|
1075
1088
|
</CardContent>
|
|
1076
1089
|
</Card>
|
|
1077
1090
|
|
|
@@ -1122,7 +1135,7 @@ export function PermissionsManager({
|
|
|
1122
1135
|
<CommandInput placeholder="Buscar módulo…" />
|
|
1123
1136
|
<CommandList className="max-h-[360px]">
|
|
1124
1137
|
<CommandEmpty>Sin módulos.</CommandEmpty>
|
|
1125
|
-
{(
|
|
1138
|
+
{(pickerGroups ?? []).map((group, gi) => (
|
|
1126
1139
|
<CommandGroup
|
|
1127
1140
|
key={group.title || `__untitled_${gi}`}
|
|
1128
1141
|
heading={group.title || undefined}
|
|
@@ -51,7 +51,15 @@ export function PrintDocumentButton({
|
|
|
51
51
|
if (e.defaultPrevented) return
|
|
52
52
|
setBusy(true)
|
|
53
53
|
try {
|
|
54
|
-
|
|
54
|
+
// A caller that handles errors itself keeps ownership of the error toast.
|
|
55
|
+
await printDocument({
|
|
56
|
+
model,
|
|
57
|
+
id,
|
|
58
|
+
key: documentKey,
|
|
59
|
+
mode,
|
|
60
|
+
filename,
|
|
61
|
+
feedback: onError ? { progress: true, success: true, error: false } : true,
|
|
62
|
+
})
|
|
55
63
|
} catch (err) {
|
|
56
64
|
onError?.(err)
|
|
57
65
|
} finally {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// record-mutation-events — a tiny window-level bus that tells every mounted
|
|
2
|
+
// list (DynamicTable, DynamicKanban) "a record of model X just changed".
|
|
3
|
+
//
|
|
4
|
+
// Why: the lists only reloaded when the *caller* bumped `refreshTrigger`. A
|
|
5
|
+
// create/edit/delete done from a dialog the shell opens (the dynamic record
|
|
6
|
+
// dialog, a federated action modal, a wizard, a confirmation) never reached
|
|
7
|
+
// the table, so the row stayed stale until a manual refresh (#1020). Emitting
|
|
8
|
+
// here decouples the mutation site from the list that displays the model.
|
|
9
|
+
|
|
10
|
+
export type RecordMutationKind = 'create' | 'update' | 'delete'
|
|
11
|
+
|
|
12
|
+
export const RECORD_MUTATION_EVENT = 'metacore:record-mutated'
|
|
13
|
+
|
|
14
|
+
export interface RecordMutationDetail {
|
|
15
|
+
/** Model key as the emitter knows it (matched case-insensitively). */
|
|
16
|
+
model: string
|
|
17
|
+
kind: RecordMutationKind
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const norm = (m: string | undefined | null) => (m ?? '').trim().toLowerCase()
|
|
21
|
+
|
|
22
|
+
/** Announce a successful mutation. Safe to call outside a browser (no-op). */
|
|
23
|
+
export function emitRecordMutation(model: string | undefined | null, kind: RecordMutationKind): void {
|
|
24
|
+
if (!model || typeof window === 'undefined' || typeof CustomEvent === 'undefined') return
|
|
25
|
+
try {
|
|
26
|
+
window.dispatchEvent(
|
|
27
|
+
new CustomEvent<RecordMutationDetail>(RECORD_MUTATION_EVENT, { detail: { model, kind } }),
|
|
28
|
+
)
|
|
29
|
+
} catch {
|
|
30
|
+
/* a listener must never break the save flow */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Listen for mutations of `model` (case-insensitive). Returns an unsubscribe.
|
|
36
|
+
*/
|
|
37
|
+
export function subscribeRecordMutations(
|
|
38
|
+
model: string | undefined | null,
|
|
39
|
+
cb: (detail: RecordMutationDetail) => void,
|
|
40
|
+
): () => void {
|
|
41
|
+
if (typeof window === 'undefined') return () => {}
|
|
42
|
+
const want = norm(model)
|
|
43
|
+
const handler = (e: Event) => {
|
|
44
|
+
const d = (e as CustomEvent<RecordMutationDetail>).detail
|
|
45
|
+
if (!d || !want || norm(d.model) !== want) return
|
|
46
|
+
cb(d)
|
|
47
|
+
}
|
|
48
|
+
window.addEventListener(RECORD_MUTATION_EVENT, handler)
|
|
49
|
+
return () => window.removeEventListener(RECORD_MUTATION_EVENT, handler)
|
|
50
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -22,6 +22,48 @@ export interface ImportSpecMeta {
|
|
|
22
22
|
instructions?: string[]
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** Paso de renglones de un tipo de documento (usa el editor de renglones del SDK). */
|
|
26
|
+
export interface DocumentFormLines {
|
|
27
|
+
/** Campo del payload que recibe los renglones. Default: `lines` (o `lines_field` del manifest). */
|
|
28
|
+
field?: string
|
|
29
|
+
/** Columnas opcionales del editor (`discount`, `tax`, `unit`…). */
|
|
30
|
+
columns?: string[]
|
|
31
|
+
/** `sale` (precio de lista) o `cost` (compras). Default `sale`. */
|
|
32
|
+
price_source?: 'sale' | 'cost'
|
|
33
|
+
/** Al menos un renglón para guardar. Default true. */
|
|
34
|
+
required?: boolean
|
|
35
|
+
/** Título del paso. Default «Renglones». */
|
|
36
|
+
title?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Un tipo de documento (Factura, Nota de crédito, REP, Global, Traslado…). */
|
|
40
|
+
export interface DocumentFormType {
|
|
41
|
+
key: string
|
|
42
|
+
label: string
|
|
43
|
+
description?: string
|
|
44
|
+
icon?: string
|
|
45
|
+
/** Valor que se escribe en `type_field` (p. ej. `"E"`). Default: `key`. */
|
|
46
|
+
value?: string
|
|
47
|
+
/** Campos del tipo, en el mismo vocabulario que los campos de una acción. */
|
|
48
|
+
fields: ActionFieldDef[]
|
|
49
|
+
/** Valores fijos que viajan en el alta de este tipo. */
|
|
50
|
+
defaults?: Record<string, unknown>
|
|
51
|
+
/** Con esto el tipo tiene paso de renglones. */
|
|
52
|
+
lines?: DocumentFormLines | boolean
|
|
53
|
+
/** Endpoint de alta propio de este tipo. Default: el del modelo. */
|
|
54
|
+
endpoint?: string
|
|
55
|
+
/** Texto del botón final. Default «Crear». */
|
|
56
|
+
submit_label?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface DocumentFormsManifest {
|
|
60
|
+
/** Columna discriminadora del modelo (p. ej. `"type"`). Sin ella no se escribe el tipo. */
|
|
61
|
+
type_field?: string
|
|
62
|
+
/** Campo por defecto de los renglones. Default `lines`. */
|
|
63
|
+
lines_field?: string
|
|
64
|
+
types: DocumentFormType[]
|
|
65
|
+
}
|
|
66
|
+
|
|
25
67
|
export interface TableMetadata {
|
|
26
68
|
title: string
|
|
27
69
|
endpoint: string
|
|
@@ -36,6 +78,20 @@ export interface TableMetadata {
|
|
|
36
78
|
canExport?: boolean
|
|
37
79
|
canImport?: boolean
|
|
38
80
|
canCreate?: boolean
|
|
81
|
+
/**
|
|
82
|
+
* Formularios de alta guiados por tipo de documento (FAC-12). Cuando trae
|
|
83
|
+
* al menos un tipo, `DynamicCRUDPage` abre `DocumentFormDialog` en lugar del
|
|
84
|
+
* formulario genérico del modelo. Lo sirve el kernel o el addon; el SDK no
|
|
85
|
+
* conoce campos fiscales.
|
|
86
|
+
*/
|
|
87
|
+
document_forms?: DocumentFormsManifest
|
|
88
|
+
/**
|
|
89
|
+
* Cómo se pinta el alta genérica. `generic` (o ausente) es el botón Crear.
|
|
90
|
+
* `hidden` lo quita. `action` lo quita porque el módulo trae la suya
|
|
91
|
+
* (`placement: create` o `replaces_create`). Lo sirve el kernel; un host
|
|
92
|
+
* también puede fijarlo con `ModelExtension.createMode` sin cambio de kernel.
|
|
93
|
+
*/
|
|
94
|
+
create_mode?: 'generic' | 'hidden' | 'action'
|
|
39
95
|
/**
|
|
40
96
|
* The model's spreadsheet-import declaration, served by the kernel: the
|
|
41
97
|
* columns of the generated template and the headers accepted when parsing
|
|
@@ -702,6 +758,13 @@ export interface ActionDefinition {
|
|
|
702
758
|
* "create" — toolbar button that replaces the generic create button.
|
|
703
759
|
*/
|
|
704
760
|
placement?: 'row' | 'table' | 'create'
|
|
761
|
+
/**
|
|
762
|
+
* Esta acción de toolbar sustituye al Crear genérico aunque su `placement`
|
|
763
|
+
* sea `table`. El kernel tiene que reenviar el campo; si lo recorta, el
|
|
764
|
+
* host usa `ModelExtension.primaryActionKey` o `createMode`.
|
|
765
|
+
*/
|
|
766
|
+
replaces_create?: boolean
|
|
767
|
+
replacesCreate?: boolean
|
|
705
768
|
/**
|
|
706
769
|
* Optional addon this action depends on, stamped by the host only while it
|
|
707
770
|
* is NOT installed (served as `requires_addon`; read through
|