@asteby/metacore-runtime-react 23.1.0 → 23.2.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 CHANGED
@@ -1,5 +1,40 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 23.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 68a6844: Scroll infinito con carga incremental en DynamicKanban y DynamicTable,
8
+ respetando la búsqueda y los filtros activos. Cero cambios de backend: se apoya
9
+ en el `page`/`per_page` que ya expone `/data/:model`.
10
+ - **Primitivos compartidos (`use-infinite-scroll`):** `dedupeById` (append puro
11
+ que descarta ids ya presentes, estable en identidad cuando no hay altas) y
12
+ `useInfiniteScrollSentinel` (IntersectionObserver sobre un sentinel; lee
13
+ `onLoadMore`/`disabled` por ref para no recrear el observer en cada render;
14
+ degrada a no-op donde no hay IntersectionObserver). Los usan ambas vistas.
15
+ - **Kanban incremental por lane:** una página global inicial (`pageSize`, 50 por
16
+ defecto) pinta el tablero agrupado —y captura naturalmente la lane
17
+ "sin asignar"— y luego cada lane rellena SU propia etapa al acercarse el
18
+ scroll al fondo (`f_<group_by>=<stage>&page=n&per_page=lanePageSize`, 25 por
19
+ defecto, sobre los filtros activos), con dedup por id y un skeleton chico al
20
+ fondo. El contador del header muestra el total real de la etapa cuando la
21
+ respuesta trae `meta.total` (`count/total`), si no el cargado. Cambiar
22
+ filtros/búsqueda resetea la paginación de todas las lanes. El drag&drop
23
+ optimista ajusta los totales de origen y destino (`applyLaneTotalsOnMove`)
24
+ para que las lanes parciales sigan mostrando un `count/total` veraz, y los
25
+ revierte si el PUT falla.
26
+ - **Tabla opt-in (`infiniteScroll?: boolean`, default `false`):** la paginación
27
+ clásica queda intacta salvo que se active. Con el flag, un sentinel al fondo
28
+ del contenedor de scroll pide la siguiente página y APPENDEA filas (dedup por
29
+ id); el pager clásico se reemplaza por un indicador "N de total". Cambiar
30
+ filtros/orden/búsqueda resetea a la página 1 y limpia el acumulado. El footer
31
+ de totales (`/aggregate`) no cambia.
32
+
33
+ ### Patch Changes
34
+
35
+ - Updated dependencies [68a6844]
36
+ - @asteby/metacore-ui@2.9.1
37
+
3
38
  ## 23.1.0
4
39
 
5
40
  ### Minor Changes
@@ -32,6 +32,16 @@ export declare function isTransitionAllowed(transitions: StageTransition[] | und
32
32
  * resolves, and so the previous grouping can be restored on failure.
33
33
  */
34
34
  export declare function applyOptimisticMove(grouped: Map<string, any[]>, cardId: string | number, fromStage: string, toStage: string, groupByKey: string): Map<string, any[]>;
35
+ /**
36
+ * Returns a NEW per-lane pagination map with the server totals adjusted for a
37
+ * card moving `fromStage` → `toStage`: the source loses one, the destination
38
+ * gains one. Lanes whose `total` is still unknown (`null`, not yet topped up)
39
+ * are left alone. Pure — backs the optimistic drag so a partial lane's
40
+ * `count/total` header stays truthful, and can be restored on PUT failure.
41
+ */
42
+ export declare function applyLaneTotalsOnMove<T extends {
43
+ total: number | null;
44
+ }>(pagination: Record<string, T>, fromStage: string, toStage: string): Record<string, T>;
35
45
  /**
36
46
  * Picks the columns shown on a card: a `title` column (first searchable column,
37
47
  * else first text-ish column) and up to `maxFields` secondary columns. Excludes
@@ -88,10 +98,16 @@ export interface DynamicKanbanProps {
88
98
  */
89
99
  onAction?: (action: string, row: any) => void;
90
100
  /**
91
- * Max cards fetched per lane render. Kanban shows all cards at once (no
92
- * pagination UI), so it requests a single large page. Defaults to 200.
101
+ * Size of the INITIAL board page (one request, grouped into lanes). Each
102
+ * lane then tops up incrementally on scroll (see `lanePageSize`). Defaults
103
+ * to 50 — enough to fill the visible lanes without loading the whole board.
93
104
  */
94
105
  pageSize?: number;
106
+ /**
107
+ * Page size for a lane's incremental top-up fetch (scoped by
108
+ * `f_<group_by>=<stage>`). Defaults to 25.
109
+ */
110
+ lanePageSize?: number;
95
111
  /** IANA timezone for datetime card fields (org config). */
96
112
  timeZone?: string;
97
113
  /** ISO 4217 currency for money card fields (org config). */
@@ -102,5 +118,5 @@ export interface DynamicKanbanProps {
102
118
  */
103
119
  defaultFilters?: Record<string, any>;
104
120
  }
105
- export declare function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, onAction, pageSize, timeZone, currency, defaultFilters, }: DynamicKanbanProps): React.JSX.Element;
121
+ export declare function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, onAction, pageSize, lanePageSize, timeZone, currency, defaultFilters, }: DynamicKanbanProps): React.JSX.Element;
106
122
  //# sourceMappingURL=dynamic-kanban.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"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"}
@@ -10,6 +10,7 @@ import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib';
10
10
  import { useApi } from './api-context';
11
11
  import { useDynamicFilters } from './use-dynamic-filters';
12
12
  import { FilterChipsRow, summarizeFilterValues, translateOptionLabels, } from './filter-chips';
13
+ import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll';
13
14
  import { useMetadataCache } from './metadata-cache';
14
15
  import { ActivityValueRenderer } from './activity-value-renderer';
15
16
  import { DynamicIcon } from './dynamic-icon';
@@ -112,6 +113,25 @@ export function applyOptimisticMove(grouped, cardId, fromStage, toStage, groupBy
112
113
  next.set(toStage, toRows);
113
114
  return next;
114
115
  }
