@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 +35 -0
- package/dist/dynamic-kanban.d.ts +19 -3
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +114 -8
- package/dist/dynamic-table.d.ts +9 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +130 -30
- package/dist/use-infinite-scroll.d.ts +27 -0
- package/dist/use-infinite-scroll.d.ts.map +1 -0
- package/dist/use-infinite-scroll.js +60 -0
- package/package.json +3 -3
- package/src/__tests__/dynamic-kanban-infinite-scroll.test.tsx +210 -0
- package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +196 -0
- package/src/__tests__/use-infinite-scroll.test.tsx +147 -0
- package/src/dynamic-kanban.tsx +168 -8
- package/src/dynamic-table.tsx +147 -8
- package/src/use-infinite-scroll.ts +94 -0
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
|
package/dist/dynamic-kanban.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
92
|
-
*
|
|
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":"AA0BA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAwD9B,OAAO,EAEH,qBAAqB,EACrB,qBAAqB,EACxB,MAAM,gBAAgB,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"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"}
|
package/dist/dynamic-kanban.js
CHANGED
|
@@ -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 =
|
|
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
|
-
// ----
|
|
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
|
-
|
|
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
|
|
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" }) })] })),
|
|
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
|
package/dist/dynamic-table.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/dynamic-table.js
CHANGED
|
@@ -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
|
-
|
|
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' }) }))] }),
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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"}
|