asdasdsa
This commit is contained in:
@@ -29,7 +29,7 @@ import SelectDropdown from '../components/form/SelectDropdown';
|
||||
import { Dropdown } from '../components/ui/dropdown/Dropdown';
|
||||
import { DropdownItem } from '../components/ui/dropdown/DropdownItem';
|
||||
import AlertModal from '../components/ui/alert/AlertModal';
|
||||
import { ChevronDownIcon, MoreDotIcon, PlusIcon } from '../icons';
|
||||
import { ChevronDownIcon, MoreDotIcon, PlusIcon, ListIcon } from '../icons';
|
||||
import { useHeaderMetrics } from '../context/HeaderMetricsContext';
|
||||
import { useToast } from '../components/ui/toast/ToastContainer';
|
||||
import { getDeleteModalConfig } from '../config/pages/delete-modal.config';
|
||||
@@ -185,6 +185,9 @@ export default function TablePageTemplate({
|
||||
const rowActionButtonRefs = React.useRef<Map<string | number, React.RefObject<HTMLButtonElement | null>>>(new Map());
|
||||
const bulkActionsButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Filter toggle state - hidden by default
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Get notification config for current page
|
||||
const deleteModalConfig = getDeleteModalConfig(location.pathname);
|
||||
const bulkActionModalConfig = getBulkActionModalConfig(location.pathname);
|
||||
@@ -237,72 +240,71 @@ export default function TablePageTemplate({
|
||||
const { setMetrics } = useHeaderMetrics();
|
||||
const toast = useToast();
|
||||
const { pageSize, setPageSize } = usePageSizeStore();
|
||||
const { pageColumns, setPageColumns, getPageColumns } = useColumnVisibilityStore();
|
||||
const { setPageColumns, getPageColumns, _hasHydrated } = useColumnVisibilityStore();
|
||||
|
||||
// Column visibility state management with Zustand store (same pattern as site/sector stores)
|
||||
// Initialize visible columns from store or defaults
|
||||
const initializeVisibleColumns = useMemo(() => {
|
||||
const savedColumnKeys = getPageColumns(location.pathname);
|
||||
|
||||
if (savedColumnKeys.length > 0) {
|
||||
const savedSet = new Set(savedColumnKeys);
|
||||
// Validate that all saved columns still exist
|
||||
const validColumns = columns.filter(col => savedSet.has(col.key));
|
||||
if (validColumns.length > 0) {
|
||||
// Add any new columns with defaultVisible !== false
|
||||
const newColumns = columns
|
||||
.filter(col => !savedSet.has(col.key) && col.defaultVisible !== false)
|
||||
.map(col => col.key);
|
||||
return new Set([...Array.from(validColumns.map(col => col.key)), ...newColumns]);
|
||||
}
|
||||
}
|
||||
|
||||
// Default: show all columns that have defaultVisible !== false
|
||||
// Column visibility state management with Zustand store
|
||||
// Start with defaults, then update once hydration completes
|
||||
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(() => {
|
||||
// Always start with defaults on initial render (before hydration)
|
||||
return new Set(
|
||||
columns
|
||||
.filter(col => col.defaultVisible !== false)
|
||||
.map(col => col.key)
|
||||
);
|
||||
}, [columns, location.pathname, getPageColumns]);
|
||||
});
|
||||
|
||||
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(initializeVisibleColumns);
|
||||
// Track if we've already loaded saved columns for this page to prevent loops
|
||||
const hasLoadedSavedColumns = useRef(false);
|
||||
|
||||
// Update visible columns when columns prop or pathname changes
|
||||
// Load saved columns from store after hydration completes (one-time per page)
|
||||
useEffect(() => {
|
||||
const savedColumnKeys = getPageColumns(location.pathname);
|
||||
console.log('🔄 LOAD EFFECT:', { hasHydrated: _hasHydrated, hasLoaded: hasLoadedSavedColumns.current, pathname: location.pathname });
|
||||
if (!_hasHydrated || hasLoadedSavedColumns.current) return;
|
||||
|
||||
if (savedColumnKeys.length > 0) {
|
||||
// Mark as loaded FIRST to prevent save effect from firing during this update
|
||||
hasLoadedSavedColumns.current = true;
|
||||
console.log('✅ Marked hasLoaded = true');
|
||||
|
||||
const savedColumnKeys = getPageColumns(location.pathname);
|
||||
console.log('📥 LOADING saved columns:', savedColumnKeys);
|
||||
|
||||
if (savedColumnKeys && savedColumnKeys.length > 0) {
|
||||
const savedSet = new Set(savedColumnKeys);
|
||||
// Validate that all saved columns still exist
|
||||
// Validate that all saved columns still exist in current columns config
|
||||
const validColumns = columns.filter(col => savedSet.has(col.key));
|
||||
|
||||
if (validColumns.length > 0) {
|
||||
// Add any new columns with defaultVisible !== false
|
||||
const newColumns = columns
|
||||
.filter(col => !savedSet.has(col.key) && col.defaultVisible !== false)
|
||||
.map(col => col.key);
|
||||
const newSet = new Set([...Array.from(validColumns.map(col => col.key)), ...newColumns]);
|
||||
setVisibleColumns(newSet);
|
||||
// Update store with validated columns
|
||||
setPageColumns(location.pathname, Array.from(newSet));
|
||||
const mergedColumns = [...Array.from(validColumns.map(col => col.key)), ...newColumns];
|
||||
console.log('✅ Setting visible columns to:', mergedColumns);
|
||||
setVisibleColumns(new Set(mergedColumns));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: show all columns that have defaultVisible !== false
|
||||
const defaultSet = new Set(
|
||||
columns
|
||||
.filter(col => col.defaultVisible !== false)
|
||||
.map(col => col.key)
|
||||
);
|
||||
setVisibleColumns(defaultSet);
|
||||
// Save defaults to store
|
||||
setPageColumns(location.pathname, Array.from(defaultSet));
|
||||
}, [columns, location.pathname, getPageColumns, setPageColumns]);
|
||||
console.log('⚠️ No valid saved columns, keeping defaults');
|
||||
}, [_hasHydrated, location.pathname]);
|
||||
// NOTE: NOT including 'getPageColumns' or 'columns' - would cause unnecessary re-runs
|
||||
|
||||
// Save to store whenever visibleColumns changes (Zustand persist middleware handles localStorage)
|
||||
// Reset loaded flag when pathname changes (navigating to different page)
|
||||
useEffect(() => {
|
||||
hasLoadedSavedColumns.current = false;
|
||||
}, [location.pathname]);
|
||||
|
||||
// Save to store whenever visibleColumns changes (only after hydration to avoid saving defaults)
|
||||
useEffect(() => {
|
||||
console.log('💾 SAVE EFFECT:', {
|
||||
hasHydrated: _hasHydrated,
|
||||
hasLoaded: hasLoadedSavedColumns.current,
|
||||
willSave: _hasHydrated && hasLoadedSavedColumns.current,
|
||||
columns: Array.from(visibleColumns)
|
||||
});
|
||||
if (!_hasHydrated || !hasLoadedSavedColumns.current) return; // Don't save until hydration completes AND saved columns have been loaded
|
||||
setPageColumns(location.pathname, Array.from(visibleColumns));
|
||||
}, [visibleColumns, location.pathname, setPageColumns]);
|
||||
}, [_hasHydrated, visibleColumns, location.pathname, setPageColumns]);
|
||||
|
||||
// Filter columns based on visibility
|
||||
const visibleColumnsList = useMemo(() => {
|
||||
@@ -597,171 +599,167 @@ export default function TablePageTemplate({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Filters Row - 75% centered, container inside stretched to 100% */}
|
||||
{(renderFilters || filters.length > 0) && (
|
||||
<div className="flex justify-center mb-4">
|
||||
<div
|
||||
className="w-[75%] igny8-filter-bar p-3 rounded-lg bg-transparent"
|
||||
style={{ boxShadow: '0 2px 6px 3px rgba(0, 0, 0, 0.08)' }}
|
||||
>
|
||||
<div className="flex flex-nowrap gap-3 items-center justify-between w-full">
|
||||
<div className="flex flex-nowrap gap-3 items-center flex-1 min-w-0 w-full">
|
||||
{renderFilters ? (
|
||||
<div className="flex flex-nowrap gap-3 items-center flex-1 min-w-0 w-full">
|
||||
{renderFilters}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filters.map((filter) => {
|
||||
// Handle custom render filters (for complex filters like volume range)
|
||||
if (filter.type === 'custom' && (filter as any).customRender) {
|
||||
return <React.Fragment key={filter.key}>{(filter as any).customRender()}</React.Fragment>;
|
||||
}
|
||||
|
||||
if (filter.type === 'text') {
|
||||
return (
|
||||
<Input
|
||||
key={filter.key}
|
||||
type="text"
|
||||
placeholder={filter.placeholder || `Search ${filter.label.toLowerCase()}...`}
|
||||
value={filterValues[filter.key] || ''}
|
||||
onChange={(e) => {
|
||||
onFilterChange?.(filter.key, e.target.value);
|
||||
}}
|
||||
className="flex-1 min-w-[200px] h-9"
|
||||
/>
|
||||
);
|
||||
} else if (filter.type === 'select') {
|
||||
const currentValue = filterValues[filter.key] || '';
|
||||
return (
|
||||
<SelectDropdown
|
||||
key={filter.key}
|
||||
options={filter.options || []}
|
||||
placeholder={filter.label}
|
||||
value={currentValue}
|
||||
onChange={(value) => {
|
||||
// Ensure we pass the value even if it's an empty string
|
||||
const newValue = value === null || value === undefined ? '' : String(value);
|
||||
onFilterChange?.(filter.key, newValue);
|
||||
}}
|
||||
className={filter.className || "flex-1 min-w-[140px]"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hasActiveFilters && onFilterReset && (
|
||||
{/* Bulk Actions and Action Buttons Row - Fixed height container */}
|
||||
<div className="flex items-center justify-between min-h-[65px] mb-4 mt-[10px]">
|
||||
{/* Left side - Bulk Actions and Filter Toggle */}
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* Bulk Actions - Single button if only one action, dropdown if multiple */}
|
||||
{bulkActions.length > 0 && (
|
||||
<div className="inline-block">
|
||||
{bulkActions.length === 1 ? (
|
||||
// Single button for single action
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onFilterReset}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk Actions and Action Buttons Row */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
{/* Bulk Actions - Single button if only one action, dropdown if multiple */}
|
||||
{bulkActions.length > 0 && (
|
||||
<div className="inline-block">
|
||||
{bulkActions.length === 1 ? (
|
||||
// Single button for single action
|
||||
<Button
|
||||
size="md"
|
||||
onClick={() => {
|
||||
if (selectedIds.length > 0) {
|
||||
handleBulkActionClick(bulkActions[0].key, selectedIds);
|
||||
}
|
||||
}}
|
||||
disabled={selectedIds.length === 0}
|
||||
variant={bulkActions[0].variant === 'success' ? 'success' : bulkActions[0].variant === 'danger' ? 'primary' : 'primary'}
|
||||
startIcon={bulkActions[0].icon}
|
||||
className={selectedIds.length === 0 ? "opacity-50 cursor-not-allowed" : ""}
|
||||
>
|
||||
{bulkActions[0].label}
|
||||
{selectedIds.length > 0 && (
|
||||
<span className="ml-2 inline-flex items-center justify-center px-2 py-0.5 text-xs font-medium rounded-full bg-white/20 text-white">
|
||||
{selectedIds.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
// Dropdown for multiple actions
|
||||
<>
|
||||
<Button
|
||||
ref={bulkActionsButtonRef}
|
||||
size="md"
|
||||
onClick={() => selectedIds.length > 0 && setIsBulkActionsDropdownOpen(!isBulkActionsDropdownOpen)}
|
||||
onClick={() => {
|
||||
if (selectedIds.length > 0) {
|
||||
handleBulkActionClick(bulkActions[0].key, selectedIds);
|
||||
}
|
||||
}}
|
||||
disabled={selectedIds.length === 0}
|
||||
className={`dropdown-toggle ${selectedIds.length === 0 ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
endIcon={<ChevronDownIcon className="w-4 h-4" />}
|
||||
variant={bulkActions[0].variant === 'success' ? 'success' : bulkActions[0].variant === 'danger' ? 'primary' : 'primary'}
|
||||
startIcon={bulkActions[0].icon}
|
||||
className={selectedIds.length === 0 ? "opacity-50 cursor-not-allowed" : ""}
|
||||
>
|
||||
Bulk Actions
|
||||
{bulkActions[0].label}
|
||||
{selectedIds.length > 0 && (
|
||||
<span className="ml-2 inline-flex items-center justify-center px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-800 dark:bg-blue-500/20 dark:text-blue-300">
|
||||
<span className="ml-2 inline-flex items-center justify-center px-2 py-0.5 text-xs font-medium rounded-full bg-white/20 text-white">
|
||||
{selectedIds.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Dropdown
|
||||
isOpen={isBulkActionsDropdownOpen && selectedIds.length > 0}
|
||||
onClose={() => setIsBulkActionsDropdownOpen(false)}
|
||||
anchorRef={bulkActionsButtonRef as React.RefObject<HTMLElement>}
|
||||
placement="bottom-left"
|
||||
className="w-48 p-2"
|
||||
>
|
||||
{bulkActions.map((action, index) => {
|
||||
const isDelete = action.key === 'delete';
|
||||
const showDivider = isDelete && index > 0;
|
||||
return (
|
||||
<React.Fragment key={action.key}>
|
||||
{showDivider && <div className="my-2 border-t border-gray-200 dark:border-gray-800"></div>}
|
||||
<DropdownItem
|
||||
onItemClick={() => {
|
||||
handleBulkActionClick(action.key, selectedIds);
|
||||
}}
|
||||
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
|
||||
isDelete
|
||||
? "text-error-500 hover:bg-error-50 hover:text-error-600 dark:text-error-400 dark:hover:bg-error-500/15 dark:hover:text-error-300"
|
||||
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{action.icon && <span className="flex-shrink-0 w-5 h-5">{action.icon}</span>}
|
||||
<span className="text-left">{action.label}</span>
|
||||
</DropdownItem>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Dropdown>
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
// Dropdown for multiple actions
|
||||
<>
|
||||
<Button
|
||||
ref={bulkActionsButtonRef}
|
||||
size="md"
|
||||
onClick={() => selectedIds.length > 0 && setIsBulkActionsDropdownOpen(!isBulkActionsDropdownOpen)}
|
||||
disabled={selectedIds.length === 0}
|
||||
className={`dropdown-toggle ${selectedIds.length === 0 ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
endIcon={<ChevronDownIcon className="w-4 h-4" />}
|
||||
>
|
||||
Bulk Actions
|
||||
{selectedIds.length > 0 && (
|
||||
<span className="ml-2 inline-flex items-center justify-center px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-800 dark:bg-blue-500/20 dark:text-blue-300">
|
||||
{selectedIds.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Dropdown
|
||||
isOpen={isBulkActionsDropdownOpen && selectedIds.length > 0}
|
||||
onClose={() => setIsBulkActionsDropdownOpen(false)}
|
||||
anchorRef={bulkActionsButtonRef as React.RefObject<HTMLElement>}
|
||||
placement="bottom-left"
|
||||
className="w-48 p-2"
|
||||
>
|
||||
{bulkActions.map((action, index) => {
|
||||
const isDelete = action.key === 'delete';
|
||||
const showDivider = isDelete && index > 0;
|
||||
return (
|
||||
<React.Fragment key={action.key}>
|
||||
{showDivider && <div className="my-2 border-t border-gray-200 dark:border-gray-800"></div>}
|
||||
<DropdownItem
|
||||
onItemClick={() => {
|
||||
handleBulkActionClick(action.key, selectedIds);
|
||||
}}
|
||||
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
|
||||
isDelete
|
||||
? "text-error-500 hover:bg-error-50 hover:text-error-600 dark:text-error-400 dark:hover:bg-error-500/15 dark:hover:text-error-300"
|
||||
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{action.icon && <span className="flex-shrink-0 w-5 h-5">{action.icon}</span>}
|
||||
<span className="text-left">{action.label}</span>
|
||||
</DropdownItem>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Dropdown>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Toggle Button */}
|
||||
{(renderFilters || filters.length > 0) && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
startIcon={<ListIcon className="w-4 h-4" />}
|
||||
>
|
||||
{showFilters ? 'Hide Filters' : 'Show Filters'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center - Filters (when toggled on) */}
|
||||
{showFilters && (renderFilters || filters.length > 0) && (
|
||||
<div className="flex-1 mx-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-800/30 rounded-lg px-4 py-2 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex gap-3 items-center flex-wrap">
|
||||
{renderFilters ? (
|
||||
renderFilters
|
||||
) : (
|
||||
<>
|
||||
{filters.map((filter) => {
|
||||
// Handle custom render filters (for complex filters like volume range)
|
||||
if (filter.type === 'custom' && (filter as any).customRender) {
|
||||
return <React.Fragment key={filter.key}>{(filter as any).customRender()}</React.Fragment>;
|
||||
}
|
||||
|
||||
if (filter.type === 'text') {
|
||||
return (
|
||||
<Input
|
||||
key={filter.key}
|
||||
type="text"
|
||||
placeholder={filter.placeholder || `Search ${filter.label.toLowerCase()}...`}
|
||||
value={filterValues[filter.key] || ''}
|
||||
onChange={(e) => {
|
||||
onFilterChange?.(filter.key, e.target.value);
|
||||
}}
|
||||
className="w-full sm:flex-1 h-9"
|
||||
/>
|
||||
);
|
||||
} else if (filter.type === 'select') {
|
||||
const currentValue = filterValues[filter.key] || '';
|
||||
return (
|
||||
<SelectDropdown
|
||||
key={filter.key}
|
||||
options={filter.options || []}
|
||||
placeholder={filter.label}
|
||||
value={currentValue}
|
||||
onChange={(value) => {
|
||||
// Ensure we pass the value even if it's an empty string
|
||||
const newValue = value === null || value === undefined ? '' : String(value);
|
||||
onFilterChange?.(filter.key, newValue);
|
||||
}}
|
||||
className={filter.className || "w-full sm:flex-1"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{hasActiveFilters && onFilterReset && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onFilterReset}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons - Right aligned */}
|
||||
{/* Right side - Action Buttons */}
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* Custom Actions */}
|
||||
{customActions}
|
||||
|
||||
{/* Column Selector */}
|
||||
<ColumnSelector
|
||||
columns={columns.map(col => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
defaultVisible: col.defaultVisible !== false,
|
||||
}))}
|
||||
visibleColumns={visibleColumns}
|
||||
onToggleColumn={handleToggleColumn}
|
||||
/>
|
||||
{onExportCSV && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -827,17 +825,34 @@ export default function TablePageTemplate({
|
||||
isHeader
|
||||
className={`px-5 py-3 font-medium text-gray-500 text-${column.align || 'start'} text-theme-xs dark:text-gray-400 ${column.sortable ? 'cursor-pointer hover:text-gray-700 dark:hover:text-gray-300' : ''} ${isLastColumn && rowActions.length > 0 ? 'pr-16' : ''}`}
|
||||
>
|
||||
{column.sortable ? (
|
||||
<div onClick={() => handleSort(column)} className="flex items-center">
|
||||
{column.label}
|
||||
{getSortIcon(column)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center flex-1">
|
||||
{column.sortable ? (
|
||||
<div onClick={() => handleSort(column)} className="flex items-center">
|
||||
{column.label}
|
||||
{getSortIcon(column)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{column.label}
|
||||
{getSortIcon(column)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{column.label}
|
||||
{getSortIcon(column)}
|
||||
</>
|
||||
)}
|
||||
{/* Column Selector in last column heading */}
|
||||
{isLastColumn && (
|
||||
<ColumnSelector
|
||||
columns={columns.map(col => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
defaultVisible: col.defaultVisible !== false,
|
||||
}))}
|
||||
visibleColumns={visibleColumns}
|
||||
onToggleColumn={handleToggleColumn}
|
||||
compact={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user