116
+ /**
117
+ * Returns a NEW per-lane pagination map with the server totals adjusted for a
118
+ * card moving `fromStage` → `toStage`: the source loses one, the destination
119
+ * gains one. Lanes whose `total` is still unknown (`null`, not yet topped up)
120
+ * are left alone. Pure — backs the optimistic drag so a partial lane's
121
+ * `count/total` header stays truthful, and can be restored on PUT failure.
122
+ */
123
+ export function applyLaneTotalsOnMove(pagination, fromStage, toStage) {
124
+ const next = { ...pagination };
125
+ const bump = (key, delta) => {
126
+ const st = next[key];
127
+ if (st && st.total != null) {
128
+ next[key] = { ...st, total: Math.max(0, st.total + delta) };
129
+ }
130
+ };
131
+ bump(fromStage, -1);
132
+ bump(toStage, +1);
133
+ return next;
134
+ }
115
135
  /**
116
136
  * Picks the columns shown on a card: a `title` column (first searchable column,
117
137
  * else first text-ish column) and up to `maxFields` secondary columns. Excludes
@@ -195,7 +215,7 @@ function useIsDarkTheme() {
195
215
  }, []);
196
216
  return isDark;
197
217
  }
198
- export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, onAction, pageSize = 200, timeZone, currency, defaultFilters, }) {
218
+ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, onAction, pageSize = 50, lanePageSize = 25, timeZone, currency, defaultFilters, }) {
199
219
  const { t, i18n } = useTranslation();
200
220
  const api = useApi();
201
221
  const isDark = useIsDarkTheme();
@@ -205,6 +225,12 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
205
225
  const [records, setRecords] = useState([]);
206
226
  const [loading, setLoading] = useState(!cachedMeta);
207
227
  const [loadingData, setLoadingData] = useState(true);
228
+ // Per-stage incremental pagination for infinite scroll. The initial board
229
+ // page (grouped into lanes) is fetched once; each lane then tops up its OWN
230
+ // stage via `f_<group_by>=<stage>&page=n`, appended (deduped by id) into the
231
+ // shared `records`. `total` is the stage's server count when the response
232
+ // meta carries it. Reset whenever the filters/search change.
233
+ const [lanePagination, setLanePagination] = useState({});
208
234
  // Active drag card id (for the DragOverlay + drop-zone highlighting).
209
235
  const [activeId, setActiveId] = useState(null);
210
236
  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
@@ -247,7 +273,10 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
247
273
  // and `f_<key>` serialization DynamicTable uses, so the board filters
248
274
  // identically to its table sibling.
249
275
  const { dynamicFilters, globalFilter, setGlobalFilter, columnFilterConfigs, filterParams, activeFilterCount, handleDynamicFilterChange, clearAll, } = useDynamicFilters(metadata, { defaultFilters, model, endpoint });
250
- // ---- records fetch (same path as DynamicTable, single large page) ----
276
+ // ---- initial board page (one request, grouped into lanes) ----
277
+ // Resets the per-lane pagination so every lane restarts its incremental
278
+ // top-up from scratch — called on mount, refresh, and any filter/search
279
+ // change (fetchData's identity changes with filterParams).
251
280
  const fetchData = useCallback(async () => {
252
281
  if (!metadata)
253
282
  return;
@@ -265,7 +294,64 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
265
294
  finally {
266
295
  setLoadingData(false);
267
296
  }
297
+ setLanePagination({});
268
298
  }, [api, endpoint, model, metadata, pageSize, filterParams]);
299
+ // Load the next page for ONE lane/stage and append it (deduped by id) into
300
+ // the shared records. Scoped by `f_<group_by>=<stage>` on top of the active
301
+ // filterParams (the stage scope wins over any global group_by filter).
302
+ const groupByKey = metadata?.group_by || '';
303
+ const loadMoreLane = useCallback(async (stageKey) => {
304
+ if (!metadata || !groupByKey)
305
+ return;
306
+ const current = lanePagination[stageKey];
307
+ if (current?.loading || current?.done)
308
+ return;
309
+ const nextPage = current?.nextPage ?? 1;
310
+ setLanePagination((p) => ({
311
+ ...p,
312
+ [stageKey]: {
313
+ nextPage,
314
+ total: current?.total ?? null,
315
+ loading: true,
316
+ done: false,
317
+ },
318
+ }));
319
+ try {
320
+ const res = (await api.get(endpoint || `/data/${model}`, {
321
+ params: {
322
+ ...filterParams,
323
+ page: nextPage,
324
+ per_page: lanePageSize,
325
+ [`f_${groupByKey}`]: stageKey,
326
+ },
327
+ }));
328
+ const rows = res.data.success ? res.data.data || [] : [];
329
+ const total = res.data.meta?.total ?? res.data.meta?.count ?? null;
330
+ setRecords((prev) => dedupeById(prev, rows));
331
+ setLanePagination((p) => ({
332
+ ...p,
333
+ [stageKey]: {
334
+ nextPage: nextPage + 1,
335
+ total,
336
+ loading: false,
337
+ // Exhausted when the server returned a short page.
338
+ done: rows.length < lanePageSize,
339
+ },
340
+ }));
341
+ }
342
+ catch (err) {
343
+ console.error(`Error al cargar más tarjetas de ${stageKey}`, err);
344
+ setLanePagination((p) => ({
345
+ ...p,
346
+ [stageKey]: {
347
+ nextPage,
348
+ total: current?.total ?? null,
349
+ loading: false,
350
+ done: false,
351
+ },
352
+ }));
353
+ }
354
+ }, [api, endpoint, model, metadata, groupByKey, filterParams, lanePageSize, lanePagination]);
269
355
  // Refetch when metadata resolves, on an explicit refresh, or when the
270
356
  // filters change. `fetchData` is stable while `filterParams` is unchanged
271
357
  // (both memoized), so this only re-runs on real input changes. Debounced so
@@ -348,7 +434,6 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
348
434
  });
349
435
  }, []);
350
436
  const stages = useMemo(() => (metadata ? deriveStages(metadata) : []), [metadata]);
351
- const groupByKey = metadata?.group_by || '';
352
437
  const transitions = metadata?.transitions;
353
438
  const grouped = useMemo(() => groupByStage(records, groupByKey, stages), [records, groupByKey, stages]);
354
439
  const { title: titleCol, fields: fieldCols } = useMemo(() => (metadata ? selectCardColumns(metadata) : { title: null, fields: [] }), [metadata]);
@@ -405,7 +490,12 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
405
490
  }
406
491
  // OPTIMISTIC: move the card in local state immediately.
407
492
  const prevRecords = records;
493
+ const prevPagination = lanePagination;
408
494
  setRecords((rs) => rs.map((r) => String(r.id) === cardId ? { ...r, [groupByKey]: destStage } : r));
495
+ // Keep the server totals consistent with the moved card so a lane's
496
+ // `count/total` header stays truthful with partial lanes: one leaves
497
+ // the source stage, one joins the destination.
498
+ setLanePagination((p) => applyLaneTotalsOnMove(p, srcStage, destStage));
409
499
  try {
410
500
  const base = endpoint || `/data/${model}`;
411
501
  // `base` is the org-scoped list endpoint (e.g. `/data/<model>/me`),
@@ -422,6 +512,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
422
512
  catch (err) {
423
513
  // REVERT + toast on failure.
424
514
  setRecords(prevRecords);
515
+ setLanePagination(prevPagination);
425
516
  toast.error(t('kanban.moveFailed', {
426
517
  defaultValue: 'No se pudo mover la tarjeta',
427
518
  }) +
@@ -429,7 +520,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
429
520
  ? `: ${err.response.data.message}`
430
521
  : ''));
431
522
  }
432
- }, [api, endpoint, groupByKey, model, records, stageOfCard, t, transitions]);
523
+ }, [api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions]);
433
524
  if (loading) {
434
525
  return (_jsx("div", { className: "flex gap-4 overflow-x-auto p-1", children: [0, 1, 2, 3].map((i) => (_jsxs("div", { className: "w-[300px] shrink-0 space-y-3", children: [_jsx(Skeleton, { className: "h-8 w-full" }), _jsx(Skeleton, { className: "h-24 w-full" }), _jsx(Skeleton, { className: "h-24 w-full" })] }, i))) }));
435
526
  }
@@ -474,7 +565,12 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
474
565
  const droppableAllowed = !activeId ||
475
566
  stage.key === activeStage ||
476
567
  isTransitionAllowed(transitions, activeStage, stage.key);
477
- return (_jsx(KanbanLane, { stage: stage, count: cards.length, totalCount: allCards.length, filterFields: filterFields, laneFilter: laneFilter, onFunnelChange: (f) => updateLaneFilter(stage.key, {
568
+ // Infinite scroll is per declared stage; the synthetic
569
+ // "unassigned" lane can't be stage-scoped, so it never tops up.
570
+ const laneState = lanePagination[stage.key];
571
+ const isUnassigned = stage.key === UNASSIGNED_LANE;
572
+ const laneHasMore = !isUnassigned && !laneState?.done;
573
+ return (_jsx(KanbanLane, { stage: stage, count: cards.length, totalCount: allCards.length, serverTotal: laneState?.total ?? null, hasMore: laneHasMore, loadingMore: !!laneState?.loading, onLoadMore: () => loadMoreLane(stage.key), filterFields: filterFields, laneFilter: laneFilter, onFunnelChange: (f) => updateLaneFilter(stage.key, {
478
574
  field: f?.field,
479
575
  values: f?.values,
480
576
  text: f?.text,
@@ -508,9 +604,15 @@ function SheetFilterRow({ field, isStage, }) {
508
604
  const summary = summarizeFilterValues(field.config.selectedValues, field.config.options);
509
605
  return (_jsx(ColumnFilterControl, { variant: "row", align: "end", icon: filterTypeIcon(field.config.filterType, isStage), label: field.label, valueSummary: summary, filterKey: field.config.filterKey, filterType: field.config.filterType, filterOptions: field.config.options, filterLoading: field.config.loading, filterSearchEndpoint: field.config.searchEndpoint, selectedValues: field.config.selectedValues, onFilterChange: field.config.onFilterChange, loadOptions: field.config.loadOptions }));
510
606
  }
511
- function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunnelChange, onQueryChange, isDark, dimmed, disabled, children, }) {
607
+ function KanbanLane({ stage, count, totalCount, serverTotal, hasMore, loadingMore, onLoadMore, filterFields, laneFilter, onFunnelChange, onQueryChange, isDark, dimmed, disabled, children, }) {
512
608
  const { t } = useTranslation();
513
609
  const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled });
610
+ // Infinite scroll: the sentinel lives at the bottom of the lane's own scroll
611
+ // container; a load in flight or an exhausted stage disables it.
612
+ const { rootRef, sentinelRef } = useInfiniteScrollSentinel({
613
+ onLoadMore,
614
+ disabled: !hasMore || loadingMore,
615
+ });
514
616
  const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
515
617
  isDark,
516
618
  });
@@ -546,7 +648,11 @@ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunn
546
648
  opacity: dimmed ? 0.45 : 1,
547
649
  outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
548
650
  outlineOffset: 2,
549
- }, "data-stage": stage.key, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: laneActive ? `${count}/${totalCount}` : count })] }), _jsxs("div", { className: "flex items-center gap-0.5", children: [_jsxs("button", { type: "button", onClick: () => setSearchOpen((o) => !o), className: `relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${queryActive ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.searchLane', {
651
+ }, "data-stage": stage.key, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: laneActive
652
+ ? `${count}/${totalCount}`
653
+ : serverTotal != null
654
+ ? `${count}/${serverTotal}`
655
+ : count })] }), _jsxs("div", { className: "flex items-center gap-0.5", children: [_jsxs("button", { type: "button", onClick: () => setSearchOpen((o) => !o), className: `relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${queryActive ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.searchLane', {
550
656
  defaultValue: 'Buscar en la columna',
551
657
  }), children: [_jsx(Search, { className: "h-3.5 w-3.5" }), queryActive && (_jsx("span", { className: "absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary" }))] }), _jsx(LaneFilterButton, { fields: filterFields, value: funnelValue, onChange: onFunnelChange })] })] }), searchOpen && (_jsx("div", { className: "px-3 pb-1.5", children: _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { ref: searchRef, value: laneFilter?.query ?? '', onChange: (e) => onQueryChange(e.target.value), onKeyDown: (e) => {
552
658
  if (e.key === 'Escape') {
@@ -560,7 +666,7 @@ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunn
560
666
  defaultValue: 'Buscar tarjetas...',
561
667
  }), className: "h-7 pl-7 text-xs" })] }) })), funnelActive && (_jsxs("div", { className: "flex items-center gap-1 px-3 pb-1.5 text-[11px] text-muted-foreground", children: [_jsx(ListFilter, { className: "h-3 w-3 shrink-0" }), _jsxs("span", { className: "truncate", children: [activeFieldLabel, ": ", funnelSummary] }), _jsx("button", { type: "button", onClick: () => onFunnelChange(null), className: "ml-auto rounded p-0.5 hover:bg-muted", "aria-label": t('kanban.clearFilters', {
562
668
  defaultValue: 'Limpiar',
563
- }), children: _jsx(X, { className: "h-3 w-3" }) })] })), _jsx("div", { className: "flex min-h-[55vh] max-h-[70vh] min-w-0 flex-col gap-2 overflow-y-auto px-2 pb-3", children: children })] }));
669
+ }), children: _jsx(X, { className: "h-3 w-3" }) })] })), _jsxs("div", { ref: rootRef, className: "flex min-h-[55vh] max-h-[70vh] min-w-0 flex-col gap-2 overflow-y-auto px-2 pb-3", children: [children, loadingMore && (_jsx(Skeleton, { className: "h-16 w-full shrink-0", "data-testid": "lane-loading-more" })), hasMore && (_jsx("div", { ref: sentinelRef, className: "h-1 w-full shrink-0", "aria-hidden": true }))] })] }));
564
670
  }
565
671
  // LaneFilterButton — the per-column funnel. Picks a field + a value and narrows
566
672
  // ONLY this lane's cards (client-side, in the parent). Draft state lives here so
@@ -36,6 +36,14 @@ export interface DynamicTableProps {
36
36
  * an explicit per-column currency. Optional — defaults to 'USD'.
37
37
  */
