@asteby/metacore-runtime-react 23.1.0 → 23.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/action-modal-dispatcher.js +4 -4
  3. package/dist/dialogs/dynamic-record.d.ts +5 -0
  4. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  5. package/dist/dialogs/dynamic-record.js +146 -31
  6. package/dist/display-value.d.ts +56 -0
  7. package/dist/display-value.d.ts.map +1 -0
  8. package/dist/display-value.js +74 -0
  9. package/dist/dynamic-columns.d.ts.map +1 -1
  10. package/dist/dynamic-columns.js +2 -53
  11. package/dist/dynamic-kanban.d.ts +19 -3
  12. package/dist/dynamic-kanban.d.ts.map +1 -1
  13. package/dist/dynamic-kanban.js +114 -8
  14. package/dist/dynamic-row-actions.d.ts.map +1 -1
  15. package/dist/dynamic-row-actions.js +5 -3
  16. package/dist/dynamic-table.d.ts +9 -1
  17. package/dist/dynamic-table.d.ts.map +1 -1
  18. package/dist/dynamic-table.js +132 -32
  19. package/dist/use-infinite-scroll.d.ts +27 -0
  20. package/dist/use-infinite-scroll.d.ts.map +1 -0
  21. package/dist/use-infinite-scroll.js +60 -0
  22. package/package.json +3 -3
  23. package/src/__tests__/dynamic-kanban-infinite-scroll.test.tsx +210 -0
  24. package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +196 -0
  25. package/src/__tests__/readonly-fields.test.tsx +97 -0
  26. package/src/__tests__/record-detail-display.test.tsx +113 -0
  27. package/src/__tests__/use-infinite-scroll.test.tsx +147 -0
  28. package/src/action-modal-dispatcher.tsx +4 -4
  29. package/src/dialogs/dynamic-record.tsx +217 -40
  30. package/src/display-value.tsx +134 -0
  31. package/src/dynamic-columns.tsx +6 -102
  32. package/src/dynamic-kanban.tsx +168 -8
  33. package/src/dynamic-row-actions.tsx +5 -3
  34. package/src/dynamic-table.tsx +149 -10
  35. package/src/use-infinite-scroll.ts +94 -0
@@ -85,6 +85,7 @@ import {
85
85
  summarizeFilterValues,
86
86
  translateOptionLabels,
87
87
  } from './filter-chips'
88
+ import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll'
88
89
  import { useMetadataCache } from './metadata-cache'
89
90
  import { ActivityValueRenderer } from './activity-value-renderer'
90
91
  import { DynamicIcon } from './dynamic-icon'
@@ -213,6 +214,30 @@ export function applyOptimisticMove(
213
214
  return next
214
215
  }
215
216
 
