@asteby/metacore-runtime-react 45.3.0 → 46.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 +23 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +25 -4
- package/dist/dynamic-kanban.d.ts +62 -4
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +158 -27
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/supervised-action.d.ts +27 -0
- package/dist/supervised-action.d.ts.map +1 -0
- package/dist/supervised-action.js +42 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/dynamic-kanban.test.tsx +141 -0
- package/src/__tests__/supervised-action.test.tsx +100 -0
- package/src/action-modal-dispatcher.tsx +27 -3
- package/src/dynamic-kanban.tsx +197 -19
- package/src/index.ts +11 -0
- package/src/supervised-action.ts +67 -0
- package/src/types.ts +12 -0
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -152,6 +152,7 @@ import { useFlipAnimation } from './use-flip-animation'
|
|
|
152
152
|
import { PortalDragOverlay } from './portal-drag-overlay'
|
|
153
153
|
import { objectLabel } from './dynamic-relation-helpers'
|
|
154
154
|
import { useMetadataCache } from './metadata-cache'
|
|
155
|
+
import { useOptionsResolver } from './use-options-resolver'
|
|
155
156
|
import { ActivityValueRenderer } from './activity-value-renderer'
|
|
156
157
|
import { DynamicIcon } from './dynamic-icon'
|
|
157
158
|
import { isColumnVisibleInTable } from './column-visibility'
|
|
@@ -343,16 +344,28 @@ export function formatLaneCount(
|
|
|
343
344
|
return String(shown)
|
|
344
345
|
}
|
|
345
346
|
|
|
347
|
+
/** Reads a boolean-ish opt-in from a column's `styleConfig` (`display_config`). */
|
|
348
|
+
function cardFlag(col: ColumnDefinition, key: string): boolean {
|
|
349
|
+
const v = (col.styleConfig as Record<string, unknown> | undefined)?.[key]
|
|
350
|
+
return v === true || v === 'true' || v === 1
|
|
351
|
+
}
|
|
352
|
+
|
|
346
353
|
/**
|
|
347
|
-
* Picks the columns shown on a card: a `title` column
|
|
348
|
-
*
|
|
349
|
-
* the
|
|
350
|
-
*
|
|
354
|
+
* Picks the columns shown on a card: a `title` column and the secondary fields.
|
|
355
|
+
* Excludes the group_by column (it's the lane itself) and any column hidden
|
|
356
|
+
* from the table view (visibility modal/list, or `hidden`).
|
|
357
|
+
*
|
|
358
|
+
* A model can lay its card out explicitly through the columns' manifest
|
|
359
|
+
* `display_config`: `card_title: true` picks the title, `card: true` marks each
|
|
360
|
+
* field the card shows, in column order (`explicit` is then true and ALL the
|
|
361
|
+
* flagged fields are shown — the card is no longer capped at `maxFields`).
|
|
362
|
+
* Without any flag the legacy heuristic applies: the first searchable / text
|
|
363
|
+
* column is the title and the next `maxFields` visible columns are the fields.
|
|
351
364
|
*/
|
|
352
365
|
export function selectCardColumns(
|
|
353
366
|
metadata: TableMetadata,
|
|
354
367
|
maxFields = 3,
|
|
355
|
-
): { title: ColumnDefinition | null; fields: ColumnDefinition[] } {
|
|
368
|
+
): { title: ColumnDefinition | null; fields: ColumnDefinition[]; explicit: boolean } {
|
|
356
369
|
const groupBy = metadata.group_by
|
|
357
370
|
const visible = metadata.columns.filter(
|
|
358
371
|
(c) =>
|
|
@@ -361,15 +374,110 @@ export function selectCardColumns(
|
|
|
361
374
|
isColumnVisibleInTable(c) &&
|
|
362
375
|
c.key !== 'id',
|
|
363
376
|
)
|
|
377
|
+
const flagged = visible.filter((c) => cardFlag(c, 'card'))
|
|
378
|
+
const flaggedTitle = visible.find((c) => cardFlag(c, 'card_title'))
|
|
379
|
+
const explicit = flagged.length > 0 || !!flaggedTitle
|
|
364
380
|
const title =
|
|
381
|
+
flaggedTitle ??
|
|
365
382
|
visible.find((c) => c.searchable) ??
|
|
366
383
|
visible.find((c) => c.type === 'text' || c.cellStyle === 'truncate-text') ??
|
|
367
384
|
visible[0] ??
|
|
368
385
|
null
|
|
369
|
-
const fields =
|
|
370
|
-
.filter((c) => c.key !== title?.key)
|
|
371
|
-
.slice(0, maxFields)
|
|
372
|
-
return { title, fields }
|
|
386
|
+
const fields = explicit
|
|
387
|
+
? flagged.filter((c) => c.key !== title?.key)
|
|
388
|
+
: visible.filter((c) => c.key !== title?.key).slice(0, maxFields)
|
|
389
|
+
return { title, fields, explicit }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Whether a card's date field is past due — the SDK paints it as a warning.
|
|
394
|
+
* A column opts in with `display_config.overdue: true`; the optional
|
|
395
|
+
* `display_config.overdue_unless` (`{ <field>: [values…] }`) exempts cards whose
|
|
396
|
+
* row matches (a work order already delivered is never late). Pure — exported
|
|
397
|
+
* for unit tests.
|
|
398
|
+
*/
|
|
399
|
+
export function isCardOverdue(card: any, col: ColumnDefinition, now: number = Date.now()): boolean {
|
|
400
|
+
if (!cardFlag(col, 'overdue')) return false
|
|
401
|
+
const raw = card?.[col.key]
|
|
402
|
+
if (raw === null || raw === undefined || raw === '') return false
|
|
403
|
+
const at = typeof raw === 'number' ? raw : Date.parse(String(raw))
|
|
404
|
+
if (!Number.isFinite(at) || at >= now) return false
|
|
405
|
+
const unless = (col.styleConfig as Record<string, unknown> | undefined)?.overdue_unless
|
|
406
|
+
if (unless && typeof unless === 'object') {
|
|
407
|
+
for (const [field, values] of Object.entries(unless as Record<string, unknown>)) {
|
|
408
|
+
const list = Array.isArray(values) ? values : [values]
|
|
409
|
+
if (list.map(String).includes(String(card?.[field] ?? ''))) return false
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return true
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The metadata regrouped by another column (a nav entry's `group_by`). A model
|
|
417
|
+
* can serve several boards over the same records — by stage, by technician, by
|
|
418
|
+
* bay — but the host projects ONE `group_by` per model, so the entry's own wins.
|
|
419
|
+
* The stage machine (stages, transitions, smart lanes) belongs to the model's
|
|
420
|
+
* stage column (`stage_field`; older hosts that do not serve it: the served
|
|
421
|
+
* group_by): grouping by it keeps the machine, grouping by anything else drops
|
|
422
|
+
* it and the lanes come from the new column. Returns the SAME object when there
|
|
423
|
+
* is nothing to change (no override, the served group_by already, or a column the
|
|
424
|
+
* model does not have). Pure — exported for unit tests.
|
|
425
|
+
*/
|
|
426
|
+
export function withGroupBy(
|
|
427
|
+
metadata: TableMetadata | null,
|
|
428
|
+
groupBy: string | undefined,
|
|
429
|
+
): TableMetadata | null {
|
|
430
|
+
if (!metadata || !groupBy || groupBy === metadata.group_by) return metadata
|
|
431
|
+
if (!metadata.columns.some((c) => c.key === groupBy)) return metadata
|
|
432
|
+
const stageColumn = metadata.stage_field ?? (metadata.stages?.length ? metadata.group_by : undefined)
|
|
433
|
+
if (groupBy === stageColumn) return { ...metadata, group_by: groupBy }
|
|
434
|
+
return {
|
|
435
|
+
...metadata,
|
|
436
|
+
group_by: groupBy,
|
|
437
|
+
stages: undefined,
|
|
438
|
+
transitions: undefined,
|
|
439
|
+
smart_lanes: undefined,
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Whether the board's lanes must come from the group_by column's REFERENCE
|
|
445
|
+
* (one lane per technician, per bay, per owner…): the metadata declares no
|
|
446
|
+
* stages, the column has no inline options, but it points at a record list.
|
|
447
|
+
* Pure — exported for unit tests.
|
|
448
|
+
*/
|
|
449
|
+
export function refLaneSource(
|
|
450
|
+
metadata: TableMetadata | null,
|
|
451
|
+
): { ref?: string; endpoint?: string } | null {
|
|
452
|
+
if (!metadata?.group_by) return null
|
|
453
|
+
if ((metadata.stages?.length ?? 0) > 0) return null
|
|
454
|
+
const col = metadata.columns.find((c) => c.key === metadata.group_by)
|
|
455
|
+
if (!col || (col.options?.length ?? 0) > 0) return null
|
|
456
|
+
if (col.searchEndpoint) return { endpoint: col.searchEndpoint }
|
|
457
|
+
if (col.ref) return { ref: col.ref }
|
|
458
|
+
return null
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The metadata with one lane per resolved option of a reference group_by column
|
|
463
|
+
* (labels ordered alphabetically). No transitions are declared, so a card can
|
|
464
|
+
* be dragged to any lane — i.e. re-assigned to another technician / bay.
|
|
465
|
+
* Returns the metadata untouched when there are no options. Pure.
|
|
466
|
+
*/
|
|
467
|
+
export function withRefLanes(
|
|
468
|
+
metadata: TableMetadata,
|
|
469
|
+
options: { value: string | number; label: string; color?: string | null }[],
|
|
470
|
+
): TableMetadata {
|
|
471
|
+
if (options.length === 0) return metadata
|
|
472
|
+
const stages: StageMeta[] = [...options]
|
|
473
|
+
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
|
|
474
|
+
.map((o, i) => ({
|
|
475
|
+
key: String(o.value),
|
|
476
|
+
label: String(o.label),
|
|
477
|
+
color: o.color ?? undefined,
|
|
478
|
+
order: i,
|
|
479
|
+
}))
|
|
480
|
+
return { ...metadata, stages }
|
|
373
481
|
}
|
|
374
482
|
|
|
375
483
|
/** The all-zeros UUID — a Go zero-value FK serialized as "set" when it isn't. */
|
|
@@ -724,6 +832,16 @@ export interface DynamicKanbanProps {
|
|
|
724
832
|
timeZone?: string
|
|
725
833
|
/** ISO 4217 currency for money card fields (org config). */
|
|
726
834
|
currency?: string
|
|
835
|
+
/**
|
|
836
|
+
* Column to group the board by, when the host's nav entry asks for one
|
|
837
|
+
* (`?group_by=`): a model can offer several boards over the same records —
|
|
838
|
+
* by stage, by technician, by bay. Absent or equal to the served
|
|
839
|
+
* `group_by` → the model's own stage machine. Otherwise the lanes come from
|
|
840
|
+
* that column (its options, or its referenced records) with no transitions,
|
|
841
|
+
* and the per-org stage customisations (custom lanes, overrides, lane order,
|
|
842
|
+
* automations) are off: they belong to the stage column.
|
|
843
|
+
*/
|
|
844
|
+
groupBy?: string
|
|
727
845
|
/**
|
|
728
846
|
* Static equality filters always applied to the board (never shown as a
|
|
729
847
|
* removable chip). Same contract as DynamicTable's `defaultFilters`.
|
|
@@ -791,6 +909,7 @@ function DynamicKanbanBoard({
|
|
|
791
909
|
timeZone: timeZoneProp,
|
|
792
910
|
currency: currencyProp,
|
|
793
911
|
defaultFilters,
|
|
912
|
+
groupBy: groupByProp,
|
|
794
913
|
}: DynamicKanbanProps) {
|
|
795
914
|
const { t, i18n } = useTranslation()
|
|
796
915
|
// The org's timezone/currency: an explicit prop wins, else the app-wide
|
|
@@ -811,16 +930,17 @@ function DynamicKanbanBoard({
|
|
|
811
930
|
|
|
812
931
|
// Stage automations (Bitrix-style per-lane rules). Degrades to no-op when
|
|
813
932
|
// the host has no `/stage-automations` endpoint — the ⚡ affordance hides.
|
|
814
|
-
const
|
|
933
|
+
const automationsHook = useStageAutomations(model)
|
|
815
934
|
|
|
816
935
|
// Custom stages (Bitrix-style user-defined columns). Degrades to no-op when
|
|
817
936
|
// the host has no `/custom-stages` endpoint — the "+ Agregar etapa" column
|
|
818
937
|
// and lane menus simply don't render.
|
|
819
|
-
const
|
|
938
|
+
const customStagesHook = useCustomStages(model)
|
|
820
939
|
// Per-org overrides for DECLARED lanes (rename/recolor/conditions). Degrades
|
|
821
940
|
// to no-op when the host has no `/stage-overrides` endpoint — the ⚙ gear then
|
|
822
941
|
// hides on declared lanes (custom lanes keep it via /custom-stages).
|
|
823
|
-
const
|
|
942
|
+
const stageOverridesHook = useStageOverrides(model)
|
|
943
|
+
const stageLayoutHook = useStageLayout(model)
|
|
824
944
|
// Dialog state: create/edit a stage, and the delete confirmation.
|
|
825
945
|
const [stageDialogOpen, setStageDialogOpen] = useState(false)
|
|
826
946
|
const [editingStage, setEditingStage] = useState<CustomStage | null>(null)
|
|
@@ -842,7 +962,47 @@ function DynamicKanbanBoard({
|
|
|
842
962
|
const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache()
|
|
843
963
|
const cachedMeta = getMetadata(model)
|
|
844
964
|
|
|
845
|
-
const [
|
|
965
|
+
const [serverMetadata, setMetadata] = useState<TableMetadata | null>(cachedMeta || null)
|
|
966
|
+
// A reference group_by (technician, bay…) has no declared stages: its lanes
|
|
967
|
+
// are the referenced records, resolved through the same options endpoint the
|
|
968
|
+
// form selects use.
|
|
969
|
+
const routedMetadata = useMemo(
|
|
970
|
+
() => withGroupBy(serverMetadata, groupByProp),
|
|
971
|
+
[serverMetadata, groupByProp],
|
|
972
|
+
)
|
|
973
|
+
// Grouped by something other than the stage column: the stage machine is gone.
|
|
974
|
+
const regrouped =
|
|
975
|
+
(serverMetadata?.stages?.length ?? 0) > 0 && (routedMetadata?.stages?.length ?? 0) === 0
|
|
976
|
+
const laneSource = useMemo(() => refLaneSource(routedMetadata), [routedMetadata])
|
|
977
|
+
const { options: laneOptions } = useOptionsResolver({
|
|
978
|
+
modelKey: '',
|
|
979
|
+
fieldKey: 'id',
|
|
980
|
+
ref: laneSource?.ref,
|
|
981
|
+
endpoint: laneSource?.endpoint,
|
|
982
|
+
limit: 200,
|
|
983
|
+
enabled: !!laneSource,
|
|
984
|
+
})
|
|
985
|
+
const metadata = useMemo(
|
|
986
|
+
() => (routedMetadata && laneSource ? withRefLanes(routedMetadata, laneOptions) : routedMetadata),
|
|
987
|
+
[routedMetadata, laneSource, laneOptions],
|
|
988
|
+
)
|
|
989
|
+
// The stage customisations belong to the stage column: off on another grouping.
|
|
990
|
+
const automations = useMemo(
|
|
991
|
+
() => (regrouped ? { ...automationsHook, available: false } : automationsHook),
|
|
992
|
+
[regrouped, automationsHook],
|
|
993
|
+
)
|
|
994
|
+
const customStages = useMemo(
|
|
995
|
+
() => (regrouped ? { ...customStagesHook, available: false } : customStagesHook),
|
|
996
|
+
[regrouped, customStagesHook],
|
|
997
|
+
)
|
|
998
|
+
const stageOverrides = useMemo(
|
|
999
|
+
() => (regrouped ? { ...stageOverridesHook, available: false } : stageOverridesHook),
|
|
1000
|
+
[regrouped, stageOverridesHook],
|
|
1001
|
+
)
|
|
1002
|
+
const stageLayout = useMemo(
|
|
1003
|
+
() => (regrouped ? { ...stageLayoutHook, available: false } : stageLayoutHook),
|
|
1004
|
+
[regrouped, stageLayoutHook],
|
|
1005
|
+
)
|
|
846
1006
|
const [records, setRecords] = useState<any[]>([])
|
|
847
1007
|
const [loading, setLoading] = useState(!cachedMeta)
|
|
848
1008
|
const [loadingData, setLoadingData] = useState(true)
|
|
@@ -870,7 +1030,6 @@ function DynamicKanbanBoard({
|
|
|
870
1030
|
// the metadata (the backend also stamps `stages[]/smart_lanes[].order`, so the
|
|
871
1031
|
// board already paints ordered on load — this only backs the live drag + the
|
|
872
1032
|
// revert-on-failure). Null → follow the metadata order.
|
|
873
|
-
const stageLayout = useStageLayout(model)
|
|
874
1033
|
const [laneOrderOverride, setLaneOrderOverride] = useState<string[] | null>(null)
|
|
875
1034
|
const laneReorderEnabled = stageLayout.available
|
|
876
1035
|
|
|
@@ -1396,11 +1555,15 @@ function DynamicKanbanBoard({
|
|
|
1396
1555
|
|
|
1397
1556
|
// Card fields: a few more candidates than a card shows, so a card whose
|
|
1398
1557
|
// first fields are empty shows the next ones that have a value.
|
|
1399
|
-
const {
|
|
1558
|
+
const {
|
|
1559
|
+
title: titleCol,
|
|
1560
|
+
fields: fieldCols,
|
|
1561
|
+
explicit: explicitCard,
|
|
1562
|
+
} = useMemo(
|
|
1400
1563
|
() =>
|
|
1401
1564
|
metadata
|
|
1402
1565
|
? selectCardColumns(metadata, CARD_FIELD_CANDIDATES)
|
|
1403
|
-
: { title: null, fields: [] },
|
|
1566
|
+
: { title: null, fields: [], explicit: false },
|
|
1404
1567
|
[metadata],
|
|
1405
1568
|
)
|
|
1406
1569
|
|
|
@@ -1948,6 +2111,7 @@ function DynamicKanbanBoard({
|
|
|
1948
2111
|
card={card}
|
|
1949
2112
|
titleCol={titleCol}
|
|
1950
2113
|
fieldCols={fieldCols}
|
|
2114
|
+
showAllFields={explicitCard}
|
|
1951
2115
|
actions={rowActions}
|
|
1952
2116
|
locale={i18n.language}
|
|
1953
2117
|
timeZone={timeZone}
|
|
@@ -1979,6 +2143,7 @@ function DynamicKanbanBoard({
|
|
|
1979
2143
|
card={card}
|
|
1980
2144
|
titleCol={titleCol}
|
|
1981
2145
|
fieldCols={fieldCols}
|
|
2146
|
+
showAllFields={explicitCard}
|
|
1982
2147
|
actions={rowActions}
|
|
1983
2148
|
locale={i18n.language}
|
|
1984
2149
|
timeZone={timeZone}
|
|
@@ -2189,6 +2354,7 @@ function DynamicKanbanBoard({
|
|
|
2189
2354
|
card={activeCard}
|
|
2190
2355
|
titleCol={titleCol}
|
|
2191
2356
|
fieldCols={fieldCols}
|
|
2357
|
+
showAllFields={explicitCard}
|
|
2192
2358
|
locale={i18n.language}
|
|
2193
2359
|
timeZone={timeZone}
|
|
2194
2360
|
currency={currency}
|
|
@@ -3008,6 +3174,8 @@ interface KanbanCardProps {
|
|
|
3008
3174
|
titleCol: ColumnDefinition | null
|
|
3009
3175
|
/** Candidate fields; the card shows the first few that have a value. */
|
|
3010
3176
|
fieldCols: ColumnDefinition[]
|
|
3177
|
+
/** The model laid its card out explicitly: show every field with a value. */
|
|
3178
|
+
showAllFields?: boolean
|
|
3011
3179
|
actions: ActionDefinition[]
|
|
3012
3180
|
locale: string
|
|
3013
3181
|
timeZone?: string
|
|
@@ -3027,14 +3195,15 @@ function CardBody({
|
|
|
3027
3195
|
card,
|
|
3028
3196
|
titleCol,
|
|
3029
3197
|
fieldCols,
|
|
3198
|
+
showAllFields,
|
|
3030
3199
|
locale,
|
|
3031
3200
|
timeZone,
|
|
3032
3201
|
currency,
|
|
3033
3202
|
menu,
|
|
3034
|
-
}: Pick<KanbanCardProps, 'card' | 'titleCol' | 'fieldCols' | 'locale' | 'timeZone' | 'currency'> & {
|
|
3203
|
+
}: Pick<KanbanCardProps, 'card' | 'titleCol' | 'fieldCols' | 'showAllFields' | 'locale' | 'timeZone' | 'currency'> & {
|
|
3035
3204
|
menu?: React.ReactNode
|
|
3036
3205
|
}) {
|
|
3037
|
-
const fields = visibleCardFields(card, fieldCols, CARD_FIELDS_SHOWN)
|
|
3206
|
+
const fields = visibleCardFields(card, fieldCols, showAllFields ? fieldCols.length : CARD_FIELDS_SHOWN)
|
|
3038
3207
|
return (
|
|
3039
3208
|
<CardContent className="space-y-1.5 p-3">
|
|
3040
3209
|
<div className="flex items-start justify-between gap-2">
|
|
@@ -3056,7 +3225,12 @@ function CardBody({
|
|
|
3056
3225
|
{fields.map((col) => (
|
|
3057
3226
|
<div
|
|
3058
3227
|
key={col.key}
|
|
3059
|
-
className=
|
|
3228
|
+
className={`flex min-w-0 items-start gap-1.5 text-xs ${
|
|
3229
|
+
isCardOverdue(card, col)
|
|
3230
|
+
? 'font-medium text-destructive'
|
|
3231
|
+
: 'text-muted-foreground'
|
|
3232
|
+
}`}
|
|
3233
|
+
data-overdue={isCardOverdue(card, col) || undefined}
|
|
3060
3234
|
>
|
|
3061
3235
|
<span className="shrink-0 opacity-70">{col.label}:</span>
|
|
3062
3236
|
<span className="min-w-0 break-words">
|
|
@@ -3078,6 +3252,7 @@ function KanbanCard({
|
|
|
3078
3252
|
card,
|
|
3079
3253
|
titleCol,
|
|
3080
3254
|
fieldCols,
|
|
3255
|
+
showAllFields,
|
|
3081
3256
|
actions,
|
|
3082
3257
|
locale,
|
|
3083
3258
|
timeZone,
|
|
@@ -3145,6 +3320,7 @@ function KanbanCard({
|
|
|
3145
3320
|
card={card}
|
|
3146
3321
|
titleCol={titleCol}
|
|
3147
3322
|
fieldCols={fieldCols}
|
|
3323
|
+
showAllFields={showAllFields}
|
|
3148
3324
|
locale={locale}
|
|
3149
3325
|
timeZone={timeZone}
|
|
3150
3326
|
currency={currency}
|
|
@@ -3160,6 +3336,7 @@ function CardPreview({
|
|
|
3160
3336
|
card,
|
|
3161
3337
|
titleCol,
|
|
3162
3338
|
fieldCols,
|
|
3339
|
+
showAllFields,
|
|
3163
3340
|
locale,
|
|
3164
3341
|
timeZone,
|
|
3165
3342
|
currency,
|
|
@@ -3178,6 +3355,7 @@ function CardPreview({
|
|
|
3178
3355
|
card={card}
|
|
3179
3356
|
titleCol={titleCol}
|
|
3180
3357
|
fieldCols={fieldCols}
|
|
3358
|
+
showAllFields={showAllFields}
|
|
3181
3359
|
locale={locale}
|
|
3182
3360
|
timeZone={timeZone}
|
|
3183
3361
|
currency={currency}
|
package/src/index.ts
CHANGED
|
@@ -60,6 +60,10 @@ export {
|
|
|
60
60
|
applyStageOverrides,
|
|
61
61
|
laneDeltasFromOverrides,
|
|
62
62
|
selectCardColumns,
|
|
63
|
+
isCardOverdue,
|
|
64
|
+
refLaneSource,
|
|
65
|
+
withGroupBy,
|
|
66
|
+
withRefLanes,
|
|
63
67
|
visibleCardFields,
|
|
64
68
|
isEmptyCardValue,
|
|
65
69
|
nextLaneCoordinates,
|
|
@@ -237,6 +241,13 @@ export {
|
|
|
237
241
|
type ApprovalRequiredInfo,
|
|
238
242
|
type RequestApprovalOptions,
|
|
239
243
|
} from './approval-gate'
|
|
244
|
+
export {
|
|
245
|
+
useSupervisor,
|
|
246
|
+
withApproval,
|
|
247
|
+
type SupervisedAction,
|
|
248
|
+
type SupervisorAuthorization,
|
|
249
|
+
type AuthorizeOptions,
|
|
250
|
+
} from './supervised-action'
|
|
240
251
|
export {
|
|
241
252
|
ReasonPromptDialog,
|
|
242
253
|
useReasonPrompt,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Supervisor authorization for actions that declare `supervisor_policy`
|
|
2
|
+
// (POS-2 / PER-2). The served action metadata carries `supervisorPolicy`; before
|
|
3
|
+
// dispatching, the action modals call `useSupervisor().authorize(action, …)`:
|
|
4
|
+
// - a caller who holds `general.approve_<policy>` (or is admin) passes straight
|
|
5
|
+
// through (the server bypasses them too);
|
|
6
|
+
// - anyone else gets the PIN dialog (ApprovalGate) and the resulting grant id
|
|
7
|
+
// travels as `approval_id` in the action payload, where the host redeems it
|
|
8
|
+
// (single use, same caller, fresh, same record — kernel ConsumePINGrant).
|
|
9
|
+
// The grant is anchored to the record so it cannot be spent on another document.
|
|
10
|
+
import { useCallback } from 'react'
|
|
11
|
+
import { useTranslation } from 'react-i18next'
|
|
12
|
+
import { useApprovalGate } from './approval-gate'
|
|
13
|
+
|
|
14
|
+
export interface SupervisedAction {
|
|
15
|
+
label?: string
|
|
16
|
+
supervisorPolicy?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SupervisorAuthorization {
|
|
20
|
+
/** Grant id to send as `approval_id`; undefined when the caller bypassed. */
|
|
21
|
+
approvalId?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AuthorizeOptions {
|
|
25
|
+
model?: string
|
|
26
|
+
recordId?: string
|
|
27
|
+
/** Free-form audit data stored with the grant (document folio, motive…). */
|
|
28
|
+
context?: Record<string, unknown>
|
|
29
|
+
description?: React.ReactNode
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolves the authorization for `action`, `{}` when it needs none, or null when
|
|
34
|
+
* the operator cancelled the PIN prompt (do NOT dispatch).
|
|
35
|
+
*/
|
|
36
|
+
export function useSupervisor() {
|
|
37
|
+
const gate = useApprovalGate()
|
|
38
|
+
const { t } = useTranslation()
|
|
39
|
+
const authorize = useCallback(
|
|
40
|
+
async (action: SupervisedAction, opts: AuthorizeOptions = {}): Promise<SupervisorAuthorization | null> => {
|
|
41
|
+
const policy = action.supervisorPolicy
|
|
42
|
+
if (!policy) return {}
|
|
43
|
+
const grant = await gate.requestApproval({
|
|
44
|
+
policy,
|
|
45
|
+
label: action.label || t(`approvals.policy.${policy}`, { defaultValue: 'Requiere autorización' }),
|
|
46
|
+
description:
|
|
47
|
+
opts.description ??
|
|
48
|
+
t('approvals.supervisor_needed', { defaultValue: 'Esta acción necesita la autorización de un supervisor.' }),
|
|
49
|
+
context: opts.context,
|
|
50
|
+
model: opts.model,
|
|
51
|
+
recordId: opts.recordId,
|
|
52
|
+
})
|
|
53
|
+
if (!grant) return null
|
|
54
|
+
return grant.bypassed || !grant.id ? {} : { approvalId: grant.id }
|
|
55
|
+
},
|
|
56
|
+
[gate, t],
|
|
57
|
+
)
|
|
58
|
+
return { authorize }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Adds `approval_id` to an action payload when a grant was collected. */
|
|
62
|
+
export function withApproval<T extends Record<string, unknown>>(
|
|
63
|
+
payload: T,
|
|
64
|
+
auth: SupervisorAuthorization | null | undefined,
|
|
65
|
+
): T & { approval_id?: string } {
|
|
66
|
+
return auth?.approvalId ? { ...payload, approval_id: auth.approvalId } : payload
|
|
67
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -66,6 +66,12 @@ export interface TableMetadata {
|
|
|
66
66
|
* lane. Mirrors the nav item's `group_by` (RFC §1.2).
|
|
67
67
|
*/
|
|
68
68
|
group_by?: string
|
|
69
|
+
/**
|
|
70
|
+
* The model's stage column (`stage_field`), when the host serves it. Lets a
|
|
71
|
+
* board grouped by ANOTHER column (`?group_by=`) tell it apart from the stage
|
|
72
|
+
* board; absent on older hosts (the served `group_by` stands in).
|
|
73
|
+
*/
|
|
74
|
+
stage_field?: string
|
|
69
75
|
/**
|
|
70
76
|
* Board lanes (the stage machine of the `group_by`/`stage_field` column).
|
|
71
77
|
* When present the kanban renders one lane per stage in `order`. When absent
|
|
@@ -680,6 +686,12 @@ export interface ActionDefinition {
|
|
|
680
686
|
/** Multi-step wizard form; when present the dispatcher renders a wizard. */
|
|
681
687
|
steps?: ActionStep[]
|
|
682
688
|
requiresState?: string[]
|
|
689
|
+
/**
|
|
690
|
+
* Manifest `supervisor_policy`: the action needs the on-the-spot
|
|
691
|
+
* authorization of a supervisor for that policy (`general.approve_<policy>`).
|
|
692
|
+
* The action modals ask for the PIN and send the grant as `approval_id`.
|
|
693
|
+
*/
|
|
694
|
+
supervisorPolicy?: string
|
|
683
695
|
executable?: boolean
|
|
684
696
|
/** Federated modal slug "<addon_key>.<action_key>" (manifest `modal`). */
|
|
685
697
|
modal?: string
|