@asteby/metacore-runtime-react 45.2.0 → 45.4.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/action-modal-dispatcher.d.ts +29 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +115 -6
  5. package/dist/activity-diff.d.ts +2 -0
  6. package/dist/activity-diff.d.ts.map +1 -1
  7. package/dist/activity-diff.js +2 -2
  8. package/dist/business/catalog.d.ts.map +1 -1
  9. package/dist/business/catalog.js +8 -0
  10. package/dist/business/index.d.ts +2 -0
  11. package/dist/business/index.d.ts.map +1 -1
  12. package/dist/business/index.js +2 -0
  13. package/dist/business/refund-destination.js +1 -1
  14. package/dist/business/refund.d.ts +1 -1
  15. package/dist/business/refund.d.ts.map +1 -1
  16. package/dist/business/refund.js +1 -0
  17. package/dist/business/return-wizard.d.ts +23 -0
  18. package/dist/business/return-wizard.d.ts.map +1 -0
  19. package/dist/business/return-wizard.js +59 -0
  20. package/dist/business/return.d.ts +90 -0
  21. package/dist/business/return.d.ts.map +1 -0
  22. package/dist/business/return.js +112 -0
  23. package/dist/dynamic-columns.d.ts.map +1 -1
  24. package/dist/dynamic-columns.js +3 -1
  25. package/dist/dynamic-kanban.d.ts +62 -4
  26. package/dist/dynamic-kanban.d.ts.map +1 -1
  27. package/dist/dynamic-kanban.js +158 -27
  28. package/dist/dynamic-relation.d.ts +4 -0
  29. package/dist/dynamic-relation.d.ts.map +1 -1
  30. package/dist/dynamic-relation.js +10 -1
  31. package/dist/dynamic-row-actions.d.ts.map +1 -1
  32. package/dist/dynamic-row-actions.js +14 -2
  33. package/dist/dynamic-table.d.ts.map +1 -1
  34. package/dist/dynamic-table.js +28 -3
  35. package/dist/index.d.ts +2 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -1
  38. package/dist/reason-prompt.d.ts +59 -0
  39. package/dist/reason-prompt.d.ts.map +1 -0
  40. package/dist/reason-prompt.js +111 -0
  41. package/dist/types.d.ts +6 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/use-addon-settings.d.ts +17 -3
  44. package/dist/use-addon-settings.d.ts.map +1 -1
  45. package/dist/use-addon-settings.js +20 -8
  46. package/package.json +1 -1
  47. package/src/__tests__/dynamic-kanban.test.tsx +141 -0
  48. package/src/__tests__/prefill-from-record.test.ts +69 -0
  49. package/src/__tests__/reason-prompt.test.tsx +121 -0
  50. package/src/__tests__/use-addon-settings.test.ts +16 -1
  51. package/src/action-modal-dispatcher.tsx +145 -3
  52. package/src/activity-diff.tsx +10 -1
  53. package/src/business/business.test.ts +46 -0
  54. package/src/business/catalog.ts +8 -0
  55. package/src/business/index.ts +2 -0
  56. package/src/business/refund-destination.tsx +2 -2
  57. package/src/business/refund.ts +2 -1
  58. package/src/business/return-wizard.tsx +223 -0
  59. package/src/business/return.ts +181 -0
  60. package/src/dynamic-columns.tsx +5 -1
  61. package/src/dynamic-kanban.tsx +197 -19
  62. package/src/dynamic-relation.tsx +14 -1
  63. package/src/dynamic-row-actions.tsx +13 -1
  64. package/src/dynamic-table.tsx +22 -2
  65. package/src/index.ts +14 -0
  66. package/src/reason-prompt.tsx +247 -0
  67. package/src/types.ts +6 -0
  68. package/src/use-addon-settings.ts +26 -7
@@ -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}
@@ -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
- {labels.emptyState}
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 api.delete(deleteEndpoint)
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>
@@ -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 api.delete(deleteEndpoint)
1139
- if (res.data.success) successCount++; else errorCount++
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
@@ -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,16 @@ export {
237
241
  type ApprovalRequiredInfo,
238
242
  type RequestApprovalOptions,
239
243
  } from './approval-gate'
244
+ export {
245
+ ReasonPromptDialog,
246
+ useReasonPrompt,
247
+ reasonRequiredInfo,
248
+ DEFAULT_REASON_MIN,
249
+ type ReasonPromptDialogProps,
250
+ type ReasonPromptApi,
251
+ type ReasonRequiredInfo,
252
+ type RunWithReasonOptions,
253
+ } from './reason-prompt'
240
254
  export {
241
255
  useDynamicRowActions,
242
256
  type UseDynamicRowActionsParams,