@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.
@@ -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 (first searchable column,
348
- * else first text-ish column) and up to `maxFields` secondary columns. Excludes
349
- * the group_by column (it's the lane itself) and any column hidden from the
350
- * table view (visibility modal/list, or `hidden`).
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 = visible
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 automations = useStageAutomations(model)
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 customStages = useCustomStages(model)
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 stageOverrides = useStageOverrides(model)
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 [metadata, setMetadata] = useState<TableMetadata | null>(cachedMeta || null)
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 { title: titleCol, fields: fieldCols } = useMemo(
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="flex min-w-0 items-start gap-1.5 text-xs text-muted-foreground"
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