@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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
+
import { useApi } from '../api-context'
|
|
3
|
+
import { buildRelationFilterParams } from '../dynamic-relation-helpers'
|
|
4
|
+
import type { DocumentSourceDef } from './types'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_LIMIT = 50
|
|
7
|
+
// El kernel ≥ 0.139.2 acepta listas `in:` largas; se parte para no exceder la URL.
|
|
8
|
+
const VIA_CHUNK = 40
|
|
9
|
+
|
|
10
|
+
type Rows = Record<string, unknown>[]
|
|
11
|
+
|
|
12
|
+
export interface DocumentSourcesState {
|
|
13
|
+
sources: Record<string, Record<string, unknown>[]>
|
|
14
|
+
loading: boolean
|
|
15
|
+
/** Vuelve a pedir todas las fuentes (tras ejecutar una acción). */
|
|
16
|
+
reload: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Carga las filas relacionadas con el documento (pagos, REP, notas de crédito…)
|
|
21
|
+
* que alimentan contadores, pestañas y derivados. Un fallo en una fuente deja
|
|
22
|
+
* esa fuente vacía: la página sigue siendo usable.
|
|
23
|
+
*/
|
|
24
|
+
export function useDocumentSources(
|
|
25
|
+
defs: DocumentSourceDef[] | undefined,
|
|
26
|
+
recordId: string | number | undefined,
|
|
27
|
+
): DocumentSourcesState {
|
|
28
|
+
const api = useApi()
|
|
29
|
+
const [sources, setSources] = useState<Record<string, Record<string, unknown>[]>>({})
|
|
30
|
+
const [loading, setLoading] = useState(false)
|
|
31
|
+
const [nonce, setNonce] = useState(0)
|
|
32
|
+
const reload = useCallback(() => setNonce((n) => n + 1), [])
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!defs || defs.length === 0 || recordId === undefined || recordId === null) {
|
|
36
|
+
setSources({})
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
let cancelled = false
|
|
40
|
+
setLoading(true)
|
|
41
|
+
const fetchRows = async (d: DocumentSourceDef, params: Record<string, string>): Promise<Rows> => {
|
|
42
|
+
try {
|
|
43
|
+
const res = await api.get(`/data/${d.model}`, {
|
|
44
|
+
params: { ...params, page: 1, per_page: d.limit ?? DEFAULT_LIMIT },
|
|
45
|
+
})
|
|
46
|
+
const body = res.data
|
|
47
|
+
const rows = (body?.data ?? body) as unknown
|
|
48
|
+
return Array.isArray(rows) ? (rows as Rows) : []
|
|
49
|
+
} catch {
|
|
50
|
+
return []
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const direct = defs.filter((d) => !d.via)
|
|
54
|
+
const hopped = defs.filter((d) => d.via)
|
|
55
|
+
;(async () => {
|
|
56
|
+
const out: Record<string, Rows> = {}
|
|
57
|
+
await Promise.all(
|
|
58
|
+
direct.map(async (d) => {
|
|
59
|
+
out[d.key] = await fetchRows(d, buildRelationFilterParams(d.foreignKey, recordId, d.where))
|
|
60
|
+
}),
|
|
61
|
+
)
|
|
62
|
+
// Segundo salto: depende de las filas del primero.
|
|
63
|
+
for (const d of hopped) {
|
|
64
|
+
const ids = Array.from(
|
|
65
|
+
new Set((out[d.via!.source] ?? []).map((r) => r[d.via!.field]).filter((v) => v != null && v !== '').map(String)),
|
|
66
|
+
)
|
|
67
|
+
const chunks: string[][] = []
|
|
68
|
+
for (let i = 0; i < ids.length; i += VIA_CHUNK) chunks.push(ids.slice(i, i + VIA_CHUNK))
|
|
69
|
+
const parts = await Promise.all(
|
|
70
|
+
chunks.map((c) => {
|
|
71
|
+
const params: Record<string, string> = { [`f_${d.foreignKey}`]: `in:${c.join(',')}` }
|
|
72
|
+
for (const [k, v] of Object.entries(d.where ?? {})) params[`f_${k}`] = `eq:${v}`
|
|
73
|
+
return fetchRows(d, params)
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
out[d.key] = parts.flat()
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
})()
|
|
80
|
+
.then((out) => {
|
|
81
|
+
if (cancelled) return
|
|
82
|
+
setSources(out)
|
|
83
|
+
setLoading(false)
|
|
84
|
+
})
|
|
85
|
+
return () => {
|
|
86
|
+
cancelled = true
|
|
87
|
+
}
|
|
88
|
+
}, [api, defs, recordId, nonce])
|
|
89
|
+
|
|
90
|
+
return { sources, loading, reload }
|
|
91
|
+
}
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -1312,7 +1312,11 @@ export function makeDefaultGetDynamicColumns(
|
|
|
1312
1312
|
|
|
1313
1313
|
case 'tags': {
|
|
1314
1314
|
const list: string[] = Array.isArray(value)
|
|
1315
|
-
? value.map(
|
|
1315
|
+
? value.map((v) =>
|
|
1316
|
+
v && typeof v === 'object'
|
|
1317
|
+
? String((v as { label?: unknown; name?: unknown; key?: unknown }).label ?? (v as { name?: unknown }).name ?? (v as { key?: unknown }).key ?? '')
|
|
1318
|
+
: String(v),
|
|
1319
|
+
).filter(Boolean)
|
|
1316
1320
|
: value
|
|
1317
1321
|
? String(value)
|
|
1318
1322
|
.split(',')
|
package/src/dynamic-relation.tsx
CHANGED
|
@@ -83,6 +83,10 @@ export {
|
|
|
83
83
|
export interface DynamicRelationStrings {
|
|
84
84
|
title: string
|
|
85
85
|
emptyState: string
|
|
86
|
+
/** Sub-tabla sin permiso de lectura (HTTP 403). Default: "Sin permiso para ver estos registros." */
|
|
87
|
+
forbiddenState?: string
|
|
88
|
+
/** Falla al cargar la sub-tabla (no 403). Default: "No se pudieron cargar los registros relacionados." */
|
|
89
|
+
errorState?: string
|
|
86
90
|
addLabel: string
|
|
87
91
|
editLabel: string
|
|
88
92
|
removeLabel: string
|
|
@@ -252,6 +256,9 @@ function OneToManyRelation({
|
|
|
252
256
|
const [optionsMap, setOptionsMap] = useState<Map<string, any[]>>(new Map())
|
|
253
257
|
const [rows, setRows] = useState<any[]>([])
|
|
254
258
|
const [loading, setLoading] = useState(true)
|
|
259
|
+
// HTTP status of the last failed fetch (null = ok). Without it a 403/500
|
|
260
|
+
// rendered the empty state and read as "no related rows" (PIT-045).
|
|
261
|
+
const [fetchErrorStatus, setFetchErrorStatus] = useState<number | null>(null)
|
|
255
262
|
// Paginación server-side + scroll infinito, igual que <DynamicTable>.
|
|
256
263
|
const [total, setTotal] = useState(0)
|
|
257
264
|
const [page, setPage] = useState(1)
|
|
@@ -341,6 +348,7 @@ function OneToManyRelation({
|
|
|
341
348
|
cacheMetadata(model, fresh)
|
|
342
349
|
}
|
|
343
350
|
const list = (dataRes as { data: ApiResponse<any[]> }).data
|
|
351
|
+
setFetchErrorStatus(null)
|
|
344
352
|
if (list.success) {
|
|
345
353
|
const fetched = list.data || []
|
|
346
354
|
setRows((prev) => (append ? dedupeById(prev, fetched) : fetched))
|
|
@@ -351,6 +359,7 @@ function OneToManyRelation({
|
|
|
351
359
|
}
|
|
352
360
|
} catch (err) {
|
|
353
361
|
console.error('DynamicRelation fetch error', err)
|
|
362
|
+
setFetchErrorStatus((err as { response?: { status?: number } })?.response?.status ?? 0)
|
|
354
363
|
} finally {
|
|
355
364
|
if (append) setLoadingMore(false)
|
|
356
365
|
else setLoading(false)
|
|
@@ -555,7 +564,11 @@ function OneToManyRelation({
|
|
|
555
564
|
</div>
|
|
556
565
|
) : rows.length === 0 ? (
|
|
557
566
|
<div className="text-center text-sm text-muted-foreground py-8 border rounded-md bg-muted/30">
|
|
558
|
-
{
|
|
567
|
+
{fetchErrorStatus === 403
|
|
568
|
+
? (labels.forbiddenState ?? 'Sin permiso para ver estos registros.')
|
|
569
|
+
: fetchErrorStatus !== null
|
|
570
|
+
? (labels.errorState ?? 'No se pudieron cargar los registros relacionados.')
|
|
571
|
+
: labels.emptyState}
|
|
559
572
|
</div>
|
|
560
573
|
) : (
|
|
561
574
|
// Real metadata-driven table — same metacore-ui primitives and
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
AlertDialogTitle,
|
|
35
35
|
} from '@asteby/metacore-ui/primitives'
|
|
36
36
|
import { useApi } from './api-context'
|
|
37
|
+
import { useReasonPrompt } from './reason-prompt'
|
|
37
38
|
import { ActionModalDispatcher } from './action-modal-dispatcher'
|
|
38
39
|
import { DynamicRecordDialog } from './dialogs/dynamic-record'
|
|
39
40
|
import { RequiresAddonDialog, resolveRequiresAddon } from './requires-addon'
|
|
@@ -102,6 +103,9 @@ export function useDynamicRowActions({
|
|
|
102
103
|
|
|
103
104
|
const [rowToDelete, setRowToDelete] = useState<any | null>(null)
|
|
104
105
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
106
|
+
// Models that declare `reason_required.delete` answer a bare DELETE with 422
|
|
107
|
+
// errors.reason; the prompt asks once and retries with `?reason=`.
|
|
108
|
+
const reasonPrompt = useReasonPrompt()
|
|
105
109
|
|
|
106
110
|
const [actionModal, setActionModal] = useState<{
|
|
107
111
|
open: boolean
|
|
@@ -157,7 +161,14 @@ export function useDynamicRowActions({
|
|
|
157
161
|
setIsDeleting(true)
|
|
158
162
|
try {
|
|
159
163
|
const deleteEndpoint = writeBase ? `${writeBase}/${rowToDelete.id}` : `/data/${model}/${rowToDelete.id}`
|
|
160
|
-
const res = await
|
|
164
|
+
const res = await reasonPrompt.run({
|
|
165
|
+
title: t('dynamic.delete_reason_title', { defaultValue: 'Eliminar registro' }),
|
|
166
|
+
description: t('dynamic.delete_reason_desc', { defaultValue: 'Este modelo pide el motivo de la eliminación.' }),
|
|
167
|
+
confirmLabel: t('dynamic.delete_confirm', { defaultValue: 'Eliminar' }),
|
|
168
|
+
request: (reason) => api.delete(deleteEndpoint, reason ? { params: { reason } } : undefined),
|
|
169
|
+
})
|
|
170
|
+
// El operador canceló el motivo: no se eliminó nada.
|
|
171
|
+
if (!res) return
|
|
161
172
|
// CRUD estándar: no usar res.data.message (el endpoint dinámico
|
|
162
173
|
// devuelve texto en inglés que se filtraría al toast). String localizado.
|
|
163
174
|
if (res.data.success) { toast.success(t('dynamic.delete_success', { defaultValue: 'Registro eliminado correctamente' })); onRefresh() }
|
|
@@ -173,6 +184,7 @@ export function useDynamicRowActions({
|
|
|
173
184
|
|
|
174
185
|
const dialogs = (
|
|
175
186
|
<>
|
|
187
|
+
{reasonPrompt.dialog}
|
|
176
188
|
<AlertDialog open={!!rowToDelete} onOpenChange={(open: boolean) => !open && setRowToDelete(null)}>
|
|
177
189
|
<AlertDialogContent>
|
|
178
190
|
<AlertDialogHeader>
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -67,6 +67,7 @@ import { toast } from 'sonner'
|
|
|
67
67
|
import { Progress } from './dialogs/_primitives'
|
|
68
68
|
import { useMetadataCache } from './metadata-cache'
|
|
69
69
|
import { useApi, useCurrentBranch } from './api-context'
|
|
70
|
+
import { useReasonPrompt } from './reason-prompt'
|
|
70
71
|
import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
|
|
71
72
|
import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns'
|
|
72
73
|
import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders'
|
|
@@ -1123,6 +1124,7 @@ export function DynamicTable({
|
|
|
1123
1124
|
onRefresh: handleRefresh,
|
|
1124
1125
|
})
|
|
1125
1126
|
|
|
1127
|
+
const bulkReasonPrompt = useReasonPrompt()
|
|
1126
1128
|
const confirmBulkDelete = async () => {
|
|
1127
1129
|
const selectedRows = table.getFilteredSelectedRowModel().rows
|
|
1128
1130
|
if (selectedRows.length === 0) return
|
|
@@ -1130,13 +1132,29 @@ export function DynamicTable({
|
|
|
1130
1132
|
setBulkDeleteTotal(selectedRows.length)
|
|
1131
1133
|
setBulkDeleteProgress(0)
|
|
1132
1134
|
let successCount = 0, errorCount = 0
|
|
1135
|
+
// One reason for the whole batch: asked the first time a row is refused
|
|
1136
|
+
// for lacking it (model declares reason_required.delete), reused after.
|
|
1137
|
+
let bulkReason: string | undefined
|
|
1138
|
+
let bulkCancelled = false
|
|
1133
1139
|
for (let i = 0; i < selectedRows.length; i++) {
|
|
1134
1140
|
const row = selectedRows[i]
|
|
1141
|
+
if (bulkCancelled) { errorCount++; setBulkDeleteProgress(i + 1); continue }
|
|
1135
1142
|
try {
|
|
1136
1143
|
const writeBase = mutationEndpoint ?? endpoint
|
|
1137
1144
|
const deleteEndpoint = writeBase ? `${writeBase}/${row.original.id}` : `/data/${model}/${row.original.id}`
|
|
1138
|
-
const res = await
|
|
1139
|
-
|
|
1145
|
+
const res = await bulkReasonPrompt.run({
|
|
1146
|
+
title: t('dynamic.bulk_delete_reason_title', { count: selectedRows.length, defaultValue: 'Eliminar {{count}} registro(s)' }),
|
|
1147
|
+
description: t('dynamic.delete_reason_desc', { defaultValue: 'Este modelo pide el motivo de la eliminación.' }),
|
|
1148
|
+
confirmLabel: t('dynamic.delete_confirm', { defaultValue: 'Eliminar' }),
|
|
1149
|
+
reason: bulkReason,
|
|
1150
|
+
request: (reason) => {
|
|
1151
|
+
if (reason) bulkReason = reason
|
|
1152
|
+
return api.delete(deleteEndpoint, reason ? { params: { reason } } : undefined)
|
|
1153
|
+
},
|
|
1154
|
+
})
|
|
1155
|
+
if (!res) { bulkCancelled = true; errorCount++ }
|
|
1156
|
+
else if (res.data.success) successCount++
|
|
1157
|
+
else errorCount++
|
|
1140
1158
|
} catch (e) { console.error('Error al eliminar', e); errorCount++ }
|
|
1141
1159
|
setBulkDeleteProgress(i + 1)
|
|
1142
1160
|
}
|
|
@@ -1863,6 +1881,8 @@ export function DynamicTable({
|
|
|
1863
1881
|
</AlertDialogContent>
|
|
1864
1882
|
</AlertDialog>
|
|
1865
1883
|
|
|
1884
|
+
{bulkReasonPrompt.dialog}
|
|
1885
|
+
|
|
1866
1886
|
{exportEnabled && (
|
|
1867
1887
|
<ExportDialog open={exportOpen} onOpenChange={setExportOpen} model={model} metadata={metadata} currentFilters={buildFilterParams()} hasActiveFilters={hasActiveFilters} />
|
|
1868
1888
|
)}
|
package/src/index.ts
CHANGED
|
@@ -218,6 +218,35 @@ export {
|
|
|
218
218
|
type CanFn,
|
|
219
219
|
type PermissionsProviderProps,
|
|
220
220
|
} from './permissions-context'
|
|
221
|
+
export {
|
|
222
|
+
ApprovalPinDialog,
|
|
223
|
+
approvalErrorMessage,
|
|
224
|
+
type ApprovalPinDialogProps,
|
|
225
|
+
type ApprovalPinSubmit,
|
|
226
|
+
} from './approval-pin-dialog'
|
|
227
|
+
export {
|
|
228
|
+
ApprovalGateProvider,
|
|
229
|
+
useApprovalGate,
|
|
230
|
+
approvalCapability,
|
|
231
|
+
approvalRequiredInfo,
|
|
232
|
+
approvalGateAvailable,
|
|
233
|
+
APPROVAL_PARKED_EVENT,
|
|
234
|
+
APPROVAL_DECIDED_EVENT,
|
|
235
|
+
type ApprovalGateValue,
|
|
236
|
+
type ApprovalGrant,
|
|
237
|
+
type ApprovalRequiredInfo,
|
|
238
|
+
type RequestApprovalOptions,
|
|
239
|
+
} from './approval-gate'
|
|
240
|
+
export {
|
|
241
|
+
ReasonPromptDialog,
|
|
242
|
+
useReasonPrompt,
|
|
243
|
+
reasonRequiredInfo,
|
|
244
|
+
DEFAULT_REASON_MIN,
|
|
245
|
+
type ReasonPromptDialogProps,
|
|
246
|
+
type ReasonPromptApi,
|
|
247
|
+
type ReasonRequiredInfo,
|
|
248
|
+
type RunWithReasonOptions,
|
|
249
|
+
} from './reason-prompt'
|
|
221
250
|
export {
|
|
222
251
|
useDynamicRowActions,
|
|
223
252
|
type UseDynamicRowActionsParams,
|
|
@@ -282,6 +311,7 @@ export {
|
|
|
282
311
|
type SidebarLayoutManagerProps,
|
|
283
312
|
} from './sidebar-layout-manager'
|
|
284
313
|
export * from './org-runtime-context'
|
|
314
|
+
export * from './business'
|
|
285
315
|
export * from './org-runtime-provider'
|
|
286
316
|
export * from './navigation-builder'
|
|
287
317
|
export * from './i18n-provider'
|
|
@@ -581,3 +611,6 @@ export {
|
|
|
581
611
|
type AttributeClass,
|
|
582
612
|
type AttributeClassSection,
|
|
583
613
|
} from './attribute-classes'
|
|
614
|
+
|
|
615
|
+
// Página de documento (benchmark §5/§6): cabecera, badges, acciones por estado, botones inteligentes.
|
|
616
|
+
export * from './document'
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Mandatory reason on destructive operations (PER-4).
|
|
2
|
+
//
|
|
3
|
+
// A manifest model declares `reason_required` (delete and/or actions such as
|
|
4
|
+
// cancel). The kernel then answers a DELETE / action WITHOUT a reason with
|
|
5
|
+
// 422 `errors.reason: [{code: "required" | "min", params: {min}}]`. Instead of
|
|
6
|
+
// every screen carrying its own "motivo" prompt, `useReasonPrompt().run()` wraps
|
|
7
|
+
// the request: it tries it, and when the server says a reason is needed it asks
|
|
8
|
+
// once (a dialog) and retries with the reason attached. Screens that already know
|
|
9
|
+
// they need one can pass `reason` up front through the same helper.
|
|
10
|
+
//
|
|
11
|
+
// The reason is stamped on the canonical event by the kernel, so the activity log
|
|
12
|
+
// (RecordHistory) shows who removed/cancelled what, when, and why.
|
|
13
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
14
|
+
import { useTranslation } from 'react-i18next'
|
|
15
|
+
import { Loader2 } from 'lucide-react'
|
|
16
|
+
import {
|
|
17
|
+
Button,
|
|
18
|
+
Dialog,
|
|
19
|
+
DialogContent,
|
|
20
|
+
DialogDescription,
|
|
21
|
+
DialogFooter,
|
|
22
|
+
DialogHeader,
|
|
23
|
+
DialogTitle,
|
|
24
|
+
Textarea,
|
|
25
|
+
} from '@asteby/metacore-ui/primitives'
|
|
26
|
+
|
|
27
|
+
/** What the server told us about the missing reason. */
|
|
28
|
+
export interface ReasonRequiredInfo {
|
|
29
|
+
/** Minimum trimmed length the policy demands (default 3). */
|
|
30
|
+
min: number
|
|
31
|
+
/** True when a reason WAS sent but was too short. */
|
|
32
|
+
tooShort: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_REASON_MIN = 3
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Detects the kernel's mandatory-reason refusal in an axios error (or a bare
|
|
39
|
+
* response body): HTTP 422 with `errors.reason[0].code` of `required` | `min`.
|
|
40
|
+
* Returns null for any other error.
|
|
41
|
+
*/
|
|
42
|
+
export function reasonRequiredInfo(err: unknown): ReasonRequiredInfo | null {
|
|
43
|
+
const data = (err as { response?: { data?: unknown } } | undefined)?.response?.data ?? err
|
|
44
|
+
const issues = (data as { errors?: { reason?: unknown } } | undefined)?.errors?.reason
|
|
45
|
+
if (!Array.isArray(issues) || issues.length === 0) return null
|
|
46
|
+
const first = issues[0] as { code?: string; params?: { min?: number } } | string
|
|
47
|
+
const code = typeof first === 'string' ? first : first?.code
|
|
48
|
+
if (code !== 'required' && code !== 'min') return null
|
|
49
|
+
const min = typeof first === 'object' && typeof first?.params?.min === 'number' ? first.params.min : DEFAULT_REASON_MIN
|
|
50
|
+
return { min, tooShort: code === 'min' }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ReasonPromptDialogProps {
|
|
54
|
+
open: boolean
|
|
55
|
+
title: string
|
|
56
|
+
description?: React.ReactNode
|
|
57
|
+
/** Minimum trimmed length; the submit button stays disabled below it. */
|
|
58
|
+
min?: number
|
|
59
|
+
/** Label of the confirm button (default "Continuar"). */
|
|
60
|
+
confirmLabel?: string
|
|
61
|
+
/** Style the confirm button as destructive (delete / cancel). */
|
|
62
|
+
destructive?: boolean
|
|
63
|
+
/** Inline error from a previous attempt. */
|
|
64
|
+
error?: string | null
|
|
65
|
+
busy?: boolean
|
|
66
|
+
onSubmit: (reason: string) => void
|
|
67
|
+
onCancel: () => void
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function ReasonPromptDialog({
|
|
71
|
+
open,
|
|
72
|
+
title,
|
|
73
|
+
description,
|
|
74
|
+
min = DEFAULT_REASON_MIN,
|
|
75
|
+
confirmLabel,
|
|
76
|
+
destructive = true,
|
|
77
|
+
error,
|
|
78
|
+
busy,
|
|
79
|
+
onSubmit,
|
|
80
|
+
onCancel,
|
|
81
|
+
}: ReasonPromptDialogProps) {
|
|
82
|
+
const { t } = useTranslation()
|
|
83
|
+
const [reason, setReason] = useState('')
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (open) setReason('')
|
|
87
|
+
}, [open])
|
|
88
|
+
|
|
89
|
+
const ready = reason.trim().length >= min
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<Dialog open={open} onOpenChange={(o: boolean) => !o && !busy && onCancel()}>
|
|
93
|
+
<DialogContent className="sm:max-w-md">
|
|
94
|
+
<DialogHeader>
|
|
95
|
+
<DialogTitle>{title}</DialogTitle>
|
|
96
|
+
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
|
97
|
+
</DialogHeader>
|
|
98
|
+
<div className="space-y-2">
|
|
99
|
+
<label htmlFor="mc-reason" className="text-sm font-medium">
|
|
100
|
+
{t('reason.label', { defaultValue: 'Motivo' })}
|
|
101
|
+
</label>
|
|
102
|
+
<Textarea
|
|
103
|
+
id="mc-reason"
|
|
104
|
+
autoFocus
|
|
105
|
+
rows={3}
|
|
106
|
+
value={reason}
|
|
107
|
+
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setReason(e.target.value)}
|
|
108
|
+
placeholder={t('reason.placeholder', { defaultValue: 'Explica por qué. Queda en la bitácora.' })}
|
|
109
|
+
/>
|
|
110
|
+
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
|
111
|
+
{!error && reason.length > 0 && !ready ? (
|
|
112
|
+
<p className="text-muted-foreground text-xs">
|
|
113
|
+
{t('reason.min', { min, defaultValue: 'Escribe al menos {{min}} caracteres.' })}
|
|
114
|
+
</p>
|
|
115
|
+
) : null}
|
|
116
|
+
</div>
|
|
117
|
+
<DialogFooter>
|
|
118
|
+
<Button variant="outline" onClick={onCancel} disabled={busy}>
|
|
119
|
+
{t('common.cancel', { defaultValue: 'Cancelar' })}
|
|
120
|
+
</Button>
|
|
121
|
+
<Button
|
|
122
|
+
variant={destructive ? 'destructive' : 'default'}
|
|
123
|
+
disabled={!ready || busy}
|
|
124
|
+
onClick={() => onSubmit(reason.trim())}
|
|
125
|
+
>
|
|
126
|
+
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
|
127
|
+
{confirmLabel ?? t('reason.confirm', { defaultValue: 'Continuar' })}
|
|
128
|
+
</Button>
|
|
129
|
+
</DialogFooter>
|
|
130
|
+
</DialogContent>
|
|
131
|
+
</Dialog>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface RunWithReasonOptions<T> {
|
|
136
|
+
/** Dialog headline, e.g. "Eliminar registro". */
|
|
137
|
+
title: string
|
|
138
|
+
description?: React.ReactNode
|
|
139
|
+
confirmLabel?: string
|
|
140
|
+
destructive?: boolean
|
|
141
|
+
/**
|
|
142
|
+
* Performs the request. Called first WITHOUT a reason; when the server
|
|
143
|
+
* demands one it is called again with the operator's reason. Throw (axios
|
|
144
|
+
* style) on failure.
|
|
145
|
+
*/
|
|
146
|
+
request: (reason?: string) => Promise<T>
|
|
147
|
+
/** A reason already collected (bulk operations reuse one for every row). */
|
|
148
|
+
reason?: string
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface ReasonPromptApi {
|
|
152
|
+
/**
|
|
153
|
+
* Resolves the request's result, or `undefined` when the operator cancelled
|
|
154
|
+
* the reason prompt. Any error that is NOT a reason refusal is rethrown.
|
|
155
|
+
*/
|
|
156
|
+
run: <T>(opts: RunWithReasonOptions<T>) => Promise<T | undefined>
|
|
157
|
+
/** Render once near the caller: the dialog the helper opens. */
|
|
158
|
+
dialog: React.ReactNode
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface PendingReason {
|
|
162
|
+
opts: RunWithReasonOptions<unknown>
|
|
163
|
+
info: ReasonRequiredInfo
|
|
164
|
+
resolve: (v: unknown) => void
|
|
165
|
+
reject: (e: unknown) => void
|
|
166
|
+
error: string | null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** See the module comment. Render `dialog` somewhere in the caller's tree. */
|
|
170
|
+
export function useReasonPrompt(): ReasonPromptApi {
|
|
171
|
+
const { t } = useTranslation()
|
|
172
|
+
const [pending, setPending] = useState<PendingReason | null>(null)
|
|
173
|
+
const [busy, setBusy] = useState(false)
|
|
174
|
+
const pendingRef = useRef<PendingReason | null>(null)
|
|
175
|
+
pendingRef.current = pending
|
|
176
|
+
|
|
177
|
+
const run = useCallback(<T,>(opts: RunWithReasonOptions<T>): Promise<T | undefined> => {
|
|
178
|
+
return new Promise<T | undefined>((resolve, reject) => {
|
|
179
|
+
opts
|
|
180
|
+
.request(opts.reason)
|
|
181
|
+
.then((v) => resolve(v))
|
|
182
|
+
.catch((err) => {
|
|
183
|
+
const info = reasonRequiredInfo(err)
|
|
184
|
+
if (!info) return reject(err)
|
|
185
|
+
setPending({
|
|
186
|
+
opts: opts as RunWithReasonOptions<unknown>,
|
|
187
|
+
info,
|
|
188
|
+
resolve: resolve as (v: unknown) => void,
|
|
189
|
+
reject,
|
|
190
|
+
error: null,
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
}, [])
|
|
195
|
+
|
|
196
|
+
const submit = async (reason: string) => {
|
|
197
|
+
const p = pendingRef.current
|
|
198
|
+
if (!p) return
|
|
199
|
+
setBusy(true)
|
|
200
|
+
try {
|
|
201
|
+
const v = await p.opts.request(reason)
|
|
202
|
+
setPending(null)
|
|
203
|
+
p.resolve(v)
|
|
204
|
+
} catch (err) {
|
|
205
|
+
const info = reasonRequiredInfo(err)
|
|
206
|
+
if (info) {
|
|
207
|
+
// Server still not satisfied (a longer minimum): stay open, say why.
|
|
208
|
+
setPending({
|
|
209
|
+
...p,
|
|
210
|
+
info,
|
|
211
|
+
error: t('reason.min', {
|
|
212
|
+
min: info.min,
|
|
213
|
+
defaultValue: 'Escribe al menos {{min}} caracteres.',
|
|
214
|
+
}),
|
|
215
|
+
})
|
|
216
|
+
} else {
|
|
217
|
+
setPending(null)
|
|
218
|
+
p.reject(err)
|
|
219
|
+
}
|
|
220
|
+
} finally {
|
|
221
|
+
setBusy(false)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const cancel = () => {
|
|
226
|
+
const p = pendingRef.current
|
|
227
|
+
setPending(null)
|
|
228
|
+
p?.resolve(undefined)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const dialog = (
|
|
232
|
+
<ReasonPromptDialog
|
|
233
|
+
open={pending !== null}
|
|
234
|
+
title={pending?.opts.title ?? ''}
|
|
235
|
+
description={pending?.opts.description}
|
|
236
|
+
min={pending?.info.min}
|
|
237
|
+
confirmLabel={pending?.opts.confirmLabel}
|
|
238
|
+
destructive={pending?.opts.destructive}
|
|
239
|
+
error={pending?.error}
|
|
240
|
+
busy={busy}
|
|
241
|
+
onSubmit={submit}
|
|
242
|
+
onCancel={cancel}
|
|
243
|
+
/>
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return { run, dialog }
|
|
247
|
+
}
|
package/src/server-error.ts
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
|
|
|
15
16
|
/** Structured, display-ready view of an error: a headline + an optional cause. */
|
|
16
17
|
export interface ExtractedError {
|
|
@@ -234,6 +235,25 @@ export function toastServerError(
|
|
|
234
235
|
const t = opts?.t
|
|
235
236
|
const lang = opts?.language
|
|
236
237
|
const cat = validationCatalog(lang)
|
|
238
|
+
// A write parked for approval is not a failure: say so, and when an
|
|
239
|
+
// <ApprovalGateProvider> is mounted offer to approve it right now with a PIN.
|
|
240
|
+
const parked = approvalRequiredInfo(err)
|
|
241
|
+
if (parked) {
|
|
242
|
+
const title = parked.label || (t ? t('approvals.sent', { defaultValue: 'Enviado a aprobación' }) : 'Enviado a aprobación')
|
|
243
|
+
toast.info(title, {
|
|
244
|
+
description: parked.roles.length
|
|
245
|
+
? (t ? t('approvals.waiting_for', { defaultValue: 'Lo decide: {{roles}}', roles: parked.roles.join(', ') }) : parked.roles.join(', '))
|
|
246
|
+
: undefined,
|
|
247
|
+
action:
|
|
248
|
+
parked.requestId && approvalGateAvailable()
|
|
249
|
+
? {
|
|
250
|
+
label: t ? t('approvals.approve_with_pin', { defaultValue: 'Aprobar con PIN' }) : 'Aprobar con PIN',
|
|
251
|
+
onClick: () => window.dispatchEvent(new CustomEvent(APPROVAL_PARKED_EVENT, { detail: parked })),
|
|
252
|
+
}
|
|
253
|
+
: undefined,
|
|
254
|
+
})
|
|
255
|
+
return
|
|
256
|
+
}
|
|
237
257
|
const map = extractFieldErrors(err)
|
|
238
258
|
if (map) {
|
|
239
259
|
const localized = t
|
|
@@ -27,9 +27,19 @@ import {
|
|
|
27
27
|
} from '@tanstack/react-query'
|
|
28
28
|
import { useApi } from './api-context'
|
|
29
29
|
|
|
30
|
-
/** Stable react-query key for an addon's per-org settings. */
|
|
31
|
-
export function addonSettingsKey(addonKey: string) {
|
|
32
|
-
return ['addon-settings', addonKey] as const
|
|
30
|
+
/** Stable react-query key for an addon's per-org settings (per branch when given). */
|
|
31
|
+
export function addonSettingsKey(addonKey: string, branchId?: string | null) {
|
|
32
|
+
return branchId ? (['addon-settings', addonKey, branchId] as const) : (['addon-settings', addonKey] as const)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Settings URL. With a branch, the host answers the settings the addon runs
|
|
37
|
+
* with there: org values with each `scope: branch` key replaced by the branch's
|
|
38
|
+
* override (POS-1). Without one, the org values (legacy behaviour).
|
|
39
|
+
*/
|
|
40
|
+
export function addonSettingsUrl(addonKey: string, branchId?: string | null): string {
|
|
41
|
+
const base = `/api/addons/${addonKey}/settings`
|
|
42
|
+
return branchId ? `${base}?branch_id=${encodeURIComponent(branchId)}` : base
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
/**
|
|
@@ -63,6 +73,12 @@ export interface UseAddonSettingsOptions<T> {
|
|
|
63
73
|
staleTime?: number
|
|
64
74
|
/** Defer fetching (e.g. until the addon key is known). */
|
|
65
75
|
enabled?: boolean
|
|
76
|
+
/**
|
|
77
|
+
* Read the settings as one branch sees them (manifest `scope: branch` keys
|
|
78
|
+
* resolve to the branch override, else the org value). Omit for org values.
|
|
79
|
+
* A terminal passes its active branch here.
|
|
80
|
+
*/
|
|
81
|
+
branchId?: string | null
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
export interface UseAddonSettingsResult<T> {
|
|
@@ -92,12 +108,12 @@ export function useAddonSettings<T extends Record<string, unknown> = Record<stri
|
|
|
92
108
|
opts: UseAddonSettingsOptions<T> = {},
|
|
93
109
|
): UseAddonSettingsResult<T> {
|
|
94
110
|
const api = useApi()
|
|
95
|
-
const { defaults, staleTime, enabled } = opts
|
|
111
|
+
const { defaults, staleTime, enabled, branchId } = opts
|
|
96
112
|
|
|
97
113
|
const query = useQuery<Partial<T>>({
|
|
98
|
-
queryKey: addonSettingsKey(addonKey),
|
|
114
|
+
queryKey: addonSettingsKey(addonKey, branchId),
|
|
99
115
|
queryFn: async () => {
|
|
100
|
-
const res = await api.get(
|
|
116
|
+
const res = await api.get(addonSettingsUrl(addonKey, branchId))
|
|
101
117
|
const body = (res as { data: any }).data
|
|
102
118
|
// Host envelope: { success, data: { <key>: <value> } }.
|
|
103
119
|
const values = body?.success ? body.data : body
|
|
@@ -134,18 +150,21 @@ export function useAddonSettings<T extends Record<string, unknown> = Record<stri
|
|
|
134
150
|
*/
|
|
135
151
|
export function useUpdateAddonSettings<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
136
152
|
addonKey: string,
|
|
153
|
+
/** Write branch overrides (only `scope: branch` keys; `null` clears one) instead of org values. */
|
|
154
|
+
branchId?: string | null,
|
|
137
155
|
) {
|
|
138
156
|
const api = useApi()
|
|
139
157
|
const qc = useQueryClient()
|
|
140
158
|
|
|
141
159
|
return useMutation<Partial<T>, Error, Partial<T>>({
|
|
142
160
|
mutationFn: async (values) => {
|
|
143
|
-
const res = await api.put(
|
|
161
|
+
const res = await api.put(addonSettingsUrl(addonKey, branchId), values)
|
|
144
162
|
const body = (res as { data: any }).data
|
|
145
163
|
const saved = body?.success ? body.data : body
|
|
146
164
|
return (saved ?? values) as Partial<T>
|
|
147
165
|
},
|
|
148
166
|
onSuccess: () => {
|
|
167
|
+
// Prefix match: refreshes the org read and every branch read.
|
|
149
168
|
void qc.invalidateQueries({ queryKey: addonSettingsKey(addonKey) })
|
|
150
169
|
},
|
|
151
170
|
})
|