qantler-flaz-dataview 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +11 -0
  2. package/src/FlazDataView.css +4423 -0
  3. package/src/FlazDataView.jsx +2943 -0
  4. package/src/FlazDataViewList.jsx +787 -0
  5. package/src/MdtActiveFilterBar.jsx +263 -0
  6. package/src/MdtBulkSelectionBar.jsx +91 -0
  7. package/src/MdtColumnPickerPopup.jsx +59 -0
  8. package/src/MdtColumnVisibilityList.jsx +38 -0
  9. package/src/MdtCreateViewModal.jsx +280 -0
  10. package/src/MdtDatetimeValueEditor.jsx +259 -0
  11. package/src/MdtDeleteViewConfirmModal.jsx +83 -0
  12. package/src/MdtDisplayPopup.jsx +170 -0
  13. package/src/MdtEmptyState.jsx +20 -0
  14. package/src/MdtFilterColumnPicker.jsx +66 -0
  15. package/src/MdtFilterOperatorDropdown.jsx +58 -0
  16. package/src/MdtFilterSection.jsx +73 -0
  17. package/src/MdtFilterToolbar.jsx +81 -0
  18. package/src/MdtInlineField.css +221 -0
  19. package/src/MdtInlineField.jsx +187 -0
  20. package/src/MdtInlineFieldRow.jsx +71 -0
  21. package/src/MdtNoDataIllustration.jsx +64 -0
  22. package/src/MdtPopupChecklist.jsx +70 -0
  23. package/src/MdtRowActionsMenu.jsx +281 -0
  24. package/src/MdtSidebarNav.jsx +26 -0
  25. package/src/MdtTabsRow.jsx +354 -0
  26. package/src/MdtTabsSection.jsx +35 -0
  27. package/src/MdtTruncateWithTooltip.jsx +82 -0
  28. package/src/MdtValueEditorPopup.jsx +950 -0
  29. package/src/filterDisplayUtils.jsx +96 -0
  30. package/src/mdtClientSideFilter.js +68 -0
  31. package/src/mdtColumnEditUtils.js +403 -0
  32. package/src/mdtDateFilterUtils.js +135 -0
  33. package/src/mdtExportUtils.js +234 -0
  34. package/src/mdtFilterUtils.js +56 -0
  35. package/src/mdtInlineFieldDisplay.js +66 -0
  36. package/src/mdtMobilePopupPosition.js +107 -0
  37. package/src/mdtMultiSelectDisplay.jsx +279 -0
  38. package/src/mdtSavedViewFilterMatch.js +154 -0
  39. package/src/mdtTabChangeUtils.js +12 -0
  40. package/src/mdtTabStatusRules.js +111 -0
  41. package/src/mdtTableConstants.js +2 -0
  42. package/src/mdtTooltipFormat.js +44 -0
  43. package/src/renderInlineFieldValue.jsx +183 -0