217
+ /**
218
+ * Returns a NEW per-lane pagination map with the server totals adjusted for a
219
+ * card moving `fromStage` → `toStage`: the source loses one, the destination
220
+ * gains one. Lanes whose `total` is still unknown (`null`, not yet topped up)
221
+ * are left alone. Pure — backs the optimistic drag so a partial lane's
222
+ * `count/total` header stays truthful, and can be restored on PUT failure.
223
+ */
224
+ export function applyLaneTotalsOnMove<T extends { total: number | null }>(
225
+ pagination: Record<string, T>,
226
+ fromStage: string,
227
+ toStage: string,
228
+ ): Record<string, T> {
229
+ const next = { ...pagination }
230
+ const bump = (key: string, delta: number) => {
231
+ const st = next[key]
232
+ if (st && st.total != null) {
233
+ next[key] = { ...st, total: Math.max(0, st.total + delta) }
234
+ }
235
+ }
236
+ bump(fromStage, -1)
237
+ bump(toStage, +1)
238
+ return next
239
+ }
240
+
216
241
  /**
217
242
  * Picks the columns shown on a card: a `title` column (first searchable column,
218
243
  * else first text-ish column) and up to `maxFields` secondary columns. Excludes
@@ -337,6 +362,18 @@ interface LaneFilterState {
337
362
  query?: string
338
363
  }
339
364
 
365
+ /** Incremental pagination bookkeeping for one lane/stage. */
366
+ interface LanePageState {
367
+ /** Next stage-scoped page to request. */
368
+ nextPage: number
369
+ /** Server total for the stage (from response meta), or null if unknown. */
370
+ total: number | null
371
+ /** A top-up request is in flight. */
372
+ loading: boolean
373
+ /** No more pages for this stage. */
374
+ done: boolean
375
+ }
376
+
340
377
  export interface DynamicKanbanProps {
341
378
  /** Model key as registered on the backend (e.g. "issue"). */
342
379
  model: string
@@ -357,10 +394,16 @@ export interface DynamicKanbanProps {
357
394
  */
358
395
  onAction?: (action: string, row: any) => void
359
396
  /**
360
- * Max cards fetched per lane render. Kanban shows all cards at once (no
361
- * pagination UI), so it requests a single large page. Defaults to 200.
397
+ * Size of the INITIAL board page (one request, grouped into lanes). Each
398
+ * lane then tops up incrementally on scroll (see `lanePageSize`). Defaults
399
+ * to 50 — enough to fill the visible lanes without loading the whole board.
362
400
  */
363
401
  pageSize?: number
402
+ /**
403
+ * Page size for a lane's incremental top-up fetch (scoped by
404
+ * `f_<group_by>=<stage>`). Defaults to 25.
405
+ */
406
+ lanePageSize?: number
364
407
  /** IANA timezone for datetime card fields (org config). */
365
408
  timeZone?: string
366
409
  /** ISO 4217 currency for money card fields (org config). */
@@ -378,7 +421,8 @@ export function DynamicKanban({
378
421
  refreshTrigger,
379
422
  onCardClick,
380
423
  onAction,
381
- pageSize = 200,
424
+ pageSize = 50,
425
+ lanePageSize = 25,
382
426
  timeZone,
383
427
  currency,
384
428
  defaultFilters,
@@ -394,6 +438,14 @@ export function DynamicKanban({
394
438
  const [records, setRecords] = useState<any[]>([])
395
439
  const [loading, setLoading] = useState(!cachedMeta)
396
440
  const [loadingData, setLoadingData] = useState(true)
441
+ // Per-stage incremental pagination for infinite scroll. The initial board
442
+ // page (grouped into lanes) is fetched once; each lane then tops up its OWN
443
+ // stage via `f_<group_by>=<stage>&page=n`, appended (deduped by id) into the
444
+ // shared `records`. `total` is the stage's server count when the response
445
+ // meta carries it. Reset whenever the filters/search change.
446
+ const [lanePagination, setLanePagination] = useState<
447
+ Record<string, LanePageState>
448
+ >({})
397
449
 
398
450
  // Active drag card id (for the DragOverlay + drop-zone highlighting).
399
451
  const [activeId, setActiveId] = useState<string | null>(null)
@@ -449,7 +501,10 @@ export function DynamicKanban({
449
501
  clearAll,
450
502
  } = useDynamicFilters(metadata, { defaultFilters, model, endpoint })
451
503
 
452
- // ---- records fetch (same path as DynamicTable, single large page) ----
504
+ // ---- initial board page (one request, grouped into lanes) ----
505
+ // Resets the per-lane pagination so every lane restarts its incremental
506
+ // top-up from scratch — called on mount, refresh, and any filter/search
507
+ // change (fetchData's identity changes with filterParams).
453
508
  const fetchData = useCallback(async () => {
454
509
  if (!metadata) return
455
510
  setLoadingData(true)
@@ -463,8 +518,67 @@ export function DynamicKanban({
463
518
  } finally {
464
519
  setLoadingData(false)
465
520
  }
521
+ setLanePagination({})
466
522
  }, [api, endpoint, model, metadata, pageSize, filterParams])
467
523
 
524
+ // Load the next page for ONE lane/stage and append it (deduped by id) into
525
+ // the shared records. Scoped by `f_<group_by>=<stage>` on top of the active
526
+ // filterParams (the stage scope wins over any global group_by filter).
527
+ const groupByKey = metadata?.group_by || ''
528
+ const loadMoreLane = useCallback(
529
+ async (stageKey: string) => {
530
+ if (!metadata || !groupByKey) return
531
+ const current = lanePagination[stageKey]
532
+ if (current?.loading || current?.done) return
533
+ const nextPage = current?.nextPage ?? 1
534
+ setLanePagination((p) => ({
535
+ ...p,
536
+ [stageKey]: {
537
+ nextPage,
538
+ total: current?.total ?? null,
539
+ loading: true,
540
+ done: false,
541
+ },
542
+ }))
543
+ try {
544
+ const res = (await api.get(endpoint || `/data/${model}`, {
545
+ params: {
546
+ ...filterParams,
547
+ page: nextPage,
548
+ per_page: lanePageSize,
549
+ [`f_${groupByKey}`]: stageKey,
550
+ },
551
+ })) as { data: ApiResponse<any[]> & { meta?: any } }
552
+ const rows = res.data.success ? res.data.data || [] : []
553
+ const total =
554
+ res.data.meta?.total ?? res.data.meta?.count ?? null
555
+ setRecords((prev) => dedupeById(prev, rows))
556
+ setLanePagination((p) => ({
557
+ ...p,
558
+ [stageKey]: {
559
+ nextPage: nextPage + 1,
560
+ total,
561
+ loading: false,
562
+ // Exhausted when the server returned a short page.
563
+ done: rows.length < lanePageSize,
564
+ },
565
+ }))
566
+ } catch (err) {
567
+ console.error(`Error al cargar más tarjetas de ${stageKey}`, err)
568
+ setLanePagination((p) => ({
569
+ ...p,
570
+ [stageKey]: {
571
+ nextPage,
572
+ total: current?.total ?? null,
573
+ loading: false,
574
+ done: false,
575
+ },
576
+ }))
577
+ }
578
+ },
579
+ [api, endpoint, model, metadata, groupByKey, filterParams, lanePageSize, lanePagination],
580
+ )
581
+
468
582
  // Refetch when metadata resolves, on an explicit refresh, or when the
469
583
  // filters change. `fetchData` is stable while `filterParams` is unchanged
470
584
  // (both memoized), so this only re-runs on real input changes. Debounced so
@@ -566,7 +680,6 @@ export function DynamicKanban({
566
680
  () => (metadata ? deriveStages(metadata) : []),
567
681
  [metadata],
568
682
  )
569
- const groupByKey = metadata?.group_by || ''
570
683
  const transitions = metadata?.transitions
571
684
 
572
685
  const grouped = useMemo(
@@ -651,11 +764,16 @@ export function DynamicKanban({
651
764
 
652
765
  // OPTIMISTIC: move the card in local state immediately.
653
766
  const prevRecords = records
767
+ const prevPagination = lanePagination
654
768
  setRecords((rs) =>
655
769
  rs.map((r) =>
656
770
  String(r.id) === cardId ? { ...r, [groupByKey]: destStage } : r,
657
771
  ),
658
772
  )
773
+ // Keep the server totals consistent with the moved card so a lane's
774
+ // `count/total` header stays truthful with partial lanes: one leaves
775
+ // the source stage, one joins the destination.
776
+ setLanePagination((p) => applyLaneTotalsOnMove(p, srcStage, destStage))
659
777
 
660
778
  try {
661
779
  const base = endpoint || `/data/${model}`
@@ -672,6 +790,7 @@ export function DynamicKanban({
672
790
  } catch (err: any) {
673
791
  // REVERT + toast on failure.
674
792
  setRecords(prevRecords)
793
+ setLanePagination(prevPagination)
675
794
  toast.error(
676
795
  t('kanban.moveFailed', {
677
796
  defaultValue: 'No se pudo mover la tarjeta',
@@ -682,7 +801,7 @@ export function DynamicKanban({
682
801
  )
683
802
  }
684
803
  },
685
- [api, endpoint, groupByKey, model, records, stageOfCard, t, transitions],
804
+ [api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions],
686
805
  )
687
806
 
688
807
  if (loading) {
@@ -855,12 +974,21 @@ export function DynamicKanban({
855
974
  !activeId ||
856
975
  stage.key === activeStage ||
857
976
  isTransitionAllowed(transitions, activeStage, stage.key)
977
+ // Infinite scroll is per declared stage; the synthetic
978
+ // "unassigned" lane can't be stage-scoped, so it never tops up.
979
+ const laneState = lanePagination[stage.key]
980
+ const isUnassigned = stage.key === UNASSIGNED_LANE
981
+ const laneHasMore = !isUnassigned && !laneState?.done
858
982
  return (
859
983
  <KanbanLane
860
984
  key={stage.key}
861
985
  stage={stage}
862
986
  count={cards.length}
863
987
  totalCount={allCards.length}
988
+ serverTotal={laneState?.total ?? null}
989
+ hasMore={laneHasMore}
990
+ loadingMore={!!laneState?.loading}
991
+ onLoadMore={() => loadMoreLane(stage.key)}
864
992
  filterFields={filterFields}
865
993
  laneFilter={laneFilter}
866
994
  onFunnelChange={(f) =>
@@ -1029,6 +1157,14 @@ interface KanbanLaneProps {
1029
1157
  stage: StageMeta
1030
1158
  count: number
1031
1159
  totalCount: number
1160
+ /** Server-reported total for the stage (from response meta), or null. */
1161
+ serverTotal: number | null
1162
+ /** More server pages available for this stage. */
1163
+ hasMore: boolean
1164
+ /** A top-up request for this stage is in flight. */
1165
+ loadingMore: boolean
1166
+ /** Request the next page for this stage. */
1167
+ onLoadMore: () => void
1032
1168
  filterFields: LaneFilterField[]
1033
1169
  laneFilter: LaneFilterState | undefined
1034
1170
  onFunnelChange: (filter: LaneFunnelValue | null) => void
@@ -1043,6 +1179,10 @@ function KanbanLane({
1043
1179
  stage,
1044
1180
  count,
1045
1181
  totalCount,
1182
+ serverTotal,
1183
+ hasMore,
1184
+ loadingMore,
1185
+ onLoadMore,
1046
1186
  filterFields,
1047
1187
  laneFilter,
1048
1188
  onFunnelChange,
@@ -1054,6 +1194,12 @@ function KanbanLane({
1054
1194
  }: KanbanLaneProps) {
1055
1195
  const { t } = useTranslation()
1056
1196
  const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled })
1197
+ // Infinite scroll: the sentinel lives at the bottom of the lane's own scroll
1198
+ // container; a load in flight or an exhausted stage disables it.
1199
+ const { rootRef, sentinelRef } = useInfiniteScrollSentinel({
1200
+ onLoadMore,
1201
+ disabled: !hasMore || loadingMore,
1202
+ })
1057
1203
  const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
1058
1204
  isDark,
1059
1205
  })
@@ -1111,7 +1257,11 @@ function KanbanLane({
1111
1257
  {t(stage.label, { defaultValue: stage.label })}
1112
1258
  </Badge>
1113
1259
  <span className="text-xs font-medium tabular-nums text-muted-foreground">
1114
- {laneActive ? `${count}/${totalCount}` : count}
1260
+ {laneActive
1261
+ ? `${count}/${totalCount}`
1262
+ : serverTotal != null
1263
+ ? `${count}/${serverTotal}`
1264
+ : count}
1115
1265
  </span>
1116
1266
  </div>
1117
1267
  {/* Lane actions — always visible in muted (a hidden hover-reveal
@@ -1189,8 +1339,18 @@ function KanbanLane({
1189
1339
  text wraps freely (no line-clamp) the cards grew past the lane
1190
1340
  and spilled out of the stage. A normal `overflow-y-auto` block
1191
1341
  constrains every card to the lane width so text wraps inside it. */}
1192
- <div className="flex min-h-[55vh] max-h-[70vh] min-w-0 flex-col gap-2 overflow-y-auto px-2 pb-3">
1342
+ <div
1343
+ ref={rootRef}
1344
+ className="flex min-h-[55vh] max-h-[70vh] min-w-0 flex-col gap-2 overflow-y-auto px-2 pb-3"
1345
+ >
1193
1346
  {children}
1347
+ {loadingMore && (
1348
+ <Skeleton className="h-16 w-full shrink-0" data-testid="lane-loading-more" />
1349
+ )}
1350
+ {/* Sentinel: entering view triggers the next stage page. */}
1351
+ {hasMore && (
1352
+ <div ref={sentinelRef} className="h-1 w-full shrink-0" aria-hidden />
1353
+ )}
1194
1354
  </div>
1195
1355
  </div>
1196
1356
  )
@@ -139,11 +139,13 @@ export function useDynamicRowActions({
139
139
  try {
140
140
  const deleteEndpoint = endpoint ? `${endpoint}/${rowToDelete.id}` : `/data/${model}/${rowToDelete.id}`
141
141
  const res = await api.delete(deleteEndpoint)
142
- if (res.data.success) { toast.success(res.data.message || 'Eliminado correctamente'); onRefresh() }
143
- else toast.error(res.data.message || 'Error al eliminar')
142
+ // CRUD estándar: no usar res.data.message (el endpoint dinámico
143
+ // devuelve texto en inglés que se filtraría al toast). String localizado.
144
+ if (res.data.success) { toast.success(t('dynamic.delete_success', { defaultValue: 'Registro eliminado correctamente' })); onRefresh() }
145
+ else toast.error(t('dynamic.delete_error', { defaultValue: 'No se pudo eliminar el registro' }))
144
146
  } catch (error) {
145
147
  console.error('Error al eliminar', error)
146
- toast.error('Error al eliminar el registro')
148
+ toast.error(t('dynamic.delete_error', { defaultValue: 'No se pudo eliminar el registro' }))
147
149
  } finally {
148
150
  setIsDeleting(false)
149
151
  setRowToDelete(null)
@@ -68,6 +68,7 @@ import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-sh
68
68
  import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns'
69
69
  import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders'
70
70
  import { FilterChipsRow, translateOptionLabels, type FilterChipField } from './filter-chips'
71
+ import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll'
71
72
  import { OptionsContext } from './options-context'
72
73
  import type { TableMetadata, ApiResponse } from './types'
73
74
  import { getSearchableColumnKeys } from './column-visibility'
@@ -112,6 +113,14 @@ export interface DynamicTableProps {
112
113
  * an explicit per-column currency. Optional — defaults to 'USD'.
113
114
  */
114
115
  currency?: string
116
+ /**
117
+ * Opt into infinite scroll: instead of the classic pager, rows accumulate as
118
+ * the user scrolls (a sentinel at the bottom fetches + appends the next
119
+ * page, deduped by id, respecting the active filters/search). Changing any
120
+ * filter/sort/search resets to page 1. Default false — existing hosts keep
121
+ * the classic pagination untouched.
122
+ */
123
+ infiniteScroll?: boolean
115
124
  }
116
125
 
117
126
  export function DynamicTable({
@@ -127,6 +136,7 @@ export function DynamicTable({
127
136
  getDynamicColumns = defaultGetDynamicColumns,
128
137
  timeZone,
129
138
  currency,
139
+ infiniteScroll = false,
130
140
  }: DynamicTableProps) {
131
141
  const { t, i18n } = useTranslation()
132
142
  const api = useApi()
@@ -144,6 +154,10 @@ export function DynamicTable({
144
154
  const [footerTotals, setFooterTotals] = useState<Record<string, any>>({})
145
155
  const [loading, setLoading] = useState(!cachedMeta)
146
156
  const [loadingData, setLoadingData] = useState(true)
157
+ // Infinite-scroll: a top-up page is in flight (distinct from the initial
158
+ // page load so only a small bottom spinner shows, not the whole-table one).
159
+ const [loadingMore, setLoadingMore] = useState(false)
160
+ const infPageRef = useRef(1)
147
161
  const [optionsMap, setOptionsMap] = useState<Map<string, any[]>>(new Map())
148
162
 
149
163
  const [exportOpen, setExportOpen] = useState(false)
@@ -472,9 +486,69 @@ export function DynamicTable({
472
486
  }
473
487
  }, [model, metadata, aggregateColumns, buildFilterParams, endpoint, currentBranch?.id, api])
474
488
 
489
+ // ---- infinite scroll: page fetch that REPLACES (page 1) or APPENDS ----
490
+ const infPageSize = 30
491
+ const fetchPage = useCallback(
492
+ async (page: number, append: boolean) => {
493
+ if (!metadata) return
494
+ if (append) setLoadingMore(true)
495
+ else setLoadingData(true)
496
+ try {
497
+ const params: Record<string, any> = {
498
+ page,
499
+ per_page: infPageSize,
500
+ ...buildFilterParams(),
501
+ }
502
+ const res = (await api.get(endpoint || `/data/${model}`, {
503
+ params,
504
+ })) as { data: ApiResponse<any[]> }
505
+ if (res.data.success) {
506
+ const rows = res.data.data || []
507
+ setData((prev) => (append ? dedupeById(prev, rows) : rows))
508
+ if (res.data.meta) setRowCount(res.data.meta.total)
509
+ }
510
+ } catch (error) {
511
+ console.error('Error al cargar los datos', error)
512
+ } finally {
513
+ if (append) setLoadingMore(false)
514
+ else setLoadingData(false)
515
+ }
516
+ },
517
+ [metadata, buildFilterParams, endpoint, model, api, currentBranch?.id],
518
+ )
519
+
520
+ // Signature of everything that must reset the incremental list to page 1:
521
+ // the filters/search AND the sort (both live in buildFilterParams).
522
+ const filterSignature = useMemo(
523
+ () => JSON.stringify(buildFilterParams()),
524
+ [buildFilterParams],
525
+ )
526
+
527
+ const loadNextPage = useCallback(() => {
528
+ if (loadingMore || loadingData) return
529
+ if (data.length >= rowCount) return
530
+ infPageRef.current += 1
531
+ void fetchPage(infPageRef.current, true)
532
+ }, [loadingMore, loadingData, data.length, rowCount, fetchPage])
533
+
534
+ // Infinite-scroll sentinels. There are two scroll containers (desktop
535
+ // table + mobile card list) but only one is laid out at a time — the CSS
536
+ // `hidden`/`sm:hidden` container has no box, so its observer never fires.
537
+ // Each sentinel drives the SAME `loadNextPage`; its internal guards + the
538
+ // `disabled` flag keep concurrent/exhausted fetches from doubling up.
539
+ const infScrollDisabled =
540
+ !infiniteScroll || loadingMore || loadingData || data.length >= rowCount
541
+ const { rootRef: infDesktopRoot, sentinelRef: infDesktopSentinel } =
542
+ useInfiniteScrollSentinel({ onLoadMore: loadNextPage, disabled: infScrollDisabled })
543
+ const { rootRef: infMobileRoot, sentinelRef: infMobileSentinel } =
544
+ useInfiniteScrollSentinel({ onLoadMore: loadNextPage, disabled: infScrollDisabled })
545
+
475
546
  const initialFetchDone = useRef(false)
476
547
  useEffect(() => {
477
548
  if (!metadata) return
549
+ // Infinite mode owns its own fetching (reset-to-page-1 effect below);
550
+ // the classic pagination-driven path is skipped entirely.
551
+ if (infiniteScroll) return
478
552
  if (!initialFetchDone.current) {
479
553
  initialFetchDone.current = true
480
554
  fetchData()
@@ -486,9 +560,41 @@ export function DynamicTable({
486
560
  fetchAggregates()
487
561
  }, 300)
488
562
  return () => clearTimeout(timeoutId)
489
- }, [fetchData, fetchAggregates, metadata])
563
+ }, [fetchData, fetchAggregates, metadata, infiniteScroll])
490
564
 
491
- const handleRefresh = useCallback(() => { fetchData(); fetchAggregates() }, [fetchData, fetchAggregates])
565
+ // Infinite mode: (re)load page 1 on mount and whenever the filters/sort/
566
+ // search change (or an explicit refreshTrigger) — replacing the accumulated
567
+ // rows and resetting the cursor.
568
+ useEffect(() => {
569
+ if (!infiniteScroll || !metadata) return
570
+ infPageRef.current = 1
571
+ const first = !initialFetchDone.current
572
+ initialFetchDone.current = true
573
+ if (first) {
574
+ void fetchPage(1, false)
575
+ void fetchAggregates()
576
+ return
577
+ }
578
+ const timeoutId = setTimeout(() => {
579
+ void fetchPage(1, false)
580
+ void fetchAggregates()
581
+ }, 300)
582
+ return () => clearTimeout(timeoutId)
583
+ // eslint-disable-next-line react-hooks/exhaustive-deps
584
+ }, [infiniteScroll, metadata, filterSignature])
585
+
586
+ const handleRefresh = useCallback(() => {
587
+ // Infinite mode owns its own list: refresh reloads page 1 and drops the
588
+ // accumulated pages (a classic fetchData would collapse it to one small
589
+ // pagination page). Classic mode keeps the pagination-driven refetch.
590
+ if (infiniteScroll) {
591
+ infPageRef.current = 1
592
+ void fetchPage(1, false)
593
+ } else {
594
+ fetchData()
595
+ }
596
+ fetchAggregates()
597
+ }, [infiniteScroll, fetchPage, fetchData, fetchAggregates])
492
598
 
493
599
  // Per-row action dispatch (view/edit/delete/link/custom) + its dialogs live
494
600
  // in the shared hook so DynamicKanban's card menu behaves identically.
@@ -522,8 +628,8 @@ export function DynamicTable({
522
628
  setBulkDeleteProgress(0)
523
629
  setBulkDeleteTotal(0)
524
630
  setRowSelection({})
525
- if (successCount > 0) toast.success(`${successCount} registro(s) eliminado(s) correctamente`)
526
- if (errorCount > 0) toast.error(`${errorCount} registro(s) no pudieron ser eliminados`)
631
+ if (successCount > 0) toast.success(t('dynamic.bulk_delete_success', { count: successCount, defaultValue: '{{count}} registro(s) eliminado(s) correctamente' }))
632
+ if (errorCount > 0) toast.error(t('dynamic.bulk_delete_error', { count: errorCount, defaultValue: '{{count}} registro(s) no pudieron ser eliminados' }))
527
633
  handleRefresh()
528
634
  }
529
635
 
@@ -838,7 +944,7 @@ export function DynamicTable({
838
944
  {/* Desktop: classic horizontal-scroll table. Hidden on phones —
839
945
  a 7-column table forces a wide horizontal scroll there, so we
840
946
  render a card-per-row list instead (see MobileCards below). */}
841
- <div className='hidden sm:block flex-1 min-h-0 overflow-auto border rounded-md bg-card'>
947
+ <div ref={infDesktopRoot} className='hidden sm:block flex-1 min-h-0 overflow-auto border rounded-md bg-card'>
842
948
  <Table noWrapper className={cn('min-w-max w-full', aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && 'h-full')}>
843
949
  <TableHeader className='sticky top-0 z-10'>
844
950
  {table.getHeaderGroups().map((headerGroup: HeaderGroup<any>) => (
@@ -954,12 +1060,25 @@ export function DynamicTable({
954
1060
  </TableFooter>
955
1061
  )}
956
1062
  </Table>
1063
+ {infiniteScroll && (
1064
+ <>
1065
+ {loadingMore && (
1066
+ <div className='p-2'>
1067
+ <Skeleton className='h-8 w-full' data-testid='table-loading-more' />
1068
+ </div>
1069
+ )}
1070
+ {/* Sentinel stays mounted for the whole infinite-scroll
1071
+ session so its observer attaches once; `disabled`
1072
+ (no more pages / load in flight) gates the fetch. */}
1073
+ <div ref={infDesktopSentinel} className='h-1 w-full' aria-hidden />
1074
+ </>
1075
+ )}
957
1076
  </div>
958
1077
 
959
1078
  {/* Mobile: one card per row — no horizontal scroll. Each card
960
1079
  stacks its columns as label : value pairs with the row actions
961
1080
  pinned at the bottom. */}
962
- <div className='flex flex-1 min-h-0 flex-col gap-2 overflow-y-auto sm:hidden'>
1081
+ <div ref={infMobileRoot} className='flex flex-1 min-h-0 flex-col gap-2 overflow-y-auto sm:hidden'>
963
1082
  {loadingData && data.length === 0 ? (
964
1083
  Array.from({ length: 5 }).map((_, i) => (
965
1084
  <div key={i} className='rounded-lg border bg-card p-3'>
@@ -1014,13 +1133,33 @@ export function DynamicTable({
1014
1133
  <p className='text-sm text-muted-foreground'>No hay datos para mostrar en este momento.</p>
1015
1134
  </div>
1016
1135
  )}
1136
+ {infiniteScroll && (
1137
+ <>
1138
+ {loadingMore && (
1139
+ <Skeleton className='h-16 w-full shrink-0' data-testid='table-loading-more-mobile' />
1140
+ )}
1141
+ <div ref={infMobileSentinel} className='h-1 w-full shrink-0' aria-hidden />
1142
+ </>
1143
+ )}
1017
1144
  </div>
1018
1145
 
1019
1146
  <div className='shrink-0 pt-4'>
1020
- <DataTablePagination
1021
- table={table}
1022
- pageSizeOptions={metadata.perPageOptions}
1023
- />
1147
+ {infiniteScroll ? (
1148
+ data.length > 0 && (
1149
+ <p className='text-center text-xs text-muted-foreground tabular-nums'>
1150
+ {t('common.showingCount', {
1151
+ defaultValue: '{{count}} de {{total}}',
1152
+ count: data.length,
1153
+ total: rowCount,
1154
+ })}
1155
+ </p>
1156
+ )
1157
+ ) : (
1158
+ <DataTablePagination
1159
+ table={table}
1160
+ pageSizeOptions={metadata.perPageOptions}
1161
+ />
1162
+ )}
1024
1163
  </div>
1025
1164
  </div>
1026
1165
 
@@ -0,0 +1,94 @@
1
+ // Shared incremental-loading primitives for DynamicTable and DynamicKanban.
2
+ // - `dedupeById` appends a page of rows to the accumulated set, dropping any
3
+ // id already present (the server may re-paginate / overlap between a global
4
+ // page and a scoped top-up).
5
+ // - `useInfiniteScrollSentinel` wires an IntersectionObserver: attach
6
+ // `rootRef` to the scroll container and `sentinelRef` to a sentinel at its
7
+ // bottom; when the sentinel enters view (and it's enabled) `onLoadMore`
8
+ // fires. Degrades to a no-op where IntersectionObserver is unavailable
9
+ // (older/SSR/happy-dom) so callers still render.
10
+ import { useEffect, useRef } from 'react'
11
+
12
+ /**
13
+ * Returns a NEW array = `existing` followed by every row in `incoming` whose
14
+ * `id` is not already present. Stable on identity/order of `existing`. Pure.
15
+ */
16
+ export function dedupeById<T extends { id?: any }>(
17
+ existing: T[],
18
+ incoming: T[],
19
+ ): T[] {
20
+ if (incoming.length === 0) return existing
21
+ const seen = new Set(existing.map((r) => String(r?.id)))
22
+ const additions: T[] = []
23
+ for (const row of incoming) {
24
+ const key = String(row?.id)
25
+ if (seen.has(key)) continue
26
+ seen.add(key)
27
+ additions.push(row)
28
+ }
29
+ return additions.length === 0 ? existing : [...existing, ...additions]
30
+ }
31
+
32
+ export interface UseInfiniteScrollOptions {
33
+ /** Fired when the sentinel scrolls into view and loading is enabled. */
34
+ onLoadMore: () => void
35
+ /** When true, the observer is inert (no more pages, or a load in flight). */
36
+ disabled?: boolean
37
+ /** Pixels of pre-fetch margin below the viewport. Default 200. */
38
+ rootMargin?: number
39
+ }
40
+
41
+ export interface InfiniteScrollRefs<
42
+ R extends HTMLElement = HTMLDivElement,
43
+ S extends HTMLElement = HTMLDivElement,
44
+ > {
45
+ rootRef: React.RefObject<R | null>
46
+ sentinelRef: React.RefObject<S | null>
47
+ }
48
+
49
+ /**
50
+ * IntersectionObserver-backed infinite scroll. Attach `rootRef` to the
51
+ * scrollable container and `sentinelRef` to a small element at its bottom. The
52
+ * latest `onLoadMore`/`disabled` are read through a ref so the observer isn't
53
+ * torn down and rebuilt on every render.
54
+ */
55
+ export function useInfiniteScrollSentinel<
56
+ R extends HTMLElement = HTMLDivElement,
57
+ S extends HTMLElement = HTMLDivElement,
58
+ >({
59
+ onLoadMore,
60
+ disabled = false,
61
+ rootMargin = 200,
62
+ }: UseInfiniteScrollOptions): InfiniteScrollRefs<R, S> {
63
+ const rootRef = useRef<R | null>(null)
64
+ const sentinelRef = useRef<S | null>(null)
65
+ const cb = useRef(onLoadMore)
66
+ const disabledRef = useRef(disabled)
67
+ cb.current = onLoadMore
68
+ disabledRef.current = disabled
69
+
70
+ useEffect(() => {
71
+ const sentinel = sentinelRef.current
72
+ if (
73
+ !sentinel ||
74
+ typeof IntersectionObserver === 'undefined'
75
+ ) {
76
+ return
77
+ }
78
+ const observer = new IntersectionObserver(
79
+ (entries) => {
80
+ for (const entry of entries) {
81
+ if (entry.isIntersecting && !disabledRef.current) {
82
+ cb.current()
83
+ }
84
+ }
85
+ },
86
+ { root: rootRef.current ?? null, rootMargin: `0px 0px ${rootMargin}px 0px` },
87
+ )
88
+ observer.observe(sentinel)
89
+ return () => observer.disconnect()
90
+ // Re-create only when the margin changes; onLoadMore/disabled are read live.
91
+ }, [rootMargin])
92
+
93
+ return { rootRef, sentinelRef }
94
+ }