38
38
  currency?: string;
39
+ /**
40
+ * Opt into infinite scroll: instead of the classic pager, rows accumulate as
41
+ * the user scrolls (a sentinel at the bottom fetches + appends the next
42
+ * page, deduped by id, respecting the active filters/search). Changing any
43
+ * filter/sort/search resets to page 1. Default false — existing hosts keep
44
+ * the classic pagination untouched.
45
+ */
46
+ infiniteScroll?: boolean;
39
47
  }
40
- export declare function DynamicTable({ model, endpoint, enableUrlSync, hiddenColumns, onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns, getDynamicColumns, timeZone, currency, }: DynamicTableProps): import("react").JSX.Element;
48
+ export declare function DynamicTable({ model, endpoint, enableUrlSync, hiddenColumns, onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns, getDynamicColumns, timeZone, currency, infiniteScroll, }: DynamicTableProps): import("react").JSX.Element;
41
49
  //# sourceMappingURL=dynamic-table.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAYnF,MAAM,WAAW,iBAAiB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC/B,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EACR,QAAQ,GACX,EAAE,iBAAiB,+BAw6BnB"}
1
+ {"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAanF,MAAM,WAAW,iBAAiB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC/B,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CAC3B;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EACR,QAAQ,EACR,cAAsB,GACzB,EAAE,iBAAiB,+BAyiCnB"}
@@ -26,13 +26,14 @@ import { useApi, useCurrentBranch } from './api-context';
26
26
  import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns';
