@addsign/moje-agenda-shared-lib 2.0.71 → 2.0.72

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.
@@ -1,1369 +1,1399 @@
1
- import React, {
2
- useCallback,
3
- useEffect,
4
- useMemo,
5
- useRef,
6
- useState,
7
- } from "react";
8
-
9
- import {
10
- Button,
11
- DataTableColumn,
12
- DateField,
13
- DateRangeField,
14
- FormField,
15
- IOptionItem,
16
- Spinner,
17
- handleErrors,
18
- useFederationContext,
19
- } from "../../main";
20
- import {
21
- MdArrowBack,
22
- MdArrowDownward,
23
- MdArrowForward,
24
- MdArrowUpward,
25
- MdClose,
26
- MdOutlineFilterAlt,
27
- MdOutlineFilterAltOff,
28
- MdOutlineUnfoldMore,
29
- MdSearch,
30
- } from "react-icons/md";
31
- import { Input } from "../ui/input";
32
- import { Resizable } from "./Resizable";
33
- import { DatatableSettings } from "./DatatableSettings";
34
- import { MultiSelect } from "../ui/multi-select";
35
- import {
36
- Select,
37
- SelectContent,
38
- SelectItem,
39
- SelectTrigger,
40
- SelectValue,
41
- } from "../ui/select";
42
-
43
- import * as XLSX from "xlsx";
44
-
45
- interface ISortConfig {
46
- sortParam: string;
47
- direction: "asc" | "desc" | null;
48
- }
49
-
50
- interface DataTableServerProps<T> {
51
- id: string;
52
- url: string;
53
- columns: DataTableColumn<T | "actions">[];
54
- title?: string;
55
- subtitle?: string;
56
- allowSearch?: boolean;
57
- showHeader?: boolean;
58
- rowAction?: (item: T) => void;
59
- // bulkActions?: BulkAction<T>[];
60
- bulkAction?: (items: T[]) => JSX.Element;
61
- filters?: object;
62
- selectedItemKey?: string;
63
- itemsPerPageOptions?: IOptionItem[];
64
- setMinWidth?: boolean;
65
- onDataLoaded?: (data: IPageable<T>) => void;
66
- }
67
- const defaultItemsPerPageOptions: IOptionItem[] = [
68
- { value: 10, label: "10" },
69
- { value: 50, label: "50" },
70
- { value: 100, label: "100" },
71
- ];
72
-
73
- type DataTableInternalItems = {
74
- _isHighlighted?: boolean;
75
- id: string; // Assuming items have an `id` field for identification
76
- };
77
- interface DataTableStorageObject {
78
- columnFilters: Record<string, string>;
79
- showColFilters: boolean;
80
- currentPage: number;
81
- itemsPerPage: number;
82
- sortConfig: ISortConfig | null;
83
- }
84
-
85
- export const resetAllDataTablePaging = () => {
86
- Object.keys(localStorage)
87
- .filter((key) => key.startsWith("datatable:"))
88
- .forEach((key) => {
89
- const storageObject = localStorage.getItem(key);
90
- if (storageObject) {
91
- const parsedObject: DataTableStorageObject = JSON.parse(storageObject);
92
- parsedObject.currentPage = 0;
93
- localStorage.setItem(key, JSON.stringify(parsedObject));
94
- }
95
- });
96
- };
97
-
98
- interface IPageable<T> {
99
- content: T[];
100
- empty: boolean;
101
- first: boolean;
102
- last: boolean;
103
- number: number;
104
- numberOfElements: number;
105
- size: number;
106
- totalElements: number;
107
- totalPages: number;
108
- isPageable?: boolean;
109
- }
110
-
111
- function DataTableServer<T extends DataTableInternalItems>({
112
- id,
113
- url,
114
- columns,
115
- title,
116
- subtitle,
117
- allowSearch = false,
118
- showHeader = true,
119
- rowAction,
120
- bulkAction,
121
- filters,
122
- selectedItemKey = "id",
123
- itemsPerPageOptions = defaultItemsPerPageOptions,
124
- setMinWidth = false,
125
- onDataLoaded,
126
- }: DataTableServerProps<T>) {
127
- const abortControllerRef = useRef<AbortController | null>(null);
128
- const topScrollbarRef = useRef<HTMLDivElement | null>(null);
129
- const bottomScrollbarRef = useRef<HTMLDivElement | null>(null);
130
- const tableRef = useRef<HTMLTableElement | null>(null);
131
- const syncWidthRef = useRef<HTMLDivElement | null>(null);
132
-
133
- const [itemsPerPageLocal, setItemsPerPageLocal] = useState<number>(10);
134
- const federationContext = useFederationContext();
135
- const [data, setData] = useState<IPageable<T>>();
136
- const [isLoading, setIsLoading] = useState(false);
137
- const [isLocalStorageLoaded, setIsLocalStorageLoaded] = useState(false);
138
- const [tableKey, setTableKey] = useState(0);
139
-
140
- const [hasMounted, setHasMounted] = useState(false);
141
-
142
- const [currentPage, setCurrentPage] = useState<number>(0);
143
- const [selectedItems, setSelectedItems] = useState<T[]>([]);
144
- const [fulltextSearch, setFulltextSearch] = useState("");
145
- const [filterOptions, setFilterOptions] = useState<Record<string, any[]>>({});
146
- const [columnFilters, setColumnFilters] = useState<{ [key: string]: any }>(
147
- {}
148
- );
149
- const [showColFilters, setShowColFilters] = useState<boolean>(false);
150
- const [sortConfig, setSortConfig] = useState<ISortConfig | null>(null);
151
- const prevDepsRef = useRef<any[]>([]);
152
- const prevFilterDepsRef = useRef<any[]>([]);
153
-
154
- const createDataPageable = (
155
- response: any,
156
- itemsPerPage: number
157
- ): IPageable<T> => {
158
- const isPageable = !!response.data?.content;
159
- return {
160
- content: response.data.content || response.data,
161
- empty: isPageable ? response.data.empty : true,
162
- first: isPageable ? response.data.first : true,
163
- last: isPageable ? response.data.last : true,
164
- number: isPageable ? response.data.number : 0,
165
- numberOfElements: response.data.numberOfElements || response.data.length,
166
- size: response.data?.size || itemsPerPage,
167
- totalElements: response.data?.totalElements || response.data.length,
168
- totalPages: response.data?.totalPages || 1,
169
- isPageable: isPageable,
170
- };
171
- };
172
-
173
- const [reloadData, setReloadData] = useState(false);
174
-
175
- const mergedFilters: { [key: string]: any } = useMemo(() => {
176
- return showColFilters ? { ...columnFilters, ...filters } : filters || {};
177
- }, [columnFilters, filters, showColFilters]);
178
- useEffect(() => {
179
- const currentDeps = [
180
- url,
181
- showColFilters,
182
- columnFilters,
183
- itemsPerPageLocal,
184
- currentPage,
185
- sortConfig,
186
- filters,
187
- tableKey,
188
- ];
189
-
190
- // Kontrola, jestli se skutečně něco změnilo
191
- const hasChanged = currentDeps.some((dep, index) => {
192
- return JSON.stringify(dep) !== JSON.stringify(prevDepsRef.current[index]);
193
- });
194
-
195
- if (hasChanged) {
196
- console.log(
197
- "setting reloadData - actual change detected",
198
- url,
199
- showColFilters,
200
- columnFilters,
201
- itemsPerPageLocal,
202
- currentPage,
203
- sortConfig,
204
- filters,
205
- tableKey,
206
- reloadData
207
- );
208
- setReloadData(true);
209
- }
210
-
211
- prevDepsRef.current = currentDeps;
212
- }, [
213
- url,
214
- showColFilters,
215
- columnFilters,
216
- itemsPerPageLocal,
217
- currentPage,
218
- sortConfig,
219
- filters,
220
- tableKey,
221
- ]);
222
-
223
- useEffect(() => {
224
- console.log("reloadData", reloadData);
225
- if (reloadData) {
226
- if (abortControllerRef.current) {
227
- abortControllerRef.current.abort();
228
- }
229
- // Create a new AbortController for the new request
230
- abortControllerRef.current = new AbortController();
231
- const currentAbortController = abortControllerRef.current;
232
- // load data from API
233
-
234
- if (currentPage === undefined) return;
235
- setIsLoading(true);
236
-
237
- //odebrani prazdny filteru a transformace multi-select hodnot
238
- const filteredMergedFilters = Object.entries(mergedFilters).reduce(
239
- (acc: Record<string, any>, [key, value]) => {
240
- // Skip null and empty string values
241
- if (value === null || value === "") {
242
- return acc;
243
- }
244
-
245
- // Handle multi-select values (arrays)
246
- if (Array.isArray(value)) {
247
- if (value.length > 0) {
248
- acc[key] = value.join(",");
249
- }
250
- return acc;
251
- }
252
-
253
- // Handle other values
254
- acc[key] = value;
255
- return acc;
256
- },
257
- {}
258
- );
259
-
260
- federationContext.apiClient
261
- .get(url, {
262
- signal: currentAbortController.signal,
263
- params: {
264
- ...filteredMergedFilters,
265
- pageSize: itemsPerPageLocal,
266
- page: currentPage,
267
- sortBy: sortConfig?.sortParam,
268
- sortDirection: sortConfig?.direction,
269
- },
270
- })
271
- .then((response) => {
272
- const dataPageable: IPageable<T> = createDataPageable(
273
- response,
274
- itemsPerPageLocal || 10
275
- );
276
-
277
- setData(dataPageable);
278
- // setData(response.data);
279
- setIsLoading(false);
280
- onDataLoaded?.(dataPageable);
281
- })
282
- .catch((error) => {
283
- if (error.code === "ERR_CANCELED") {
284
- console.log("Request was aborted");
285
- } else {
286
- console.error("Error fetching data:", error);
287
- handleErrors(error, federationContext.emitter);
288
- const emptyDataPageable: IPageable<T> = {
289
- content: [],
290
- empty: true,
291
- first: true,
292
- last: true,
293
- number: 0,
294
- numberOfElements: 0,
295
- size: itemsPerPageLocal || 10,
296
- totalElements: 0,
297
- totalPages: 1,
298
- };
299
- setData(emptyDataPageable);
300
- setIsLoading(false);
301
- onDataLoaded?.(emptyDataPageable);
302
- }
303
- });
304
-
305
- setReloadData(false);
306
- }
307
- }, [reloadData]);
308
- // nastaveni currentPage u jinych datatable na 0
309
- useEffect(() => {
310
- Object.keys(localStorage)
311
- .filter(
312
- (key) => key.startsWith("datatable:") && key !== `datatable:${id}`
313
- )
314
- .forEach((key) => {
315
- const storageObject = localStorage.getItem(key);
316
- if (storageObject) {
317
- const parsedObject: DataTableStorageObject =
318
- JSON.parse(storageObject);
319
- parsedObject.currentPage = 0;
320
- localStorage.setItem(key, JSON.stringify(parsedObject));
321
- }
322
- });
323
- }, [id]);
324
-
325
- //load from localstorage
326
- useEffect(() => {
327
- if (id) {
328
- const storageKey = `datatable:${id}`;
329
-
330
- const storedStorageObject = localStorage.getItem(storageKey);
331
- if (storedStorageObject) {
332
- const storageObject: DataTableStorageObject =
333
- JSON.parse(storedStorageObject);
334
- setColumnFilters(storageObject.columnFilters);
335
- setShowColFilters(storageObject.showColFilters);
336
- setCurrentPage(storageObject.currentPage || 0);
337
- setSortConfig(storageObject.sortConfig || null);
338
-
339
- setItemsPerPageLocal(storageObject.itemsPerPage || 10);
340
- }
341
- setIsLocalStorageLoaded(true);
342
- }
343
- }, [id]);
344
-
345
- useEffect(() => {
346
- const currentFilterDeps = [columns, federationContext.apiClient];
347
-
348
- // Kontrola, jestli se skutečně něco změnilo
349
- const hasFilterChanged = currentFilterDeps.some((dep, index) => {
350
- const prev = prevFilterDepsRef.current[index];
351
- if (index === 1) {
352
- // federationContext.apiClient (index 1) - referenční porovnání
353
- return dep !== prev;
354
- } else {
355
- // columns (index 0) - deep porovnání
356
- return JSON.stringify(dep) !== JSON.stringify(prev);
357
- }
358
- });
359
-
360
- if (hasFilterChanged) {
361
- const fetchFilterOptions = async (column: DataTableColumn<T>) => {
362
- if (column.filterOptions) {
363
- return column.filterOptions;
364
- } else if (column.filterSource) {
365
- try {
366
- const response = await federationContext.apiClient.get(
367
- column.filterSource
368
- );
369
-
370
- const options: IOptionItem[] = [];
371
-
372
- response.data.forEach((item: any) => {
373
- const categoryId =
374
- item[column.filterValueKey as keyof typeof item]?.toString();
375
- const categoryLabel =
376
- item[column.filterLabelKey as keyof typeof item]?.toString();
377
- const subcategories = item.subcategories;
378
-
379
- if (
380
- Array.isArray(subcategories) &&
381
- subcategories.length > 0 &&
382
- column.filterParam2
383
- ) {
384
- //add parent categoriz without subcategiries
385
- options.push({
386
- value: categoryId,
387
- label: categoryLabel,
388
- });
389
- // Multiply options by subcategories
390
- subcategories.forEach((subcategory: any) => {
391
- const subcategoryId = subcategory.id?.toString();
392
- const subcategoryLabel = subcategory.name?.toString();
393
-
394
- if (subcategoryId && subcategoryLabel) {
395
- options.push({
396
- value: `${categoryId}-${subcategoryId}`,
397
- label: `${categoryLabel} - ${subcategoryLabel}`,
398
- });
399
- }
400
- });
401
- } else {
402
- // No subcategories, use category only
403
- options.push({
404
- value: categoryId,
405
- label: categoryLabel,
406
- });
407
- }
408
- });
409
-
410
- return options;
411
- } catch (error) {
412
- console.error("Error fetching filter options:", error);
413
- return [];
414
- }
415
- } else {
416
- return [];
417
- }
418
- };
419
-
420
- const updateFilterOptions = async () => {
421
- const newFilterOptions: Record<string, any[]> = {};
422
-
423
- for (const column of columns) {
424
- if (
425
- (column.filterType === "select" ||
426
- column.filterType === "multi-select") &&
427
- (column.filterSource || column.filterOptions) &&
428
- column.filterValueKey &&
429
- column.filterLabelKey &&
430
- column.filterParam
431
- ) {
432
- const options = await fetchFilterOptions(column);
433
-
434
- if (options && column.filterType === "select") {
435
- // Filter out empty values and add a "clear" option
436
- const filteredOptions = options.filter(
437
- (option: IOptionItem) =>
438
- option.value !== null &&
439
- option.value !== undefined &&
440
- option.value !== ""
441
- );
442
- newFilterOptions[column.filterParam as string] = [
443
- { value: "__clear__", label: "Všechny" },
444
- ...filteredOptions,
445
- ];
446
- } else if (options && column.filterType === "multi-select") {
447
- newFilterOptions[column.filterParam as string] = options;
448
- }
449
- }
450
- }
451
-
452
- setFilterOptions(newFilterOptions);
453
- };
454
-
455
- console.log(
456
- "updateFilterOptions - actual change detected",
457
- columns,
458
- federationContext.apiClient
459
- );
460
- updateFilterOptions();
461
- }
462
-
463
- prevFilterDepsRef.current = currentFilterDeps;
464
- }, [columns, federationContext.apiClient]);
465
-
466
- const hasSomeColFilters = useMemo(() => {
467
- return columns.some((column) => !!column.filterParam);
468
- }, [columns]);
469
-
470
- const requestSort = (sortParam: string) => {
471
- setSortConfig((prevSortConfig) => {
472
- if (
473
- prevSortConfig?.sortParam === sortParam &&
474
- prevSortConfig.direction !== null
475
- ) {
476
- return prevSortConfig.direction === "asc"
477
- ? { sortParam, direction: "desc" }
478
- : null;
479
- } else {
480
- return { sortParam, direction: "asc" };
481
- }
482
- });
483
- };
484
-
485
- const getSortIcon = (sortParam: string) => {
486
- if (sortConfig?.sortParam === sortParam) {
487
- return sortConfig.direction === "asc" ? (
488
- <MdArrowUpward fontSize="small" />
489
- ) : sortConfig.direction === "desc" ? (
490
- <MdArrowDownward fontSize="small" />
491
- ) : (
492
- <MdOutlineUnfoldMore fontSize="small" className=" " />
493
- );
494
- }
495
- return <MdOutlineUnfoldMore fontSize="small" className=" " />;
496
- };
497
-
498
- const handleSelectItem = (item: T) => {
499
- setSelectedItems((prevSelectedItems) => {
500
- if (
501
- prevSelectedItems.find(
502
- (selectedItem) =>
503
- selectedItem[selectedItemKey as keyof T] ===
504
- item[selectedItemKey as keyof T]
505
- )
506
- ) {
507
- return prevSelectedItems.filter(
508
- (selectedItem) =>
509
- selectedItem[selectedItemKey as keyof T] !==
510
- item[selectedItemKey as keyof T]
511
- );
512
- } else {
513
- return [...prevSelectedItems, item];
514
- }
515
- });
516
- };
517
-
518
- const handleSelectAll = () => {
519
- if (data && selectedItems.length === data.content.length) {
520
- setSelectedItems([]);
521
- } else if (data) {
522
- setSelectedItems(data.content);
523
- }
524
- };
525
-
526
- const isSelected = useCallback(
527
- (item: T) => {
528
- return selectedItems.some(
529
- (selectedItem) =>
530
- selectedItem[selectedItemKey as keyof T] ===
531
- item[selectedItemKey as keyof T]
532
- );
533
- },
534
- [selectedItems, selectedItemKey]
535
- );
536
-
537
- const nextPage = () => {
538
- setCurrentPage((currentPage || 0) + 1);
539
- };
540
-
541
- const prevPage = () => {
542
- setCurrentPage((currentPage || 0) - 1);
543
- };
544
-
545
- const handleSearchChanged = (
546
- e: React.ChangeEvent<
547
- HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
548
- >
549
- ) => {
550
- setFulltextSearch(e.target?.value);
551
- setCurrentPage(0);
552
- };
553
- // Pagination display logic
554
- const paginationDisplay = `Strana ${(currentPage || 0) + 1} z ${data?.totalPages || 1}`;
555
-
556
- const filterHandler = (
557
- filterParam: keyof T,
558
- value: string | string[],
559
- filterParam2?: string,
560
- clearFilterParam2?: boolean
561
- ) => {
562
- setColumnFilters((prev) => {
563
- const newFilters = { ...prev };
564
-
565
- // Handle clearing both filters
566
- if (
567
- value === "" ||
568
- value === "__clear__" ||
569
- (Array.isArray(value) && value.length === 0)
570
- ) {
571
- delete newFilters[String(filterParam)];
572
- if (filterParam2) {
573
- delete newFilters[filterParam2];
574
- }
575
- return newFilters;
576
- }
577
-
578
- // Handle filterParam2 logic
579
- if (filterParam2) {
580
- // Check if we should clear filterParam2
581
- if (clearFilterParam2) {
582
- newFilters[String(filterParam)] = value;
583
- delete newFilters[filterParam2];
584
- return newFilters;
585
- }
586
-
587
- // Handle combined values with delimiter
588
- if (typeof value === "string" && value.includes("-")) {
589
- const parts = value.split("-");
590
- if (parts.length === 2) {
591
- newFilters[String(filterParam)] = parts[0];
592
- newFilters[filterParam2] = parts[1];
593
- return newFilters;
594
- }
595
- }
596
-
597
- // Handle array values (multi-select) with subcategories
598
- if (Array.isArray(value)) {
599
- const subcategoryIds: string[] = [];
600
- value.forEach((val: string) => {
601
- if (val.includes("-")) {
602
- const parts = val.split("-");
603
- if (parts.length === 2) {
604
- subcategoryIds.push(parts[1]);
605
- }
606
- }
607
- });
608
-
609
- newFilters[String(filterParam)] =
610
- value.length > 0 ? value : undefined;
611
- if (subcategoryIds.length > 0) {
612
- newFilters[filterParam2] = subcategoryIds;
613
- } else {
614
- delete newFilters[filterParam2];
615
- }
616
- return newFilters;
617
- }
618
-
619
- // Value without subcategory - clear filterParam2
620
- newFilters[String(filterParam)] = value;
621
- delete newFilters[filterParam2];
622
- return newFilters;
623
- }
624
-
625
- // Regular case without filterParam2
626
- newFilters[String(filterParam)] = value;
627
- return newFilters;
628
- });
629
- setCurrentPage(0);
630
- };
631
- const handleToggleShowColFilters = () => {
632
- if (showColFilters && columnFilters !== undefined) {
633
- setColumnFilters(columnFilters);
634
- }
635
-
636
- setShowColFilters(!showColFilters);
637
- };
638
-
639
- // osetreni logiky pro useEffect. prvni nacteni filteru currentPage neresetuje
640
- useEffect(() => {
641
- if (!hasMounted) {
642
- setHasMounted(true); // Ensures this block won't run again
643
- } else {
644
- setCurrentPage(0);
645
- }
646
- }, [filters]);
647
-
648
- //store table settings in localstorage
649
- useEffect(() => {
650
- if (id && isLocalStorageLoaded) {
651
- const storageKey = `datatable:${id}`;
652
-
653
- const storageObject: DataTableStorageObject = localStorage.getItem(
654
- storageKey
655
- )
656
- ? (JSON.parse(
657
- localStorage.getItem(storageKey)!
658
- ) as DataTableStorageObject)
659
- : ({} as DataTableStorageObject);
660
-
661
- storageObject.columnFilters = columnFilters || {};
662
- // if (showColFilters !== undefined) {
663
- // storageObject.showColFilters = showColFilters;
664
- // }
665
- storageObject.showColFilters = showColFilters || false;
666
- storageObject.currentPage = currentPage || 0;
667
- storageObject.sortConfig = sortConfig || null;
668
-
669
- storageObject.itemsPerPage = itemsPerPageLocal || 10;
670
-
671
- localStorage.setItem(storageKey, JSON.stringify(storageObject));
672
- }
673
- }, [
674
- columnFilters,
675
- showColFilters,
676
- currentPage,
677
- // id, // predbihalo se to a ukladaly se sortCOnfigy z predchozich tabulek
678
- itemsPerPageLocal,
679
- isLocalStorageLoaded,
680
- sortConfig,
681
- ]);
682
-
683
- const rerenderTable = () => {
684
- setTableKey((previous) => previous + 1);
685
- };
686
-
687
- const exportToXLSX = useCallback(() => {
688
- // load fresh complete data without pagination ,filters
689
- setIsLoading(true);
690
-
691
- //odebrani prazdny filteru a transformace multi-select hodnot
692
- const filteredMergedFilters = Object.entries(mergedFilters || {}).reduce(
693
- (acc: Record<string, any>, [key, value]) => {
694
- // Skip null and empty string values
695
- if (value === null || value === "") {
696
- return acc;
697
- }
698
-
699
- // Handle multi-select values (arrays)
700
- if (Array.isArray(value)) {
701
- if (value.length > 0) {
702
- acc[key] = value.join(",");
703
- }
704
- return acc;
705
- }
706
-
707
- // Handle other values
708
- acc[key] = value;
709
- return acc;
710
- },
711
- {}
712
- );
713
-
714
- federationContext.apiClient
715
- .get(url, {
716
- params: {
717
- ...filteredMergedFilters,
718
- pageSize: 1000000,
719
- page: 0,
720
- sortBy: sortConfig?.sortParam,
721
- sortDirection: sortConfig?.direction,
722
- },
723
- })
724
- .then((response) => {
725
- setIsLoading(false);
726
- const dataPageable: IPageable<T> = createDataPageable(
727
- response,
728
- 1000000
729
- );
730
-
731
- const worksheet = XLSX.utils.json_to_sheet(
732
- dataPageable.content.map((item: any) => {
733
- const row = {};
734
- columns.forEach((column) => {
735
- let value;
736
- if (column.renderXls) {
737
- value = column.renderXls(item);
738
- } else if (column.render) {
739
- value = column.render(item)?.toString();
740
- }
741
-
742
- // Handle number formatting
743
- if (
744
- column.renderXlsOptions?.type === "number" ||
745
- typeof value === "number" ||
746
- (typeof value === "string" && !isNaN(Number(value)))
747
- ) {
748
- (row as any)[column.header] = Number(value);
749
- } else {
750
- (row as any)[column.header] = value?.toString();
751
- }
752
- });
753
- return row;
754
- })
755
- );
756
-
757
- // Apply number formatting to columns that contain numbers
758
- const range = XLSX.utils.decode_range(worksheet["!ref"] || "A1");
759
- for (let C = range.s.c; C <= range.e.c; ++C) {
760
- const col = XLSX.utils.encode_col(C);
761
- let hasNumbers = false;
762
-
763
- // Check if column contains numbers
764
- for (let R = range.s.r; R <= range.e.r; ++R) {
765
- const cell = worksheet[col + (R + 1)];
766
- if (cell && typeof cell.v === "number") {
767
- hasNumbers = true;
768
- break;
769
- }
770
- }
771
- // Apply number format if column contains numbers
772
- if (hasNumbers) {
773
- for (let R = range.s.r; R <= range.e.r; ++R) {
774
- const cell = worksheet[col + (R + 1)];
775
- if (cell && typeof cell.v === "number") {
776
- // Get the column index by converting the column letter to number
777
- const colIndex = XLSX.utils.decode_col(col);
778
- // Find the corresponding column definition
779
- const column = columns[colIndex];
780
- // Apply the format if specified in renderXlsOptions
781
- if (column?.renderXlsOptions?.format) {
782
- cell.z = column.renderXlsOptions.format;
783
- } else {
784
- // Check if the number has decimal places
785
- const hasDecimal = cell.v % 1 !== 0;
786
- cell.z = hasDecimal ? "#,##0.##" : "#,##0"; // Use different formats based on whether decimals exist
787
- }
788
- }
789
- }
790
- }
791
- }
792
-
793
- const workbook = XLSX.utils.book_new();
794
- XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
795
- XLSX.writeFile(workbook, "export.xlsx");
796
- })
797
- .catch((error) => {
798
- console.error("Error fetching data:", error);
799
- handleErrors(error, federationContext.emitter);
800
- setIsLoading(false);
801
- });
802
- }, [
803
- setIsLoading,
804
- federationContext.apiClient,
805
- url,
806
- mergedFilters,
807
- showColFilters,
808
- columnFilters,
809
- sortConfig,
810
- handleErrors,
811
- columns,
812
- ]);
813
-
814
- // Update sync width to match table content width
815
- const updateSyncWidth = useCallback(() => {
816
- if (tableRef.current && syncWidthRef.current && setMinWidth) {
817
- syncWidthRef.current.style.width = tableRef.current.scrollWidth + "px";
818
- }
819
- }, [setMinWidth]);
820
-
821
- // Set up scroll synchronization between top and bottom scrollbars
822
- useEffect(() => {
823
- if (!setMinWidth) return;
824
- const topScrollbar = topScrollbarRef.current;
825
- const bottomScrollbar = bottomScrollbarRef.current;
826
-
827
- if (!topScrollbar || !bottomScrollbar) return;
828
-
829
- const handleTopScroll = () => {
830
- bottomScrollbar.scrollLeft = topScrollbar.scrollLeft;
831
- };
832
-
833
- const handleBottomScroll = () => {
834
- topScrollbar.scrollLeft = bottomScrollbar.scrollLeft;
835
- };
836
-
837
- topScrollbar.addEventListener("scroll", handleTopScroll);
838
- bottomScrollbar.addEventListener("scroll", handleBottomScroll);
839
-
840
- return () => {
841
- topScrollbar.removeEventListener("scroll", handleTopScroll);
842
- bottomScrollbar.removeEventListener("scroll", handleBottomScroll);
843
- };
844
- }, [setMinWidth]);
845
-
846
- // Set up ResizeObserver to update sync width when table content changes
847
- useEffect(() => {
848
- if (!tableRef.current) return;
849
-
850
- const resizeObserver = new ResizeObserver(updateSyncWidth);
851
- resizeObserver.observe(tableRef.current);
852
-
853
- // Initial update
854
- updateSyncWidth();
855
-
856
- return () => {
857
- resizeObserver.disconnect();
858
- };
859
- }, [updateSyncWidth, data, isLoading]);
860
-
861
- const handleItemsPerPageChange = (value: string) => {
862
- const selectedItemsPerPage = Number(value);
863
- setItemsPerPageLocal(selectedItemsPerPage);
864
- setCurrentPage(0); // Reset the current page to 0 when changing items per page
865
- };
866
-
867
- return (
868
- <>
869
- <div
870
- className="shadow-lg border border-gray-200 rounded-xl"
871
- style={{ overflowY: "visible" }}
872
- data-cy={"datatable-container-" + id}
873
- >
874
- {showHeader && (
875
- <div className="p-4 leading-9 flex ">
876
- <div className="flex-grow content-center">
877
- {title && (
878
- <h1 className="font-semibold text-xl leading-[42px]">
879
- {title}
880
- </h1>
881
- )}
882
- {subtitle && (
883
- <p className="font-normal text-gray-600">{subtitle}</p>
884
- )}{" "}
885
- {bulkAction && selectedItems.length > 0 && (
886
- <div className="">{bulkAction(selectedItems)}</div>
887
- )}
888
- </div>
889
-
890
- <DatatableSettings
891
- tableId={id}
892
- onSuccess={rerenderTable}
893
- onExport={exportToXLSX}
894
- ></DatatableSettings>
895
- {hasSomeColFilters && (
896
- <div
897
- className="flex items-center text-xl h-full p-3 cursor-pointer text-gray-500 hover:text-black"
898
- title={
899
- showColFilters
900
- ? "Zrušit filtr podle sloupců"
901
- : "Filtrovat podle sloupců"
902
- }
903
- onClick={handleToggleShowColFilters}
904
- data-cy="datatable-filter-toggle"
905
- >
906
- {!showColFilters && <MdOutlineFilterAlt />}
907
- {showColFilters && (
908
- <MdOutlineFilterAltOff className="text-danger" />
909
- )}
910
- </div>
911
- )}
912
- {allowSearch && (
913
- <div className="ml-5">
914
- <FormField
915
- placeholder="Vyhledávání"
916
- name="search"
917
- onInputChange={handleSearchChanged}
918
- type="text"
919
- value={fulltextSearch}
920
- >
921
- <div className=" text-gray-500 leading-5 flex items-center h-full">
922
- {!fulltextSearch && <MdSearch></MdSearch>}
923
- {fulltextSearch && (
924
- <MdClose onClick={() => setFulltextSearch("")} />
925
- )}
926
- </div>
927
- </FormField>
928
- </div>
929
- )}
930
- </div>
931
- )}
932
- {/* Top horizontal scrollbar */}
933
- {setMinWidth && (
934
- <div ref={topScrollbarRef} className="overflow-x-auto h-4 mb-1 mmmmm">
935
- <div ref={syncWidthRef} className="h-full"></div>
936
- </div>
937
- )}
938
-
939
- {/* Main scrollable content area */}
940
- <div
941
- ref={bottomScrollbarRef}
942
- className="overflow-auto min-h-[500px] relative"
943
- >
944
- {isLoading && (
945
- <div className="absolute inset-0 flex items-center justify-center h-[480px] py-2 z-10">
946
- <Spinner />
947
- </div>
948
- )}
949
- {!isLoading && (!data || data?.content?.length === 0) && (
950
- <div className="absolute inset-0 flex items-center justify-center py-2 text-gray-600 font-medium text-xs top-[90px]">
951
- Žádná data
952
- </div>
953
- )}
954
- <table
955
- ref={tableRef}
956
- className="w-full leading-normal"
957
- key={tableKey}
958
- data-cy={"datatable-table-" + id}
959
- >
960
- <thead>
961
- <tr>
962
- {data && bulkAction && (
963
- <th className="w-[20px] h-10 hover:bg-gray-200 bg-gray-50 font-medium text-xs text-center text-gray-600 cursor-pointer border-t border-b border-gray-200">
964
- <label className="w-full h-full flex items-center justify-center cursor-pointer px-2">
965
- <input
966
- id="selectAll"
967
- type="checkbox"
968
- className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded !focus:ring-indigo-200 focus:ring-4"
969
- onChange={handleSelectAll}
970
- checked={
971
- data &&
972
- selectedItems.length === data.content.length &&
973
- data.content.length > 0
974
- }
975
- />
976
- </label>
977
- </th>
978
- )}
979
- {columns.map(
980
- (
981
- {
982
- key,
983
- header,
984
- actions,
985
- sortParam,
986
- width,
987
- filterType,
988
- filterParam,
989
- filterParam2,
990
- },
991
- index
992
- ) => (
993
- <Resizable
994
- // key={String(key) + currentPage}
995
- key={String(key)}
996
- tableId={id}
997
- colKey={String(key)}
998
- defaultWidth={width || "auto"}
999
- setMinWidth={setMinWidth}
1000
- >
1001
- {({ ref }: { ref: any }) => (
1002
- <th
1003
- className={`tableHeader relative font-medium text-xs !leading-9 text-left px-3 text-gray-600
1004
- bg-gray-50 border-t border-b border-gray-200 content-start ${
1005
- !title && !subtitle ? "border-t-0" : ""
1006
- } ${sortParam ? " cursor-pointer " : ""}`}
1007
- onClick={() =>
1008
- sortParam ? requestSort(sortParam) : undefined
1009
- }
1010
- data-cy={"datatable-header-" + id + "-" + String(key)}
1011
- >
1012
- <span className="inline-flex items-center gap-2 group select-none w-full">
1013
- {header}{" "}
1014
- {!actions && sortParam
1015
- ? getSortIcon(sortParam)
1016
- : ""}
1017
- </span>
1018
- <div
1019
- className={`resizer absolute top-0 right-0 h-full w-2 bg-transparent ${
1020
- index < columns.length - 1
1021
- ? "cursor-col-resize hover:border-x border-gray-300 border-r w-1"
1022
- : "w-0"
1023
- }`}
1024
- ref={ref}
1025
- onClick={(e) => e.stopPropagation()}
1026
- />
1027
- {showColFilters && (
1028
- <div
1029
- className="p-0 m-0 pb-2"
1030
- onClick={(e) => e.stopPropagation()}
1031
- data-cy={
1032
- "datatable-filter-container-" +
1033
- id +
1034
- "-" +
1035
- String(key)
1036
- }
1037
- >
1038
- {filterType === "select" ? (
1039
- <Select
1040
- onValueChange={(value) => {
1041
- if (value === "__clear__") {
1042
- filterHandler(
1043
- filterParam as keyof T,
1044
- "",
1045
- filterParam2
1046
- );
1047
- } else {
1048
- // Check if value contains delimiter (has subcategory)
1049
- if (value.includes("-") && filterParam2) {
1050
- filterHandler(
1051
- filterParam as keyof T,
1052
- value,
1053
- filterParam2
1054
- );
1055
- } else {
1056
- // Value without subcategory - clear filterParam2 if it exists
1057
- filterHandler(
1058
- filterParam as keyof T,
1059
- value,
1060
- filterParam2,
1061
- true
1062
- );
1063
- }
1064
- }
1065
- }}
1066
- value={
1067
- filterParam2 &&
1068
- mergedFilters?.[String(filterParam)] &&
1069
- mergedFilters?.[String(filterParam2)]
1070
- ? `${mergedFilters[String(filterParam)]}-${mergedFilters[String(filterParam2)]}`
1071
- : mergedFilters?.[
1072
- String(filterParam)
1073
- ]?.toString() || "__clear__"
1074
- }
1075
- disabled={Object.keys(
1076
- (filters as object) || {}
1077
- ).includes(String(filterParam))}
1078
- >
1079
- <SelectTrigger className="flex-1 w-full px-2 font-normal placeholder-muted-foreground">
1080
- <SelectValue placeholder="Zadejte filtr" />
1081
- </SelectTrigger>
1082
- <SelectContent>
1083
- {filterOptions[String(filterParam)]
1084
- ?.filter(
1085
- (option: IOptionItem) =>
1086
- option.value !== null &&
1087
- option.value !== undefined &&
1088
- option.value !== ""
1089
- )
1090
- ?.map((option: IOptionItem) => (
1091
- <SelectItem
1092
- key={option.value}
1093
- value={
1094
- option.value?.toString() ||
1095
- "unknown"
1096
- }
1097
- >
1098
- {option.label}
1099
- </SelectItem>
1100
- ))}
1101
- </SelectContent>
1102
- </Select>
1103
- ) : filterType === "multi-select" ? (
1104
- <MultiSelect
1105
- // key={JSON.stringify(mergedFilters)}
1106
- options={
1107
- filterOptions[String(filterParam)] || []
1108
- }
1109
- onChange={(values) => {
1110
- filterHandler(
1111
- filterParam as keyof T,
1112
- values,
1113
- filterParam2
1114
- );
1115
- }}
1116
- value={
1117
- Array.isArray(
1118
- mergedFilters?.[String(filterParam)]
1119
- )
1120
- ? mergedFilters[String(filterParam)]
1121
- : mergedFilters?.[String(filterParam)]
1122
- ? [mergedFilters[String(filterParam)]]
1123
- : []
1124
- }
1125
- placeholder={"Zadejte filtr"}
1126
- className="px-0"
1127
- disabled={Object.keys(
1128
- (filters as object) || {}
1129
- ).includes(String(filterParam))}
1130
- variant="secondary"
1131
- maxCount={0}
1132
- />
1133
- ) : filterType === "dateRange" ? (
1134
- <DateRangeField
1135
- // key={JSON.stringify(mergedFilters)}
1136
- name={String(filterParam)}
1137
- nameEnd={String(filterParam2)}
1138
- onInputChange={(e) =>
1139
- filterHandler(
1140
- e.target.name as keyof T,
1141
- e.target.value
1142
- )
1143
- }
1144
- type={filterType}
1145
- value={{
1146
- startDate:
1147
- mergedFilters?.[String(filterParam)] ||
1148
- "",
1149
- endDate:
1150
- mergedFilters?.[String(filterParam2)] ||
1151
- "",
1152
- }}
1153
- clearable
1154
- className=" px-0 py-0 "
1155
- placeholder={"Zadejte filtr"}
1156
- rounded={true}
1157
- disabled={Object.keys(
1158
- (filters as object) || {}
1159
- ).includes(String(filterParam))}
1160
- />
1161
- ) : filterType === "date" ? (
1162
- <DateField
1163
- // key={JSON.stringify(mergedFilters)}
1164
- name={String(filterParam)}
1165
- onInputChange={(e) =>
1166
- filterHandler(
1167
- e.target.name as keyof T,
1168
- e.target.value
1169
- )
1170
- }
1171
- type={filterType}
1172
- value={
1173
- mergedFilters?.[String(filterParam)] || ""
1174
- }
1175
- clearable
1176
- className=" px-0 py-0 "
1177
- placeholder={"Zadejte filtr"}
1178
- rounded={true}
1179
- disabled={Object.keys(
1180
- (filters as object) || {}
1181
- ).includes(String(filterParam))}
1182
- />
1183
- ) : filterType === "text" ? (
1184
- <Input
1185
- onChange={(e) =>
1186
- filterHandler(
1187
- filterParam as keyof T,
1188
- e.target.value
1189
- )
1190
- }
1191
- value={
1192
- mergedFilters?.[String(filterParam)] || ""
1193
- }
1194
- disabled={Object.keys(
1195
- (filters as object) || {}
1196
- ).includes(String(filterParam))}
1197
- clearable
1198
- className="min-w-[100px] px-2 font-normal placeholder-muted-foreground
1199
- "
1200
- placeholder={"Zadejte filtr"}
1201
- debounceTimeout={1000}
1202
- />
1203
- ) : null}
1204
- </div>
1205
- )}
1206
- </th>
1207
- )}
1208
- </Resizable>
1209
- )
1210
- )}
1211
- </tr>
1212
- </thead>
1213
- {!isLoading &&
1214
- data &&
1215
- data?.content &&
1216
- data?.content.length > 0 && (
1217
- <tbody className="relative">
1218
- {data.content.map((item, rowIndex) => (
1219
- <tr
1220
- key={rowIndex}
1221
- className={`${
1222
- item._isHighlighted || isSelected(item)
1223
- ? "bg-gray-50"
1224
- : ""
1225
- } hover:bg-gray-100 border-gray-200 border-b text-sm`}
1226
- >
1227
- {bulkAction && (
1228
- <td className="w-[20px] h-[52px] hover:bg-gray-200 font-medium text-xs text-center text-gray-600 cursor-pointer">
1229
- <label className="w-full h-full flex items-center justify-center cursor-pointer px-2">
1230
- <input
1231
- type="checkbox"
1232
- className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded !focus:ring-indigo-200 focus:ring-4"
1233
- checked={isSelected(item) || false}
1234
- onChange={() => handleSelectItem(item)}
1235
- />
1236
- </label>
1237
- </td>
1238
- )}
1239
- {columns.map(({ render, actions, classes }, colIndex) => (
1240
- <td
1241
- key={`${rowIndex}-${colIndex}`}
1242
- onClick={
1243
- rowAction ? () => rowAction(item) : undefined
1244
- }
1245
- className={`px-3 py-2 text-gray-800 ${rowAction ? "cursor-pointer" : ""} ${
1246
- colIndex === 0 ? "font-bold " : ""
1247
- } ${classes || ""}`}
1248
- >
1249
- {render ? render(item) : ""}
1250
- {actions &&
1251
- actions
1252
- .filter((it) => {
1253
- if (it.rowAction) return false;
1254
- if (it.visible) {
1255
- return it.visible(item);
1256
- } else return true;
1257
- })
1258
- .map((action, actionIndex) => (
1259
- <div
1260
- key={`${rowIndex}-${colIndex}-${actionIndex}`}
1261
- className="inline-flex align-middle"
1262
- >
1263
- {action.icon && (
1264
- <Button
1265
- variant="icon"
1266
- onClick={() => action.onClick(item)}
1267
- >
1268
- {action.icon}
1269
- </Button>
1270
- )}
1271
- {!action.icon && (
1272
- <Button
1273
- variant="primary"
1274
- onClick={(e) => {
1275
- e.stopPropagation();
1276
- action.onClick(item);
1277
- }}
1278
- >
1279
- {action.label}
1280
- </Button>
1281
- )}
1282
- </div>
1283
- ))}
1284
- </td>
1285
- ))}
1286
- </tr>
1287
- ))}
1288
- {data?.content?.length === 0 && (
1289
- <tr key="tr-nodata">
1290
- <td
1291
- key="td-nodata"
1292
- className="px-5 py-3 border-b border-gray-200 bg-white text-sm items-center justify-center align-middle"
1293
- colSpan={columns.length}
1294
- >
1295
- No data
1296
- </td>
1297
- </tr>
1298
- )}
1299
- </tbody>
1300
- )}
1301
- </table>
1302
- </div>
1303
-
1304
- {data?.isPageable && (
1305
- <div
1306
- data-cy="pagination"
1307
- className="w-full p-5 flex gap-5 justify-between md:flex-row flex-col"
1308
- >
1309
- <div className="flex gap-5 text-sm ">
1310
- {data && (
1311
- <Button
1312
- variant="secondary"
1313
- onClick={prevPage}
1314
- className="flex items-center"
1315
- disabled={data.first || isLoading}
1316
- data-cy="prev-page"
1317
- >
1318
- <MdArrowBack className="mr-1.5" /> Předchozí
1319
- </Button>
1320
- )}
1321
- {data && (
1322
- <Button
1323
- variant="secondary"
1324
- onClick={nextPage}
1325
- className="flex items-center"
1326
- disabled={data.last || isLoading}
1327
- data-cy="next-page"
1328
- >
1329
- Následující <MdArrowForward className="ml-2" size={20} />
1330
- </Button>
1331
- )}
1332
- </div>
1333
- <div className="flex items-center justify-center text-gray-800">
1334
- {paginationDisplay}
1335
- </div>
1336
- <div
1337
- className="content-center w-auto items-center justify-end flex-row gap-5 flex md:mt-0 mt-5"
1338
- data-cy="items-per-page"
1339
- >
1340
- <span className=" whitespace-nowrap flex-grow">
1341
- Počet řádků na stránku:
1342
- </span>
1343
- <Select
1344
- onValueChange={handleItemsPerPageChange}
1345
- value={itemsPerPageLocal?.toString()}
1346
- >
1347
- <SelectTrigger className="w-[100px]">
1348
- <SelectValue placeholder="Vyberte počet" />
1349
- </SelectTrigger>
1350
- <SelectContent>
1351
- {itemsPerPageOptions?.map((option) => (
1352
- <SelectItem
1353
- key={option.value}
1354
- value={option.value?.toString() || ""}
1355
- >
1356
- {option.label}
1357
- </SelectItem>
1358
- ))}
1359
- </SelectContent>
1360
- </Select>
1361
- </div>
1362
- </div>
1363
- )}
1364
- </div>
1365
- </>
1366
- );
1367
- }
1368
-
1369
- export default DataTableServer;
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from "react";
8
+
9
+ import {
10
+ Button,
11
+ DataTableColumn,
12
+ DateField,
13
+ DateRangeField,
14
+ FormField,
15
+ IOptionItem,
16
+ Spinner,
17
+ handleErrors,
18
+ useFederationContext,
19
+ } from "../../main";
20
+ import {
21
+ MdArrowBack,
22
+ MdArrowDownward,
23
+ MdArrowForward,
24
+ MdArrowUpward,
25
+ MdClose,
26
+ MdOutlineFilterAlt,
27
+ MdOutlineFilterAltOff,
28
+ MdOutlineUnfoldMore,
29
+ MdSearch,
30
+ } from "react-icons/md";
31
+ import { Input } from "../ui/input";
32
+ import { Resizable } from "./Resizable";
33
+ import { DatatableSettings } from "./DatatableSettings";
34
+ import { MultiSelect } from "../ui/multi-select";
35
+ import {
36
+ Select,
37
+ SelectContent,
38
+ SelectItem,
39
+ SelectTrigger,
40
+ SelectValue,
41
+ } from "../ui/select";
42
+
43
+ import * as XLSX from "xlsx";
44
+
45
+ interface ISortConfig {
46
+ sortParam: string;
47
+ direction: "asc" | "desc" | null;
48
+ }
49
+
50
+ interface DataTableServerProps<T> {
51
+ id: string;
52
+ url: string;
53
+ columns: DataTableColumn<T | "actions">[];
54
+ title?: string;
55
+ subtitle?: string;
56
+ allowSearch?: boolean;
57
+ showHeader?: boolean;
58
+ rowAction?: (item: T) => void;
59
+ // bulkActions?: BulkAction<T>[];
60
+ bulkAction?: (items: T[]) => JSX.Element;
61
+ filters?: object;
62
+ selectedItemKey?: string;
63
+ itemsPerPageOptions?: IOptionItem[];
64
+ setMinWidth?: boolean;
65
+ onDataLoaded?: (data: IPageable<T>) => void;
66
+ }
67
+ const defaultItemsPerPageOptions: IOptionItem[] = [
68
+ { value: 10, label: "10" },
69
+ { value: 50, label: "50" },
70
+ { value: 100, label: "100" },
71
+ ];
72
+
73
+ type DataTableInternalItems = {
74
+ _isHighlighted?: boolean;
75
+ id: string; // Assuming items have an `id` field for identification
76
+ };
77
+ interface DataTableStorageObject {
78
+ columnFilters: Record<string, string>;
79
+ showColFilters: boolean;
80
+ currentPage: number;
81
+ itemsPerPage: number;
82
+ sortConfig: ISortConfig | null;
83
+ }
84
+
85
+ export const resetAllDataTablePaging = () => {
86
+ Object.keys(localStorage)
87
+ .filter((key) => key.startsWith("datatable:"))
88
+ .forEach((key) => {
89
+ const storageObject = localStorage.getItem(key);
90
+ if (storageObject) {
91
+ const parsedObject: DataTableStorageObject = JSON.parse(storageObject);
92
+ parsedObject.currentPage = 0;
93
+ localStorage.setItem(key, JSON.stringify(parsedObject));
94
+ }
95
+ });
96
+ };
97
+
98
+ interface IPageable<T> {
99
+ content: T[];
100
+ empty: boolean;
101
+ first: boolean;
102
+ last: boolean;
103
+ number: number;
104
+ numberOfElements: number;
105
+ size: number;
106
+ totalElements: number;
107
+ totalPages: number;
108
+ isPageable?: boolean;
109
+ }
110
+
111
+ function DataTableServer<T extends DataTableInternalItems>({
112
+ id,
113
+ url,
114
+ columns,
115
+ title,
116
+ subtitle,
117
+ allowSearch = false,
118
+ showHeader = true,
119
+ rowAction,
120
+ bulkAction,
121
+ filters,
122
+ selectedItemKey = "id",
123
+ itemsPerPageOptions = defaultItemsPerPageOptions,
124
+ setMinWidth = false,
125
+ onDataLoaded,
126
+ }: DataTableServerProps<T>) {
127
+ const abortControllerRef = useRef<AbortController | null>(null);
128
+ const topScrollbarRef = useRef<HTMLDivElement | null>(null);
129
+ const bottomScrollbarRef = useRef<HTMLDivElement | null>(null);
130
+ const tableRef = useRef<HTMLTableElement | null>(null);
131
+ const syncWidthRef = useRef<HTMLDivElement | null>(null);
132
+
133
+ const [itemsPerPageLocal, setItemsPerPageLocal] = useState<number>(10);
134
+ const federationContext = useFederationContext();
135
+ const [data, setData] = useState<IPageable<T>>();
136
+ const [isLoading, setIsLoading] = useState(false);
137
+ const [isLocalStorageLoaded, setIsLocalStorageLoaded] = useState(false);
138
+ const [tableKey, setTableKey] = useState(0);
139
+
140
+ const [hasMounted, setHasMounted] = useState(false);
141
+
142
+ const [currentPage, setCurrentPage] = useState<number>(0);
143
+ const [selectedItems, setSelectedItems] = useState<T[]>([]);
144
+ const [fulltextSearch, setFulltextSearch] = useState("");
145
+ const [filterOptions, setFilterOptions] = useState<Record<string, any[]>>({});
146
+ const [columnFilters, setColumnFilters] = useState<{ [key: string]: any }>(
147
+ {}
148
+ );
149
+ const [showColFilters, setShowColFilters] = useState<boolean>(false);
150
+ const [sortConfig, setSortConfig] = useState<ISortConfig | null>(null);
151
+ const prevDepsRef = useRef<any[]>([]);
152
+ const prevFilterDepsRef = useRef<any[]>([]);
153
+
154
+ const createDataPageable = (
155
+ response: any,
156
+ itemsPerPage: number
157
+ ): IPageable<T> => {
158
+ const isPageable = !!response.data?.content;
159
+ return {
160
+ content: response.data.content || response.data,
161
+ empty: isPageable ? response.data.empty : true,
162
+ first: isPageable ? response.data.first : true,
163
+ last: isPageable ? response.data.last : true,
164
+ number: isPageable ? response.data.number : 0,
165
+ numberOfElements: response.data.numberOfElements || response.data.length,
166
+ size: response.data?.size || itemsPerPage,
167
+ totalElements: response.data?.totalElements || response.data.length,
168
+ totalPages: response.data?.totalPages || 1,
169
+ isPageable: isPageable,
170
+ };
171
+ };
172
+
173
+ const [reloadData, setReloadData] = useState(false);
174
+
175
+ const mergedFilters: { [key: string]: any } = useMemo(() => {
176
+ return showColFilters ? { ...columnFilters, ...filters } : filters || {};
177
+ }, [columnFilters, filters, showColFilters]);
178
+ useEffect(() => {
179
+ const currentDeps = [
180
+ url,
181
+ showColFilters,
182
+ columnFilters,
183
+ itemsPerPageLocal,
184
+ currentPage,
185
+ sortConfig,
186
+ filters,
187
+ tableKey,
188
+ ];
189
+
190
+ // Kontrola, jestli se skutečně něco změnilo
191
+ const hasChanged = currentDeps.some((dep, index) => {
192
+ return JSON.stringify(dep) !== JSON.stringify(prevDepsRef.current[index]);
193
+ });
194
+
195
+ if (hasChanged) {
196
+ console.log(
197
+ "setting reloadData - actual change detected",
198
+ url,
199
+ showColFilters,
200
+ columnFilters,
201
+ itemsPerPageLocal,
202
+ currentPage,
203
+ sortConfig,
204
+ filters,
205
+ tableKey,
206
+ reloadData
207
+ );
208
+ setReloadData(true);
209
+ }
210
+
211
+ prevDepsRef.current = currentDeps;
212
+ }, [
213
+ url,
214
+ showColFilters,
215
+ columnFilters,
216
+ itemsPerPageLocal,
217
+ currentPage,
218
+ sortConfig,
219
+ filters,
220
+ tableKey,
221
+ ]);
222
+
223
+ useEffect(() => {
224
+ console.log("reloadData", reloadData);
225
+ if (reloadData) {
226
+ if (abortControllerRef.current) {
227
+ abortControllerRef.current.abort();
228
+ }
229
+ // Create a new AbortController for the new request
230
+ abortControllerRef.current = new AbortController();
231
+ const currentAbortController = abortControllerRef.current;
232
+ // load data from API
233
+
234
+ if (currentPage === undefined) return;
235
+ setIsLoading(true);
236
+
237
+ //odebrani prazdny filteru a transformace multi-select hodnot
238
+ const filteredMergedFilters = Object.entries(mergedFilters).reduce(
239
+ (acc: Record<string, any>, [key, value]) => {
240
+ // Skip null and empty string values
241
+ if (value === null || value === "") {
242
+ return acc;
243
+ }
244
+
245
+ // Handle multi-select values (arrays)
246
+ if (Array.isArray(value)) {
247
+ if (value.length > 0) {
248
+ acc[key] = value.join(",");
249
+ }
250
+ return acc;
251
+ }
252
+
253
+ // Handle other values
254
+ acc[key] = value;
255
+ return acc;
256
+ },
257
+ {}
258
+ );
259
+
260
+ federationContext.apiClient
261
+ .get(url, {
262
+ signal: currentAbortController.signal,
263
+ params: {
264
+ ...filteredMergedFilters,
265
+ pageSize: itemsPerPageLocal,
266
+ page: currentPage,
267
+ sortBy: sortConfig?.sortParam,
268
+ sortDirection: sortConfig?.direction,
269
+ },
270
+ })
271
+ .then((response) => {
272
+ const dataPageable: IPageable<T> = createDataPageable(
273
+ response,
274
+ itemsPerPageLocal || 10
275
+ );
276
+
277
+ setData(dataPageable);
278
+ // setData(response.data);
279
+ setIsLoading(false);
280
+ onDataLoaded?.(dataPageable);
281
+ })
282
+ .catch((error) => {
283
+ if (error.code === "ERR_CANCELED") {
284
+ console.log("Request was aborted");
285
+ } else {
286
+ console.error("Error fetching data:", error);
287
+ handleErrors(error, federationContext.emitter);
288
+ const emptyDataPageable: IPageable<T> = {
289
+ content: [],
290
+ empty: true,
291
+ first: true,
292
+ last: true,
293
+ number: 0,
294
+ numberOfElements: 0,
295
+ size: itemsPerPageLocal || 10,
296
+ totalElements: 0,
297
+ totalPages: 1,
298
+ };
299
+ setData(emptyDataPageable);
300
+ setIsLoading(false);
301
+ onDataLoaded?.(emptyDataPageable);
302
+ }
303
+ });
304
+
305
+ setReloadData(false);
306
+ }
307
+ }, [reloadData]);
308
+ // nastaveni currentPage u jinych datatable na 0
309
+ useEffect(() => {
310
+ Object.keys(localStorage)
311
+ .filter(
312
+ (key) => key.startsWith("datatable:") && key !== `datatable:${id}`
313
+ )
314
+ .forEach((key) => {
315
+ const storageObject = localStorage.getItem(key);
316
+ if (storageObject) {
317
+ const parsedObject: DataTableStorageObject =
318
+ JSON.parse(storageObject);
319
+ parsedObject.currentPage = 0;
320
+ localStorage.setItem(key, JSON.stringify(parsedObject));
321
+ }
322
+ });
323
+ }, [id]);
324
+
325
+ //load from localstorage
326
+ useEffect(() => {
327
+ if (id) {
328
+ const storageKey = `datatable:${id}`;
329
+
330
+ const storedStorageObject = localStorage.getItem(storageKey);
331
+ if (storedStorageObject) {
332
+ const storageObject: DataTableStorageObject =
333
+ JSON.parse(storedStorageObject);
334
+ setColumnFilters(storageObject.columnFilters);
335
+ setShowColFilters(storageObject.showColFilters);
336
+ setCurrentPage(storageObject.currentPage || 0);
337
+ setSortConfig(storageObject.sortConfig || null);
338
+
339
+ setItemsPerPageLocal(storageObject.itemsPerPage || 10);
340
+ }
341
+ setIsLocalStorageLoaded(true);
342
+ }
343
+ }, [id]);
344
+
345
+ useEffect(() => {
346
+ const currentFilterDeps = [columns, federationContext.apiClient];
347
+
348
+ // Kontrola, jestli se skutečně něco změnilo
349
+ const hasFilterChanged = currentFilterDeps.some((dep, index) => {
350
+ const prev = prevFilterDepsRef.current[index];
351
+ if (index === 1) {
352
+ // federationContext.apiClient (index 1) - referenční porovnání
353
+ return dep !== prev;
354
+ } else {
355
+ // columns (index 0) - deep porovnání
356
+ return JSON.stringify(dep) !== JSON.stringify(prev);
357
+ }
358
+ });
359
+
360
+ if (hasFilterChanged) {
361
+ const fetchFilterOptions = async (column: DataTableColumn<T>) => {
362
+ if (column.filterOptions) {
363
+ return column.filterOptions;
364
+ } else if (column.filterSource) {
365
+ try {
366
+ const response = await federationContext.apiClient.get(
367
+ column.filterSource
368
+ );
369
+
370
+ const options: IOptionItem[] = [];
371
+
372
+ response.data.forEach((item: any) => {
373
+ const categoryId =
374
+ item[column.filterValueKey as keyof typeof item]?.toString();
375
+ const categoryLabel =
376
+ item[column.filterLabelKey as keyof typeof item]?.toString();
377
+ const subcategories = item.subcategories;
378
+
379
+ if (
380
+ Array.isArray(subcategories) &&
381
+ subcategories.length > 0 &&
382
+ column.filterParam2
383
+ ) {
384
+ //add parent categoriz without subcategiries
385
+ options.push({
386
+ value: categoryId,
387
+ label: categoryLabel,
388
+ });
389
+ // Multiply options by subcategories
390
+ subcategories.forEach((subcategory: any) => {
391
+ const subcategoryId = subcategory.id?.toString();
392
+ const subcategoryLabel = subcategory.name?.toString();
393
+
394
+ if (subcategoryId && subcategoryLabel) {
395
+ options.push({
396
+ value: `${categoryId}-${subcategoryId}`,
397
+ label: `${categoryLabel} - ${subcategoryLabel}`,
398
+ });
399
+ }
400
+ });
401
+ } else {
402
+ // No subcategories, use category only
403
+ options.push({
404
+ value: categoryId,
405
+ label: categoryLabel,
406
+ });
407
+ }
408
+ });
409
+
410
+ return options;
411
+ } catch (error) {
412
+ console.error("Error fetching filter options:", error);
413
+ return [];
414
+ }
415
+ } else {
416
+ return [];
417
+ }
418
+ };
419
+
420
+ const updateFilterOptions = async () => {
421
+ const newFilterOptions: Record<string, any[]> = {};
422
+
423
+ for (const column of columns) {
424
+ if (
425
+ (column.filterType === "select" ||
426
+ column.filterType === "multi-select") &&
427
+ (column.filterSource || column.filterOptions) &&
428
+ column.filterValueKey &&
429
+ column.filterLabelKey &&
430
+ column.filterParam
431
+ ) {
432
+ const options = await fetchFilterOptions(column);
433
+
434
+ if (options && column.filterType === "select") {
435
+ // Filter out empty values and add a "clear" option
436
+ const filteredOptions = options.filter(
437
+ (option: IOptionItem) =>
438
+ option.value !== null &&
439
+ option.value !== undefined &&
440
+ option.value !== ""
441
+ );
442
+ newFilterOptions[column.filterParam as string] = [
443
+ { value: "__clear__", label: "Všechny" },
444
+ ...filteredOptions,
445
+ ];
446
+ } else if (options && column.filterType === "multi-select") {
447
+ newFilterOptions[column.filterParam as string] = options;
448
+ }
449
+ }
450
+ }
451
+
452
+ setFilterOptions(newFilterOptions);
453
+ };
454
+
455
+ console.log(
456
+ "updateFilterOptions - actual change detected",
457
+ columns,
458
+ federationContext.apiClient
459
+ );
460
+ updateFilterOptions();
461
+ }
462
+
463
+ prevFilterDepsRef.current = currentFilterDeps;
464
+ }, [columns, federationContext.apiClient]);
465
+
466
+ const hasSomeColFilters = useMemo(() => {
467
+ return columns.some((column) => !!column.filterParam);
468
+ }, [columns]);
469
+
470
+ const requestSort = (sortParam: string) => {
471
+ setSortConfig((prevSortConfig) => {
472
+ if (
473
+ prevSortConfig?.sortParam === sortParam &&
474
+ prevSortConfig.direction !== null
475
+ ) {
476
+ return prevSortConfig.direction === "asc"
477
+ ? { sortParam, direction: "desc" }
478
+ : null;
479
+ } else {
480
+ return { sortParam, direction: "asc" };
481
+ }
482
+ });
483
+ };
484
+
485
+ const getSortIcon = (sortParam: string) => {
486
+ if (sortConfig?.sortParam === sortParam) {
487
+ return sortConfig.direction === "asc" ? (
488
+ <MdArrowUpward fontSize="small" />
489
+ ) : sortConfig.direction === "desc" ? (
490
+ <MdArrowDownward fontSize="small" />
491
+ ) : (
492
+ <MdOutlineUnfoldMore fontSize="small" className=" " />
493
+ );
494
+ }
495
+ return <MdOutlineUnfoldMore fontSize="small" className=" " />;
496
+ };
497
+
498
+ const handleSelectItem = (item: T) => {
499
+ setSelectedItems((prevSelectedItems) => {
500
+ if (
501
+ prevSelectedItems.find(
502
+ (selectedItem) =>
503
+ selectedItem[selectedItemKey as keyof T] ===
504
+ item[selectedItemKey as keyof T]
505
+ )
506
+ ) {
507
+ return prevSelectedItems.filter(
508
+ (selectedItem) =>
509
+ selectedItem[selectedItemKey as keyof T] !==
510
+ item[selectedItemKey as keyof T]
511
+ );
512
+ } else {
513
+ return [...prevSelectedItems, item];
514
+ }
515
+ });
516
+ };
517
+
518
+ const handleSelectAll = () => {
519
+ if (data && selectedItems.length === data.content.length) {
520
+ setSelectedItems([]);
521
+ } else if (data) {
522
+ setSelectedItems(data.content);
523
+ }
524
+ };
525
+
526
+ const isSelected = useCallback(
527
+ (item: T) => {
528
+ return selectedItems.some(
529
+ (selectedItem) =>
530
+ selectedItem[selectedItemKey as keyof T] ===
531
+ item[selectedItemKey as keyof T]
532
+ );
533
+ },
534
+ [selectedItems, selectedItemKey]
535
+ );
536
+
537
+ const nextPage = () => {
538
+ setCurrentPage((currentPage || 0) + 1);
539
+ };
540
+
541
+ const prevPage = () => {
542
+ setCurrentPage((currentPage || 0) - 1);
543
+ };
544
+
545
+ const handleSearchChanged = (
546
+ e: React.ChangeEvent<
547
+ HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
548
+ >
549
+ ) => {
550
+ setFulltextSearch(e.target?.value);
551
+ setCurrentPage(0);
552
+ };
553
+ // Pagination display logic
554
+ const paginationDisplay = `Strana ${(currentPage || 0) + 1} z ${data?.totalPages || 1}`;
555
+
556
+ const filterHandler = (
557
+ filterParam: keyof T,
558
+ value: string | string[],
559
+ filterParam2?: string,
560
+ clearFilterParam2?: boolean
561
+ ) => {
562
+ setColumnFilters((prev) => {
563
+ const newFilters = { ...prev };
564
+
565
+ // Handle clearing both filters
566
+ if (
567
+ value === "" ||
568
+ value === "__clear__" ||
569
+ (Array.isArray(value) && value.length === 0)
570
+ ) {
571
+ delete newFilters[String(filterParam)];
572
+ if (filterParam2) {
573
+ delete newFilters[filterParam2];
574
+ }
575
+ return newFilters;
576
+ }
577
+
578
+ // Handle filterParam2 logic
579
+ if (filterParam2) {
580
+ // Check if we should clear filterParam2
581
+ if (clearFilterParam2) {
582
+ newFilters[String(filterParam)] = value;
583
+ delete newFilters[filterParam2];
584
+ return newFilters;
585
+ }
586
+
587
+ // Handle combined values with delimiter
588
+ if (typeof value === "string" && value.includes("-")) {
589
+ const parts = value.split("-");
590
+ if (parts.length === 2) {
591
+ newFilters[String(filterParam)] = parts[0];
592
+ newFilters[filterParam2] = parts[1];
593
+ return newFilters;
594
+ }
595
+ }
596
+
597
+ // Handle array values (multi-select) with subcategories
598
+ if (Array.isArray(value)) {
599
+ const subcategoryIds: string[] = [];
600
+ value.forEach((val: string) => {
601
+ if (val.includes("-")) {
602
+ const parts = val.split("-");
603
+ if (parts.length === 2) {
604
+ subcategoryIds.push(parts[1]);
605
+ }
606
+ }
607
+ });
608
+
609
+ newFilters[String(filterParam)] =
610
+ value.length > 0 ? value : undefined;
611
+ if (subcategoryIds.length > 0) {
612
+ newFilters[filterParam2] = subcategoryIds;
613
+ } else {
614
+ delete newFilters[filterParam2];
615
+ }
616
+ return newFilters;
617
+ }
618
+
619
+ // Value without subcategory - clear filterParam2
620
+ newFilters[String(filterParam)] = value;
621
+ delete newFilters[filterParam2];
622
+ return newFilters;
623
+ }
624
+
625
+ // Regular case without filterParam2
626
+ newFilters[String(filterParam)] = value;
627
+ return newFilters;
628
+ });
629
+ setCurrentPage(0);
630
+ };
631
+ const handleToggleShowColFilters = () => {
632
+ if (showColFilters && columnFilters !== undefined) {
633
+ setColumnFilters(columnFilters);
634
+ }
635
+
636
+ setShowColFilters(!showColFilters);
637
+ };
638
+
639
+ // osetreni logiky pro useEffect. prvni nacteni filteru currentPage neresetuje
640
+ useEffect(() => {
641
+ if (!hasMounted) {
642
+ setHasMounted(true); // Ensures this block won't run again
643
+ } else {
644
+ setCurrentPage(0);
645
+ }
646
+ }, [filters]);
647
+
648
+ //store table settings in localstorage
649
+ useEffect(() => {
650
+ if (id && isLocalStorageLoaded) {
651
+ const storageKey = `datatable:${id}`;
652
+
653
+ const storageObject: DataTableStorageObject = localStorage.getItem(
654
+ storageKey
655
+ )
656
+ ? (JSON.parse(
657
+ localStorage.getItem(storageKey)!
658
+ ) as DataTableStorageObject)
659
+ : ({} as DataTableStorageObject);
660
+
661
+ storageObject.columnFilters = columnFilters || {};
662
+ // if (showColFilters !== undefined) {
663
+ // storageObject.showColFilters = showColFilters;
664
+ // }
665
+ storageObject.showColFilters = showColFilters || false;
666
+ storageObject.currentPage = currentPage || 0;
667
+ storageObject.sortConfig = sortConfig || null;
668
+
669
+ storageObject.itemsPerPage = itemsPerPageLocal || 10;
670
+
671
+ localStorage.setItem(storageKey, JSON.stringify(storageObject));
672
+ }
673
+ }, [
674
+ columnFilters,
675
+ showColFilters,
676
+ currentPage,
677
+ // id, // predbihalo se to a ukladaly se sortCOnfigy z predchozich tabulek
678
+ itemsPerPageLocal,
679
+ isLocalStorageLoaded,
680
+ sortConfig,
681
+ ]);
682
+
683
+ const rerenderTable = () => {
684
+ setTableKey((previous) => previous + 1);
685
+ };
686
+
687
+ const exportToXLSX = useCallback(() => {
688
+ // load fresh complete data without pagination ,filters
689
+ setIsLoading(true);
690
+
691
+ //odebrani prazdny filteru a transformace multi-select hodnot
692
+ const filteredMergedFilters = Object.entries(mergedFilters || {}).reduce(
693
+ (acc: Record<string, any>, [key, value]) => {
694
+ // Skip null and empty string values
695
+ if (value === null || value === "") {
696
+ return acc;
697
+ }
698
+
699
+ // Handle multi-select values (arrays)
700
+ if (Array.isArray(value)) {
701
+ if (value.length > 0) {
702
+ acc[key] = value.join(",");
703
+ }
704
+ return acc;
705
+ }
706
+
707
+ // Handle other values
708
+ acc[key] = value;
709
+ return acc;
710
+ },
711
+ {}
712
+ );
713
+
714
+ federationContext.apiClient
715
+ .get(url, {
716
+ params: {
717
+ ...filteredMergedFilters,
718
+ pageSize: 1000000,
719
+ page: 0,
720
+ sortBy: sortConfig?.sortParam,
721
+ sortDirection: sortConfig?.direction,
722
+ },
723
+ })
724
+ .then((response) => {
725
+ setIsLoading(false);
726
+ const dataPageable: IPageable<T> = createDataPageable(
727
+ response,
728
+ 1000000
729
+ );
730
+
731
+ const worksheet = XLSX.utils.json_to_sheet(
732
+ dataPageable.content.map((item: any) => {
733
+ const row = {};
734
+ columns.forEach((column) => {
735
+ let value;
736
+ if (column.renderXls) {
737
+ value = column.renderXls(item);
738
+ } else if (column.render) {
739
+ value = column.render(item)?.toString();
740
+ }
741
+
742
+ // Handle number formatting
743
+ if (
744
+ column.renderXlsOptions?.type === "number" ||
745
+ typeof value === "number" ||
746
+ (typeof value === "string" && !isNaN(Number(value)))
747
+ ) {
748
+ (row as any)[column.header] = Number(value);
749
+ } else {
750
+ (row as any)[column.header] = value?.toString();
751
+ }
752
+ });
753
+ return row;
754
+ })
755
+ );
756
+
757
+ // Apply number formatting to columns that contain numbers
758
+ const range = XLSX.utils.decode_range(worksheet["!ref"] || "A1");
759
+ for (let C = range.s.c; C <= range.e.c; ++C) {
760
+ const col = XLSX.utils.encode_col(C);
761
+ let hasNumbers = false;
762
+
763
+ // Check if column contains numbers
764
+ for (let R = range.s.r; R <= range.e.r; ++R) {
765
+ const cell = worksheet[col + (R + 1)];
766
+ if (cell && typeof cell.v === "number") {
767
+ hasNumbers = true;
768
+ break;
769
+ }
770
+ }
771
+ // Apply number format if column contains numbers
772
+ if (hasNumbers) {
773
+ for (let R = range.s.r; R <= range.e.r; ++R) {
774
+ const cell = worksheet[col + (R + 1)];
775
+ if (cell && typeof cell.v === "number") {
776
+ // Get the column index by converting the column letter to number
777
+ const colIndex = XLSX.utils.decode_col(col);
778
+ // Find the corresponding column definition
779
+ const column = columns[colIndex];
780
+ // Apply the format if specified in renderXlsOptions
781
+ if (column?.renderXlsOptions?.format) {
782
+ cell.z = column.renderXlsOptions.format;
783
+ } else {
784
+ // Check if the number has decimal places
785
+ const hasDecimal = cell.v % 1 !== 0;
786
+ cell.z = hasDecimal ? "#,##0.##" : "#,##0"; // Use different formats based on whether decimals exist
787
+ }
788
+ }
789
+ }
790
+ }
791
+ }
792
+
793
+ const workbook = XLSX.utils.book_new();
794
+ XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
795
+ XLSX.writeFile(workbook, "export.xlsx");
796
+ })
797
+ .catch((error) => {
798
+ console.error("Error fetching data:", error);
799
+ handleErrors(error, federationContext.emitter);
800
+ setIsLoading(false);
801
+ });
802
+ }, [
803
+ setIsLoading,
804
+ federationContext.apiClient,
805
+ url,
806
+ mergedFilters,
807
+ showColFilters,
808
+ columnFilters,
809
+ sortConfig,
810
+ handleErrors,
811
+ columns,
812
+ ]);
813
+
814
+ // Update sync width to match table content width
815
+ const updateSyncWidth = useCallback(() => {
816
+ if (tableRef.current && syncWidthRef.current && setMinWidth) {
817
+ syncWidthRef.current.style.width = tableRef.current.scrollWidth + "px";
818
+ }
819
+ }, [setMinWidth]);
820
+
821
+ // Set up scroll synchronization between top and bottom scrollbars
822
+ useEffect(() => {
823
+ if (!setMinWidth) return;
824
+ const topScrollbar = topScrollbarRef.current;
825
+ const bottomScrollbar = bottomScrollbarRef.current;
826
+
827
+ if (!topScrollbar || !bottomScrollbar) return;
828
+
829
+ const handleTopScroll = () => {
830
+ bottomScrollbar.scrollLeft = topScrollbar.scrollLeft;
831
+ };
832
+
833
+ const handleBottomScroll = () => {
834
+ topScrollbar.scrollLeft = bottomScrollbar.scrollLeft;
835
+ };
836
+
837
+ topScrollbar.addEventListener("scroll", handleTopScroll);
838
+ bottomScrollbar.addEventListener("scroll", handleBottomScroll);
839
+
840
+ return () => {
841
+ topScrollbar.removeEventListener("scroll", handleTopScroll);
842
+ bottomScrollbar.removeEventListener("scroll", handleBottomScroll);
843
+ };
844
+ }, [setMinWidth]);
845
+
846
+ // Set up ResizeObserver to update sync width when table content changes
847
+ useEffect(() => {
848
+ if (!tableRef.current) return;
849
+
850
+ const resizeObserver = new ResizeObserver(updateSyncWidth);
851
+ resizeObserver.observe(tableRef.current);
852
+
853
+ // Initial update
854
+ updateSyncWidth();
855
+
856
+ return () => {
857
+ resizeObserver.disconnect();
858
+ };
859
+ }, [updateSyncWidth, data, isLoading]);
860
+
861
+ const handleItemsPerPageChange = (value: string) => {
862
+ const selectedItemsPerPage = Number(value);
863
+ setItemsPerPageLocal(selectedItemsPerPage);
864
+ setCurrentPage(0); // Reset the current page to 0 when changing items per page
865
+ };
866
+
867
+ return (
868
+ <>
869
+ <div
870
+ className="shadow-lg border border-gray-200 rounded-xl"
871
+ style={{ overflowY: "visible" }}
872
+ data-cy={"datatable-container-" + id}
873
+ >
874
+ {showHeader && (
875
+ <div className="p-4 leading-9 flex ">
876
+ <div className="flex-grow content-center">
877
+ {title && (
878
+ <h1 className="font-semibold text-xl leading-[42px]">
879
+ {title}
880
+ </h1>
881
+ )}
882
+ {subtitle && (
883
+ <p className="font-normal text-gray-600">{subtitle}</p>
884
+ )}{" "}
885
+ {bulkAction && selectedItems.length > 0 && (
886
+ <div className="">{bulkAction(selectedItems)}</div>
887
+ )}
888
+ </div>
889
+
890
+ <DatatableSettings
891
+ tableId={id}
892
+ onSuccess={rerenderTable}
893
+ onExport={exportToXLSX}
894
+ ></DatatableSettings>
895
+ {hasSomeColFilters && (
896
+ <div
897
+ className="flex items-center text-xl h-full p-3 cursor-pointer text-gray-500 hover:text-black"
898
+ title={
899
+ showColFilters
900
+ ? "Zrušit filtr podle sloupců"
901
+ : "Filtrovat podle sloupců"
902
+ }
903
+ onClick={handleToggleShowColFilters}
904
+ data-cy="datatable-filter-toggle"
905
+ >
906
+ {!showColFilters && <MdOutlineFilterAlt />}
907
+ {showColFilters && (
908
+ <MdOutlineFilterAltOff className="text-danger" />
909
+ )}
910
+ </div>
911
+ )}
912
+ {allowSearch && (
913
+ <div className="ml-5">
914
+ <FormField
915
+ placeholder="Vyhledávání"
916
+ name="search"
917
+ onInputChange={handleSearchChanged}
918
+ type="text"
919
+ value={fulltextSearch}
920
+ >
921
+ <div className=" text-gray-500 leading-5 flex items-center h-full">
922
+ {!fulltextSearch && <MdSearch></MdSearch>}
923
+ {fulltextSearch && (
924
+ <MdClose onClick={() => setFulltextSearch("")} />
925
+ )}
926
+ </div>
927
+ </FormField>
928
+ </div>
929
+ )}
930
+ </div>
931
+ )}
932
+ {/* Top horizontal scrollbar */}
933
+ {setMinWidth && (
934
+ <div ref={topScrollbarRef} className="overflow-x-auto h-4 mb-1 mmmmm">
935
+ <div ref={syncWidthRef} className="h-full"></div>
936
+ </div>
937
+ )}
938
+
939
+ {/* Main scrollable content area */}
940
+ <div
941
+ ref={bottomScrollbarRef}
942
+ className="overflow-auto min-h-[500px] relative"
943
+ >
944
+ {isLoading && (
945
+ <div className="absolute inset-0 flex items-center justify-center h-[480px] py-2 z-10">
946
+ <Spinner />
947
+ </div>
948
+ )}
949
+ {!isLoading && (!data || data?.content?.length === 0) && (
950
+ <div className="absolute inset-0 flex items-center justify-center py-2 text-gray-600 font-medium text-xs top-[90px]">
951
+ Žádná data
952
+ </div>
953
+ )}
954
+ <table
955
+ ref={tableRef}
956
+ className="w-full leading-normal"
957
+ key={tableKey}
958
+ data-cy={"datatable-table-" + id}
959
+ >
960
+ <thead>
961
+ <tr>
962
+ {data && bulkAction && (
963
+ <th className="w-[20px] h-10 hover:bg-gray-200 bg-gray-50 font-medium text-xs text-center text-gray-600 cursor-pointer border-t border-b border-gray-200">
964
+ <label className="w-full h-full flex items-center justify-center cursor-pointer px-2">
965
+ <input
966
+ id="selectAll"
967
+ type="checkbox"
968
+ className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded !focus:ring-indigo-200 focus:ring-4"
969
+ onChange={handleSelectAll}
970
+ checked={
971
+ data &&
972
+ selectedItems.length === data.content.length &&
973
+ data.content.length > 0
974
+ }
975
+ />
976
+ </label>
977
+ </th>
978
+ )}
979
+ {columns.map(
980
+ (
981
+ {
982
+ key,
983
+ header,
984
+ actions,
985
+ sortParam,
986
+ width,
987
+ filterType,
988
+ filterParam,
989
+ filterParam2,
990
+ },
991
+ index
992
+ ) => (
993
+ <Resizable
994
+ // key={String(key) + currentPage}
995
+ key={String(key)}
996
+ tableId={id}
997
+ colKey={String(key)}
998
+ defaultWidth={width || "auto"}
999
+ setMinWidth={setMinWidth}
1000
+ >
1001
+ {({ ref }: { ref: any }) => {
1002
+ // Check if filter is active (non-empty)
1003
+ const hasActiveFilter = (() => {
1004
+ if (!filterParam) return false;
1005
+
1006
+ const filterValue = mergedFilters?.[String(filterParam)];
1007
+ const filterValue2 = filterParam2 ? mergedFilters?.[String(filterParam2)] : null;
1008
+
1009
+ if (filterType === "select") {
1010
+ return filterValue && filterValue !== "__clear__" && filterValue !== "";
1011
+ }
1012
+
1013
+ if (filterType === "multi-select") {
1014
+ return Array.isArray(filterValue) && filterValue.length > 0;
1015
+ }
1016
+
1017
+ if (filterType === "dateRange") {
1018
+ return (filterValue && filterValue !== "") || (filterValue2 && filterValue2 !== "");
1019
+ }
1020
+
1021
+ if (filterType === "date" || filterType === "text") {
1022
+ return filterValue && filterValue !== "";
1023
+ }
1024
+
1025
+ return false;
1026
+ })();
1027
+
1028
+ return (
1029
+ <th
1030
+ className={`tableHeader relative font-medium text-xs !leading-9 text-left px-3 text-gray-600
1031
+ bg-gray-50 border-t border-b border-gray-200 content-start ${
1032
+ !title && !subtitle ? "border-t-0" : ""
1033
+ } ${sortParam ? " cursor-pointer " : ""} ${
1034
+ hasActiveFilter ? "!bg-[#f3f3f3] !shadow-[inset_0_2px_0_0_#525252] !text-black" : ""
1035
+ }`}
1036
+ onClick={() =>
1037
+ sortParam ? requestSort(sortParam) : undefined
1038
+ }
1039
+ data-cy={"datatable-header-" + id + "-" + String(key)}
1040
+ >
1041
+ <span className="inline-flex items-center gap-2 group select-none w-full">
1042
+ {header}{" "}
1043
+ {!actions && sortParam
1044
+ ? getSortIcon(sortParam)
1045
+ : ""}
1046
+ </span>
1047
+ <div
1048
+ className={`resizer absolute top-0 right-0 h-full w-2 bg-transparent ${
1049
+ index < columns.length - 1
1050
+ ? "cursor-col-resize hover:border-x border-gray-300 border-r w-1"
1051
+ : "w-0"
1052
+ }`}
1053
+ ref={ref}
1054
+ onClick={(e) => e.stopPropagation()}
1055
+ />
1056
+ {showColFilters && (
1057
+ <div
1058
+ className="p-0 m-0 pb-2"
1059
+ onClick={(e) => e.stopPropagation()}
1060
+ data-cy={
1061
+ "datatable-filter-container-" +
1062
+ id +
1063
+ "-" +
1064
+ String(key)
1065
+ }
1066
+ >
1067
+ {filterType === "select" ? (
1068
+ <Select
1069
+ onValueChange={(value) => {
1070
+ if (value === "__clear__") {
1071
+ filterHandler(
1072
+ filterParam as keyof T,
1073
+ "",
1074
+ filterParam2
1075
+ );
1076
+ } else {
1077
+ // Check if value contains delimiter (has subcategory)
1078
+ if (value.includes("-") && filterParam2) {
1079
+ filterHandler(
1080
+ filterParam as keyof T,
1081
+ value,
1082
+ filterParam2
1083
+ );
1084
+ } else {
1085
+ // Value without subcategory - clear filterParam2 if it exists
1086
+ filterHandler(
1087
+ filterParam as keyof T,
1088
+ value,
1089
+ filterParam2,
1090
+ true
1091
+ );
1092
+ }
1093
+ }
1094
+ }}
1095
+ value={
1096
+ filterParam2 &&
1097
+ mergedFilters?.[String(filterParam)] &&
1098
+ mergedFilters?.[String(filterParam2)]
1099
+ ? `${mergedFilters[String(filterParam)]}-${mergedFilters[String(filterParam2)]}`
1100
+ : mergedFilters?.[
1101
+ String(filterParam)
1102
+ ]?.toString() || "__clear__"
1103
+ }
1104
+ disabled={Object.keys(
1105
+ (filters as object) || {}
1106
+ ).includes(String(filterParam))}
1107
+ >
1108
+ <SelectTrigger className="flex-1 w-full px-2 font-normal placeholder-muted-foreground">
1109
+ <SelectValue placeholder="Zadejte filtr" />
1110
+ </SelectTrigger>
1111
+ <SelectContent>
1112
+ {filterOptions[String(filterParam)]
1113
+ ?.filter(
1114
+ (option: IOptionItem) =>
1115
+ option.value !== null &&
1116
+ option.value !== undefined &&
1117
+ option.value !== ""
1118
+ )
1119
+ ?.map((option: IOptionItem) => (
1120
+ <SelectItem
1121
+ key={option.value}
1122
+ value={
1123
+ option.value?.toString() ||
1124
+ "unknown"
1125
+ }
1126
+ >
1127
+ {option.label}
1128
+ </SelectItem>
1129
+ ))}
1130
+ </SelectContent>
1131
+ </Select>
1132
+ ) : filterType === "multi-select" ? (
1133
+ <MultiSelect
1134
+ // key={JSON.stringify(mergedFilters)}
1135
+ options={
1136
+ filterOptions[String(filterParam)] || []
1137
+ }
1138
+ onChange={(values) => {
1139
+ filterHandler(
1140
+ filterParam as keyof T,
1141
+ values,
1142
+ filterParam2
1143
+ );
1144
+ }}
1145
+ value={
1146
+ Array.isArray(
1147
+ mergedFilters?.[String(filterParam)]
1148
+ )
1149
+ ? mergedFilters[String(filterParam)]
1150
+ : mergedFilters?.[String(filterParam)]
1151
+ ? [mergedFilters[String(filterParam)]]
1152
+ : []
1153
+ }
1154
+ placeholder={"Zadejte filtr"}
1155
+ className="px-0"
1156
+ disabled={Object.keys(
1157
+ (filters as object) || {}
1158
+ ).includes(String(filterParam))}
1159
+ variant="secondary"
1160
+ maxCount={0}
1161
+ />
1162
+ ) : filterType === "dateRange" ? (
1163
+ <DateRangeField
1164
+ // key={JSON.stringify(mergedFilters)}
1165
+ name={String(filterParam)}
1166
+ nameEnd={String(filterParam2)}
1167
+ onInputChange={(e) =>
1168
+ filterHandler(
1169
+ e.target.name as keyof T,
1170
+ e.target.value
1171
+ )
1172
+ }
1173
+ type={filterType}
1174
+ value={{
1175
+ startDate:
1176
+ mergedFilters?.[String(filterParam)] ||
1177
+ "",
1178
+ endDate:
1179
+ mergedFilters?.[String(filterParam2)] ||
1180
+ "",
1181
+ }}
1182
+ clearable
1183
+ className=" px-0 py-0 "
1184
+ placeholder={"Zadejte filtr"}
1185
+ rounded={true}
1186
+ disabled={Object.keys(
1187
+ (filters as object) || {}
1188
+ ).includes(String(filterParam))}
1189
+ />
1190
+ ) : filterType === "date" ? (
1191
+ <DateField
1192
+ // key={JSON.stringify(mergedFilters)}
1193
+ name={String(filterParam)}
1194
+ onInputChange={(e) =>
1195
+ filterHandler(
1196
+ e.target.name as keyof T,
1197
+ e.target.value
1198
+ )
1199
+ }
1200
+ type={filterType}
1201
+ value={
1202
+ mergedFilters?.[String(filterParam)] || ""
1203
+ }
1204
+ clearable
1205
+ className=" px-0 py-0 "
1206
+ placeholder={"Zadejte filtr"}
1207
+ rounded={true}
1208
+ disabled={Object.keys(
1209
+ (filters as object) || {}
1210
+ ).includes(String(filterParam))}
1211
+ />
1212
+ ) : filterType === "text" ? (
1213
+ <Input
1214
+ onChange={(e) =>
1215
+ filterHandler(
1216
+ filterParam as keyof T,
1217
+ e.target.value
1218
+ )
1219
+ }
1220
+ value={
1221
+ mergedFilters?.[String(filterParam)] || ""
1222
+ }
1223
+ disabled={Object.keys(
1224
+ (filters as object) || {}
1225
+ ).includes(String(filterParam))}
1226
+ clearable
1227
+ className="min-w-[100px] px-2 font-normal placeholder-muted-foreground
1228
+ "
1229
+ placeholder={"Zadejte filtr"}
1230
+ debounceTimeout={1000}
1231
+ />
1232
+ ) : null}
1233
+ </div>
1234
+ )}
1235
+ </th>
1236
+ );
1237
+ }}
1238
+ </Resizable>
1239
+ )
1240
+ )}
1241
+ </tr>
1242
+ </thead>
1243
+ {!isLoading &&
1244
+ data &&
1245
+ data?.content &&
1246
+ data?.content.length > 0 && (
1247
+ <tbody className="relative">
1248
+ {data.content.map((item, rowIndex) => (
1249
+ <tr
1250
+ key={rowIndex}
1251
+ className={`${
1252
+ item._isHighlighted || isSelected(item)
1253
+ ? "bg-gray-50"
1254
+ : ""
1255
+ } hover:bg-gray-100 border-gray-200 border-b text-sm`}
1256
+ >
1257
+ {bulkAction && (
1258
+ <td className="w-[20px] h-[52px] hover:bg-gray-200 font-medium text-xs text-center text-gray-600 cursor-pointer">
1259
+ <label className="w-full h-full flex items-center justify-center cursor-pointer px-2">
1260
+ <input
1261
+ type="checkbox"
1262
+ className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded !focus:ring-indigo-200 focus:ring-4"
1263
+ checked={isSelected(item) || false}
1264
+ onChange={() => handleSelectItem(item)}
1265
+ />
1266
+ </label>
1267
+ </td>
1268
+ )}
1269
+ {columns.map(({ render, actions, classes }, colIndex) => (
1270
+ <td
1271
+ key={`${rowIndex}-${colIndex}`}
1272
+ onClick={
1273
+ rowAction ? () => rowAction(item) : undefined
1274
+ }
1275
+ className={`px-3 py-2 text-gray-800 ${rowAction ? "cursor-pointer" : ""} ${
1276
+ colIndex === 0 ? "font-bold " : ""
1277
+ } ${classes || ""}`}
1278
+ >
1279
+ {render ? render(item) : ""}
1280
+ {actions &&
1281
+ actions
1282
+ .filter((it) => {
1283
+ if (it.rowAction) return false;
1284
+ if (it.visible) {
1285
+ return it.visible(item);
1286
+ } else return true;
1287
+ })
1288
+ .map((action, actionIndex) => (
1289
+ <div
1290
+ key={`${rowIndex}-${colIndex}-${actionIndex}`}
1291
+ className="inline-flex align-middle"
1292
+ >
1293
+ {action.icon && (
1294
+ <Button
1295
+ variant="icon"
1296
+ onClick={() => action.onClick(item)}
1297
+ >
1298
+ {action.icon}
1299
+ </Button>
1300
+ )}
1301
+ {!action.icon && (
1302
+ <Button
1303
+ variant="primary"
1304
+ onClick={(e) => {
1305
+ e.stopPropagation();
1306
+ action.onClick(item);
1307
+ }}
1308
+ >
1309
+ {action.label}
1310
+ </Button>
1311
+ )}
1312
+ </div>
1313
+ ))}
1314
+ </td>
1315
+ ))}
1316
+ </tr>
1317
+ ))}
1318
+ {data?.content?.length === 0 && (
1319
+ <tr key="tr-nodata">
1320
+ <td
1321
+ key="td-nodata"
1322
+ className="px-5 py-3 border-b border-gray-200 bg-white text-sm items-center justify-center align-middle"
1323
+ colSpan={columns.length}
1324
+ >
1325
+ No data
1326
+ </td>
1327
+ </tr>
1328
+ )}
1329
+ </tbody>
1330
+ )}
1331
+ </table>
1332
+ </div>
1333
+
1334
+ {data?.isPageable && (
1335
+ <div
1336
+ data-cy="pagination"
1337
+ className="w-full p-5 flex gap-5 justify-between md:flex-row flex-col"
1338
+ >
1339
+ <div className="flex gap-5 text-sm ">
1340
+ {data && (
1341
+ <Button
1342
+ variant="secondary"
1343
+ onClick={prevPage}
1344
+ className="flex items-center"
1345
+ disabled={data.first || isLoading}
1346
+ data-cy="prev-page"
1347
+ >
1348
+ <MdArrowBack className="mr-1.5" /> Předchozí
1349
+ </Button>
1350
+ )}
1351
+ {data && (
1352
+ <Button
1353
+ variant="secondary"
1354
+ onClick={nextPage}
1355
+ className="flex items-center"
1356
+ disabled={data.last || isLoading}
1357
+ data-cy="next-page"
1358
+ >
1359
+ Následující <MdArrowForward className="ml-2" size={20} />
1360
+ </Button>
1361
+ )}
1362
+ </div>
1363
+ <div className="flex items-center justify-center text-gray-800">
1364
+ {paginationDisplay}
1365
+ </div>
1366
+ <div
1367
+ className="content-center w-auto items-center justify-end flex-row gap-5 flex md:mt-0 mt-5"
1368
+ data-cy="items-per-page"
1369
+ >
1370
+ <span className=" whitespace-nowrap flex-grow">
1371
+ Počet řádků na stránku:
1372
+ </span>
1373
+ <Select
1374
+ onValueChange={handleItemsPerPageChange}
1375
+ value={itemsPerPageLocal?.toString()}
1376
+ >
1377
+ <SelectTrigger className="w-[100px]">
1378
+ <SelectValue placeholder="Vyberte počet" />
1379
+ </SelectTrigger>
1380
+ <SelectContent>
1381
+ {itemsPerPageOptions?.map((option) => (
1382
+ <SelectItem
1383
+ key={option.value}
1384
+ value={option.value?.toString() || ""}
1385
+ >
1386
+ {option.label}
1387
+ </SelectItem>
1388
+ ))}
1389
+ </SelectContent>
1390
+ </Select>
1391
+ </div>
1392
+ </div>
1393
+ )}
1394
+ </div>
1395
+ </>
1396
+ );
1397
+ }
1398
+
1399
+ export default DataTableServer;