@@ -0,0 +1,2943 @@
1
+ import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import {
4
+ ArrowDownWideNarrow,
5
+ ArrowUpNarrowWide,
6
+ ChevronDown,
7
+ Eraser,
8
+ ListFilter,
9
+ Menu,
10
+ ChevronsLeft,
11
+ Layout,
12
+ Check,
13
+ X,
14
+ MoreHorizontal,
15
+ SlidersHorizontal,
16
+ Download,
17
+ Search,
18
+ } from "lucide-react";
19
+ import MdtRowActionsMenu from "./MdtRowActionsMenu";
20
+ import DeleteConfirmModal from "../../custom/DeleteConfirmModal";
21
+ import MdtCreateViewModal from "./MdtCreateViewModal";
22
+ import MdtActiveFilterBar from "./MdtActiveFilterBar";
23
+ import MdtDisplayPopup from "./MdtDisplayPopup";
24
+ import MdtBulkSelectionBar from "./MdtBulkSelectionBar";
25
+ import MdtTabsSection from "./MdtTabsSection";
26
+ import MdtSidebarNav from "./MdtSidebarNav";
27
+ import { resolveFilterConfig, getFilterableColumns } from "./mdtFilterUtils";
28
+ import { getBulkFieldsFromColumns } from "./bulkBarUtils";
29
+ import "./FlazDataView.css";
30
+ import { renderColumnIcon, renderColumnDefIcon, resolveFilterColumnIcon } from "../../ui/tableColumnIcons";
31
+ import FlazDataViewList from "./FlazDataViewList";
32
+ import MdtEmptyState from "./MdtEmptyState";
33
+ import MdtValueEditorPopup from "./MdtValueEditorPopup";
34
+ import {
35
+ EDIT_MODE,
36
+ getRowFieldEditValue,
37
+ isColumnCellEditable,
38
+ normalizeEditApplyValue,
39
+ computeCellEditPopupPosition,
40
+ refineCellEditPopupPosition,
41
+ buildBulkEditPayload,
42
+ getColumnFieldVariant,
43
+ getColumnSelectOptions,
44
+ isColumnSelectMulti,
45
+ resolveEditColumnConfig,
46
+ isColumnDisplayHideable,
47
+ } from "./mdtColumnEditUtils";
48
+ import {
49
+ MdtMultiSelectCellDisplay,
50
+ getColumnRowIconName,
51
+ hasCustomColumnCell,
52
+ hasStatusValueRenderer,
53
+ renderStatusValueDisplay,
54
+ shouldUseMultiSelectSummary,
55
+ } from "./mdtMultiSelectDisplay";
56
+ import MdtTruncateWithTooltip from "./MdtTruncateWithTooltip";
57
+ import {
58
+ syncAnchoredPopupTop,
59
+ clearMobilePopupTop,
60
+ isMobilePopupLayout,
61
+ } from "./mdtMobilePopupPosition";
62
+ import { isSameMdtTab } from "./mdtTabChangeUtils";
63
+ import {
64
+ DEFAULT_DATE_FILTER_OPERATOR,
65
+ isDateLikeFilterColumn,
66
+ isFilterValueSet,
67
+ } from "./mdtDateFilterUtils";
68
+ import { applyClientSideFilters } from "./mdtClientSideFilter";
69
+ import { getColumnTooltipText } from "./mdtTooltipFormat";
70
+ import apiService from "../../../actions/api";
71
+ import API from "../../../helpers/endpoint";
72
+
73
+ const DEFAULT_VIEW_MODE = "table";
74
+
75
+ const FlazDataView = ({
76
+ title = "",
77
+ titleIcon = null,
78
+ countText = "",
79
+ /** Total count badge shown beside the module title */
80
+ totalCount = null,
81
+ rows = [],
82
+ columns = [],
83
+ onExport,
84
+ onPrimaryAction,
85
+ primaryActionLabel = "New",
86
+ /**
87
+ * Header toolbar action visibility. All default to true.
88
+ * showNew — primary action (e.g. "New"); still hidden when hideHeader is true.
89
+ * showExport — export icon button; renders only when onExport is also provided.
90
+ */
91
+ headerActions = {},
92
+ onRowClick,
93
+ isLoading = false,
94
+ emptyStateTitle = "No data found",
95
+ skeletonRows = 12,
96
+ onTableScroll,
97
+ onSort,
98
+ sortConfig = null,
99
+ /**
100
+ * Default sort when sortConfig is not provided (uncontrolled).
101
+ * e.g. { column: "applicationId", direction: "desc" }
102
+ */
103
+ defaultSort = null,
104
+ tabs = [],
105
+ activeTab = null,
106
+ onTabChange,
107
+ hideHeader = false, // hides the title/icon row only — filter+layout always shown
108
+ /** Optional non-clickable breadcrumb labels for header (e.g. Admin > Master Data). */
109
+ headerBreadcrumbs = null,
110
+ /** Optional trailing breadcrumb dropdown (e.g. Languages + count). */
111
+ headerBreadcrumbMenu = null,
112
+ /** Mobile breadcrumb: show compact "..." (non-clickable) + current page only. */
113
+ enableCompactBreadcrumbEllipsis = false,
114
+ /** Renders layout/filter/display/new toolbar into #mdt-breadcrumb-toolbar-host (global breadcrumb). */
115
+ toolbarInBreadcrumb = false,
116
+ /** Breadcrumb toolbar host element id (default: mdt-breadcrumb-toolbar-host). */
117
+ breadcrumbToolbarHostId = "mdt-breadcrumb-toolbar-host",
118
+ /** Bump when the breadcrumb toolbar host mounts (e.g. tab switch) to re-bind the portal. */
119
+ toolbarHostSeed = 0,
120
+ onFilterChange,
121
+ /** Filter UI labels/visibility — see mdtFilterUtils DEFAULT_FILTER_CONFIG */
122
+ filterConfig = {},
123
+ /** Tabs row — counts, saved-view edit, more button label, className */
124
+ tabsConfig = {},
125
+ /** Called when user creates a saved view from the Create View modal. */
126
+ onCreateView,
127
+ /** @deprecated Use onCreateView instead */
128
+ onSaveFiltersAs,
129
+ /** Bump to refresh live saved-view tab counts from API (e.g. after list mutations). */
130
+ savedViewsRefreshKey = 0,
131
+ /** When true, the first saved view is NOT auto-selected on initial load. Use for admin/config modules. */
132
+ disableAutoSelectFirstView = false,
133
+ /** Extra query params to append to saved view counts API call (e.g. "clientId=129"). */
134
+ savedViewCountsExtraParams = "",
135
+ /** Parent receives `() => refresh saved-view counts` (e.g. after inline edit). */
136
+ onBindSavedViewCountsRefresh,
137
+ rowKey = "applicationId",
138
+ /** Table view: column pinned to the left when scrolling horizontally (defaults to rowKey). */
139
+ tableStickyColumnKey = null,
140
+ /**
141
+ * List view: columns always shown on the left (main ID + secondary line).
142
+ * Remaining visible columns render as chips on the right.
143
+ */
144
+ listLeftColumnKeys = null,
145
+ /** List view primary column (defaults to rowKey). */
146
+ listMainColumnKey = null,
147
+ /**
148
+ * List secondary line keys after main ID. null = all listLeftColumnKeys except main.
149
+ * [] = no secondary line. e.g. ["jobOpeningName", "candidateName"].
150
+ */
151
+ listLeftSecondaryKeys = null,
152
+ /** Joins listLeftSecondaryKeys, e.g. " - " for "Opening - Candidate". */
153
+ listLeftSecondarySeparator = " - ",
154
+ /**
155
+ * Columns always visible — cannot be hidden (defaults to rowKey).
156
+ * Per-column: displayHideable: false (or hideable: false) also prevents hiding.
157
+ */
158
+ displayLockedColumnKeys = null,
159
+ onRowEdit,
160
+ onRowDownload,
161
+ onRowDelete,
162
+ /** Extra ⋯ menu items: [{ id?, label, icon?, onClick(rowId, row), disabled?, hidden? }] */
163
+ rowMenuActions = [],
164
+ deleteConfirmConfig = {},
165
+ /** Optional per-row delete guard, e.g. (row) => row.isDeletable !== false */
166
+ isRowDeletable = null,
167
+ /** When true, shows row checkboxes and the bottom bulk selection bar. */
168
+ enableBulkSelection = false,
169
+ /**
170
+ * Bulk bar pills — full override. If omitted, built from columns with bulkEditable: true.
171
+ */
172
+ bulkBarFields,
173
+ /** Allow-list of accessorKeys for bulk pills (used when bulkBarFields is omitted). */
174
+ bulkEditableKeys = null,
175
+ onBulkBarFieldClick,
176
+ onBulkDelete,
177
+ bulkBarRightAction = null,
178
+ /** When set, list view starts grouped by this column; `null` after choosing "None" shows a flat list. */
179
+ defaultListGroupByKey = null,
180
+ /**
181
+ * Table cell inline edit. Fires when user applies a value from the cell editor popup.
182
+ * Only columns with `editable: true`.
183
+ * Column config: editVariant ("text"|"select"|"date"|"number"|"boolean"), editMulti, …
184
+ * editValueKey, editPlaceholder, editRenderIcon (falls back to filter* props).
185
+ */
186
+ onCellEdit,
187
+ onCellEditStart,
188
+ /**
189
+ * Bulk bar apply — same editor UI; uses bulk* column config when set.
190
+ * bulkVariant, bulkMulti, bulkSelectOptions, bulkPlaceholder, bulkRenderIcon
191
+ * (falls back to edit* then filter*). Returns JSON via buildBulkEditPayload.
192
+ */
193
+ onBulkEdit,
194
+ /** When false, shows sidebar expand control (pass from page Redux/connect). */
195
+ menuToggle,
196
+ /** Called when user clicks sidebar expand (e.g. props.toggleMenu from Job Applications). */
197
+ onSidebarToggle,
198
+ /** When true, keeps sidebar toggle visible even when sidebar is already expanded. */
199
+ showSidebarToggleAlways = false,
200
+ /** Optional master-data style left sidebar config: { items, activeKey, onItemClick, width }. */
201
+ sidebarNavConfig = null,
202
+ /** Optional per-page width lock for table columns (prevents width jump after data load). */
203
+ tableLayoutFixed = true,
204
+ /**
205
+ * When true, filter value/operator popups use position:fixed + viewport anchoring
206
+ * (for nested layouts where overflow:hidden would clip absolute popups).
207
+ */
208
+ anchoredFilterPopups = false,
209
+ /** When set, locks table/list mode and hides the layout switch (e.g. embedded candidate tabs). */
210
+ lockedViewMode = null,
211
+ }) => {
212
+ const resolvedHeaderActions = {
213
+ showNew: true,
214
+ showExport: true,
215
+ showDisplay: true,
216
+ showFilter: true,
217
+ showLayoutSwitch: true,
218
+ ...headerActions,
219
+ };
220
+
221
+ const resolvedFilterConfig = React.useMemo(
222
+ () => resolveFilterConfig(filterConfig),
223
+ [filterConfig],
224
+ );
225
+
226
+ const storageScopeKey = React.useMemo(() => {
227
+ const titlePart = String(title || rowKey || "flaz-data-view")
228
+ .trim()
229
+ .toLowerCase()
230
+ .replace(/\s+/g, "-");
231
+ return `mdt:view-mode:${titlePart}`;
232
+ }, [rowKey, title]);
233
+
234
+ const savedViewPageKey = React.useMemo(
235
+ () => String(title || rowKey || "flaz-data-view").trim().toLowerCase(),
236
+ [rowKey, title],
237
+ );
238
+
239
+ const resolveViewMode = React.useCallback(
240
+ (mode) => (mode === "list" ? "list" : DEFAULT_VIEW_MODE),
241
+ [],
242
+ );
243
+
244
+ const getStoredViewMode = React.useCallback(() => {
245
+ if (lockedViewMode) return resolveViewMode(lockedViewMode);
246
+ if (typeof window === "undefined") return DEFAULT_VIEW_MODE;
247
+ const stored = window.localStorage.getItem(storageScopeKey);
248
+ return stored === "list" ? "list" : DEFAULT_VIEW_MODE;
249
+ }, [lockedViewMode, resolveViewMode, storageScopeKey]);
250
+
251
+ const [viewMode, setViewModeState] = useState(() => getStoredViewMode());
252
+
253
+ const setViewMode = React.useCallback(
254
+ (nextMode) => {
255
+ if (lockedViewMode) return;
256
+ const resolvedMode = resolveViewMode(nextMode);
257
+ setViewModeState(resolvedMode);
258
+ if (typeof window !== "undefined") {
259
+ window.localStorage.setItem(storageScopeKey, resolvedMode);
260
+ }
261
+ },
262
+ [lockedViewMode, resolveViewMode, storageScopeKey],
263
+ );
264
+ const [isFilterBarOpen, setIsFilterBarOpen] = useState(false);
265
+ const [activeFilters, setActiveFiltersState] = useState([]);
266
+
267
+ const resolveInitialSort = () => {
268
+ if (sortConfig?.column) {
269
+ return {
270
+ column: sortConfig.column,
271
+ direction: sortConfig.direction === "desc" ? "desc" : "asc",
272
+ };
273
+ }
274
+ if (defaultSort?.column) {
275
+ return {
276
+ column: defaultSort.column,
277
+ direction: defaultSort.direction === "desc" ? "desc" : "asc",
278
+ };
279
+ }
280
+ return { column: null, direction: null };
281
+ };
282
+
283
+ const [openPopup, setOpenPopup] = useState(null);
284
+ const [internalSortState, setInternalSortState] = useState(resolveInitialSort);
285
+ const [showFilterDropdown, setShowFilterDropdown] = useState(false);
286
+ const [showHeaderBreadcrumbMenu, setShowHeaderBreadcrumbMenu] = useState(false);
287
+ const [filterSearch, setFilterSearch] = useState("");
288
+ const [showInnerFilterDropdown, setShowInnerFilterDropdown] = useState(false);
289
+ const [innerFilterSearch, setInnerFilterSearch] = useState("");
290
+ const [replacingFilterId, setReplacingFilterId] = useState(null);
291
+ const [replaceSearch, setReplaceSearch] = useState("");
292
+ const [valueEditId, setValueEditId] = useState(null);
293
+ const [valueInputText, setValueInputText] = useState("");
294
+ const [valueSelectSearch, setValueSelectSearch] = useState("");
295
+ // calendar picker state
296
+ const today = new Date();
297
+ const [calYear, setCalYear] = useState(today.getFullYear());
298
+ const [calMonth, setCalMonth] = useState(today.getMonth()); // 0-indexed
299
+
300
+ // Display popup state
301
+ const [showDisplayPopup, setShowDisplayPopup] = useState(false);
302
+ const [displayPropsOpen, setDisplayPropsOpen] = useState(true);
303
+ const [groupByOpen, setGroupByOpen] = useState(true);
304
+ const [orderByOpen, setOrderByOpen] = useState(true);
305
+ // Track which columns are visible (default: all visible)
306
+ const [hiddenColumns, setHiddenColumns] = useState([]);
307
+ const [groupByKey, setGroupByKey] = useState(
308
+ () => (defaultListGroupByKey != null ? defaultListGroupByKey : null),
309
+ );
310
+
311
+ const popupRef = useRef(null);
312
+ const filterDropdownRef = useRef(null);
313
+ const innerFilterRef = useRef(null);
314
+ const filterBarRef = useRef(null);
315
+ const chipReplaceRef = useRef(null);
316
+ const chipValueRef = useRef(null);
317
+ const displayPopupRef = useRef(null);
318
+ const headerBreadcrumbMenuRef = useRef(null);
319
+ const tableWrapRef = useRef(null);
320
+ const listWrapRef = useRef(null);
321
+ const [showCreateViewModal, setShowCreateViewModal] = useState(false);
322
+ const [editingViewId, setEditingViewId] = useState(null);
323
+ const modalFilterSnapshotRef = useRef(null);
324
+ const [autoSkeletonRows, setAutoSkeletonRows] = useState(skeletonRows);
325
+ const [openRowMenuKey, setOpenRowMenuKey] = useState(null);
326
+ const [openRowMenuRow, setOpenRowMenuRow] = useState(null);
327
+ const [rowMenuPosition, setRowMenuPosition] = useState(null);
328
+ const [tableHasHScroll, setTableHasHScroll] = useState(false);
329
+ const [savedViews, setSavedViews] = useState([]);
330
+ const [activeSavedViewId, setActiveSavedViewId] = useState(null);
331
+ const [selectedRowIds, setSelectedRowIds] = useState(() => new Set());
332
+ const selectAllRef = useRef(null);
333
+ const [cellEdit, setCellEdit] = useState(null);
334
+ const [cellEditPopupPos, setCellEditPopupPos] = useState(null);
335
+ const [cellEditDraftValue, setCellEditDraftValue] = useState(null);
336
+ const cellEditAnchorRef = useRef(null);
337
+ const cellEditPopupRef = useRef(null);
338
+ const liveCellEditColumn = React.useMemo(() => {
339
+ if (!cellEdit?.column) return null;
340
+ const key = cellEdit.columnKey ?? cellEdit.column.accessorKey;
341
+ return columns.find((col) => col?.accessorKey === key) ?? cellEdit.column;
342
+ }, [cellEdit, columns]);
343
+ const [bulkEdit, setBulkEdit] = useState(null);
344
+ const [bulkEditPopupPos, setBulkEditPopupPos] = useState(null);
345
+ const [bulkEditDraftValue, setBulkEditDraftValue] = useState(null);
346
+ const bulkEditPopupRef = useRef(null);
347
+ const bulkEditAnchorRef = useRef(null);
348
+ const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
349
+ const [locallyDeletedIds, setLocallyDeletedIds] = useState(() => new Set());
350
+ const columnsRef = useRef(columns);
351
+
352
+ useEffect(() => {
353
+ columnsRef.current = columns;
354
+ }, [columns]);
355
+
356
+ const deserializeSavedViewFilterValue = useCallback((column, value) => {
357
+ if (value == null) {
358
+ return isColumnSelectMulti(column, EDIT_MODE.FILTER) ? [] : "";
359
+ }
360
+ if (getColumnFieldVariant(column, EDIT_MODE.FILTER) === "select") {
361
+ const options = getColumnSelectOptions(column, EDIT_MODE.FILTER);
362
+ const rawValues = Array.isArray(value)
363
+ ? value
364
+ : String(value)
365
+ .split(",")
366
+ .map((v) => v.trim())
367
+ .filter(Boolean);
368
+ const normalizedValues = rawValues.map((raw) => {
369
+ const match = options.find(
370
+ (opt) =>
371
+ String(opt.value).trim().toLowerCase() === String(raw).trim().toLowerCase() ||
372
+ String(opt.label).trim().toLowerCase() === String(raw).trim().toLowerCase(),
373
+ );
374
+ return match ? String(match.value) : String(raw);
375
+ });
376
+ if (normalizedValues.length > 1) {
377
+ return normalizedValues;
378
+ }
379
+ return normalizedValues[0] ?? "";
380
+ }
381
+ if (isColumnSelectMulti(column, EDIT_MODE.FILTER)) {
382
+ return String(value)
383
+ .split(",")
384
+ .map((v) => v.trim())
385
+ .filter(Boolean);
386
+ }
387
+ // Always split comma-separated strings into arrays so saved view
388
+ // multi-value filters are displayed and applied like manual filters.
389
+ if (typeof value === "string" && value.includes(",")) {
390
+ return String(value)
391
+ .split(",")
392
+ .map((v) => v.trim())
393
+ .filter(Boolean);
394
+ }
395
+ return value;
396
+ }, []);
397
+
398
+ const normalizeSavedViewFilters = useCallback(
399
+ (filters = []) => {
400
+ const next = [];
401
+
402
+ filters.forEach((filter, index) => {
403
+ const key = filter?.key ?? filter?.id;
404
+ const column = columnsRef.current.find(
405
+ (col) => col?.accessorKey === key,
406
+ );
407
+ const normalized = {
408
+ id: key || `saved-filter-${index}`,
409
+ key,
410
+ label: filter?.label ?? column?.header ?? key ?? "Filter",
411
+ icon: filter?.icon ?? column?.icon ?? column?.filterIcon ?? null,
412
+ value: deserializeSavedViewFilterValue(column, filter?.value),
413
+ operator: filter?.operator,
414
+ };
415
+
416
+ const existing = next.find((item) => item.key === normalized.key);
417
+ if (!existing) {
418
+ next.push(normalized);
419
+ return;
420
+ }
421
+
422
+ const selectVariant = getColumnFieldVariant(column, EDIT_MODE.FILTER) === "select";
423
+ if (!selectVariant) {
424
+ existing.value = normalized.value;
425
+ existing.label = normalized.label;
426
+ existing.icon = normalized.icon;
427
+ existing.operator = normalized.operator ?? existing.operator;
428
+ return;
429
+ }
430
+
431
+ const existingValues = Array.isArray(existing.value)
432
+ ? existing.value
433
+ : existing.value != null && existing.value !== ""
434
+ ? [existing.value]
435
+ : [];
436
+ const incomingValues = Array.isArray(normalized.value)
437
+ ? normalized.value
438
+ : normalized.value != null && normalized.value !== ""
439
+ ? [normalized.value]
440
+ : [];
441
+ const mergedValues = Array.from(
442
+ new Set([...existingValues, ...incomingValues].map((v) => String(v).trim()).filter(Boolean)),
443
+ );
444
+ existing.value = mergedValues.length > 1 ? mergedValues : (mergedValues[0] ?? "");
445
+ });
446
+
447
+ return next;
448
+ },
449
+ [deserializeSavedViewFilterValue],
450
+ );
451
+
452
+ const normalizeSavedView = useCallback(
453
+ (view) => ({
454
+ id: view?.id ?? view?.savedViewId ?? null,
455
+ pageKey: view?.pageKey ?? savedViewPageKey,
456
+ name: view?.name ?? "Untitled",
457
+ description: view?.description ?? "",
458
+ viewMode: view?.viewMode === "list" ? "list" : "table",
459
+ filters: Array.isArray(view?.filters)
460
+ ? normalizeSavedViewFilters(view.filters)
461
+ : [],
462
+ hiddenColumns: Array.isArray(view?.hiddenColumns) ? view.hiddenColumns : [],
463
+ groupByKey: view?.groupByKey ?? null,
464
+ sort: view?.sort ?? null,
465
+ count: null,
466
+ createdBy: view?.createdBy ?? null,
467
+ createdDate: view?.createdDate ?? null,
468
+ modifiedBy: view?.modifiedBy ?? null,
469
+ modifiedDate: view?.modifiedDate ?? null,
470
+ isPublic: Boolean(view?.isPublic),
471
+ isSystem: Boolean(view?.isSystem),
472
+ }),
473
+ [normalizeSavedViewFilters, savedViewPageKey],
474
+ );
475
+
476
+ const showRowMenuColumn = Boolean(
477
+ onRowEdit || onRowDownload || onRowDelete || rowMenuActions.length > 0,
478
+ );
479
+ const ROW_MENU_COL_WIDTH = 48;
480
+
481
+ const visibleColumns = columns.filter(
482
+ (column) => column?.accessorKey && column.accessorKey !== "action",
483
+ );
484
+
485
+ const filterableColumns = React.useMemo(
486
+ () => getFilterableColumns(visibleColumns),
487
+ [visibleColumns],
488
+ );
489
+
490
+ const lockedColumnKeys = displayLockedColumnKeys ?? [rowKey];
491
+ const lockedColumnSet = React.useMemo(
492
+ () => new Set(lockedColumnKeys),
493
+ [lockedColumnKeys],
494
+ );
495
+ const resolvedListLeftKeys = listLeftColumnKeys ?? [
496
+ rowKey,
497
+ "jobOpeningName",
498
+ "candidateName",
499
+ ];
500
+
501
+ const resolvedBulkBarFields = React.useMemo(() => {
502
+ if (bulkBarFields !== undefined) return bulkBarFields;
503
+ return getBulkFieldsFromColumns(visibleColumns, bulkEditableKeys);
504
+ }, [bulkBarFields, visibleColumns, bulkEditableKeys]);
505
+ const groupByColumns = visibleColumns.filter(
506
+ (col) => col.accessorKey !== rowKey,
507
+ );
508
+ // Columns rendered — locked columns (e.g. Application ID) always stay visible
509
+ const renderedColumns = React.useMemo(
510
+ () =>
511
+ visibleColumns.filter(
512
+ (col) =>
513
+ lockedColumnSet.has(col.accessorKey) ||
514
+ !hiddenColumns.includes(col.accessorKey),
515
+ ),
516
+ [visibleColumns, hiddenColumns, lockedColumnSet],
517
+ );
518
+
519
+ const displayPropertyColumns = React.useMemo(
520
+ () =>
521
+ visibleColumns.filter(
522
+ (col) =>
523
+ !lockedColumnSet.has(col.accessorKey) && isColumnDisplayHideable(col),
524
+ ),
525
+ [visibleColumns, lockedColumnSet],
526
+ );
527
+
528
+ const filterValueIsSet = (filter) => isFilterValueSet(filter);
529
+
530
+ const normalizeOutgoingFilters = (filters) =>
531
+ filters.map((f) => ({
532
+ ...f,
533
+ key: f.key || f.id,
534
+ value: f.value,
535
+ }));
536
+
537
+ const withSavedViewMeta = useCallback(
538
+ (meta = {}) =>
539
+ activeSavedViewId != null
540
+ ? { ...meta, savedViewId: meta.savedViewId ?? activeSavedViewId }
541
+ : meta,
542
+ [activeSavedViewId],
543
+ );
544
+
545
+ const emitFilterChangeImmediate = useCallback(
546
+ (filters, meta = {}) => {
547
+ if (onFilterChange) {
548
+ onFilterChange(
549
+ normalizeOutgoingFilters(filters.filter((f) => filterValueIsSet(f))),
550
+ withSavedViewMeta(meta),
551
+ );
552
+ }
553
+ },
554
+ [onFilterChange, withSavedViewMeta],
555
+ );
556
+
557
+ const emitFilterChange = useCallback(
558
+ (filters, meta = {}) => {
559
+ if (!onFilterChange) return;
560
+ const nextFilters = filters.filter((f) => filterValueIsSet(f));
561
+ onFilterChange(
562
+ normalizeOutgoingFilters(nextFilters),
563
+ withSavedViewMeta(meta),
564
+ );
565
+ },
566
+ [onFilterChange, withSavedViewMeta],
567
+ );
568
+
569
+ const cloneFilters = (filters) =>
570
+ filters.map((f) => ({
571
+ ...f,
572
+ value: Array.isArray(f.value) ? [...f.value] : f.value,
573
+ }));
574
+
575
+ const captureModalFilterSnapshot = () => {
576
+ modalFilterSnapshotRef.current = cloneFilters(activeFilters);
577
+ };
578
+
579
+ const restoreModalFilterSnapshot = () => {
580
+ if (!modalFilterSnapshotRef.current) return;
581
+ const restored = cloneFilters(modalFilterSnapshotRef.current);
582
+ setActiveFiltersState(restored);
583
+ setIsFilterBarOpen(restored.length > 0);
584
+ modalFilterSnapshotRef.current = null;
585
+ };
586
+
587
+ const clearModalFilterSnapshot = () => {
588
+ modalFilterSnapshotRef.current = null;
589
+ };
590
+
591
+ const setActiveFilters = (nextFilters, { notify = false } = {}) => {
592
+ setActiveFiltersState(nextFilters);
593
+ const shouldNotify = notify && !showCreateViewModal;
594
+ if (shouldNotify) {
595
+ emitFilterChange(nextFilters);
596
+ }
597
+ };
598
+
599
+ const updateFilterValue = (filterId, value) => {
600
+ const nextFilters = activeFilters.map((f) =>
601
+ f.id === filterId ? { ...f, value } : f,
602
+ );
603
+ setActiveFilters(nextFilters, { notify: true });
604
+ };
605
+
606
+ const updateFilterOperator = (filterId, operator) => {
607
+ setActiveFiltersState((prev) =>
608
+ prev.map((f) =>
609
+ f.id === filterId ? { ...f, operator, value: null } : f,
610
+ ),
611
+ );
612
+ };
613
+
614
+ // Sort state — shared by column-header popup and Display → Order By
615
+ const sortState = sortConfig || internalSortState;
616
+ const setSortState = (s) => {
617
+ if (onSort) onSort(s.column, s.direction);
618
+ else setInternalSortState(s);
619
+ };
620
+ const orderDirEffective = sortState.direction === "desc" ? "desc" : "asc";
621
+
622
+ const toggleColumnVisibility = useCallback(
623
+ (accessorKey) => {
624
+ if (lockedColumnSet.has(accessorKey)) return;
625
+ const col = visibleColumns.find((c) => c.accessorKey === accessorKey);
626
+ if (col && !isColumnDisplayHideable(col)) return;
627
+ setHiddenColumns((prev) =>
628
+ prev.includes(accessorKey)
629
+ ? prev.filter((k) => k !== accessorKey)
630
+ : [...prev, accessorKey],
631
+ );
632
+ },
633
+ [lockedColumnSet, visibleColumns],
634
+ );
635
+
636
+ useEffect(() => {
637
+ if (sortConfig) return;
638
+ if (!defaultSort?.column || !onSort) return;
639
+ onSort(
640
+ defaultSort.column,
641
+ defaultSort.direction === "desc" ? "desc" : "asc",
642
+ );
643
+ // Notify parent of initial default sort once (uncontrolled mode).
644
+ // eslint-disable-next-line react-hooks/exhaustive-deps
645
+ }, []);
646
+
647
+ useEffect(() => {
648
+ setViewModeState(getStoredViewMode());
649
+ }, [getStoredViewMode]);
650
+
651
+ const applySavedViewCounts = useCallback(
652
+ async (viewsSnapshot = null) => {
653
+ if (!savedViewPageKey) return viewsSnapshot ?? null;
654
+ try {
655
+ let url = `${API.GET_SAVED_VIEW_COUNTS}?pageKey=${encodeURIComponent(savedViewPageKey)}`;
656
+ if (savedViewCountsExtraParams) {
657
+ url += `&${savedViewCountsExtraParams}`;
658
+ }
659
+ const response = await apiService.GET(url);
660
+ const counts = Array.isArray(response?.data) ? response.data : [];
661
+ const countById = new Map(
662
+ counts.map((entry) => [String(entry.id), Number(entry.count)]),
663
+ );
664
+ setSavedViews((prev) => {
665
+ const base = viewsSnapshot ?? prev;
666
+ if (!base.length) return base;
667
+ return base.map((view) => {
668
+ const viewId = String(view.id);
669
+ return {
670
+ ...view,
671
+ count: countById.has(viewId) ? countById.get(viewId) : view.count,
672
+ };
673
+ });
674
+ });
675
+ } catch (error) {
676
+ console.error("Failed to load saved view counts", error);
677
+ }
678
+ return viewsSnapshot ?? null;
679
+ },
680
+ [savedViewPageKey, savedViewCountsExtraParams],
681
+ );
682
+
683
+ useEffect(() => {
684
+ let cancelled = false;
685
+
686
+ const loadSavedViews = async () => {
687
+ setSavedViews([]);
688
+ setActiveSavedViewId(null);
689
+ setEditingViewId(null);
690
+ try {
691
+ const response = await apiService.GET(
692
+ `${API.GET_SAVED_VIEWS}?pageKey=${encodeURIComponent(savedViewPageKey)}`,
693
+ );
694
+ if (cancelled) return;
695
+ const nextViews = Array.isArray(response?.data)
696
+ ? response.data.map(normalizeSavedView)
697
+ : [];
698
+ setSavedViews(nextViews);
699
+ if (nextViews.length > 0) {
700
+ await applySavedViewCounts(nextViews);
701
+ }
702
+ } catch (error) {
703
+ if (cancelled) return;
704
+ console.error("Failed to load saved views", error);
705
+ setSavedViews([]);
706
+ }
707
+ };
708
+
709
+ if (savedViewPageKey) {
710
+ loadSavedViews();
711
+ }
712
+
713
+ return () => {
714
+ cancelled = true;
715
+ };
716
+ }, [normalizeSavedView, savedViewPageKey, applySavedViewCounts]);
717
+
718
+ const initialAutoSelectDoneRef = React.useRef(false);
719
+ const lastSavedViewsRefreshKeyRef = React.useRef(savedViewsRefreshKey);
720
+
721
+ // Reset auto-select when page changes
722
+ useEffect(() => {
723
+ initialAutoSelectDoneRef.current = false;
724
+ }, [savedViewPageKey]);
725
+
726
+ useEffect(() => {
727
+ onBindSavedViewCountsRefresh?.(() => applySavedViewCounts());
728
+ return () => onBindSavedViewCountsRefresh?.(null);
729
+ }, [applySavedViewCounts, onBindSavedViewCountsRefresh]);
730
+
731
+ useEffect(() => {
732
+ if (savedViews.length === 0) return;
733
+ if (lastSavedViewsRefreshKeyRef.current === savedViewsRefreshKey) return;
734
+ lastSavedViewsRefreshKeyRef.current = savedViewsRefreshKey;
735
+ if (savedViewsRefreshKey <= 0) return;
736
+ applySavedViewCounts();
737
+ }, [savedViewsRefreshKey, savedViews.length, applySavedViewCounts]);
738
+
739
+ const setOrderFromDisplay = useCallback(
740
+ (column, direction, { notifyParent = true } = {}) => {
741
+ if (!column) {
742
+ if (notifyParent && onSort) {
743
+ onSort(null, null);
744
+ } else if (!sortConfig) {
745
+ setInternalSortState({ column: null, direction: null });
746
+ }
747
+ return;
748
+ }
749
+ const dir = direction === "desc" ? "desc" : "asc";
750
+ if (notifyParent && onSort) {
751
+ onSort(column, dir);
752
+ } else if (!sortConfig) {
753
+ setInternalSortState({ column, direction: dir });
754
+ }
755
+ },
756
+ [onSort, sortConfig],
757
+ );
758
+
759
+ const getRowId = (row) => {
760
+ const id = row[rowKey] ?? row.id ?? row.applicationId;
761
+ return id == null || id === "" ? null : String(id);
762
+ };
763
+
764
+ const sortedRows = React.useMemo(() => {
765
+ const sourceRows = onFilterChange
766
+ ? rows
767
+ : applyClientSideFilters(rows, activeFilters, columns);
768
+
769
+ const base = (() => {
770
+ if (!sortState.column || !sortState.direction || onSort) return sourceRows;
771
+ return [...sourceRows].sort((a, b) => {
772
+ let aVal = a[sortState.column];
773
+ let bVal = b[sortState.column];
774
+ if (typeof aVal === 'string') aVal = aVal.toLowerCase();
775
+ if (typeof bVal === 'string') bVal = bVal.toLowerCase();
776
+ if (aVal === bVal) return 0;
777
+ if (aVal === null || aVal === undefined) return 1;
778
+ if (bVal === null || bVal === undefined) return -1;
779
+ const result = aVal < bVal ? -1 : 1;
780
+ return sortState.direction === "asc" ? result : -result;
781
+ });
782
+ })();
783
+ if (locallyDeletedIds.size === 0) return base;
784
+ return base.filter((row) => {
785
+ const id = getRowId(row);
786
+ return id == null || !locallyDeletedIds.has(id);
787
+ });
788
+ // eslint-disable-next-line react-hooks/exhaustive-deps
789
+ }, [rows, sortState, onSort, locallyDeletedIds, onFilterChange, activeFilters, columns]);
790
+
791
+ const displayedTotalCount = React.useMemo(() => {
792
+ const hasAnyFilterValue = activeFilters.some((f) => filterValueIsSet(f));
793
+ if (hasAnyFilterValue) {
794
+ return sortedRows.length;
795
+ }
796
+ return totalCount != null ? totalCount : sortedRows.length;
797
+ }, [activeFilters, sortedRows.length, totalCount]);
798
+
799
+ const isInitialLoading = isLoading && sortedRows.length === 0;
800
+ const isLoadingMore = isLoading && sortedRows.length > 0;
801
+
802
+ const visibleRowIds = React.useMemo(
803
+ () =>
804
+ isInitialLoading
805
+ ? []
806
+ : sortedRows.map(getRowId).filter((id) => id != null),
807
+ // getRowId is stable enough via rowKey; sortedRows/isLoading drive updates
808
+ // eslint-disable-next-line react-hooks/exhaustive-deps
809
+ [sortedRows, isInitialLoading, rowKey],
810
+ );
811
+
812
+ const rowCanDelete = useCallback(
813
+ (row) => (typeof isRowDeletable === "function" ? isRowDeletable(row) : true),
814
+ [isRowDeletable],
815
+ );
816
+
817
+ const selectableVisibleRowIds = visibleRowIds;
818
+
819
+ const selectedCount = selectedRowIds.size;
820
+ const hasSelection = selectedCount > 0;
821
+ const allVisibleSelected =
822
+ selectableVisibleRowIds.length > 0 &&
823
+ selectableVisibleRowIds.every((id) => selectedRowIds.has(id));
824
+ const someVisibleSelected = selectableVisibleRowIds.some((id) =>
825
+ selectedRowIds.has(id),
826
+ );
827
+
828
+ useEffect(() => {
829
+ const el = selectAllRef.current;
830
+ if (!el) return;
831
+ el.indeterminate = someVisibleSelected && !allVisibleSelected;
832
+ }, [someVisibleSelected, allVisibleSelected]);
833
+
834
+ const toggleRowSelection = (rowId, e) => {
835
+ e?.stopPropagation();
836
+ if (!rowId) return;
837
+ setSelectedRowIds((prev) => {
838
+ const next = new Set(prev);
839
+ if (next.has(rowId)) next.delete(rowId);
840
+ else next.add(rowId);
841
+ return next;
842
+ });
843
+ };
844
+
845
+ const toggleSelectAllVisible = (e) => {
846
+ e?.stopPropagation();
847
+ setSelectedRowIds((prev) => {
848
+ const next = new Set(prev);
849
+ if (allVisibleSelected || someVisibleSelected) {
850
+ selectableVisibleRowIds.forEach((id) => next.delete(id));
851
+ } else {
852
+ selectableVisibleRowIds.forEach((id) => next.add(id));
853
+ }
854
+ return next;
855
+ });
856
+ };
857
+
858
+ const toggleGroupSelection = (rowIds, e) => {
859
+ e?.stopPropagation();
860
+ const ids = rowIds.map((id) => String(id)).filter(Boolean);
861
+ if (!ids.length) return;
862
+ const allInGroupSelected = ids.every((id) => selectedRowIds.has(id));
863
+ setSelectedRowIds((prev) => {
864
+ const next = new Set(prev);
865
+ if (allInGroupSelected) {
866
+ ids.forEach((id) => next.delete(id));
867
+ } else {
868
+ ids.forEach((id) => next.add(id));
869
+ }
870
+ return next;
871
+ });
872
+ };
873
+
874
+ const clearRowSelection = () => setSelectedRowIds(new Set());
875
+
876
+ useEffect(() => {
877
+ if (!enableBulkSelection) setSelectedRowIds(new Set());
878
+ }, [enableBulkSelection]);
879
+
880
+ const isRowSelected = (rowId) => rowId != null && selectedRowIds.has(rowId);
881
+
882
+ // Close dropdowns/popups on outside click
883
+ useEffect(() => {
884
+ const handleClickOutside = (e) => {
885
+ if (filterDropdownRef.current && !filterDropdownRef.current.contains(e.target)) {
886
+ setShowFilterDropdown(false);
887
+ setFilterSearch("");
888
+ }
889
+ if (
890
+ replacingFilterId &&
891
+ !e.target.closest(".mdt-chip-replace-dropdown") &&
892
+ !e.target.closest(".mdt-chip-label-btn")
893
+ ) {
894
+ setReplacingFilterId(null);
895
+ setReplaceSearch("");
896
+ }
897
+ if (
898
+ valueEditId &&
899
+ !e.target.closest(".mdt-value-popup") &&
900
+ !e.target.closest(".mdt-chip-value") &&
901
+ !e.target.closest(".mdt-chip-value-wrap") &&
902
+ !e.target.closest(".mdt-filter-op-dropdown") &&
903
+ !e.target.closest(".mdt-chip-op-btn")
904
+ ) {
905
+ setValueEditId(null);
906
+ setValueInputText("");
907
+ setValueSelectSearch("");
908
+ }
909
+ if (
910
+ showInnerFilterDropdown &&
911
+ !e.target.closest(".mdt-filter-button-wrap")
912
+ ) {
913
+ setShowInnerFilterDropdown(false);
914
+ setInnerFilterSearch("");
915
+ }
916
+
917
+ if (displayPopupRef.current && !displayPopupRef.current.contains(e.target)) {
918
+ setShowDisplayPopup(false);
919
+ }
920
+ if (
921
+ showHeaderBreadcrumbMenu &&
922
+ headerBreadcrumbMenuRef.current &&
923
+ !headerBreadcrumbMenuRef.current.contains(e.target)
924
+ ) {
925
+ setShowHeaderBreadcrumbMenu(false);
926
+ }
927
+ if (
928
+ openRowMenuKey &&
929
+ !e.target.closest(".mdt-row-menu-trigger") &&
930
+ !e.target.closest(".mdt-row-menu-popup")
931
+ ) {
932
+ setOpenRowMenuKey(null);
933
+ setOpenRowMenuRow(null);
934
+ setRowMenuPosition(null);
935
+ }
936
+ if (
937
+ openPopup &&
938
+ popupRef.current &&
939
+ !popupRef.current.contains(e.target) &&
940
+ !e.target.closest(".mdt-header-cell")
941
+ ) {
942
+ setOpenPopup(null);
943
+ }
944
+ if (
945
+ cellEdit &&
946
+ cellEditPopupRef.current &&
947
+ !cellEditPopupRef.current.contains(e.target) &&
948
+ cellEditAnchorRef.current &&
949
+ !cellEditAnchorRef.current.contains(e.target)
950
+ ) {
951
+ setCellEdit(null);
952
+ setCellEditDraftValue(null);
953
+ setCellEditPopupPos(null);
954
+ }
955
+ if (
956
+ bulkEdit &&
957
+ bulkEditPopupRef.current &&
958
+ !bulkEditPopupRef.current.contains(e.target) &&
959
+ !e.target.closest(".mdt-bulk-field-btn")
960
+ ) {
961
+ setBulkEdit(null);
962
+ setBulkEditPopupPos(null);
963
+ setBulkEditDraftValue(null);
964
+ }
965
+ };
966
+ if (
967
+ openPopup ||
968
+ showHeaderBreadcrumbMenu ||
969
+ showFilterDropdown ||
970
+ showInnerFilterDropdown ||
971
+ !!replacingFilterId ||
972
+ showDisplayPopup ||
973
+ !!valueEditId ||
974
+ openRowMenuKey ||
975
+ cellEdit ||
976
+ bulkEdit
977
+ ) {
978
+ document.addEventListener("mousedown", handleClickOutside, true);
979
+ }
980
+ return () => document.removeEventListener("mousedown", handleClickOutside, true);
981
+ }, [openPopup, showHeaderBreadcrumbMenu, showFilterDropdown, showInnerFilterDropdown, showDisplayPopup, replacingFilterId, valueEditId, openRowMenuKey, cellEdit, bulkEdit]);
982
+
983
+ const closeFilterChipPopups = useCallback(() => {
984
+ setValueEditId(null);
985
+ setValueInputText("");
986
+ setValueSelectSearch("");
987
+ setReplacingFilterId(null);
988
+ setReplaceSearch("");
989
+ setShowInnerFilterDropdown(false);
990
+ setInnerFilterSearch("");
991
+ }, []);
992
+
993
+ const applySavedView = useCallback(
994
+ (view, { notify = true } = {}) => {
995
+ closeFilterChipPopups();
996
+
997
+ if (!view) {
998
+ setActiveSavedViewId(null);
999
+ setActiveFilters([], { notify });
1000
+ setIsFilterBarOpen(false);
1001
+ setHiddenColumns([]);
1002
+ setGroupByKey(null);
1003
+ setOrderFromDisplay(null, null, { notifyParent: notify });
1004
+ return;
1005
+ }
1006
+
1007
+ setActiveSavedViewId(view.id);
1008
+ const nextFilters = (view.filters || []).map((f, index) => ({
1009
+ ...f,
1010
+ id: f.id || `${f.key}-${view.id}-${index}`,
1011
+ }));
1012
+ // Update filter state without emitting (emitFilterChangeImmediate below handles notification)
1013
+ setActiveFilters(nextFilters);
1014
+ setIsFilterBarOpen(nextFilters.length > 0);
1015
+ if (notify) {
1016
+ emitFilterChangeImmediate(nextFilters, {
1017
+ sort: view.sort ?? null,
1018
+ groupByKey: view.groupByKey ?? null,
1019
+ viewMode: view.viewMode === "list" ? "list" : "table",
1020
+ savedViewId: view.id,
1021
+ });
1022
+ }
1023
+ setHiddenColumns(
1024
+ (view.hiddenColumns || []).filter((k) => !lockedColumnSet.has(k)),
1025
+ );
1026
+ setGroupByKey(view.groupByKey ?? null);
1027
+ if (view.sort?.column) {
1028
+ setOrderFromDisplay(view.sort.column, view.sort.direction || "asc", {
1029
+ notifyParent: false,
1030
+ });
1031
+ } else {
1032
+ setOrderFromDisplay(null, null, { notifyParent: false });
1033
+ }
1034
+ },
1035
+ [closeFilterChipPopups, setOrderFromDisplay, emitFilterChangeImmediate, lockedColumnSet],
1036
+ );
1037
+
1038
+ const resolvedActiveTabId = activeSavedViewId
1039
+ ? `view-${activeSavedViewId}`
1040
+ : activeTab;
1041
+
1042
+ const mergedTabs = React.useMemo(() => {
1043
+ const defaultTabOrder = new Map([
1044
+ ["active", 0],
1045
+ ["draft", 1],
1046
+ ["closed", 2],
1047
+ ["all", 3],
1048
+ ]);
1049
+
1050
+ const nextTabs = [...tabs, ...savedViews.map((view) => ({
1051
+ id: `view-${view.id}`,
1052
+ label: view.name,
1053
+ count: view.count,
1054
+ isSavedView: true,
1055
+ isSystem: Boolean(view.isSystem),
1056
+ createdBy: view.createdBy ?? null,
1057
+ savedViewId: view.id,
1058
+ }))];
1059
+
1060
+ const deduped = [];
1061
+ const seen = new Set();
1062
+ nextTabs.forEach((tab) => {
1063
+ const key = String(tab?.label ?? tab?.id ?? "").trim().toLowerCase();
1064
+ if (!key) return;
1065
+ const existingIndex = deduped.findIndex(
1066
+ (item) => String(item?.label ?? item?.id ?? "").trim().toLowerCase() === key,
1067
+ );
1068
+ if (existingIndex >= 0) {
1069
+ const existing = deduped[existingIndex];
1070
+ if (tab.isSavedView && !existing.isSavedView) {
1071
+ deduped[existingIndex] = tab;
1072
+ }
1073
+ return;
1074
+ }
1075
+ if (seen.has(key)) return;
1076
+ seen.add(key);
1077
+ deduped.push(tab);
1078
+ });
1079
+
1080
+ const withActiveCount = deduped.map((tab) => {
1081
+ const isActive =
1082
+ isSameMdtTab(resolvedActiveTabId, tab.id) ||
1083
+ (activeSavedViewId != null && String(tab.savedViewId) === String(activeSavedViewId)) ||
1084
+ (activeSavedViewId == null && tab.isSavedView);
1085
+ if (isActive && displayedTotalCount != null) {
1086
+ return { ...tab, count: displayedTotalCount };
1087
+ }
1088
+ return tab;
1089
+ });
1090
+
1091
+ return withActiveCount.sort((a, b) => {
1092
+ const aKey = String(a?.label ?? a?.id ?? "").trim().toLowerCase();
1093
+ const bKey = String(b?.label ?? b?.id ?? "").trim().toLowerCase();
1094
+ const aIsDefault = defaultTabOrder.has(aKey);
1095
+ const bIsDefault = defaultTabOrder.has(bKey);
1096
+ if (aIsDefault && bIsDefault) {
1097
+ return defaultTabOrder.get(aKey) - defaultTabOrder.get(bKey);
1098
+ }
1099
+ if (aIsDefault) return -1;
1100
+ if (bIsDefault) return 1;
1101
+ if (a.isSavedView && !b.isSavedView) return -1;
1102
+ if (!a.isSavedView && b.isSavedView) return 1;
1103
+ return String(a.label ?? "").localeCompare(String(b.label ?? ""));
1104
+ });
1105
+ }, [tabs, savedViews, resolvedActiveTabId, activeSavedViewId, displayedTotalCount]);
1106
+
1107
+ // Auto-select the first saved view on initial page load when no active tab/view exists
1108
+ useEffect(() => {
1109
+ if (disableAutoSelectFirstView) return;
1110
+ if (savedViews.length === 0) return;
1111
+ if (initialAutoSelectDoneRef.current) return;
1112
+ if (activeSavedViewId) return;
1113
+ const tabExists = activeTab && mergedTabs.some((t) => t.id === activeTab);
1114
+ if (tabExists) return;
1115
+ initialAutoSelectDoneRef.current = true;
1116
+ const firstView = savedViews[0];
1117
+ if (firstView) {
1118
+ applySavedView(firstView, { notify: true });
1119
+ }
1120
+ }, [savedViews, activeSavedViewId, activeTab, mergedTabs, applySavedView, disableAutoSelectFirstView]);
1121
+
1122
+ useEffect(() => {
1123
+ setSelectedRowIds(new Set());
1124
+ }, [resolvedActiveTabId]);
1125
+
1126
+ useEffect(() => {
1127
+ if (tableWrapRef.current) {
1128
+ tableWrapRef.current.scrollTop = 0;
1129
+ }
1130
+ if (listWrapRef.current) {
1131
+ listWrapRef.current.scrollTop = 0;
1132
+ }
1133
+ }, [sortConfig?.column, sortConfig?.direction, resolvedActiveTabId]);
1134
+
1135
+ const listGroupHeadPrefix = React.useMemo(() => {
1136
+ if (!mergedTabs.length) return "All";
1137
+ const tab =
1138
+ mergedTabs.find((t) => isSameMdtTab(resolvedActiveTabId, t.id)) ??
1139
+ mergedTabs.find((t) => isSameMdtTab(t.id, "all")) ??
1140
+ mergedTabs[0];
1141
+ return tab?.label ?? "All";
1142
+ }, [mergedTabs, resolvedActiveTabId]);
1143
+
1144
+ const listGroupHeadCount = React.useMemo(() => {
1145
+ if (mergedTabs.length) {
1146
+ const tab =
1147
+ mergedTabs.find((t) => isSameMdtTab(resolvedActiveTabId, t.id)) ??
1148
+ mergedTabs.find((t) => isSameMdtTab(t.id, "all")) ??
1149
+ mergedTabs[0];
1150
+ if (tab?.isSavedView) {
1151
+ if (tab.count != null && tab.count !== "") return tab.count;
1152
+ return null;
1153
+ }
1154
+ if (tab?.count != null && tab.count !== "") {
1155
+ return tab.count;
1156
+ }
1157
+ }
1158
+ if (activeSavedViewId) return null;
1159
+ if (displayedTotalCount != null) return displayedTotalCount;
1160
+ return sortedRows.length;
1161
+ }, [
1162
+ mergedTabs,
1163
+ resolvedActiveTabId,
1164
+ activeSavedViewId,
1165
+ displayedTotalCount,
1166
+ sortedRows.length,
1167
+ ]);
1168
+
1169
+ const dismissCreateViewModal = useCallback(() => {
1170
+ clearModalFilterSnapshot();
1171
+ setEditingViewId(null);
1172
+ setShowCreateViewModal(false);
1173
+ closeFilterChipPopups();
1174
+ }, [closeFilterChipPopups]);
1175
+
1176
+ const handleTabClick = useCallback(
1177
+ (tab) => {
1178
+ if (showCreateViewModal) {
1179
+ dismissCreateViewModal();
1180
+ }
1181
+
1182
+ if (tab.isSavedView) {
1183
+ const view = savedViews.find((v) => v.id === tab.savedViewId);
1184
+ if (view) applySavedView(view, { notify: true });
1185
+ return;
1186
+ }
1187
+
1188
+ const leavingSavedView = Boolean(activeSavedViewId);
1189
+ if (leavingSavedView) {
1190
+ applySavedView(null, { notify: false });
1191
+ }
1192
+
1193
+ if (!leavingSavedView && isSameMdtTab(activeTab, tab.id)) {
1194
+ return;
1195
+ }
1196
+
1197
+ if (onTabChange) {
1198
+ onTabChange(tab.id, { clearFilters: leavingSavedView });
1199
+ }
1200
+ },
1201
+ [
1202
+ savedViews,
1203
+ applySavedView,
1204
+ onTabChange,
1205
+ showCreateViewModal,
1206
+ dismissCreateViewModal,
1207
+ activeSavedViewId,
1208
+ activeTab,
1209
+ ],
1210
+ );
1211
+
1212
+ const updateTableScrollState = useCallback(() => {
1213
+ const el = tableWrapRef.current;
1214
+ if (!el) return;
1215
+ setTableHasHScroll(el.scrollWidth > el.clientWidth + 1);
1216
+ }, []);
1217
+
1218
+ const rowMenuColClassName = tableHasHScroll
1219
+ ? "mdt-col-row-menu mdt-col-row-menu--scroll"
1220
+ : "mdt-col-row-menu";
1221
+
1222
+ const resolvedStickyColumnKey = tableStickyColumnKey ?? rowKey;
1223
+ const stickyLeftColClassName = tableHasHScroll
1224
+ ? "mdt-col-sticky-left mdt-col-sticky-left--scroll"
1225
+ : "mdt-col-sticky-left";
1226
+
1227
+ const isStickyLeftColumn = useCallback(
1228
+ (col) => col.accessorKey === resolvedStickyColumnKey,
1229
+ [resolvedStickyColumnKey],
1230
+ );
1231
+
1232
+ const getTableCellClassName = (col, colIndex, { header = false } = {}) => {
1233
+ const cellEditable = !header && isColumnCellEditable(col, onCellEdit);
1234
+ return (
1235
+ [
1236
+ "mdt-col-data",
1237
+ !header && col.valueClassName,
1238
+ enableBulkSelection && colIndex === 0 ? "mdt-td-first-with-select" : "",
1239
+ enableBulkSelection && header && colIndex === 0
1240
+ ? "mdt-th-first-with-select"
1241
+ : "",
1242
+ isStickyLeftColumn(col) ? stickyLeftColClassName : "",
1243
+ header && isStickyLeftColumn(col) ? "mdt-th-sticky-left" : "",
1244
+ !header && onCellEdit ? (cellEditable ? "mdt-td-editable" : "mdt-td-readonly") : "",
1245
+ ]
1246
+ .filter(Boolean)
1247
+ .join(" ") || undefined
1248
+ );
1249
+ };
1250
+
1251
+ // Auto-calculate skeleton rows + detect horizontal scroll for sticky menu column border
1252
+ useEffect(() => {
1253
+ const update = () => {
1254
+ const isList = viewMode === "list";
1255
+ const el = isList ? listWrapRef.current : tableWrapRef.current;
1256
+ const h = el?.clientHeight || 0;
1257
+ if (!h) return;
1258
+ const headerReserve = isList ? 48 : 44;
1259
+ const rowHeight = 48;
1260
+ const fit = Math.ceil(Math.max(h - headerReserve, 0) / rowHeight);
1261
+ setAutoSkeletonRows(Math.max(skeletonRows, fit));
1262
+ if (!isList) updateTableScrollState();
1263
+ };
1264
+ update();
1265
+ window.addEventListener("resize", update);
1266
+
1267
+ const el = viewMode === "list" ? listWrapRef.current : tableWrapRef.current;
1268
+ let ro;
1269
+ if (el) {
1270
+ ro = new ResizeObserver(update);
1271
+ ro.observe(el);
1272
+ }
1273
+
1274
+ return () => {
1275
+ window.removeEventListener("resize", update);
1276
+ ro?.disconnect();
1277
+ };
1278
+ }, [
1279
+ skeletonRows,
1280
+ showRowMenuColumn,
1281
+ updateTableScrollState,
1282
+ renderedColumns.length,
1283
+ rows.length,
1284
+ isLoading,
1285
+ viewMode,
1286
+ ]);
1287
+
1288
+ // Mobile / nested layouts: keep anchored popups inside the viewport
1289
+ useLayoutEffect(() => {
1290
+ const useAnchoredPopups = isMobilePopupLayout() || anchoredFilterPopups;
1291
+ if (!useAnchoredPopups) {
1292
+ clearMobilePopupTop();
1293
+ return undefined;
1294
+ }
1295
+
1296
+ let anchor = null;
1297
+ if (valueEditId && chipValueRef.current) {
1298
+ anchor = chipValueRef.current;
1299
+ } else if (replacingFilterId && chipReplaceRef.current) {
1300
+ anchor = chipReplaceRef.current;
1301
+ } else if (showInnerFilterDropdown && innerFilterRef.current) {
1302
+ anchor = innerFilterRef.current;
1303
+ } else if (showFilterDropdown && filterDropdownRef.current) {
1304
+ anchor = filterDropdownRef.current;
1305
+ } else if (showDisplayPopup && displayPopupRef.current) {
1306
+ anchor = displayPopupRef.current;
1307
+ }
1308
+
1309
+ const sync = () => {
1310
+ if (anchor) {
1311
+ syncAnchoredPopupTop(anchor, { fullWidth: isMobilePopupLayout() });
1312
+ } else {
1313
+ clearMobilePopupTop();
1314
+ }
1315
+ };
1316
+ sync();
1317
+
1318
+ const scrollTargets = [];
1319
+ if (anchor) {
1320
+ let node = anchor.parentElement;
1321
+ while (node && node !== document.body) {
1322
+ const { overflowY, overflow } = getComputedStyle(node);
1323
+ if (
1324
+ overflowY === "auto" ||
1325
+ overflowY === "scroll" ||
1326
+ overflow === "auto" ||
1327
+ overflow === "scroll"
1328
+ ) {
1329
+ scrollTargets.push(node);
1330
+ }
1331
+ node = node.parentElement;
1332
+ }
1333
+ }
1334
+
1335
+ window.addEventListener("resize", sync);
1336
+ scrollTargets.forEach((el) => el.addEventListener("scroll", sync, { passive: true }));
1337
+ return () => {
1338
+ window.removeEventListener("resize", sync);
1339
+ scrollTargets.forEach((el) => el.removeEventListener("scroll", sync));
1340
+ clearMobilePopupTop();
1341
+ };
1342
+ }, [
1343
+ anchoredFilterPopups,
1344
+ valueEditId,
1345
+ replacingFilterId,
1346
+ showInnerFilterDropdown,
1347
+ showFilterDropdown,
1348
+ showDisplayPopup,
1349
+ ]);
1350
+
1351
+ // ── Filter helpers ──────────────────────────────────────────────────────────
1352
+
1353
+ const addFilter = (col) => {
1354
+ const isDuplicate = activeFilters.find((f) => f.key === col.accessorKey);
1355
+ const newId = `${col.accessorKey}-${Date.now()}`;
1356
+ if (!isDuplicate) {
1357
+ setActiveFiltersState((prev) => [
1358
+ ...prev,
1359
+ {
1360
+ id: newId,
1361
+ key: col.accessorKey,
1362
+ label: col.header,
1363
+ icon: col.headerIcon || col.rowIcon || null,
1364
+ operator: isDateLikeFilterColumn(col)
1365
+ ? DEFAULT_DATE_FILTER_OPERATOR
1366
+ : undefined,
1367
+ },
1368
+ ]);
1369
+ setValueEditId(newId);
1370
+ setValueInputText("");
1371
+ setValueSelectSearch("");
1372
+ } else {
1373
+ setValueEditId(isDuplicate.id);
1374
+ setValueInputText(isDuplicate.value || "");
1375
+ setValueSelectSearch("");
1376
+ }
1377
+ setIsFilterBarOpen(true);
1378
+ setShowFilterDropdown(false);
1379
+ setShowInnerFilterDropdown(false);
1380
+ setFilterSearch("");
1381
+ };
1382
+
1383
+ const removeFilter = (filterId) => {
1384
+ const updated = activeFilters.filter((f) => f.id !== filterId);
1385
+ setActiveFilters(updated, { notify: true });
1386
+ if (updated.length === 0) setIsFilterBarOpen(false);
1387
+ };
1388
+
1389
+ const clearAllFilters = () => {
1390
+ setActiveSavedViewId(null);
1391
+ setActiveFilters([], { notify: true });
1392
+ setIsFilterBarOpen(false);
1393
+ if (onTabChange) {
1394
+ onTabChange("all", { clearFilters: true });
1395
+ }
1396
+ };
1397
+
1398
+ const onReplaceFilterKey = (filterId, col) => {
1399
+ setActiveFiltersState((prev) =>
1400
+ prev.map((f) =>
1401
+ f.id === filterId
1402
+ ? {
1403
+ ...f,
1404
+ key: col.accessorKey,
1405
+ label: col.header,
1406
+ icon: col.headerIcon || col.rowIcon || null,
1407
+ value: null,
1408
+ operator: isDateLikeFilterColumn(col)
1409
+ ? DEFAULT_DATE_FILTER_OPERATOR
1410
+ : undefined,
1411
+ }
1412
+ : f,
1413
+ ),
1414
+ );
1415
+ };
1416
+
1417
+ const closeFilterBarPopups = () => {
1418
+ setShowFilterDropdown(false);
1419
+ setShowInnerFilterDropdown(false);
1420
+ setOpenPopup(null);
1421
+ };
1422
+
1423
+ const closeCellEdit = useCallback(() => {
1424
+ setCellEdit(null);
1425
+ setCellEditPopupPos(null);
1426
+ setCellEditDraftValue(null);
1427
+ }, []);
1428
+
1429
+ const closeBulkEdit = useCallback(() => {
1430
+ bulkEditAnchorRef.current = null;
1431
+ setBulkEdit(null);
1432
+ setBulkEditPopupPos(null);
1433
+ setBulkEditDraftValue(null);
1434
+ }, []);
1435
+
1436
+ const getSelectedRows = useCallback(
1437
+ () =>
1438
+ sortedRows.filter((row) => {
1439
+ const id = getRowId(row);
1440
+ return id != null && selectedRowIds.has(String(id));
1441
+ }),
1442
+ [sortedRows, selectedRowIds, rowKey],
1443
+ );
1444
+
1445
+ const getDeletableSelectedRows = useCallback(
1446
+ () => getSelectedRows().filter((row) => rowCanDelete(row)),
1447
+ [getSelectedRows, rowCanDelete],
1448
+ );
1449
+
1450
+ const deletableSelectedCount = React.useMemo(
1451
+ () => getDeletableSelectedRows().length,
1452
+ [getDeletableSelectedRows],
1453
+ );
1454
+
1455
+ const skippedDeleteCount = Math.max(0, selectedCount - deletableSelectedCount);
1456
+
1457
+ const buildDefaultBulkDeleteMessage = useCallback(() => {
1458
+ if (deletableSelectedCount === 0) {
1459
+ return "None of the selected items can be deleted.";
1460
+ }
1461
+ if (skippedDeleteCount === 0) {
1462
+ return `Are you sure you want to delete ${deletableSelectedCount} selected item(s)? This action cannot be undone.`;
1463
+ }
1464
+ return `Delete ${deletableSelectedCount} of ${selectedCount} selected item(s)? ${skippedDeleteCount} item(s) are in use and will be skipped. This action cannot be undone.`;
1465
+ }, [deletableSelectedCount, skippedDeleteCount, selectedCount]);
1466
+
1467
+ const bulkDeleteCounts = React.useMemo(
1468
+ () => ({
1469
+ deletableCount: deletableSelectedCount,
1470
+ skippedCount: skippedDeleteCount,
1471
+ deletableRows: getDeletableSelectedRows(),
1472
+ }),
1473
+ [deletableSelectedCount, skippedDeleteCount, getDeletableSelectedRows],
1474
+ );
1475
+
1476
+ useLayoutEffect(() => {
1477
+ if (!cellEdit || !cellEditPopupRef.current) return;
1478
+ const popupRect = cellEditPopupRef.current.getBoundingClientRect();
1479
+ const next = refineCellEditPopupPosition(cellEdit.anchorRect, popupRect);
1480
+ if (next) {
1481
+ const measuredW = Math.ceil(popupRect.width);
1482
+ setCellEditPopupPos((prev) => ({
1483
+ top: next.top,
1484
+ left: next.left,
1485
+ width: Math.min(320, Math.max(measuredW, prev?.width ?? cellEdit.width)),
1486
+ }));
1487
+ }
1488
+ }, [cellEdit, cellEditDraftValue]);
1489
+
1490
+ useLayoutEffect(() => {
1491
+ if (!bulkEdit || !bulkEditPopupRef.current) return;
1492
+ const popupRect = bulkEditPopupRef.current.getBoundingClientRect();
1493
+ const next = refineCellEditPopupPosition(bulkEdit.anchorRect, popupRect);
1494
+ if (next) {
1495
+ const measuredW = Math.ceil(popupRect.width);
1496
+ setBulkEditPopupPos((prev) => ({
1497
+ top: next.top,
1498
+ left: next.left,
1499
+ width: Math.min(320, Math.max(measuredW, prev?.width ?? bulkEdit.width)),
1500
+ }));
1501
+ }
1502
+ }, [bulkEdit, bulkEditDraftValue]);
1503
+
1504
+ const openBulkEditPopup = useCallback(
1505
+ (field, e) => {
1506
+ if (!onBulkEdit || !field?.column) return;
1507
+ const anchorEl = e?.currentTarget;
1508
+ if (!anchorEl) return;
1509
+
1510
+ closeCellEdit();
1511
+ closeFilterChipPopups();
1512
+ closeRowMenu();
1513
+
1514
+ bulkEditAnchorRef.current = anchorEl;
1515
+ const anchor = anchorEl.getBoundingClientRect();
1516
+ const anchorRect = {
1517
+ top: anchor.top,
1518
+ left: anchor.left,
1519
+ right: anchor.right,
1520
+ bottom: anchor.bottom,
1521
+ width: anchor.width,
1522
+ height: anchor.height,
1523
+ };
1524
+ const position = computeCellEditPopupPosition(anchorRect, field.column, EDIT_MODE.BULK);
1525
+ setBulkEdit({
1526
+ fieldId: field.id,
1527
+ field,
1528
+ column: field.column,
1529
+ anchorRect,
1530
+ width: position.width,
1531
+ });
1532
+ setBulkEditPopupPos(position);
1533
+ setBulkEditDraftValue(null);
1534
+ },
1535
+ [onBulkEdit, closeCellEdit, closeFilterChipPopups],
1536
+ );
1537
+
1538
+ const handleBulkFieldClick = useCallback(
1539
+ (fieldId, field, e) => {
1540
+ if (onBulkBarFieldClick) {
1541
+ onBulkBarFieldClick(fieldId, field, e);
1542
+ return;
1543
+ }
1544
+ openBulkEditPopup(field, e);
1545
+ },
1546
+ [onBulkBarFieldClick, openBulkEditPopup],
1547
+ );
1548
+
1549
+ const handleBulkEditApply = useCallback(
1550
+ (nextValue) => {
1551
+ if (!bulkEdit || !onBulkEdit) return;
1552
+ const selectedRows = getSelectedRows();
1553
+ if (selectedRows.length === 0) {
1554
+ closeBulkEdit();
1555
+ return;
1556
+ }
1557
+ const payload = buildBulkEditPayload({
1558
+ column: bulkEdit.column,
1559
+ columnKey: bulkEdit.fieldId,
1560
+ value: nextValue,
1561
+ rows: selectedRows,
1562
+ getRowId,
1563
+ });
1564
+ onBulkEdit(payload);
1565
+ closeBulkEdit();
1566
+ },
1567
+ [bulkEdit, onBulkEdit, getSelectedRows, closeBulkEdit],
1568
+ );
1569
+
1570
+ const handleCellClick = (e, column, row) => {
1571
+ if (!isColumnCellEditable(column, onCellEdit)) return;
1572
+ e.stopPropagation();
1573
+ onCellEditStart?.(column, row);
1574
+ const anchor =
1575
+ e.currentTarget.closest(".mdt-cell-editable-wrap, .mdt-list-chip-edit-anchor")
1576
+ ?.getBoundingClientRect()
1577
+ ?? e.currentTarget.getBoundingClientRect();
1578
+ const anchorRect = {
1579
+ top: anchor.top,
1580
+ left: anchor.left,
1581
+ right: anchor.right,
1582
+ bottom: anchor.bottom,
1583
+ width: anchor.width,
1584
+ height: anchor.height,
1585
+ };
1586
+ const position = computeCellEditPopupPosition(anchorRect, column, EDIT_MODE.INLINE);
1587
+ setCellEdit({
1588
+ rowId: getRowId(row),
1589
+ row,
1590
+ column,
1591
+ columnKey: column.accessorKey,
1592
+ anchorRect,
1593
+ width: position.width,
1594
+ });
1595
+ setCellEditPopupPos(position);
1596
+ setCellEditDraftValue(getRowFieldEditValue(column, row));
1597
+ closeBulkEdit();
1598
+ closeFilterChipPopups();
1599
+ setOpenPopup(null);
1600
+ closeRowMenu();
1601
+ };
1602
+
1603
+ const handleCellEditApply = (nextValue) => {
1604
+ if (!cellEdit || !onCellEdit) return;
1605
+ const col = liveCellEditColumn ?? cellEdit.column;
1606
+ const resolved = resolveEditColumnConfig(col, EDIT_MODE.INLINE);
1607
+ const normalized = normalizeEditApplyValue(col, nextValue, EDIT_MODE.INLINE);
1608
+ const isMulti = isColumnSelectMulti(resolved, EDIT_MODE.INLINE);
1609
+ const previous = getRowFieldEditValue(col, cellEdit.row, EDIT_MODE.INLINE);
1610
+ onCellEdit({
1611
+ action: "inlineUpdate",
1612
+ rowId: cellEdit.rowId,
1613
+ row: cellEdit.row,
1614
+ columnKey: col.accessorKey,
1615
+ key: col.accessorKey,
1616
+ column: col,
1617
+ variant: getColumnFieldVariant(resolved, EDIT_MODE.INLINE),
1618
+ multi: isMulti,
1619
+ value: normalized,
1620
+ previousValue: previous,
1621
+ previousValueKey:
1622
+ col.editValueKey != null ? cellEdit.row[col.editValueKey] : undefined,
1623
+ });
1624
+
1625
+ if (isMulti) {
1626
+ return;
1627
+ }
1628
+ closeCellEdit();
1629
+ };
1630
+
1631
+ const hasAnyFilterValue = activeFilters.some((f) => filterValueIsSet(f));
1632
+
1633
+ const availableForDropdown = (search) => {
1634
+ const q = search.toLowerCase();
1635
+ return filterableColumns.filter((col) => {
1636
+ if (!col.header.toLowerCase().includes(q)) return false;
1637
+ return !activeFilters.find((f) => f.key === col.accessorKey);
1638
+ });
1639
+ };
1640
+
1641
+ const filterBarProps = {
1642
+ filterConfig: resolvedFilterConfig,
1643
+ filterableColumns,
1644
+ activeFilters,
1645
+ replacingFilterId,
1646
+ setReplacingFilterId,
1647
+ replaceSearch,
1648
+ setReplaceSearch,
1649
+ valueEditId,
1650
+ setValueEditId,
1651
+ valueInputText,
1652
+ setValueInputText,
1653
+ valueSelectSearch,
1654
+ setValueSelectSearch,
1655
+ showInnerFilterDropdown,
1656
+ setShowInnerFilterDropdown,
1657
+ innerFilterSearch,
1658
+ setInnerFilterSearch,
1659
+ calYear,
1660
+ calMonth,
1661
+ setCalYear,
1662
+ setCalMonth,
1663
+ today,
1664
+ chipReplaceRef,
1665
+ chipValueRef,
1666
+ innerFilterRef,
1667
+ onRemoveFilter: removeFilter,
1668
+ onClearAll: clearAllFilters,
1669
+ onAddFilter: addFilter,
1670
+ onUpdateFilterValue: updateFilterValue,
1671
+ onUpdateFilterOperator: updateFilterOperator,
1672
+ onReplaceFilterKey,
1673
+ availableForDropdown,
1674
+ onCloseOtherPopups: closeFilterBarPopups,
1675
+ onCloseColumnSortPopup: () => setOpenPopup(null),
1676
+ hasAnyFilterValue,
1677
+ };
1678
+
1679
+ const displayPopupProps = {
1680
+ visibleColumns,
1681
+ displayPropertyColumns,
1682
+ hiddenColumns,
1683
+ onToggleColumn: toggleColumnVisibility,
1684
+ groupByColumns,
1685
+ groupByKey,
1686
+ onGroupByChange: setGroupByKey,
1687
+ sortState,
1688
+ orderDirEffective,
1689
+ onOrderFromDisplay: setOrderFromDisplay,
1690
+ displayPropsOpen,
1691
+ onToggleDisplayPropsOpen: () => setDisplayPropsOpen((v) => !v),
1692
+ orderByOpen,
1693
+ onToggleOrderByOpen: () => setOrderByOpen((v) => !v),
1694
+ groupByOpen,
1695
+ onToggleGroupByOpen: () => setGroupByOpen((v) => !v),
1696
+ };
1697
+
1698
+ const filtersWithValues = activeFilters
1699
+ .filter((f) => filterValueIsSet(f))
1700
+ .map(({ id, key, label, icon, value, operator }) => ({
1701
+ id,
1702
+ key,
1703
+ label,
1704
+ icon,
1705
+ value,
1706
+ operator,
1707
+ }));
1708
+
1709
+ const serializeSavedViewFilterValue = (value) => {
1710
+ if (Array.isArray(value)) {
1711
+ return value.map((v) => String(v)).join(",");
1712
+ }
1713
+ if (value == null) return "";
1714
+ return String(value);
1715
+ };
1716
+
1717
+ const editingView = React.useMemo(
1718
+ () => savedViews.find((v) => v.id === editingViewId) ?? null,
1719
+ [savedViews, editingViewId],
1720
+ );
1721
+
1722
+ const buildViewPayload = (payload) => {
1723
+ const savedFilterSnapshot = activeFilters
1724
+ .map((f) => {
1725
+ const withValue = (payload.filters || filtersWithValues).find(
1726
+ (v) => v.key === f.key || v.id === f.id,
1727
+ );
1728
+ return {
1729
+ key: f.key,
1730
+ label: f.label,
1731
+ icon: f.icon ?? null,
1732
+ value: withValue?.value ?? f.value,
1733
+ operator: f.operator ?? withValue?.operator,
1734
+ };
1735
+ })
1736
+ .filter((f) => filterValueIsSet(f.value));
1737
+
1738
+ const dedupedFilters = savedFilterSnapshot.reduce((acc, filter) => {
1739
+ const existing = acc.find((item) => item.key === filter.key);
1740
+ if (!existing) {
1741
+ acc.push(filter);
1742
+ return acc;
1743
+ }
1744
+
1745
+ const existingValues = Array.isArray(existing.value)
1746
+ ? existing.value
1747
+ : existing.value != null && existing.value !== ""
1748
+ ? [existing.value]
1749
+ : [];
1750
+ const incomingValues = Array.isArray(filter.value)
1751
+ ? filter.value
1752
+ : filter.value != null && filter.value !== ""
1753
+ ? [filter.value]
1754
+ : [];
1755
+ const mergedValues = Array.from(
1756
+ new Set([...existingValues, ...incomingValues].map((v) => String(v).trim()).filter(Boolean)),
1757
+ );
1758
+ existing.value = mergedValues.length > 1 ? mergedValues : (mergedValues[0] ?? "");
1759
+ existing.label = filter.label ?? existing.label;
1760
+ existing.icon = filter.icon ?? existing.icon;
1761
+ existing.operator = filter.operator ?? existing.operator;
1762
+ return acc;
1763
+ }, []);
1764
+
1765
+ return {
1766
+ pageKey: savedViewPageKey,
1767
+ name: payload.name?.trim() || "Untitled",
1768
+ description: payload.description?.trim() || "",
1769
+ viewMode: payload.viewMode === "list" ? "list" : "table",
1770
+ filters: dedupedFilters.map((f) => ({
1771
+ id: f.key ?? f.id,
1772
+ value: serializeSavedViewFilterValue(f.value),
1773
+ })),
1774
+ hiddenColumns: hiddenColumns.filter((k) => !lockedColumnSet.has(k)),
1775
+ groupByKey,
1776
+ sort: sortState.column
1777
+ ? { column: sortState.column, direction: sortState.direction }
1778
+ : null,
1779
+ isPublic: Boolean(payload.isPublic),
1780
+ };
1781
+ };
1782
+
1783
+ const handleOpenCreateViewModal = () => {
1784
+ captureModalFilterSnapshot();
1785
+ setEditingViewId(null);
1786
+ setIsFilterBarOpen(true);
1787
+ setShowDisplayPopup(false);
1788
+ setShowCreateViewModal(true);
1789
+ };
1790
+
1791
+ const handleEditSavedView = (viewId) => {
1792
+ const view = savedViews.find((v) => v.id === viewId);
1793
+ if (!view) return;
1794
+ applySavedView(view, { notify: false });
1795
+ captureModalFilterSnapshot();
1796
+ setEditingViewId(viewId);
1797
+ setShowDisplayPopup(false);
1798
+ setShowCreateViewModal(true);
1799
+ };
1800
+
1801
+ const handleCloseCreateViewModal = () => {
1802
+ restoreModalFilterSnapshot();
1803
+ closeFilterChipPopups();
1804
+ setEditingViewId(null);
1805
+ setShowCreateViewModal(false);
1806
+ };
1807
+
1808
+ const handleDeleteSavedView = useCallback(async () => {
1809
+ if (!editingViewId) return;
1810
+ try {
1811
+ await apiService.DELETE(API.DELETE_SAVED_VIEW(editingViewId));
1812
+ const deletingActive = activeSavedViewId === editingViewId;
1813
+ setSavedViews((prev) => prev.filter((v) => v.id !== editingViewId));
1814
+ clearModalFilterSnapshot();
1815
+ setEditingViewId(null);
1816
+ closeFilterChipPopups();
1817
+ setShowCreateViewModal(false);
1818
+ if (deletingActive) {
1819
+ applySavedView(null, { notify: false });
1820
+ const fallbackTabId = activeTab || tabs[0]?.id;
1821
+ if (fallbackTabId && onTabChange) {
1822
+ onTabChange(fallbackTabId, { clearFilters: true });
1823
+ }
1824
+ }
1825
+ } catch (error) {
1826
+ console.error("Failed to delete saved view", error);
1827
+ throw error;
1828
+ }
1829
+ }, [
1830
+ editingViewId,
1831
+ activeSavedViewId,
1832
+ activeTab,
1833
+ tabs,
1834
+ applySavedView,
1835
+ onTabChange,
1836
+ closeFilterChipPopups,
1837
+ ]);
1838
+
1839
+ const handleSaveView = async (payload) => {
1840
+ const viewPayload = buildViewPayload(payload);
1841
+ try {
1842
+ const response = editingViewId
1843
+ ? await apiService.PUT(API.PUT_SAVED_VIEW(editingViewId), viewPayload)
1844
+ : await apiService.POST(API.POST_SAVED_VIEW, viewPayload);
1845
+ const nextView = {
1846
+ ...normalizeSavedView(response?.data ?? response),
1847
+ count: null,
1848
+ };
1849
+ setSavedViews((prev) =>
1850
+ editingViewId
1851
+ ? prev.map((v) => (v.id === editingViewId ? nextView : v))
1852
+ : [...prev, nextView],
1853
+ );
1854
+ setActiveSavedViewId(nextView.id);
1855
+ applySavedView(nextView, { notify: true });
1856
+ clearModalFilterSnapshot();
1857
+ setEditingViewId(null);
1858
+ closeFilterChipPopups();
1859
+ setShowCreateViewModal(false);
1860
+ applySavedViewCounts();
1861
+ if (onCreateView) onCreateView(nextView);
1862
+ else if (onSaveFiltersAs && !editingViewId) {
1863
+ onSaveFiltersAs(nextView.filters, nextView.name);
1864
+ }
1865
+ } catch (error) {
1866
+ console.error("Failed to save view", error);
1867
+ throw error;
1868
+ }
1869
+ };
1870
+
1871
+ // ── Sort helpers ─────────────────────────────────────────────────────────────
1872
+
1873
+ const handleHeaderClick = (accessorKey) => {
1874
+ setShowFilterDropdown(false);
1875
+ setShowDisplayPopup(false);
1876
+ setShowInnerFilterDropdown(false);
1877
+ setOpenPopup((prev) => (prev === accessorKey ? null : accessorKey));
1878
+ };
1879
+
1880
+ const handleSort = (dir) => { setSortState({ column: openPopup, direction: dir }); setOpenPopup(null); };
1881
+ const handleClearSort = () => { setSortState({ column: null, direction: null }); setOpenPopup(null); };
1882
+
1883
+ const renderSortPopupContent = (column) => {
1884
+ const isDate =
1885
+ getColumnFieldVariant(column, EDIT_MODE.FILTER) === "date" ||
1886
+ column.headerIcon === "calendar" ||
1887
+ column.rowIcon === "calendar";
1888
+ return (
1889
+ <>
1890
+ <button type="button" className="mdt-sort-option" onClick={() => handleSort("asc")}>
1891
+ <ArrowUpNarrowWide size={12} className="mdt-sort-option-icon" />
1892
+ <span>{isDate ? "New → Old" : "A → Z"}</span>
1893
+ {sortState.column === column.accessorKey && sortState.direction === "asc" && (
1894
+ <Check size={11} className="mdt-sort-check" />
1895
+ )}
1896
+ </button>
1897
+ <button type="button" className="mdt-sort-option" onClick={() => handleSort("desc")}>
1898
+ <ArrowDownWideNarrow size={12} className="mdt-sort-option-icon" />
1899
+ <span>{isDate ? "Old → New" : "Z → A"}</span>
1900
+ {sortState.column === column.accessorKey && sortState.direction === "desc" && (
1901
+ <Check size={11} className="mdt-sort-check" />
1902
+ )}
1903
+ </button>
1904
+ {sortState.column === column.accessorKey && (
1905
+ <button type="button" className="mdt-sort-option mdt-sort-clear" onClick={handleClearSort}>
1906
+ <Eraser size={12} className="mdt-sort-option-icon" />
1907
+ <span>Clear sorting</span>
1908
+ </button>
1909
+ )}
1910
+ </>
1911
+ );
1912
+ };
1913
+
1914
+ const SORT_COLOR = "oklch(0.4377 0.0066 230.87)";
1915
+ const getSortIcon = (accessorKey) => {
1916
+ const isSorted = sortState.column === accessorKey;
1917
+ if (!isSorted) return <ChevronDown className="mdt-header-chevron" size={14} />;
1918
+ const SortIcon = sortState.direction === "asc" ? ArrowUpNarrowWide : ArrowDownWideNarrow;
1919
+ return (
1920
+ <span className="mdt-header-icons-sorted">
1921
+ <SortIcon size={14} style={{ color: SORT_COLOR }} />
1922
+ <ChevronDown size={14} style={{ color: SORT_COLOR }} />
1923
+ </span>
1924
+ );
1925
+ };
1926
+
1927
+ // ── Cell renderer ─────────────────────────────────────────────────────────────
1928
+
1929
+ const getCellTooltipText = (column, row) => getColumnTooltipText(column, row);
1930
+
1931
+ const getTableEmptyPlaceholder = (column, row) => {
1932
+ if (column?.emptyPlaceholder === false) return "-";
1933
+ if (typeof column?.emptyPlaceholder === "function") {
1934
+ return column.emptyPlaceholder({ column, row });
1935
+ }
1936
+ if (typeof column?.emptyPlaceholder === "string" && column.emptyPlaceholder.trim() !== "") {
1937
+ return column.emptyPlaceholder;
1938
+ }
1939
+
1940
+ const variant = getColumnFieldVariant(column, EDIT_MODE.INLINE);
1941
+ const header = String(column?.header || "Value").trim();
1942
+
1943
+ if (variant === "select" || variant === "boolean") {
1944
+ return column?.editable ? `Select ${header}` : header;
1945
+ }
1946
+
1947
+ return header || "Value";
1948
+ };
1949
+
1950
+ const wrapCellTruncate = (content, tooltipText, { skip = false } = {}) => {
1951
+ if (skip) return content;
1952
+ return (
1953
+ <MdtTruncateWithTooltip title={tooltipText || undefined}>
1954
+ {content}
1955
+ </MdtTruncateWithTooltip>
1956
+ );
1957
+ };
1958
+
1959
+ const renderCell = (column, row, { editable = false, onCellActivate } = {}) => {
1960
+ const value = row?.[column.accessorKey];
1961
+ const useMultiSummary = shouldUseMultiSelectSummary(column);
1962
+ const hasCustomCell = hasCustomColumnCell(column);
1963
+ const tooltipText = getCellTooltipText(column, row);
1964
+ const isEmptyValue =
1965
+ value == null ||
1966
+ value === "" ||
1967
+ value === "-" ||
1968
+ (Array.isArray(value) && value.length === 0);
1969
+ const rowIconName = getColumnRowIconName(column, {
1970
+ hasCustomCell,
1971
+ isMultiSummary: useMultiSummary,
1972
+ });
1973
+ const rowIcon = rowIconName
1974
+ ? renderColumnIcon(rowIconName, { className: "mdt-row-icon" })
1975
+ : null;
1976
+ let customCell;
1977
+ if (useMultiSummary) {
1978
+ customCell = <MdtMultiSelectCellDisplay row={row} column={column} />;
1979
+ } else if (typeof column.Cell === "function") {
1980
+ customCell = column.Cell({
1981
+ cell: { getValue: () => value },
1982
+ row: { original: row },
1983
+ });
1984
+ } else if (isEmptyValue) {
1985
+ customCell = getTableEmptyPlaceholder(column, row);
1986
+ } else if (hasStatusValueRenderer(column)) {
1987
+ customCell = renderStatusValueDisplay(value, column) ?? (value ?? "-");
1988
+ } else {
1989
+ customCell = value ?? "-";
1990
+ }
1991
+ let inner = useMultiSummary ? (
1992
+ customCell
1993
+ ) : !rowIcon ? (
1994
+ customCell
1995
+ ) : (
1996
+ <span className="mdt-cell-with-icon">
1997
+ {rowIcon}
1998
+ <span className="mdt-cell-with-icon-text">{customCell}</span>
1999
+ </span>
2000
+ );
2001
+ const skipTruncate =
2002
+ useMultiSummary
2003
+ || (hasCustomCell && column.accessorKey !== "title" && !column.cellTruncatable);
2004
+ inner = wrapCellTruncate(inner, tooltipText, { skip: skipTruncate });
2005
+ if (!editable || !onCellActivate) return inner;
2006
+ return (
2007
+ <button
2008
+ type="button"
2009
+ className="mdt-cell-editable-trigger"
2010
+ onClick={(e) => onCellActivate(e, column, row)}
2011
+ title={`Edit ${column.header}`}
2012
+ >
2013
+ {inner}
2014
+ </button>
2015
+ );
2016
+ };
2017
+
2018
+ // ── Column sizing ────────────────────────────────────────────────────────────
2019
+
2020
+ const toPx = (v) => {
2021
+ if (v === null || v === undefined || v === "") return null;
2022
+ if (typeof v === "number") return `${v}px`;
2023
+ const t = String(v).trim();
2024
+ if (!t) return null;
2025
+ return /^\d+(\.\d+)?$/.test(t) ? `${t}px` : t;
2026
+ };
2027
+
2028
+ const getColumnWidthPx = (col) => {
2029
+ const n = parseFloat(String(toPx(col.size)));
2030
+ return !isNaN(n) && n > 0 ? n : 120;
2031
+ };
2032
+
2033
+ /**
2034
+ * Data columns: exact min width from col.size (px).
2035
+ * Last data column is width:auto so it takes leftover table space (menu col is separate).
2036
+ */
2037
+ const getDataColumnWidthStyle = (col, colIndex) => {
2038
+ const px = getColumnWidthPx(col);
2039
+ const minW = `${px}px`;
2040
+ const isLastDataCol = colIndex === renderedColumns.length - 1;
2041
+
2042
+ if (isLastDataCol) {
2043
+ return { width: "auto", minWidth: minW };
2044
+ }
2045
+ return { width: minW, minWidth: minW, maxWidth: minW };
2046
+ };
2047
+
2048
+ const getColumnStyle = (col, colIndex) => getDataColumnWidthStyle(col, colIndex);
2049
+
2050
+ /** col element — data columns only */
2051
+ const getDataColWidth = (col, colIndex) => getDataColumnWidthStyle(col, colIndex);
2052
+
2053
+ const calcTableMinWidth = () => {
2054
+ let total = showRowMenuColumn ? ROW_MENU_COL_WIDTH : 0;
2055
+ renderedColumns.forEach((col) => {
2056
+ total += getColumnWidthPx(col);
2057
+ });
2058
+ return total > 0 ? `${total}px` : undefined;
2059
+ };
2060
+
2061
+ /** Hard-coded 48px — only on options column th/td (not data columns) */
2062
+ const OPTIONS_COL_STYLE = {
2063
+ width: "48px",
2064
+ minWidth: "48px",
2065
+ maxWidth: "48px",
2066
+ };
2067
+
2068
+ const closeRowMenu = () => {
2069
+ setOpenRowMenuKey(null);
2070
+ setOpenRowMenuRow(null);
2071
+ setRowMenuPosition(null);
2072
+ };
2073
+
2074
+ const handleOpenRowMenu = (rowId, row, position) => {
2075
+ setOpenRowMenuKey(rowId);
2076
+ setOpenRowMenuRow(row);
2077
+ setRowMenuPosition(position);
2078
+ };
2079
+
2080
+ const renderRowMenuCell = (row) => {
2081
+ const id = getRowId(row);
2082
+ if (!id) return null;
2083
+
2084
+ const isOpen = openRowMenuKey === id;
2085
+
2086
+ return (
2087
+ <div
2088
+ className={`mdt-row-menu-cell${isOpen ? " mdt-row-menu-cell-open" : ""}`}
2089
+ onClick={(e) => e.stopPropagation()}
2090
+ onMouseDown={(e) => e.stopPropagation()}
2091
+ >
2092
+ <MdtRowActionsMenu
2093
+ variant="table"
2094
+ row={isOpen ? openRowMenuRow ?? row : row}
2095
+ rowId={id}
2096
+ isOpen={isOpen}
2097
+ menuPosition={isOpen ? rowMenuPosition : null}
2098
+ onOpen={handleOpenRowMenu}
2099
+ onClose={closeRowMenu}
2100
+ onRowEdit={onRowEdit}
2101
+ onRowDownload={onRowDownload}
2102
+ onRowDelete={onRowDelete}
2103
+ rowMenuActions={rowMenuActions}
2104
+ deleteConfirmConfig={deleteConfirmConfig}
2105
+ isDeletable={rowCanDelete(row)}
2106
+ />
2107
+ </div>
2108
+ );
2109
+ };
2110
+
2111
+ const showNewButton =
2112
+ resolvedHeaderActions.showNew &&
2113
+ onPrimaryAction &&
2114
+ (!hideHeader || toolbarInBreadcrumb);
2115
+
2116
+ const renderNewButton = (slotClass = "") =>
2117
+ showNewButton ? (
2118
+ <button
2119
+ className={`mdt-primary${slotClass ? ` ${slotClass}` : ""}`}
2120
+ type="button"
2121
+ onClick={onPrimaryAction}
2122
+ >
2123
+ {primaryActionLabel}
2124
+ </button>
2125
+ ) : null;
2126
+
2127
+ const [breadcrumbToolbarHost, setBreadcrumbToolbarHost] = useState(null);
2128
+
2129
+ useLayoutEffect(() => {
2130
+ if (!toolbarInBreadcrumb) {
2131
+ setBreadcrumbToolbarHost(null);
2132
+ return undefined;
2133
+ }
2134
+
2135
+ let cancelled = false;
2136
+ const resolveHost = () => {
2137
+ if (cancelled) return;
2138
+ const host = document.getElementById(breadcrumbToolbarHostId);
2139
+ if (host) {
2140
+ setBreadcrumbToolbarHost(host);
2141
+ }
2142
+ };
2143
+
2144
+ resolveHost();
2145
+ const rafId = requestAnimationFrame(() => {
2146
+ resolveHost();
2147
+ requestAnimationFrame(resolveHost);
2148
+ });
2149
+
2150
+ return () => {
2151
+ cancelled = true;
2152
+ cancelAnimationFrame(rafId);
2153
+ };
2154
+ }, [toolbarInBreadcrumb, breadcrumbToolbarHostId, toolbarHostSeed]);
2155
+
2156
+ const renderToolbarActions = () => (
2157
+ <div className={`mdt-actions-right${toolbarInBreadcrumb ? " mdt-actions-right--in-breadcrumb" : ""}`}>
2158
+ {resolvedHeaderActions.showLayoutSwitch && !lockedViewMode ? (
2159
+ <div className="mdt-layout-switch" role="tablist" aria-label="Table mode">
2160
+ <button
2161
+ type="button"
2162
+ title="List layout"
2163
+ className={viewMode === "list" ? "mdt-layout-active" : ""}
2164
+ onClick={() => setViewMode("list")}
2165
+ >
2166
+ <Menu size={16} />
2167
+ </button>
2168
+ <button
2169
+ type="button"
2170
+ title="Spreadsheet layout"
2171
+ className={viewMode === "table" ? "mdt-layout-active" : ""}
2172
+ onClick={() => setViewMode("table")}
2173
+ >
2174
+ <Layout size={16} />
2175
+ </button>
2176
+ </div>
2177
+ ) : null}
2178
+
2179
+ {resolvedHeaderActions.showFilter && (
2180
+ <div className="mdt-filter-button-wrap" ref={filterDropdownRef}>
2181
+ <button
2182
+ className={`mdt-ghost icon${activeFilters.length > 0 || isFilterBarOpen ? " mdt-filter-active" : ""}`}
2183
+ type="button"
2184
+ title={resolvedFilterConfig.toolbarButtonTitle}
2185
+ onClick={() => {
2186
+ if (isFilterBarOpen && activeFilters.length > 0) {
2187
+ setIsFilterBarOpen(false);
2188
+ setOpenPopup(null);
2189
+ return;
2190
+ }
2191
+ if (activeFilters.length > 0) {
2192
+ setIsFilterBarOpen(true);
2193
+ setOpenPopup(null);
2194
+ return;
2195
+ }
2196
+ setOpenPopup(null);
2197
+ setShowDisplayPopup(false);
2198
+ setShowFilterDropdown((v) => !v);
2199
+ setReplacingFilterId(null);
2200
+ setReplaceSearch("");
2201
+ }}
2202
+ >
2203
+ <ListFilter size={16} />
2204
+ </button>
2205
+
2206
+ {showFilterDropdown && (
2207
+ <div className="mdt-filter-dropdown" style={{ top: "calc(100% + 8px)" }}>
2208
+ <div className="mdt-fd-search">
2209
+ <div className="mdt-fd-search-wrap">
2210
+ <span className="mdt-fd-search-icon">
2211
+ <Search size={12} />
2212
+ </span>
2213
+ <input
2214
+ type="text"
2215
+ placeholder={resolvedFilterConfig.searchPlaceholder}
2216
+ value={filterSearch}
2217
+ onChange={(e) => setFilterSearch(e.target.value)}
2218
+ autoFocus
2219
+ />
2220
+ </div>
2221
+ </div>
2222
+ <div className="mdt-fd-list">
2223
+ {availableForDropdown(filterSearch).map((col) => (
2224
+ <button
2225
+ key={col.accessorKey}
2226
+ type="button"
2227
+ className="mdt-fd-item"
2228
+ onClick={() => addFilter(col)}
2229
+ >
2230
+ <span className="mdt-fd-item-icon">
2231
+ {renderColumnIcon(col.headerIcon || col.rowIcon || "default", {
2232
+ className: "mdt-fd-svg",
2233
+ })}
2234
+ </span>
2235
+ <span>{col.header}</span>
2236
+ </button>
2237
+ ))}
2238
+ {availableForDropdown(filterSearch).length === 0 && (
2239
+ <div className="mdt-fd-empty">
2240
+ {resolvedFilterConfig.emptyColumnsMessage}
2241
+ </div>
2242
+ )}
2243
+ </div>
2244
+ </div>
2245
+ )}
2246
+ </div>
2247
+ )}
2248
+
2249
+ {resolvedHeaderActions.showDisplay && (
2250
+ <div className="mdt-filter-button-wrap" ref={displayPopupRef}>
2251
+ <button
2252
+ className={`mdt-ghost icon mdt-display-btn${showDisplayPopup ? " mdt-filter-active" : ""}`}
2253
+ type="button"
2254
+ title="Display"
2255
+ onClick={() => {
2256
+ setOpenPopup(null);
2257
+ setShowFilterDropdown(false);
2258
+ setShowDisplayPopup((v) => !v);
2259
+ }}
2260
+ >
2261
+ <SlidersHorizontal size={16} />
2262
+ </button>
2263
+
2264
+ {showDisplayPopup && (
2265
+ <MdtDisplayPopup
2266
+ {...displayPopupProps}
2267
+ viewMode={viewMode}
2268
+ />
2269
+ )}
2270
+ </div>
2271
+ )}
2272
+
2273
+ {resolvedHeaderActions.showExport && onExport && (
2274
+ <div className="mdt-toolbar-cell">
2275
+ <button
2276
+ className="mdt-ghost icon mdt-export-btn"
2277
+ type="button"
2278
+ title="Export"
2279
+ onClick={() =>
2280
+ onExport({
2281
+ // Match show/hide column (Display) settings in the Excel export.
2282
+ columns: renderedColumns,
2283
+ hiddenColumns,
2284
+ // Flat rows currently shown (Group by is client-side only).
2285
+ rows: sortedRows,
2286
+ groupByKey,
2287
+ })
2288
+ }
2289
+ >
2290
+ <Download size={16} />
2291
+ </button>
2292
+ </div>
2293
+ )}
2294
+
2295
+ {renderNewButton(toolbarInBreadcrumb ? "" : "mdt-new-desktop")}
2296
+ </div>
2297
+ );
2298
+
2299
+ const toolbarActionsNode = renderToolbarActions();
2300
+ const portaledToolbar =
2301
+ toolbarInBreadcrumb && breadcrumbToolbarHost
2302
+ ? createPortal(toolbarActionsNode, breadcrumbToolbarHost)
2303
+ : null;
2304
+
2305
+ // ── Render ───────────────────────────────────────────────────────────────────
2306
+
2307
+ const hasSidebarNav = Boolean(sidebarNavConfig?.items?.length);
2308
+ const sidebarWidth = sidebarNavConfig?.width ?? 240;
2309
+ const hasHeaderBreadcrumbMenu = Boolean(headerBreadcrumbMenu?.label);
2310
+ const isHeaderBreadcrumbMenuInteractive =
2311
+ hasHeaderBreadcrumbMenu && (isMobilePopupLayout() || menuToggle === false);
2312
+ const compactBreadcrumbsSource = Array.isArray(headerBreadcrumbs)
2313
+ ? headerBreadcrumbs.filter(
2314
+ (crumb) => String(crumb?.label ?? "").trim().toLowerCase() !== "admin",
2315
+ )
2316
+ : [];
2317
+ const useCompactBreadcrumbs =
2318
+ enableCompactBreadcrumbEllipsis && isMobilePopupLayout() && compactBreadcrumbsSource.length > 0;
2319
+
2320
+ return (
2321
+ <div
2322
+ className={`mdt-page-shell${enableBulkSelection && hasSelection ? " mdt-page-shell--bulk-bar" : ""}`}
2323
+ >
2324
+ <div className="mdt-root">
2325
+ {portaledToolbar}
2326
+
2327
+ {/* ── Top action bar: hidden when toolbar is in global breadcrumb ── */}
2328
+ {!toolbarInBreadcrumb && (
2329
+ <div className="mdt-breadcrumb-row">
2330
+ <div className="mdt-breadcrumb-left">
2331
+ {(onSidebarToggle && (menuToggle === false || showSidebarToggleAlways)) && (
2332
+ <div
2333
+ className="mdt-sidebar-toggle"
2334
+ title={menuToggle === false ? "Show Sidebar" : "Toggle Sidebar"}
2335
+ onClick={onSidebarToggle}
2336
+ >
2337
+ <ChevronsLeft size={18} />
2338
+ </div>
2339
+ )}
2340
+ {!hideHeader && Array.isArray(headerBreadcrumbs) && headerBreadcrumbs.length > 0 ? (
2341
+ <span className="mdt-bc-crumbs" aria-label="Breadcrumb">
2342
+ {useCompactBreadcrumbs ? (
2343
+ <>
2344
+ <span className="mdt-bc-ellipsis" aria-hidden="true">
2345
+ <MoreHorizontal size={16} />
2346
+ </span>
2347
+ </>
2348
+ ) : (
2349
+ headerBreadcrumbs.map((crumb, index) => (
2350
+ <React.Fragment key={`${crumb?.label || "crumb"}-${index}`}>
2351
+ {index > 0 && <span className="mdt-bc-separator">›</span>}
2352
+ <span className="mdt-bc-crumb">
2353
+ {crumb?.icon ? <span className="mdt-bc-crumb-icon">{crumb.icon}</span> : null}
2354
+ <span className="mdt-bc-crumb-label">{crumb?.label}</span>
2355
+ </span>
2356
+ </React.Fragment>
2357
+ ))
2358
+ )}
2359
+ {hasHeaderBreadcrumbMenu ? (
2360
+ <>
2361
+ <span className="mdt-bc-separator">›</span>
2362
+ <span className="mdt-bc-menu-wrap" ref={headerBreadcrumbMenuRef}>
2363
+ {isHeaderBreadcrumbMenuInteractive ? (
2364
+ <>
2365
+ <button
2366
+ type="button"
2367
+ className={`mdt-bc-menu-btn${showHeaderBreadcrumbMenu ? " mdt-bc-menu-btn--open" : ""}`}
2368
+ onClick={() => setShowHeaderBreadcrumbMenu((prev) => !prev)}
2369
+ >
2370
+ <span className="mdt-bc-menu-label">{headerBreadcrumbMenu.label}</span>
2371
+ {headerBreadcrumbMenu.count != null && (
2372
+ <span className="mdt-bc-count-badge mdt-bc-count-badge--crumb">
2373
+ {headerBreadcrumbMenu.count}
2374
+ </span>
2375
+ )}
2376
+ <ChevronDown size={12} />
2377
+ </button>
2378
+ {showHeaderBreadcrumbMenu && Array.isArray(headerBreadcrumbMenu.items) && (
2379
+ <div className="mdt-filter-dropdown mdt-fd-left" style={{ top: "calc(100% + 8px)" }}>
2380
+ <div className="mdt-fd-list">
2381
+ {headerBreadcrumbMenu.items.map((item) => {
2382
+ const hasActiveKey = headerBreadcrumbMenu?.activeKey != null;
2383
+ const isActive = hasActiveKey
2384
+ ? String(item?.key ?? "") === String(headerBreadcrumbMenu.activeKey)
2385
+ : String(item?.label ?? "") === String(headerBreadcrumbMenu?.label ?? "");
2386
+
2387
+ return (
2388
+ <button
2389
+ key={String(item?.key ?? item?.label)}
2390
+ type="button"
2391
+ className="mdt-fd-item"
2392
+ onClick={() => {
2393
+ setShowHeaderBreadcrumbMenu(false);
2394
+ if (headerBreadcrumbMenu?.onSelect) headerBreadcrumbMenu.onSelect(item);
2395
+ }}
2396
+ >
2397
+ <span style={{ flex: 1, minWidth: 0 }}>{item?.label}</span>
2398
+ {isActive && (
2399
+ <Check
2400
+ size={12}
2401
+ style={{ color: "oklch(0.2378 0.0029 230.83)", flexShrink: 0 }}
2402
+ />
2403
+ )}
2404
+ </button>
2405
+ );
2406
+ })}
2407
+ </div>
2408
+ </div>
2409
+ )}
2410
+ </>
2411
+ ) : (
2412
+ <span className="mdt-bc-menu-btn" aria-label={headerBreadcrumbMenu.label}>
2413
+ <span className="mdt-bc-menu-label">{headerBreadcrumbMenu.label}</span>
2414
+ {headerBreadcrumbMenu.count != null && (
2415
+ <span className="mdt-bc-count-badge mdt-bc-count-badge--crumb">
2416
+ {headerBreadcrumbMenu.count}
2417
+ </span>
2418
+ )}
2419
+ </span>
2420
+ )}
2421
+ </span>
2422
+ </>
2423
+ ) : null}
2424
+ {(displayedTotalCount != null || countText) &&
2425
+ !(hasHeaderBreadcrumbMenu && headerBreadcrumbMenu.count != null) && (
2426
+ <span className="mdt-bc-count-badge">
2427
+ {displayedTotalCount != null ? displayedTotalCount : countText}
2428
+ </span>
2429
+ )}
2430
+ </span>
2431
+ ) : !hideHeader && (
2432
+ <span className="mdt-bc-item">
2433
+ {titleIcon}
2434
+ <span className="mdt-bc-title">{title}</span>
2435
+ {(displayedTotalCount != null || countText) && (
2436
+ <span className="mdt-bc-count-badge">
2437
+ {displayedTotalCount != null ? displayedTotalCount : countText}
2438
+ </span>
2439
+ )}
2440
+ </span>
2441
+ )}
2442
+ {renderNewButton("mdt-new-mobile")}
2443
+ </div>
2444
+
2445
+ {toolbarActionsNode}
2446
+ </div>
2447
+ )}
2448
+
2449
+ <div className="mdt-content-layout">
2450
+ {hasSidebarNav && (
2451
+ <aside
2452
+ className="mdt-shell-side-panel"
2453
+ style={{ width: sidebarWidth, minWidth: sidebarWidth }}
2454
+ >
2455
+ <MdtSidebarNav
2456
+ items={sidebarNavConfig.items}
2457
+ activeKey={sidebarNavConfig.activeKey}
2458
+ onItemClick={sidebarNavConfig.onItemClick}
2459
+ />
2460
+ </aside>
2461
+ )}
2462
+ <div className="mdt-content-main">
2463
+
2464
+ {/* ── Tabs: default (Active, Drafts, …) + saved views ── */}
2465
+ <MdtTabsSection
2466
+ tabs={mergedTabs}
2467
+ activeTabId={resolvedActiveTabId}
2468
+ onTabClick={handleTabClick}
2469
+ onEditSavedView={handleEditSavedView}
2470
+ tabsConfig={tabsConfig}
2471
+ rowClassName={
2472
+ viewMode === "list" &&
2473
+ !groupByKey &&
2474
+ enableBulkSelection &&
2475
+ hasSelection
2476
+ ? "mdt-tabs-row--has-selection"
2477
+ : ""
2478
+ }
2479
+ leadingSlot={
2480
+ viewMode === "list" && !groupByKey && enableBulkSelection ? (
2481
+ <div
2482
+ className="mdt-tabs-list-select-all"
2483
+ onClick={(e) => e.stopPropagation()}
2484
+ >
2485
+ <input
2486
+ ref={selectAllRef}
2487
+ type="checkbox"
2488
+ className="mdt-row-select-checkbox mdt-row-select-checkbox--header"
2489
+ checked={allVisibleSelected}
2490
+ onChange={toggleSelectAllVisible}
2491
+ onClick={(e) => e.stopPropagation()}
2492
+ aria-label="Select all in list"
2493
+ disabled={selectableVisibleRowIds.length === 0}
2494
+ />
2495
+ </div>
2496
+ ) : null
2497
+ }
2498
+ />
2499
+
2500
+ {/* ── Active filter chips bar ── */}
2501
+ {isFilterBarOpen &&
2502
+ activeFilters.length > 0 &&
2503
+ !showCreateViewModal && (
2504
+ <div className="mdt-filter-bar" ref={filterBarRef}>
2505
+ <MdtActiveFilterBar
2506
+ {...filterBarProps}
2507
+ showSaveAs={resolvedFilterConfig.showSaveAs}
2508
+ onSaveAs={handleOpenCreateViewModal}
2509
+ />
2510
+ </div>
2511
+ )}
2512
+
2513
+ {/* ── Table / List view ── */}
2514
+ {viewMode === 'list' ? (
2515
+ <FlazDataViewList
2516
+ wrapRef={listWrapRef}
2517
+ rows={sortedRows}
2518
+ columns={renderedColumns}
2519
+ onRowClick={onRowClick}
2520
+ isLoading={isInitialLoading}
2521
+ isLoadingMore={isLoadingMore}
2522
+ skeletonRows={autoSkeletonRows}
2523
+ onListScroll={onTableScroll}
2524
+ groupByKey={groupByKey}
2525
+ title={title}
2526
+ listGroupHeadPrefix={listGroupHeadPrefix}
2527
+ listGroupHeadCount={listGroupHeadCount}
2528
+ suppressListRowCountFallback={Boolean(activeSavedViewId)}
2529
+ rowKey={rowKey}
2530
+ listMainColumnKey={listMainColumnKey ?? rowKey}
2531
+ listLeftColumnKeys={resolvedListLeftKeys}
2532
+ listLeftSecondaryKeys={listLeftSecondaryKeys}
2533
+ listLeftSecondarySeparator={listLeftSecondarySeparator}
2534
+ onRowEdit={onRowEdit}
2535
+ onRowDownload={onRowDownload}
2536
+ onRowDelete={onRowDelete}
2537
+ rowMenuActions={rowMenuActions}
2538
+ deleteConfirmConfig={deleteConfirmConfig}
2539
+ isRowDeletable={isRowDeletable}
2540
+ emptyStateTitle={emptyStateTitle}
2541
+ {...(enableBulkSelection
2542
+ ? {
2543
+ selectedRowIds,
2544
+ onToggleRowSelection: toggleRowSelection,
2545
+ onToggleGroupSelection: toggleGroupSelection,
2546
+ hasSelection,
2547
+ }
2548
+ : {})}
2549
+ onCellActivate={onCellEdit ? handleCellClick : null}
2550
+ isColumnEditable={(col) => isColumnCellEditable(col, onCellEdit)}
2551
+ cellEdit={cellEdit}
2552
+ cellEditAnchorRef={cellEditAnchorRef}
2553
+ />
2554
+ ) : (
2555
+ <div
2556
+ className={`mdt-table-wrap${!isInitialLoading && sortedRows.length === 0 ? " mdt-table-wrap--empty" : ""}`}
2557
+ ref={tableWrapRef}
2558
+ onScroll={(e) => {
2559
+ const t = e.currentTarget;
2560
+ updateTableScrollState();
2561
+ if (openRowMenuKey) closeRowMenu();
2562
+ if (onTableScroll) {
2563
+ onTableScroll({
2564
+ scrollTop: t.scrollTop,
2565
+ scrollHeight: t.scrollHeight,
2566
+ clientHeight: t.clientHeight,
2567
+ });
2568
+ }
2569
+ }}
2570
+ >
2571
+ {!isInitialLoading && sortedRows.length === 0 ? (
2572
+ <MdtEmptyState title={emptyStateTitle} />
2573
+ ) : (
2574
+ <table
2575
+ className={`mdt-table${enableBulkSelection && hasSelection ? " mdt-has-selection" : ""}`}
2576
+ style={{
2577
+ tableLayout: tableLayoutFixed ? "fixed" : "auto",
2578
+ width: "100%",
2579
+ minWidth: calcTableMinWidth(),
2580
+ }}
2581
+ >
2582
+ <colgroup>
2583
+ {renderedColumns.map((col, colIndex) => (
2584
+ <col
2585
+ key={`col-${col.accessorKey}`}
2586
+ className="mdt-col-data"
2587
+ style={getDataColWidth(col, colIndex)}
2588
+ />
2589
+ ))}
2590
+ {showRowMenuColumn && (
2591
+ <col
2592
+ key="col-row-menu"
2593
+ className="mdt-col-row-menu-col"
2594
+ />
2595
+ )}
2596
+ </colgroup>
2597
+ <thead>
2598
+ <tr>
2599
+ {renderedColumns.map((column, colIndex) => (
2600
+ <th
2601
+ key={column.accessorKey}
2602
+ style={getColumnStyle(column, colIndex)}
2603
+ className={getTableCellClassName(column, colIndex, {
2604
+ header: true,
2605
+ })}
2606
+ >
2607
+ {enableBulkSelection && colIndex === 0 && (
2608
+ <div
2609
+ className="mdt-cell-select-slot"
2610
+ onClick={(e) => e.stopPropagation()}
2611
+ >
2612
+ <input
2613
+ ref={selectAllRef}
2614
+ type="checkbox"
2615
+ className="mdt-row-select-checkbox mdt-row-select-checkbox--header"
2616
+ checked={allVisibleSelected}
2617
+ onChange={toggleSelectAllVisible}
2618
+ onClick={(e) => e.stopPropagation()}
2619
+ aria-label="Select all rows"
2620
+ />
2621
+ </div>
2622
+ )}
2623
+ <div className="mdt-th-wrapper">
2624
+ <span
2625
+ className="mdt-header-cell"
2626
+ onClick={() => handleHeaderClick(column.accessorKey)}
2627
+ >
2628
+ <span className="mdt-header-label">
2629
+ {renderColumnDefIcon(column, { className: "mdt-header-icon" })}
2630
+ <span className="mdt-header-title">{column.header}</span>
2631
+ </span>
2632
+ <span className="mdt-header-sort-slot" aria-hidden="false">
2633
+ {getSortIcon(column.accessorKey)}
2634
+ </span>
2635
+ </span>
2636
+
2637
+ {openPopup === column.accessorKey && (
2638
+ <div className="mdt-sort-popup" ref={popupRef}>
2639
+ {renderSortPopupContent(column)}
2640
+ </div>
2641
+ )}
2642
+ </div>
2643
+ </th>
2644
+ ))}
2645
+ {showRowMenuColumn && (
2646
+ <th
2647
+ key="col-settings"
2648
+ style={OPTIONS_COL_STYLE}
2649
+ className={`mdt-th-row-menu ${rowMenuColClassName}`}
2650
+ >
2651
+ <div className="mdt-th-wrapper mdt-th-row-menu-inner">
2652
+ <MoreHorizontal
2653
+ size={14}
2654
+ className="mdt-row-menu-header-icon"
2655
+ aria-hidden="true"
2656
+ />
2657
+ </div>
2658
+ </th>
2659
+ )}
2660
+ </tr>
2661
+ </thead>
2662
+ <tbody>
2663
+ {isInitialLoading
2664
+ ? Array.from({ length: autoSkeletonRows }).map((_, i) => (
2665
+ <tr key={`skeleton-${i}`} className="mdt-table-skeleton-row">
2666
+ {renderedColumns.map((col, j) => (
2667
+ <td
2668
+ key={`sk-${i}-${col.accessorKey}-${j}`}
2669
+ style={getColumnStyle(col, j)}
2670
+ className={getTableCellClassName(col, j)}
2671
+ >
2672
+ {enableBulkSelection && j === 0 && (
2673
+ <div className="mdt-cell-select-slot" aria-hidden />
2674
+ )}
2675
+ <div className="mdt-cell-skeleton-wrap">
2676
+ <span className="mdt-skeleton-cell mdt-skeleton-table-cell" />
2677
+ </div>
2678
+ </td>
2679
+ ))}
2680
+ {showRowMenuColumn && (
2681
+ <td
2682
+ key={`sk-${i}-menu`}
2683
+ style={OPTIONS_COL_STYLE}
2684
+ className={rowMenuColClassName}
2685
+ />
2686
+ )}
2687
+ </tr>
2688
+ ))
2689
+ : sortedRows.map((row) => {
2690
+ const key = getRowId(row);
2691
+ const selected = isRowSelected(key);
2692
+ return (
2693
+ <tr
2694
+ key={key}
2695
+ className={[
2696
+ onRowClick ? "mdt-row-clickable" : "",
2697
+ enableBulkSelection && selected ? "mdt-row-selected" : "",
2698
+ ]
2699
+ .filter(Boolean)
2700
+ .join(" ")}
2701
+ onClick={onRowClick && key ? () => onRowClick(key) : undefined}
2702
+ >
2703
+ {renderedColumns.map((col, colIndex) => {
2704
+ const cellClickable =
2705
+ typeof col.onCellClick === "function"
2706
+ && (typeof col.isCellClickable !== "function"
2707
+ || col.isCellClickable(row));
2708
+ return (
2709
+ <td
2710
+ key={`${key}-${col.accessorKey}`}
2711
+ style={getColumnStyle(col, colIndex)}
2712
+ className={[
2713
+ getTableCellClassName(col, colIndex),
2714
+ cellClickable ? "mdt-td-clickable" : "",
2715
+ typeof col.onCellClick === "function" && !cellClickable
2716
+ ? "mdt-td-clickable-disabled"
2717
+ : "",
2718
+ ]
2719
+ .filter(Boolean)
2720
+ .join(" ")}
2721
+ onClick={
2722
+ cellClickable
2723
+ ? (event) => {
2724
+ event.stopPropagation();
2725
+ col.onCellClick(row, event);
2726
+ }
2727
+ : undefined
2728
+ }
2729
+ >
2730
+ {enableBulkSelection && colIndex === 0 && (
2731
+ <div
2732
+ className="mdt-cell-select-slot"
2733
+ onClick={(e) => e.stopPropagation()}
2734
+ >
2735
+ <input
2736
+ type="checkbox"
2737
+ className="mdt-row-select-checkbox"
2738
+ checked={selected}
2739
+ onChange={(e) => toggleRowSelection(key, e)}
2740
+ onClick={(e) => e.stopPropagation()}
2741
+ aria-label={`Select row ${key}`}
2742
+ />
2743
+ </div>
2744
+ )}
2745
+ <div
2746
+ className={[
2747
+ "mdt-td-inner",
2748
+ isColumnCellEditable(col, onCellEdit)
2749
+ ? "mdt-cell-editable-wrap"
2750
+ : "mdt-td-readonly-inner",
2751
+ enableBulkSelection && colIndex === 0
2752
+ ? "mdt-cell-first-content"
2753
+ : "",
2754
+ ]
2755
+ .filter(Boolean)
2756
+ .join(" ")}
2757
+ ref={
2758
+ cellEdit?.rowId === key &&
2759
+ cellEdit?.column?.accessorKey === col.accessorKey
2760
+ ? cellEditAnchorRef
2761
+ : null
2762
+ }
2763
+ >
2764
+ {renderCell(col, row, {
2765
+ editable: isColumnCellEditable(col, onCellEdit),
2766
+ onCellActivate: handleCellClick,
2767
+ })}
2768
+ </div>
2769
+ </td>
2770
+ );
2771
+ })}
2772
+ {showRowMenuColumn && (
2773
+ <td
2774
+ key={`${key}-row-menu`}
2775
+ style={OPTIONS_COL_STYLE}
2776
+ className={rowMenuColClassName}
2777
+ >
2778
+ {renderRowMenuCell(row)}
2779
+ </td>
2780
+ )}
2781
+ </tr>
2782
+ );
2783
+ })}
2784
+ {isLoadingMore && (
2785
+ <tr className="mdt-table-loading-more" aria-hidden>
2786
+ {renderedColumns.map((col, j) => (
2787
+ <td
2788
+ key={`load-more-${col.accessorKey}`}
2789
+ style={getColumnStyle(col, j)}
2790
+ className={getTableCellClassName(col, j)}
2791
+ >
2792
+ {enableBulkSelection && j === 0 && (
2793
+ <div className="mdt-cell-select-slot" aria-hidden />
2794
+ )}
2795
+ <div className="mdt-cell-skeleton-wrap">
2796
+ <span className="mdt-skeleton-cell mdt-skeleton-table-cell" />
2797
+ </div>
2798
+ </td>
2799
+ ))}
2800
+ {showRowMenuColumn && (
2801
+ <td style={OPTIONS_COL_STYLE} className={rowMenuColClassName} />
2802
+ )}
2803
+ </tr>
2804
+ )}
2805
+ </tbody>
2806
+ </table>
2807
+ )}
2808
+ </div>
2809
+ )}
2810
+ </div>
2811
+ </div>
2812
+ </div>
2813
+ {cellEdit && cellEditPopupPos && (
2814
+ <div
2815
+ ref={cellEditPopupRef}
2816
+ className="mdt-cell-edit-popup-anchor"
2817
+ style={{
2818
+ position: "fixed",
2819
+ top: cellEditPopupPos.top,
2820
+ left: cellEditPopupPos.left,
2821
+ width: cellEditPopupPos.width,
2822
+ minWidth: cellEditPopupPos.width,
2823
+ maxWidth: isMobilePopupLayout()
2824
+ ? "calc(100vw - 16px)"
2825
+ : "min(320px, calc(100vw - 16px))",
2826
+ zIndex: 10060,
2827
+ }}
2828
+ onClick={(e) => e.stopPropagation()}
2829
+ onMouseDown={(e) => e.stopPropagation()}
2830
+ onWheel={(e) => e.stopPropagation()}
2831
+ >
2832
+ <MdtValueEditorPopup
2833
+ key={`${cellEdit.rowId ?? "row"}-${cellEdit.columnKey ?? cellEdit.column?.accessorKey ?? "col"}`}
2834
+ column={liveCellEditColumn ?? cellEdit.column}
2835
+ mode={EDIT_MODE.INLINE}
2836
+ value={cellEditDraftValue}
2837
+ onApply={handleCellEditApply}
2838
+ onCancel={closeCellEdit}
2839
+ row={cellEdit.row}
2840
+ />
2841
+ </div>
2842
+ )}
2843
+ {bulkEdit && bulkEditPopupPos && (
2844
+ <div
2845
+ ref={bulkEditPopupRef}
2846
+ className="mdt-cell-edit-popup-anchor mdt-bulk-edit-popup-anchor"
2847
+ style={{
2848
+ position: "fixed",
2849
+ top: bulkEditPopupPos.top,
2850
+ left: bulkEditPopupPos.left,
2851
+ width: bulkEditPopupPos.width,
2852
+ minWidth: bulkEditPopupPos.width,
2853
+ maxWidth: isMobilePopupLayout()
2854
+ ? "calc(100vw - 16px)"
2855
+ : "min(320px, calc(100vw - 16px))",
2856
+ zIndex: 10070,
2857
+ }}
2858
+ onClick={(e) => e.stopPropagation()}
2859
+ onMouseDown={(e) => e.stopPropagation()}
2860
+ onWheel={(e) => e.stopPropagation()}
2861
+ >
2862
+ <MdtValueEditorPopup
2863
+ column={bulkEdit.column}
2864
+ mode={EDIT_MODE.BULK}
2865
+ value={bulkEditDraftValue}
2866
+ onApply={handleBulkEditApply}
2867
+ onCancel={closeBulkEdit}
2868
+ row={bulkEdit?.row}
2869
+ />
2870
+ </div>
2871
+ )}
2872
+ <MdtCreateViewModal
2873
+ open={showCreateViewModal}
2874
+ mode={editingViewId ? "edit" : "create"}
2875
+ editingView={editingView}
2876
+ onClose={handleCloseCreateViewModal}
2877
+ onCreate={handleSaveView}
2878
+ onDelete={editingViewId ? handleDeleteSavedView : undefined}
2879
+ initialViewMode={editingView?.viewMode || viewMode}
2880
+ filterBar={
2881
+ <div className="mdt-filter-bar mdt-cv-filter-bar">
2882
+ <MdtActiveFilterBar {...filterBarProps} showSaveAs={false} />
2883
+ </div>
2884
+ }
2885
+ displayPopupProps={displayPopupProps}
2886
+ />
2887
+ <DeleteConfirmModal
2888
+ open={showBulkDeleteConfirm}
2889
+ title={deleteConfirmConfig.bulkTitle || "Delete Selected Items"}
2890
+ message={
2891
+ deleteConfirmConfig.getBulkMessage
2892
+ ? deleteConfirmConfig.getBulkMessage(
2893
+ selectedCount,
2894
+ getSelectedRows(),
2895
+ bulkDeleteCounts,
2896
+ )
2897
+ : (deleteConfirmConfig.bulkMessage || buildDefaultBulkDeleteMessage())
2898
+ }
2899
+ cancelText={deleteConfirmConfig.cancelText || "Cancel"}
2900
+ confirmText={deleteConfirmConfig.confirmText || "Delete"}
2901
+ onCancel={() => setShowBulkDeleteConfirm(false)}
2902
+ onConfirm={() => {
2903
+ const deletableRows = getDeletableSelectedRows();
2904
+ const idsToDelete = deletableRows
2905
+ .map((row) => getRowId(row))
2906
+ .filter((id) => id != null);
2907
+ const rowsToDelete = deletableRows;
2908
+ setShowBulkDeleteConfirm(false);
2909
+ // Optimistically remove only deletable rows from UI immediately
2910
+ setLocallyDeletedIds((prev) => {
2911
+ const next = new Set(prev);
2912
+ idsToDelete.forEach((id) => next.add(id));
2913
+ return next;
2914
+ });
2915
+ clearRowSelection();
2916
+ // Fire API call silently in the background
2917
+ if (onBulkDelete) {
2918
+ onBulkDelete(idsToDelete, rowsToDelete);
2919
+ }
2920
+ }}
2921
+ />
2922
+ {enableBulkSelection && (
2923
+ <MdtBulkSelectionBar
2924
+ selectedCount={selectedCount}
2925
+ onClearSelection={clearRowSelection}
2926
+ bulkFields={resolvedBulkBarFields}
2927
+ onBulkFieldClick={handleBulkFieldClick}
2928
+ onBulkDelete={
2929
+ onBulkDelete ? () => setShowBulkDeleteConfirm(true) : undefined
2930
+ }
2931
+ bulkDeleteDisabled={Boolean(onBulkDelete) && deletableSelectedCount === 0}
2932
+ bulkDeleteDisabledReason={
2933
+ deleteConfirmConfig.deleteDisabledReason ||
2934
+ "None of the selected items can be deleted."
2935
+ }
2936
+ rightAction={bulkBarRightAction}
2937
+ />
2938
+ )}
2939
+ </div>
2940
+ );
2941
+ };
2942
+
2943
+ export default FlazDataView;