27
27
  import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders';
28
28
  import { FilterChipsRow, translateOptionLabels } from './filter-chips';
29
+ import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll';
29
30
  import { OptionsContext } from './options-context';
30
31
  import { getSearchableColumnKeys } from './column-visibility';
31
32
  import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context';
32
33
  import { useDynamicRowActions } from './dynamic-row-actions';
33
34
  import { ExportDialog } from './dialogs/export';
34
35
  import { ImportDialog } from './dialogs/import';
35
- export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColumns = [], onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns = [], getDynamicColumns = defaultGetDynamicColumns, timeZone, currency, }) {
36
+ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColumns = [], onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns = [], getDynamicColumns = defaultGetDynamicColumns, timeZone, currency, infiniteScroll = false, }) {
36
37
  const { t, i18n } = useTranslation();
37
38
  const api = useApi();
38
39
  const currentBranch = useCurrentBranch();
@@ -46,6 +47,10 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
46
47
  const [footerTotals, setFooterTotals] = useState({});
47
48
  const [loading, setLoading] = useState(!cachedMeta);
48
49
  const [loadingData, setLoadingData] = useState(true);
50
+ // Infinite-scroll: a top-up page is in flight (distinct from the initial
51
+ // page load so only a small bottom spinner shows, not the whole-table one).
52
+ const [loadingMore, setLoadingMore] = useState(false);
53
+ const infPageRef = useRef(1);
49
54
  const [optionsMap, setOptionsMap] = useState(new Map());
50
55
  const [exportOpen, setExportOpen] = useState(false);
51
56
  const [importOpen, setImportOpen] = useState(false);
@@ -392,10 +397,68 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
392
397
  console.error('Error al cargar los totales', error);
393
398
  }
394
399
  }, [model, metadata, aggregateColumns, buildFilterParams, endpoint, currentBranch?.id, api]);
400
+ // ---- infinite scroll: page fetch that REPLACES (page 1) or APPENDS ----
401
+ const infPageSize = 30;
402
+ const fetchPage = useCallback(async (page, append) => {
403
+ if (!metadata)
404
+ return;
405
+ if (append)
406
+ setLoadingMore(true);
407
+ else
408
+ setLoadingData(true);
409
+ try {
410
+ const params = {
411
+ page,
412
+ per_page: infPageSize,
413
+ ...buildFilterParams(),
414
+ };
415
+ const res = (await api.get(endpoint || `/data/${model}`, {
416
+ params,
417
+ }));
418
+ if (res.data.success) {
419
+ const rows = res.data.data || [];
420
+ setData((prev) => (append ? dedupeById(prev, rows) : rows));
421
+ if (res.data.meta)
422
+ setRowCount(res.data.meta.total);
423
+ }
424
+ }
425
+ catch (error) {
426
+ console.error('Error al cargar los datos', error);
427
+ }
428
+ finally {
429
+ if (append)
430
+ setLoadingMore(false);
431
+ else
432
+ setLoadingData(false);
433
+ }
434
+ }, [metadata, buildFilterParams, endpoint, model, api, currentBranch?.id]);
435
+ // Signature of everything that must reset the incremental list to page 1:
436
+ // the filters/search AND the sort (both live in buildFilterParams).
437
+ const filterSignature = useMemo(() => JSON.stringify(buildFilterParams()), [buildFilterParams]);
438
+ const loadNextPage = useCallback(() => {
439
+ if (loadingMore || loadingData)
440
+ return;
441
+ if (data.length >= rowCount)
442
+ return;
443
+ infPageRef.current += 1;
444
+ void fetchPage(infPageRef.current, true);
445
+ }, [loadingMore, loadingData, data.length, rowCount, fetchPage]);
446
+ // Infinite-scroll sentinels. There are two scroll containers (desktop
447
+ // table + mobile card list) but only one is laid out at a time — the CSS
448
+ // `hidden`/`sm:hidden` container has no box, so its observer never fires.
449
+ // Each sentinel drives the SAME `loadNextPage`; its internal guards + the
450
+ // `disabled` flag keep concurrent/exhausted fetches from doubling up.
451
+ const infScrollDisabled = !infiniteScroll || loadingMore || loadingData || data.length >= rowCount;
452
+ const { rootRef: infDesktopRoot, sentinelRef: infDesktopSentinel } = useInfiniteScrollSentinel({ onLoadMore: loadNextPage, disabled: infScrollDisabled });
453
+ const { rootRef: infMobileRoot, sentinelRef: infMobileSentinel } = useInfiniteScrollSentinel({ onLoadMore: loadNextPage, disabled: infScrollDisabled });
395
454
  const initialFetchDone = useRef(false);
