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