396
455
  useEffect(() => {
397
456
  if (!metadata)
398
457
  return;
458
+ // Infinite mode owns its own fetching (reset-to-page-1 effect below);
459
+ // the classic pagination-driven path is skipped entirely.
460
+ if (infiniteScroll)
461
+ return;
399
462
  if (!initialFetchDone.current) {
400
463
  initialFetchDone.current = true;
401
464
  fetchData();
@@ -407,8 +470,41 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
407
470
  fetchAggregates();
408
471
  }, 300);
409
472
  return () => clearTimeout(timeoutId);
410
- }, [fetchData, fetchAggregates, metadata]);
411
- const handleRefresh = useCallback(() => { fetchData(); fetchAggregates(); }, [fetchData, fetchAggregates]);
473
+ }, [fetchData, fetchAggregates, metadata, infiniteScroll]);
474
+ // Infinite mode: (re)load page 1 on mount and whenever the filters/sort/
475
+ // search change (or an explicit refreshTrigger) — replacing the accumulated
476
+ // rows and resetting the cursor.
477
+ useEffect(() => {
478
+ if (!infiniteScroll || !metadata)
479
+ return;
480
+ infPageRef.current = 1;
481
+ const first = !initialFetchDone.current;
482
+ initialFetchDone.current = true;
483
+ if (first) {
484
+ void fetchPage(1, false);
485
+ void fetchAggregates();
486
+ return;
487
+ }
488
+ const timeoutId = setTimeout(() => {
489
+ void fetchPage(1, false);
490
+ void fetchAggregates();
491
+ }, 300);
492
+ return () => clearTimeout(timeoutId);
493
+ // eslint-disable-next-line react-hooks/exhaustive-deps
494
+ }, [infiniteScroll, metadata, filterSignature]);
495
+ const handleRefresh = useCallback(() => {
496
+ // Infinite mode owns its own list: refresh reloads page 1 and drops the
497
+ // accumulated pages (a classic fetchData would collapse it to one small
498
+ // pagination page). Classic mode keeps the pagination-driven refetch.
499
+ if (infiniteScroll) {
500
+ infPageRef.current = 1;
501
+ void fetchPage(1, false);
502
+ }
503
+ else {
504
+ fetchData();
505
+ }
506
+ fetchAggregates();
507
+ }, [infiniteScroll, fetchPage, fetchData, fetchAggregates]);
412
508
  // Per-row action dispatch (view/edit/delete/link/custom) + its dialogs live
413
509
  // in the shared hook so DynamicKanban's card menu behaves identically.
414
510
  const { handleInternalAction, dialogs: rowActionDialogs } = useDynamicRowActions({
@@ -682,31 +778,35 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
682
778
  if (!metadata) {
683
779
  return _jsx("div", { className: "text-center text-muted-foreground py-8", children: "Error al cargar la configuraci\u00F3n de la tabla." });
684
780
  }
685
- return (_jsxs(OptionsContext.Provider, { value: { optionsMap }, children: [_jsxs("div", { className: 'flex flex-col h-full min-h-0 w-full', children: [_jsxs("div", { className: 'pb-4 shrink-0', children: [_jsx(DataTableToolbar, { table: table, searchPlaceholder: metadata.searchPlaceholder || 'Buscar...', filters: filters, activeFilters: dynamicFilters, onDynamicFilterChange: handleDynamicFilterChange, dateFilter: { value: dateRange, onChange: setDateRange, placeholder: 'Filtrar por fecha' }, perPageOptions: metadata.perPageOptions, onRefresh: handleRefresh, isLoading: loadingData, selectedCount: Object.keys(rowSelection).length, onBulkDelete: () => setShowBulkDeleteConfirm(true), extraActions: _jsxs(_Fragment, { children: [viewMetadata?.canExport && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-8", onClick: () => setExportOpen(true), children: [_jsx(Download, { className: "h-4 w-4 mr-1" }), " Exportar"] })), viewMetadata?.canImport && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-8", onClick: () => setImportOpen(true), children: [_jsx(Upload, { className: "h-4 w-4 mr-1" }), " Importar"] }))] }) }), activeFilterChips.length > 0 && (_jsx("div", { className: 'pt-2', children: _jsx(FilterChipsRow, { fields: activeFilterChips, onClearAll: clearAllDynamicFilters, "data-testid": 'table-filter-chips' }) }))] }), _jsx("div", { className: 'hidden sm:block flex-1 min-h-0 overflow-auto border rounded-md bg-card', children: _jsxs(Table, { noWrapper: true, className: cn('min-w-max w-full', aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && 'h-full'), children: [_jsx(TableHeader, { className: 'sticky top-0 z-10', children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: headerGroup.headers.map((header) => {
686
- const isActionsColumn = header.id === 'actions';
687
- return (_jsx(TableHead, { colSpan: header.colSpan, style: header.column.columnDef.size ? { width: header.column.columnDef.size } : undefined, className: cn('bg-card border-b h-10', isActionsColumn && 'sticky right-0 z-20 bg-card shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]'), children: header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext()) }, header.id));
688
- }) }, headerGroup.id))) }), _jsx(TableBody, { children: loadingData && data.length === 0 ? (_jsx(TableSkeleton, {})) : table.getRowModel().rows?.length ? (_jsxs(_Fragment, { children: [table.getRowModel().rows.map((row) => (_jsx(TableRow, { "data-state": row.getIsSelected() && 'selected', className: cn(onRowClick && 'cursor-pointer'), onClick: onRowClick ? () => onRowClick(row.original) : undefined, children: row.getVisibleCells().map((cell) => {
689
- const isActionsColumn = cell.column.id === 'actions';
690
- const isSelectColumn = cell.column.id === 'select';
691
- return (_jsx(TableCell, { style: cell.column.columnDef.size ? { width: cell.column.columnDef.size } : undefined, className: cn('py-2', isActionsColumn && 'sticky right-0 bg-card shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]'), onClick: (isActionsColumn || isSelectColumn) ? (e) => e.stopPropagation() : undefined, children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id));
692
- }) }, row.id))), aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && (_jsx(TableRow, { className: 'border-0 hover:bg-transparent', children: _jsx(TableCell, { colSpan: columns.length, className: 'h-full p-0' }) }))] })) : (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: _jsx(TableCell, { colSpan: columns.length, className: 'h-full p-0', children: _jsxs("div", { className: "flex h-full py-12 flex-col items-center justify-center gap-2 text-muted-foreground", children: [_jsx("div", { className: "flex h-20 w-20 items-center justify-center rounded-full bg-muted/50", children: _jsx(Inbox, { className: "h-10 w-10" }) }), _jsxs("div", { className: "flex flex-col items-center gap-1", children: [_jsx("h3", { className: "text-lg font-semibold text-foreground", children: "No se encontraron resultados" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "No hay datos para mostrar en este momento." })] })] }) }) })) }), aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && (_jsx(TableFooter, { className: "bg-transparent", children: _jsx(TableRow, { className: "hover:bg-transparent", children: table.getVisibleLeafColumns().map((leaf, idx) => {
693
- const col = (metadata?.columns ?? []).find((c) => c.key === leaf.id);
694
- const isFirst = idx === 0;
695
- const stickyBase = 'sticky bottom-0 z-10 border-t bg-background py-2 font-semibold';
696
- // Aggregate cell: render the SUM formatted like the body cell.
697
- if (col && aggregateOf(col)) {
698
- return (_jsx(TableCell, { className: `${stickyBase} text-right tabular-nums`, children: formatAggregateTotal(col, footerTotals[leaf.id], currency, i18n.language) }, leaf.id));
699
- }
700
- // First non-aggregate column carries the "Total" label.
701
- return (_jsx(TableCell, { className: stickyBase, children: isFirst ? t('common.total', 'Total') : '' }, leaf.id));
702
- }) }) }))] }) }), _jsx("div", { className: 'flex flex-1 min-h-0 flex-col gap-2 overflow-y-auto sm:hidden', children: loadingData && data.length === 0 ? (Array.from({ length: 5 }).map((_, i) => (_jsxs("div", { className: 'rounded-lg border bg-card p-3', children: [_jsx(Skeleton, { className: 'h-4 w-24' }), _jsx(Skeleton, { className: 'mt-2 h-4 w-40' }), _jsx(Skeleton, { className: 'mt-2 h-4 w-32' })] }, i)))) : table.getRowModel().rows?.length ? (table.getRowModel().rows.map((row) => {
703
- const cells = row.getVisibleCells();
704
- const actionsCell = cells.find((c) => c.column.id === 'actions');
705
- const dataCells = cells.filter((c) => c.column.id !== 'actions' && c.column.id !== 'select');
706
- return (_jsxs("div", { "data-state": row.getIsSelected() && 'selected', className: cn('flex flex-col gap-1.5 rounded-lg border bg-card p-3 data-[state=selected]:border-primary/40', onRowClick && 'cursor-pointer'), onClick: onRowClick ? () => onRowClick(row.original) : undefined, children: [dataCells.map((cell) => {
707
- const header = cell.column.columnDef.header;
708
- const label = typeof header === 'string' ? header : cell.column.id;
709
- return (_jsxs("div", { className: 'flex items-start justify-between gap-3 text-sm', children: [_jsx("span", { className: 'shrink-0 text-muted-foreground', children: label }), _jsx("span", { className: 'min-w-0 break-words text-right font-medium', children: flexRender(cell.column.columnDef.cell, cell.getContext()) })] }, cell.id));
710
- }), actionsCell && (_jsx("div", { className: 'flex justify-end border-t pt-2', onClick: onRowClick ? (e) => e.stopPropagation() : undefined, children: flexRender(actionsCell.column.columnDef.cell, actionsCell.getContext()) }))] }, row.id));
711
- })) : (_jsxs("div", { className: 'flex flex-col items-center justify-center gap-2 rounded-lg border bg-card py-12 text-muted-foreground', children: [_jsx("div", { className: 'flex h-16 w-16 items-center justify-center rounded-full bg-muted/50', children: _jsx(Inbox, { className: 'h-8 w-8' }) }), _jsx("h3", { className: 'text-base font-semibold text-foreground', children: "No se encontraron resultados" }), _jsx("p", { className: 'text-sm text-muted-foreground', children: "No hay datos para mostrar en este momento." })] })) }), _jsx("div", { className: 'shrink-0 pt-4', children: _jsx(DataTablePagination, { table: table, pageSizeOptions: metadata.perPageOptions }) })] }), rowActionDialogs, _jsx(AlertDialog, { open: showBulkDeleteConfirm, onOpenChange: (open) => !open && !isBulkDeleting && setShowBulkDeleteConfirm(false), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: isBulkDeleting ? 'Eliminando registros...' : '¿Eliminar múltiples registros?' }), _jsx(AlertDialogDescription, { children: isBulkDeleting ? (_jsxs("div", { className: "space-y-4 mt-4", children: [_jsx(Progress, { value: (bulkDeleteProgress / bulkDeleteTotal) * 100 }), _jsxs("p", { className: "text-center text-sm", children: ["Procesando ", bulkDeleteProgress, " de ", bulkDeleteTotal, " registros..."] })] })) : (_jsxs(_Fragment, { children: ["Esta acci\u00F3n no se puede deshacer. Se eliminar\u00E1n permanentemente ", _jsx("strong", { children: Object.keys(rowSelection).length }), " registro(s) de nuestros servidores."] })) })] }), !isBulkDeleting && (_jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { children: t('common.cancel') }), _jsx(AlertDialogAction, { onClick: (e) => { e.preventDefault(); confirmBulkDelete(); }, className: "bg-red-600 hover:bg-red-700", children: "Eliminar todos" })] }))] }) }), viewMetadata?.canExport && (_jsx(ExportDialog, { open: exportOpen, onOpenChange: setExportOpen, model: model, metadata: metadata, currentFilters: buildFilterParams(), hasActiveFilters: hasActiveFilters })), viewMetadata?.canImport && (_jsx(ImportDialog, { open: importOpen, onOpenChange: setImportOpen, model: model, metadata: metadata, onImported: handleRefresh })), _jsx(DataTableBulkActions, { table: table, entityName: "registro", children: _jsxs(Button, { variant: "destructive", size: "sm", className: "h-8", onClick: () => setShowBulkDeleteConfirm(true), children: [_jsx(Trash2, { className: "h-4 w-4 mr-1.5" }), " Eliminar"] }) })] }));
781
+ return (_jsxs(OptionsContext.Provider, { value: { optionsMap }, children: [_jsxs("div", { className: 'flex flex-col h-full min-h-0 w-full', children: [_jsxs("div", { className: 'pb-4 shrink-0', children: [_jsx(DataTableToolbar, { table: table, searchPlaceholder: metadata.searchPlaceholder || 'Buscar...', filters: filters, activeFilters: dynamicFilters, onDynamicFilterChange: handleDynamicFilterChange, dateFilter: { value: dateRange, onChange: setDateRange, placeholder: 'Filtrar por fecha' }, perPageOptions: metadata.perPageOptions, onRefresh: handleRefresh, isLoading: loadingData, selectedCount: Object.keys(rowSelection).length, onBulkDelete: () => setShowBulkDeleteConfirm(true), extraActions: _jsxs(_Fragment, { children: [viewMetadata?.canExport && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-8", onClick: () => setExportOpen(true), children: [_jsx(Download, { className: "h-4 w-4 mr-1" }), " Exportar"] })), viewMetadata?.canImport && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-8", onClick: () => setImportOpen(true), children: [_jsx(Upload, { className: "h-4 w-4 mr-1" }), " Importar"] }))] }) }), activeFilterChips.length > 0 && (_jsx("div", { className: 'pt-2', children: _jsx(FilterChipsRow, { fields: activeFilterChips, onClearAll: clearAllDynamicFilters, "data-testid": 'table-filter-chips' }) }))] }), _jsxs("div", { ref: infDesktopRoot, className: 'hidden sm:block flex-1 min-h-0 overflow-auto border rounded-md bg-card', children: [_jsxs(Table, { noWrapper: true, className: cn('min-w-max w-full', aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && 'h-full'), children: [_jsx(TableHeader, { className: 'sticky top-0 z-10', children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: headerGroup.headers.map((header) => {
782
+ const isActionsColumn = header.id === 'actions';
783
+ return (_jsx(TableHead, { colSpan: header.colSpan, style: header.column.columnDef.size ? { width: header.column.columnDef.size } : undefined, className: cn('bg-card border-b h-10', isActionsColumn && 'sticky right-0 z-20 bg-card shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]'), children: header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext()) }, header.id));
784
+ }) }, headerGroup.id))) }), _jsx(TableBody, { children: loadingData && data.length === 0 ? (_jsx(TableSkeleton, {})) : table.getRowModel().rows?.length ? (_jsxs(_Fragment, { children: [table.getRowModel().rows.map((row) => (_jsx(TableRow, { "data-state": row.getIsSelected() && 'selected', className: cn(onRowClick && 'cursor-pointer'), onClick: onRowClick ? () => onRowClick(row.original) : undefined, children: row.getVisibleCells().map((cell) => {
785
+ const isActionsColumn = cell.column.id === 'actions';
786
+ const isSelectColumn = cell.column.id === 'select';
787
+ return (_jsx(TableCell, { style: cell.column.columnDef.size ? { width: cell.column.columnDef.size } : undefined, className: cn('py-2', isActionsColumn && 'sticky right-0 bg-card shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]'), onClick: (isActionsColumn || isSelectColumn) ? (e) => e.stopPropagation() : undefined, children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id));
788
+ }) }, row.id))), aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && (_jsx(TableRow, { className: 'border-0 hover:bg-transparent', children: _jsx(TableCell, { colSpan: columns.length, className: 'h-full p-0' }) }))] })) : (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: _jsx(TableCell, { colSpan: columns.length, className: 'h-full p-0', children: _jsxs("div", { className: "flex h-full py-12 flex-col items-center justify-center gap-2 text-muted-foreground", children: [_jsx("div", { className: "flex h-20 w-20 items-center justify-center rounded-full bg-muted/50", children: _jsx(Inbox, { className: "h-10 w-10" }) }), _jsxs("div", { className: "flex flex-col items-center gap-1", children: [_jsx("h3", { className: "text-lg font-semibold text-foreground", children: "No se encontraron resultados" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "No hay datos para mostrar en este momento." })] })] }) }) })) }), aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && (_jsx(TableFooter, { className: "bg-transparent", children: _jsx(TableRow, { className: "hover:bg-transparent", children: table.getVisibleLeafColumns().map((leaf, idx) => {
789
+ const col = (metadata?.columns ?? []).find((c) => c.key === leaf.id);
790
+ const isFirst = idx === 0;
791
+ const stickyBase = 'sticky bottom-0 z-10 border-t bg-background py-2 font-semibold';
792
+ // Aggregate cell: render the SUM formatted like the body cell.
793
+ if (col && aggregateOf(col)) {
794
+ return (_jsx(TableCell, { className: `${stickyBase} text-right tabular-nums`, children: formatAggregateTotal(col, footerTotals[leaf.id], currency, i18n.language) }, leaf.id));
795
+ }
796
+ // First non-aggregate column carries the "Total" label.
797
+ return (_jsx(TableCell, { className: stickyBase, children: isFirst ? t('common.total', 'Total') : '' }, leaf.id));
798
+ }) }) }))] }), infiniteScroll && (_jsxs(_Fragment, { children: [loadingMore && (_jsx("div", { className: 'p-2', children: _jsx(Skeleton, { className: 'h-8 w-full', "data-testid": 'table-loading-more' }) })), _jsx("div", { ref: infDesktopSentinel, className: 'h-1 w-full', "aria-hidden": true })] }))] }), _jsxs("div", { ref: infMobileRoot, className: 'flex flex-1 min-h-0 flex-col gap-2 overflow-y-auto sm:hidden', children: [loadingData && data.length === 0 ? (Array.from({ length: 5 }).map((_, i) => (_jsxs("div", { className: 'rounded-lg border bg-card p-3', children: [_jsx(Skeleton, { className: 'h-4 w-24' }), _jsx(Skeleton, { className: 'mt-2 h-4 w-40' }), _jsx(Skeleton, { className: 'mt-2 h-4 w-32' })] }, i)))) : table.getRowModel().rows?.length ? (table.getRowModel().rows.map((row) => {
799
+ const cells = row.getVisibleCells();
800
+ const actionsCell = cells.find((c) => c.column.id === 'actions');
801
+ const dataCells = cells.filter((c) => c.column.id !== 'actions' && c.column.id !== 'select');
802
+ return (_jsxs("div", { "data-state": row.getIsSelected() && 'selected', className: cn('flex flex-col gap-1.5 rounded-lg border bg-card p-3 data-[state=selected]:border-primary/40', onRowClick && 'cursor-pointer'), onClick: onRowClick ? () => onRowClick(row.original) : undefined, children: [dataCells.map((cell) => {
803
+ const header = cell.column.columnDef.header;
804
+ const label = typeof header === 'string' ? header : cell.column.id;
805
+ return (_jsxs("div", { className: 'flex items-start justify-between gap-3 text-sm', children: [_jsx("span", { className: 'shrink-0 text-muted-foreground', children: label }), _jsx("span", { className: 'min-w-0 break-words text-right font-medium', children: flexRender(cell.column.columnDef.cell, cell.getContext()) })] }, cell.id));
806
+ }), actionsCell && (_jsx("div", { className: 'flex justify-end border-t pt-2', onClick: onRowClick ? (e) => e.stopPropagation() : undefined, children: flexRender(actionsCell.column.columnDef.cell, actionsCell.getContext()) }))] }, row.id));
807
+ })) : (_jsxs("div", { className: 'flex flex-col items-center justify-center gap-2 rounded-lg border bg-card py-12 text-muted-foreground', children: [_jsx("div", { className: 'flex h-16 w-16 items-center justify-center rounded-full bg-muted/50', children: _jsx(Inbox, { className: 'h-8 w-8' }) }), _jsx("h3", { className: 'text-base font-semibold text-foreground', children: "No se encontraron resultados" }), _jsx("p", { className: 'text-sm text-muted-foreground', children: "No hay datos para mostrar en este momento." })] })), infiniteScroll && (_jsxs(_Fragment, { children: [loadingMore && (_jsx(Skeleton, { className: 'h-16 w-full shrink-0', "data-testid": 'table-loading-more-mobile' })), _jsx("div", { ref: infMobileSentinel, className: 'h-1 w-full shrink-0', "aria-hidden": true })] }))] }), _jsx("div", { className: 'shrink-0 pt-4', children: infiniteScroll ? (data.length > 0 && (_jsx("p", { className: 'text-center text-xs text-muted-foreground tabular-nums', children: t('common.showingCount', {
808
+ defaultValue: '{{count}} de {{total}}',
809
+ count: data.length,
810
+ total: rowCount,
811
+ }) }))) : (_jsx(DataTablePagination, { table: table, pageSizeOptions: metadata.perPageOptions })) })] }), rowActionDialogs, _jsx(AlertDialog, { open: showBulkDeleteConfirm, onOpenChange: (open) => !open && !isBulkDeleting && setShowBulkDeleteConfirm(false), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: isBulkDeleting ? 'Eliminando registros...' : '¿Eliminar múltiples registros?' }), _jsx(AlertDialogDescription, { children: isBulkDeleting ? (_jsxs("div", { className: "space-y-4 mt-4", children: [_jsx(Progress, { value: (bulkDeleteProgress / bulkDeleteTotal) * 100 }), _jsxs("p", { className: "text-center text-sm", children: ["Procesando ", bulkDeleteProgress, " de ", bulkDeleteTotal, " registros..."] })] })) : (_jsxs(_Fragment, { children: ["Esta acci\u00F3n no se puede deshacer. Se eliminar\u00E1n permanentemente ", _jsx("strong", { children: Object.keys(rowSelection).length }), " registro(s) de nuestros servidores."] })) })] }), !isBulkDeleting && (_jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { children: t('common.cancel') }), _jsx(AlertDialogAction, { onClick: (e) => { e.preventDefault(); confirmBulkDelete(); }, className: "bg-red-600 hover:bg-red-700", children: "Eliminar todos" })] }))] }) }), viewMetadata?.canExport && (_jsx(ExportDialog, { open: exportOpen, onOpenChange: setExportOpen, model: model, metadata: metadata, currentFilters: buildFilterParams(), hasActiveFilters: hasActiveFilters })), viewMetadata?.canImport && (_jsx(ImportDialog, { open: importOpen, onOpenChange: setImportOpen, model: model, metadata: metadata, onImported: handleRefresh })), _jsx(DataTableBulkActions, { table: table, entityName: "registro", children: _jsxs(Button, { variant: "destructive", size: "sm", className: "h-8", onClick: () => setShowBulkDeleteConfirm(true), children: [_jsx(Trash2, { className: "h-4 w-4 mr-1.5" }), " Eliminar"] }) })] }));
712
812
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Returns a NEW array = `existing` followed by every row in `incoming` whose
3
+ * `id` is not already present. Stable on identity/order of `existing`. Pure.
4
+ */
5
+ export declare function dedupeById<T extends {
6
+ id?: any;
7
+ }>(existing: T[], incoming: T[]): T[];
8
+ export interface UseInfiniteScrollOptions {
9
+ /** Fired when the sentinel scrolls into view and loading is enabled. */
10
+ onLoadMore: () => void;
11
+ /** When true, the observer is inert (no more pages, or a load in flight). */
12
+ disabled?: boolean;
13
+ /** Pixels of pre-fetch margin below the viewport. Default 200. */
14
+ rootMargin?: number;
15
+ }
16
+ export interface InfiniteScrollRefs<R extends HTMLElement = HTMLDivElement, S extends HTMLElement = HTMLDivElement> {
17
+ rootRef: React.RefObject<R | null>;
18
+ sentinelRef: React.RefObject<S | null>;
19
+ }
20
+ /**
21
+ * IntersectionObserver-backed infinite scroll. Attach `rootRef` to the
22
+ * scrollable container and `sentinelRef` to a small element at its bottom. The
23
+ * latest `onLoadMore`/`disabled` are read through a ref so the observer isn't
24
+ * torn down and rebuilt on every render.
25
+ */
26
+ export declare function useInfiniteScrollSentinel<R extends HTMLElement = HTMLDivElement, S extends HTMLElement = HTMLDivElement>({ onLoadMore, disabled, rootMargin, }: UseInfiniteScrollOptions): InfiniteScrollRefs<R, S>;
27
+ //# sourceMappingURL=use-infinite-scroll.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-infinite-scroll.d.ts","sourceRoot":"","sources":["../src/use-infinite-scroll.ts"],"names":[],"mappings":"AAWA;;;GAGG;AACH,wBAAgB,UAAU,CAAC,CAAC,SAAS;IAAE,EAAE,CAAC,EAAE,GAAG,CAAA;CAAE,EAC/C,QAAQ,EAAE,CAAC,EAAE,EACb,QAAQ,EAAE,CAAC,EAAE,GACZ,CAAC,EAAE,CAWL;AAED,MAAM,WAAW,wBAAwB;IACvC,wEAAwE;IACxE,UAAU,EAAE,MAAM,IAAI,CAAA;IACtB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,kBAAkB,CACjC,CAAC,SAAS,WAAW,GAAG,cAAc,EACtC,CAAC,SAAS,WAAW,GAAG,cAAc;IAEtC,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAA;IAClC,WAAW,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAA;CACvC;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CACvC,CAAC,SAAS,WAAW,GAAG,cAAc,EACtC,CAAC,SAAS,WAAW,GAAG,cAAc,EACtC,EACA,UAAU,EACV,QAAgB,EAChB,UAAgB,GACjB,EAAE,wBAAwB,GAAG,kBAAkB,CAAC,CAAC,EAAE,CAAC,CAAC,CAgCrD"}