1185 lines
51 KiB
TypeScript
1185 lines
51 KiB
TypeScript
/**
|
|
* TablePageTemplate - Master template for CRUD table pages
|
|
* Uses exact same styling as Keywords.tsx page
|
|
*
|
|
* Usage:
|
|
* <TablePageTemplate
|
|
* title="Keywords"
|
|
* subtitle="Manage and organize SEO keywords"
|
|
* columns={columns}
|
|
* data={data}
|
|
* filters={filterConfig}
|
|
* onSort={(field, direction) => ...}
|
|
* />
|
|
*/
|
|
|
|
import React, { ReactNode, useState, useEffect, useRef, useMemo } from 'react';
|
|
import { useLocation } from 'react-router';
|
|
import {
|
|
Table,
|
|
TableHeader,
|
|
TableBody,
|
|
TableRow,
|
|
TableCell,
|
|
} from '../components/ui/table';
|
|
import Checkbox from '../components/form/input/Checkbox';
|
|
import Button from '../components/ui/button/Button';
|
|
import Input from '../components/form/input/InputField';
|
|
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 { useHeaderMetrics } from '../context/HeaderMetricsContext';
|
|
import { useToast } from '../components/ui/toast/ToastContainer';
|
|
import { getDeleteModalConfig } from '../config/pages/delete-modal.config';
|
|
import { getBulkActionModalConfig } from '../config/pages/bulk-action-modal.config';
|
|
import { getTableActionsConfig } from '../config/pages/table-actions.config';
|
|
import BulkExportModal from '../components/common/BulkExportModal';
|
|
import BulkStatusUpdateModal from '../components/common/BulkStatusUpdateModal';
|
|
import { CompactPagination } from '../components/ui/pagination';
|
|
import { usePageSizeStore } from '../store/pageSizeStore';
|
|
import { useColumnVisibilityStore } from '../store/columnVisibilityStore';
|
|
import ToggleTableRow, { ToggleButton } from '../components/common/ToggleTableRow';
|
|
import ColumnSelector from '../components/common/ColumnSelector';
|
|
|
|
interface ColumnConfig {
|
|
key: string;
|
|
label: string;
|
|
sortable?: boolean;
|
|
sortField?: string; // API field name for sorting (if different from key)
|
|
badge?: boolean;
|
|
numeric?: boolean;
|
|
date?: boolean;
|
|
width?: string;
|
|
fixed?: boolean;
|
|
align?: 'left' | 'center' | 'right';
|
|
render?: (value: any, row: any) => ReactNode;
|
|
toggleable?: boolean; // If true, this column will have a toggle button for expanding content
|
|
toggleContentKey?: string; // Key of the field containing content to display when toggled
|
|
toggleContentLabel?: string; // Label for the expanded content (e.g., "Content Outline")
|
|
defaultVisible?: boolean; // Whether column is visible by default (default: true)
|
|
}
|
|
|
|
interface FilterConfig {
|
|
key: string;
|
|
label: string;
|
|
type: 'text' | 'select' | 'daterange' | 'range' | 'custom';
|
|
placeholder?: string;
|
|
options?: Array<{ value: string; label: string }>;
|
|
min?: number;
|
|
max?: number;
|
|
step?: number;
|
|
className?: string;
|
|
customRender?: () => ReactNode; // For complex custom filters like volume range
|
|
dynamicOptions?: string; // e.g., 'clusters' - flag for dynamic option loading
|
|
}
|
|
|
|
// Action configs are now imported from table-actions.config.ts
|
|
|
|
interface HeaderMetrics {
|
|
label: string;
|
|
value: string | number;
|
|
accentColor: 'blue' | 'green' | 'amber' | 'purple';
|
|
}
|
|
|
|
interface TablePageTemplateProps {
|
|
columns: ColumnConfig[];
|
|
data: any[];
|
|
loading?: boolean;
|
|
showContent?: boolean; // Controls smooth content reveal (like Keywords.tsx)
|
|
filters?: FilterConfig[];
|
|
filterValues?: Record<string, any>; // Current filter values
|
|
onFilterChange?: (key: string, value: any) => void; // Filter change handler
|
|
onFilterReset?: () => void; // Reset all filters handler
|
|
renderFilters?: ReactNode; // Custom filter rendering from parent (deprecated - use filters prop)
|
|
onEdit?: (row: any) => void; // Handler for edit action (always available)
|
|
onCreate?: () => void;
|
|
createLabel?: string;
|
|
onCreateIcon?: ReactNode; // Icon for create button
|
|
onExportCSV?: () => void; // CSV export button handler
|
|
onExportIcon?: ReactNode; // Icon for export button
|
|
onImport?: () => void;
|
|
onImportIcon?: ReactNode; // Icon for import button
|
|
headerMetrics?: HeaderMetrics[]; // Header metrics for top bar
|
|
selectionLabel?: string; // Custom label for selection count (default: "items")
|
|
pagination?: {
|
|
currentPage: number;
|
|
totalPages: number;
|
|
totalCount: number;
|
|
onPageChange: (page: number) => void;
|
|
};
|
|
selection?: {
|
|
selectedIds: string[];
|
|
onSelectionChange: (ids: string[]) => void;
|
|
};
|
|
sorting?: {
|
|
sortBy: string;
|
|
sortDirection: 'asc' | 'desc';
|
|
onSort: (field: string, direction: 'asc' | 'desc') => void;
|
|
};
|
|
// Delete functionality
|
|
onDelete?: (id: number) => Promise<void>;
|
|
onBulkDelete?: (ids: number[]) => Promise<{ deleted_count: number }>;
|
|
// Export functionality
|
|
onExport?: (row: any) => Promise<void> | void; // Single record export
|
|
// Bulk export functionality
|
|
onBulkExport?: (ids: string[]) => Promise<void>;
|
|
// Bulk status update functionality
|
|
onBulkUpdateStatus?: (ids: string[], status: string) => Promise<void>;
|
|
// Custom bulk actions handler (for actions not covered by delete/export/update_status)
|
|
onBulkAction?: (actionKey: string, ids: string[]) => Promise<void>;
|
|
// Custom row actions handler (for actions that need the full row object)
|
|
onRowAction?: (actionKey: string, row: any) => Promise<void>;
|
|
getItemDisplayName?: (row: any) => string; // Function to get display name from row (e.g., row.keyword or row.name)
|
|
className?: string;
|
|
// Custom actions to display in action buttons area (near column selector)
|
|
customActions?: ReactNode;
|
|
// Custom bulk actions configuration (overrides table-actions.config.ts)
|
|
bulkActions?: Array<{
|
|
key: string;
|
|
label: string;
|
|
icon?: ReactNode;
|
|
variant?: 'primary' | 'success' | 'danger';
|
|
}>;
|
|
}
|
|
|
|
export default function TablePageTemplate({
|
|
columns,
|
|
data,
|
|
loading: _loading = false, // Unused - component uses showContent for loading state
|
|
showContent = true,
|
|
filters = [],
|
|
filterValues = {},
|
|
onFilterChange,
|
|
onFilterReset,
|
|
renderFilters,
|
|
onEdit,
|
|
onCreate,
|
|
createLabel = '+ Add',
|
|
onCreateIcon,
|
|
onExportCSV,
|
|
onExportIcon,
|
|
onImport,
|
|
onImportIcon,
|
|
headerMetrics = [],
|
|
selectionLabel = 'items',
|
|
pagination,
|
|
selection,
|
|
sorting,
|
|
onDelete,
|
|
onBulkDelete,
|
|
onBulkExport,
|
|
onBulkUpdateStatus,
|
|
onBulkAction,
|
|
onRowAction,
|
|
onExport,
|
|
getItemDisplayName = (row: any) => row.name || row.keyword || row.title || String(row.id),
|
|
className = '',
|
|
customActions,
|
|
bulkActions: customBulkActions,
|
|
}: TablePageTemplateProps) {
|
|
const location = useLocation();
|
|
const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false);
|
|
const [openRowActions, setOpenRowActions] = useState<Map<string | number, boolean>>(new Map());
|
|
const rowActionButtonRefs = React.useRef<Map<string | number, React.RefObject<HTMLButtonElement | null>>>(new Map());
|
|
const bulkActionsButtonRef = React.useRef<HTMLButtonElement>(null);
|
|
|
|
// Get notification config for current page
|
|
const deleteModalConfig = getDeleteModalConfig(location.pathname);
|
|
const bulkActionModalConfig = getBulkActionModalConfig(location.pathname);
|
|
const tableActionsConfig = getTableActionsConfig(location.pathname);
|
|
|
|
// Get actions from config (edit/delete always included)
|
|
const rowActions = tableActionsConfig?.rowActions || [];
|
|
// Use custom bulk actions if provided, otherwise use config
|
|
const bulkActions = customBulkActions || tableActionsConfig?.bulkActions || [];
|
|
|
|
// Selection and expanded rows state
|
|
const [selectedIds, setSelectedIds] = useState<string[]>(selection?.selectedIds || []);
|
|
const [expandedRows, setExpandedRows] = useState<Set<string | number>>(new Set());
|
|
|
|
// Delete modal state
|
|
const [deleteModal, setDeleteModal] = useState<{
|
|
isOpen: boolean;
|
|
items: any[];
|
|
isBulk: boolean;
|
|
isLoading: boolean;
|
|
}>({
|
|
isOpen: false,
|
|
items: [],
|
|
isBulk: false,
|
|
isLoading: false,
|
|
});
|
|
|
|
// Bulk export modal state
|
|
const [exportModal, setExportModal] = useState<{
|
|
isOpen: boolean;
|
|
itemCount: number;
|
|
isLoading: boolean;
|
|
}>({
|
|
isOpen: false,
|
|
itemCount: 0,
|
|
isLoading: false,
|
|
});
|
|
|
|
// Bulk status update modal state
|
|
const [statusUpdateModal, setStatusUpdateModal] = useState<{
|
|
isOpen: boolean;
|
|
itemCount: number;
|
|
isLoading: boolean;
|
|
}>({
|
|
isOpen: false,
|
|
itemCount: 0,
|
|
isLoading: false,
|
|
});
|
|
|
|
const { setMetrics } = useHeaderMetrics();
|
|
const toast = useToast();
|
|
const { pageSize, setPageSize } = usePageSizeStore();
|
|
const { pageColumns, setPageColumns, getPageColumns } = 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
|
|
return new Set(
|
|
columns
|
|
.filter(col => col.defaultVisible !== false)
|
|
.map(col => col.key)
|
|
);
|
|
}, [columns, location.pathname, getPageColumns]);
|
|
|
|
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(initializeVisibleColumns);
|
|
|
|
// Update visible columns when columns prop or pathname changes
|
|
useEffect(() => {
|
|
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);
|
|
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));
|
|
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]);
|
|
|
|
// Save to store whenever visibleColumns changes (Zustand persist middleware handles localStorage)
|
|
useEffect(() => {
|
|
setPageColumns(location.pathname, Array.from(visibleColumns));
|
|
}, [visibleColumns, location.pathname, setPageColumns]);
|
|
|
|
// Filter columns based on visibility
|
|
const visibleColumnsList = useMemo(() => {
|
|
return columns.filter(col => visibleColumns.has(col.key));
|
|
}, [columns, visibleColumns]);
|
|
|
|
// Toggle column visibility
|
|
const handleToggleColumn = (columnKey: string) => {
|
|
setVisibleColumns(prev => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(columnKey)) {
|
|
newSet.delete(columnKey);
|
|
} else {
|
|
newSet.add(columnKey);
|
|
}
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
// Sync selectedIds with selection prop
|
|
// Use JSON.stringify to compare array contents, not just reference, to avoid unnecessary updates
|
|
const selectedIdsKey = selection?.selectedIds ? JSON.stringify(selection.selectedIds) : '';
|
|
useEffect(() => {
|
|
if (selection?.selectedIds) {
|
|
setSelectedIds(selection.selectedIds);
|
|
}
|
|
}, [selectedIdsKey]); // Only depend on the stringified array, not the selection object
|
|
|
|
// Handle delete click - single item
|
|
const handleDeleteClick = (row: any) => {
|
|
if (!onDelete || !deleteModalConfig) return;
|
|
setDeleteModal({ isOpen: true, items: [row], isBulk: false, isLoading: false });
|
|
};
|
|
|
|
// Handle bulk delete
|
|
const handleBulkDelete = (ids: string[]) => {
|
|
if (!onBulkDelete || !deleteModalConfig) return;
|
|
const selectedItems = data.filter(row => ids.includes(String(row.id)));
|
|
setDeleteModal({ isOpen: true, items: selectedItems, isBulk: true, isLoading: false });
|
|
};
|
|
|
|
const handleDeleteConfirm = async () => {
|
|
if (deleteModal.items.length === 0 || !deleteModalConfig) return;
|
|
|
|
setDeleteModal(prev => ({ ...prev, isLoading: true }));
|
|
|
|
try {
|
|
if (deleteModal.isBulk) {
|
|
// Bulk delete
|
|
if (onBulkDelete) {
|
|
const ids = deleteModal.items.map(item => item.id);
|
|
const result = await onBulkDelete(ids);
|
|
const count = result?.deleted_count || deleteModal.items.length;
|
|
toast.success(`${count} ${deleteModalConfig.itemNamePlural} deleted successfully`);
|
|
}
|
|
} else {
|
|
// Single delete
|
|
if (onDelete) {
|
|
await onDelete(deleteModal.items[0].id);
|
|
toast.success(`${deleteModalConfig.itemNameSingular.charAt(0).toUpperCase() + deleteModalConfig.itemNameSingular.slice(1)} deleted successfully`);
|
|
}
|
|
}
|
|
|
|
setDeleteModal({ isOpen: false, items: [], isBulk: false, isLoading: false });
|
|
// Clear selection - parent component should handle data reload
|
|
if (selection) {
|
|
selection.onSelectionChange([]);
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(`Failed to delete: ${error.message}`);
|
|
setDeleteModal(prev => ({ ...prev, isLoading: false }));
|
|
}
|
|
};
|
|
|
|
// Handle row action click - routes to appropriate handler based on action key
|
|
const handleRowActionClick = async (actionKey: string, row: any) => {
|
|
// Close dropdown for this row
|
|
setOpenRowActions(prev => {
|
|
const newMap = new Map(prev);
|
|
newMap.set(row.id || row, false);
|
|
return newMap;
|
|
});
|
|
|
|
if (actionKey === 'edit' && onEdit) {
|
|
onEdit(row);
|
|
} else if (actionKey === 'delete' && onDelete && deleteModalConfig) {
|
|
handleDeleteClick(row);
|
|
} else if (actionKey === 'export' && onExport) {
|
|
await onExport(row);
|
|
} else if (onRowAction) {
|
|
// For custom row actions, use onRowAction with full row object
|
|
onRowAction(actionKey, row).catch((error: any) => {
|
|
toast.error(`Action failed: ${error.message}`);
|
|
});
|
|
} else if (onBulkAction) {
|
|
// Fallback: For custom actions, use onBulkAction (but with single item)
|
|
onBulkAction(actionKey, [row.id?.toString() || String(row)]);
|
|
} else {
|
|
toast.info(`Action "${actionKey}" not yet implemented`);
|
|
}
|
|
};
|
|
|
|
// Handle bulk action click - routes to appropriate handler based on action key
|
|
const handleBulkActionClick = (actionKey: string, ids: string[]) => {
|
|
if (!selection) return;
|
|
const idsToUse = selection.selectedIds.length > 0 ? selection.selectedIds : ids;
|
|
|
|
if (actionKey === 'delete' && onBulkDelete && deleteModalConfig) {
|
|
handleBulkDelete(idsToUse);
|
|
} else if (actionKey === 'export' && onBulkExport && bulkActionModalConfig) {
|
|
const count = idsToUse.length;
|
|
setExportModal({
|
|
isOpen: true,
|
|
itemCount: count,
|
|
isLoading: false,
|
|
});
|
|
} else if (actionKey === 'update_status' && onBulkUpdateStatus && bulkActionModalConfig) {
|
|
setStatusUpdateModal({ isOpen: true, itemCount: idsToUse.length, isLoading: false });
|
|
} else if (onBulkAction) {
|
|
// Custom bulk actions - call page-specific handler
|
|
onBulkAction(actionKey, idsToUse).catch((error: any) => {
|
|
toast.error(`Bulk action failed: ${error.message}`);
|
|
});
|
|
} else {
|
|
// No handler available
|
|
toast.info(`Bulk action "${actionKey}" not yet implemented`);
|
|
}
|
|
setIsBulkActionsDropdownOpen(false);
|
|
};
|
|
|
|
const handleExportConfirm = async () => {
|
|
if (!onBulkExport || !selection) return;
|
|
const idsToExport = selection.selectedIds.length > 0 ? selection.selectedIds : selectedIds;
|
|
|
|
setExportModal(prev => ({ ...prev, isLoading: true }));
|
|
try {
|
|
await onBulkExport(idsToExport);
|
|
toast.success('Export successful');
|
|
setExportModal({ isOpen: false, itemCount: 0, isLoading: false });
|
|
selection.onSelectionChange([]);
|
|
} catch (error: any) {
|
|
toast.error(`Export failed: ${error.message}`);
|
|
setExportModal(prev => ({ ...prev, isLoading: false }));
|
|
}
|
|
};
|
|
|
|
const handleStatusUpdateConfirm = async (status: string) => {
|
|
if (!onBulkUpdateStatus || !selection) return;
|
|
setStatusUpdateModal(prev => ({ ...prev, isLoading: true }));
|
|
try {
|
|
await onBulkUpdateStatus(selection.selectedIds, status);
|
|
toast.success('Status updated successfully');
|
|
setStatusUpdateModal({ isOpen: false, itemCount: 0, isLoading: false });
|
|
selection.onSelectionChange([]);
|
|
} catch (error: any) {
|
|
toast.error(`Failed to update status: ${error.message}`);
|
|
setStatusUpdateModal(prev => ({ ...prev, isLoading: false }));
|
|
}
|
|
};
|
|
|
|
// Set header metrics when provided
|
|
// Use a ref to track previous metrics and only update when values actually change
|
|
const prevMetricsRef = useRef<string>('');
|
|
const hasSetMetricsRef = useRef(false);
|
|
|
|
// Create a stable key for comparison - only when headerMetrics has values
|
|
const metricsKey = useMemo(() => {
|
|
if (!headerMetrics || headerMetrics.length === 0) return '';
|
|
try {
|
|
return headerMetrics.map(m => `${m.label}:${String(m.value)}`).join('|');
|
|
} catch {
|
|
return '';
|
|
}
|
|
}, [headerMetrics]);
|
|
|
|
useEffect(() => {
|
|
// Skip if metrics haven't actually changed
|
|
if (metricsKey === prevMetricsRef.current) {
|
|
return;
|
|
}
|
|
|
|
// Update metrics if we have new values
|
|
// HeaderMetricsContext will automatically merge these with credit balance
|
|
if (metricsKey) {
|
|
setMetrics(headerMetrics);
|
|
hasSetMetricsRef.current = true;
|
|
prevMetricsRef.current = metricsKey;
|
|
} else if (hasSetMetricsRef.current) {
|
|
// Clear page metrics (credit balance will be preserved by HeaderMetricsContext)
|
|
setMetrics([]);
|
|
hasSetMetricsRef.current = false;
|
|
prevMetricsRef.current = '';
|
|
}
|
|
|
|
// Cleanup: clear page metrics when component unmounts (credit balance preserved)
|
|
return () => {
|
|
if (hasSetMetricsRef.current) {
|
|
setMetrics([]);
|
|
hasSetMetricsRef.current = false;
|
|
}
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [metricsKey]); // Only depend on the stable key
|
|
|
|
// Check if any filters are applied
|
|
// When using renderFilters, check filterValues directly; otherwise check filters prop
|
|
const hasActiveFilters = (renderFilters || filters.length > 0) && Object.values(filterValues).some((value) => {
|
|
if (value === '' || value === null || value === undefined) return false;
|
|
if (typeof value === 'object' && ('min' in value || 'max' in value)) {
|
|
return value.min !== '' && value.min !== null && value.min !== undefined ||
|
|
value.max !== '' && value.max !== null && value.max !== undefined;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
|
|
// Handle sorting
|
|
const handleSort = (column: ColumnConfig) => {
|
|
if (!column.sortable || !sorting) return;
|
|
|
|
const sortField = column.sortField || column.key;
|
|
const currentSort = sorting.sortBy === sortField ? sorting.sortDirection : null;
|
|
|
|
// Cycle: none -> asc -> desc -> none
|
|
let newDirection: 'asc' | 'desc' = 'asc';
|
|
if (currentSort === 'asc') {
|
|
newDirection = 'desc';
|
|
} else if (currentSort === 'desc') {
|
|
// Reset to default sort (created_at desc)
|
|
sorting.onSort('created_at', 'desc');
|
|
return;
|
|
}
|
|
|
|
sorting.onSort(sortField, newDirection);
|
|
};
|
|
|
|
const getSortIcon = (column: ColumnConfig) => {
|
|
if (!column.sortable || !sorting) return null;
|
|
|
|
const sortField = column.sortField || column.key;
|
|
if (sorting.sortBy !== sortField) {
|
|
return (
|
|
<span className="ml-1 cursor-pointer text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
|
⇅
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (sorting.sortDirection === 'asc') {
|
|
return (
|
|
<span className="ml-1 cursor-pointer text-brand-500">
|
|
↑
|
|
</span>
|
|
);
|
|
} else {
|
|
return (
|
|
<span className="ml-1 cursor-pointer text-brand-500">
|
|
↓
|
|
</span>
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleSelectAll = (checked: boolean) => {
|
|
if (checked) {
|
|
// Only select rows that are not already added - use same logic as handleBulkAddSelected (!keyword.isAdded)
|
|
const allIds = data
|
|
.filter((row) => !(row as any).isAdded) // Only select if isAdded is falsy (false, undefined, or null)
|
|
.map((row) => row.id?.toString() || '')
|
|
.filter(id => id !== '');
|
|
setSelectedIds(allIds);
|
|
selection?.onSelectionChange(allIds);
|
|
} else {
|
|
setSelectedIds([]);
|
|
selection?.onSelectionChange([]);
|
|
}
|
|
};
|
|
|
|
const handleSelectRow = (id: string, checked: boolean) => {
|
|
// Don't allow selecting rows that are already added - use same logic as handleBulkAddSelected
|
|
const row = data.find(r => String(r.id) === id);
|
|
if (row && !!(row as any).isAdded) {
|
|
return; // Don't allow selection of already added items
|
|
}
|
|
|
|
const newSelected = checked
|
|
? [...selectedIds, id]
|
|
: selectedIds.filter((selectedId) => selectedId !== id);
|
|
setSelectedIds(newSelected);
|
|
selection?.onSelectionChange(newSelected);
|
|
};
|
|
|
|
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 && (
|
|
<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)}
|
|
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>
|
|
)}
|
|
|
|
{/* Action Buttons - Right aligned */}
|
|
<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"
|
|
size="md"
|
|
endIcon={onExportIcon}
|
|
onClick={onExportCSV}
|
|
>
|
|
Export CSV
|
|
</Button>
|
|
)}
|
|
{onImport && (
|
|
<Button
|
|
variant="secondary"
|
|
size="md"
|
|
endIcon={onImportIcon}
|
|
onClick={onImport}
|
|
>
|
|
Import
|
|
</Button>
|
|
)}
|
|
{onCreate && (
|
|
<Button
|
|
variant="primary"
|
|
size="md"
|
|
endIcon={onCreateIcon}
|
|
onClick={onCreate}
|
|
>
|
|
{createLabel}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Data Table - Match Keywords.tsx exact styling */}
|
|
<div className={`rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03] igny8-table-container ${!showContent ? 'loading' : 'loaded'}`} style={{ overflowX: 'auto', overflowY: 'visible' }}>
|
|
<div className="igny8-table-wrapper" style={{ overflow: 'visible' }}>
|
|
<Table className="igny8-table-compact igny8-table-smooth">
|
|
<TableHeader className="border-b border-gray-100 dark:border-white/[0.05]">
|
|
<TableRow>
|
|
{selection && (
|
|
<TableCell
|
|
isHeader
|
|
className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400 w-12"
|
|
>
|
|
{showContent && (
|
|
<Checkbox
|
|
checked={(() => {
|
|
// Use same logic as handleBulkAddSelected - filter by !isAdded
|
|
const selectableRows = data.filter((row) => !(row as any).isAdded);
|
|
return selectableRows.length > 0 && selectedIds.length === selectableRows.length;
|
|
})()}
|
|
onChange={handleSelectAll}
|
|
id="select-all"
|
|
/>
|
|
)}
|
|
</TableCell>
|
|
)}
|
|
{visibleColumnsList.map((column, colIndex) => {
|
|
const isLastColumn = colIndex === visibleColumnsList.length - 1;
|
|
return (
|
|
<TableCell
|
|
key={column.key}
|
|
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>
|
|
) : (
|
|
<>
|
|
{column.label}
|
|
{getSortIcon(column)}
|
|
</>
|
|
)}
|
|
</TableCell>
|
|
);
|
|
})}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody className="divide-y divide-gray-100 dark:divide-white/[0.05] igny8-table-body">
|
|
{!showContent ? (
|
|
// Loading Skeleton Rows - Always show 10 rows to match page size
|
|
Array.from({ length: 10 }).map((_, index) => (
|
|
<TableRow key={`skeleton-${index}`} className="igny8-skeleton-row">
|
|
{selection && <TableCell><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div></TableCell>}
|
|
{visibleColumnsList.map((_, colIndex) => (
|
|
<TableCell key={colIndex}><div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div></TableCell>
|
|
))}
|
|
</TableRow>
|
|
))
|
|
) : data.length === 0 ? null : (
|
|
data.map((row, index) => {
|
|
// Use consistent key for expandedRows (number or index)
|
|
const rowKey = row.id || index;
|
|
const isRowExpanded = expandedRows.has(rowKey);
|
|
|
|
// Find toggleable column
|
|
const toggleableColumn = columns.find(col => col.toggleable);
|
|
const toggleContentKey = toggleableColumn?.toggleContentKey || toggleableColumn?.key;
|
|
const toggleContentLabel = toggleableColumn?.toggleContentLabel || 'Content';
|
|
const toggleContent = toggleContentKey ? row[toggleContentKey] : null;
|
|
// Check if content exists - handle both strings and objects (JSON)
|
|
const hasToggleContent = toggleContent && (
|
|
typeof toggleContent === 'string'
|
|
? toggleContent.trim().length > 0
|
|
: typeof toggleContent === 'object' && toggleContent !== null && Object.keys(toggleContent).length > 0
|
|
);
|
|
|
|
// Calculate colSpan for toggle row
|
|
const colSpan = (selection ? 1 : 0) + visibleColumnsList.length;
|
|
|
|
const handleToggle = (expanded: boolean, id: string | number) => {
|
|
setExpandedRows(prev => {
|
|
const newSet = new Set(prev);
|
|
// Use the id parameter (which should match rowKey when called from button)
|
|
const keyToUse = id !== undefined && id !== null ? id : rowKey;
|
|
if (expanded) {
|
|
newSet.add(keyToUse);
|
|
} else {
|
|
newSet.delete(keyToUse);
|
|
}
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
// Check if row is already added (for keyword opportunities)
|
|
// Use same logic as handleBulkAddSelected - check if isAdded is truthy
|
|
const isRowAdded = !!(row as any).isAdded;
|
|
|
|
return (
|
|
<React.Fragment key={row.id || index}>
|
|
<TableRow
|
|
className={`igny8-data-row ${isRowAdded ? 'bg-blue-50 dark:bg-blue-500/10' : ''}`}
|
|
>
|
|
{selection && (
|
|
<TableCell className="px-5 py-4 text-start">
|
|
<Checkbox
|
|
checked={selectedIds.includes(row.id?.toString() || '')}
|
|
onChange={(checked) => handleSelectRow(row.id?.toString() || '', checked)}
|
|
id={`checkbox-${row.id}`}
|
|
disabled={isRowAdded}
|
|
/>
|
|
</TableCell>
|
|
)}
|
|
{visibleColumnsList.map((column, colIndex) => {
|
|
const isLastColumn = colIndex === visibleColumnsList.length - 1;
|
|
const rowId = row.id || index;
|
|
|
|
// Get or create ref for this row's actions button
|
|
if (isLastColumn && rowActions.length > 0) {
|
|
if (!rowActionButtonRefs.current.has(rowId)) {
|
|
const ref = React.createRef<HTMLButtonElement>();
|
|
rowActionButtonRefs.current.set(rowId, ref);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<TableCell
|
|
key={column.key}
|
|
className={`px-5 py-4 text-${column.align || 'start'} text-gray-800 dark:text-white/90 ${isLastColumn && rowActions.length > 0 ? 'relative pr-16' : ''}`}
|
|
>
|
|
<div className={`flex items-center ${column.toggleable && hasToggleContent ? 'justify-between w-full' : ''} gap-2`}>
|
|
<div className="flex-1">
|
|
{column.render ? (
|
|
column.render(row[column.key], row)
|
|
) : (
|
|
<span>{row[column.key]?.toString() || '-'}</span>
|
|
)}
|
|
</div>
|
|
{column.toggleable && hasToggleContent && (
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<ToggleButton
|
|
isExpanded={isRowExpanded}
|
|
onClick={() => {
|
|
handleToggle(!isRowExpanded, rowKey);
|
|
}}
|
|
hasContent={hasToggleContent}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions button - absolutely positioned in last column */}
|
|
{isLastColumn && rowActions.length > 0 && (() => {
|
|
// Check if row is already added - use same logic as handleBulkAddSelected
|
|
const isRowAdded = !!(row as any).isAdded;
|
|
|
|
// Single text link if only one action, dropdown if multiple
|
|
if (rowActions.length === 1) {
|
|
const action = rowActions[0];
|
|
|
|
// If already added, show "Added" text in info color
|
|
if (isRowAdded) {
|
|
return (
|
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-10">
|
|
<span className="text-sm font-medium text-blue-light-500 dark:text-blue-light-400">
|
|
Added
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Otherwise show "Add +" button
|
|
return (
|
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-10">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleRowActionClick(action.key, row);
|
|
}}
|
|
className="flex items-center gap-1 text-sm font-medium text-success-500 hover:text-success-600 dark:text-success-400 dark:hover:text-success-300 transition-colors cursor-pointer"
|
|
>
|
|
{action.label}
|
|
<span className="inline-flex items-center">
|
|
<PlusIcon className="w-4 h-4 fill-current" />
|
|
</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Multiple actions - use dropdown
|
|
const buttonRef = rowActionButtonRefs.current.get(rowId);
|
|
if (!buttonRef) return null;
|
|
const isOpen = openRowActions.get(rowId) || false;
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
ref={buttonRef}
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setOpenRowActions(prev => {
|
|
const newMap = new Map(prev);
|
|
// Close all other dropdowns
|
|
newMap.forEach((_, key) => {
|
|
if (key !== rowId) newMap.set(key, false);
|
|
});
|
|
// Toggle current row dropdown
|
|
newMap.set(rowId, !newMap.get(rowId));
|
|
return newMap;
|
|
});
|
|
}}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center w-8 h-8 rounded-lg transition-colors text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:bg-gray-800 dropdown-toggle z-10"
|
|
title="Actions"
|
|
>
|
|
<MoreDotIcon className="w-5 h-5" />
|
|
</button>
|
|
<Dropdown
|
|
isOpen={isOpen}
|
|
onClose={() => {
|
|
setOpenRowActions(prev => {
|
|
const newMap = new Map(prev);
|
|
newMap.set(rowId, false);
|
|
return newMap;
|
|
});
|
|
}}
|
|
anchorRef={buttonRef as React.RefObject<HTMLElement>}
|
|
placement="right"
|
|
className="w-48 p-2"
|
|
>
|
|
{rowActions
|
|
.filter((action) => !action.shouldShow || action.shouldShow(row))
|
|
.map((action) => {
|
|
const isEdit = action.key === 'edit';
|
|
const isDelete = action.key === 'delete';
|
|
const isExport = action.key === 'export';
|
|
|
|
// Clone icon with appropriate color based on action type
|
|
const getIconWithColor = () => {
|
|
if (!action.icon) return null;
|
|
const iconElement = action.icon as React.ReactElement;
|
|
const existingClassName = (iconElement.props as any)?.className || "";
|
|
const baseSize = existingClassName.includes("w-") ? "" : "w-5 h-5 ";
|
|
|
|
if (isEdit) {
|
|
return React.cloneElement(iconElement, {
|
|
className: `${baseSize}text-blue-light-500 ${existingClassName}`.trim()
|
|
} as any);
|
|
} else if (isDelete) {
|
|
return React.cloneElement(iconElement, {
|
|
className: `${baseSize}text-error-500 ${existingClassName}`.trim()
|
|
} as any);
|
|
} else if (isExport) {
|
|
return React.cloneElement(iconElement, {
|
|
className: `${baseSize}text-gray-600 dark:text-gray-400 ${existingClassName}`.trim()
|
|
} as any);
|
|
}
|
|
return action.icon;
|
|
};
|
|
|
|
return (
|
|
<React.Fragment key={action.key}>
|
|
<DropdownItem
|
|
onItemClick={() => handleRowActionClick(action.key, row)}
|
|
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
|
|
isEdit
|
|
? "text-blue-light-500 hover:bg-blue-light-50 hover:text-blue-light-600 dark:text-blue-light-400 dark:hover:bg-blue-light-500/15 dark:hover:text-blue-light-300"
|
|
: 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"
|
|
}`}
|
|
>
|
|
<span className="flex-shrink-0 w-5 h-5">{getIconWithColor()}</span>
|
|
<span className="text-left">{action.label}</span>
|
|
</DropdownItem>
|
|
{isExport && (
|
|
<div className="my-2 border-t border-gray-200 dark:border-gray-800"></div>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</Dropdown>
|
|
</>
|
|
);
|
|
})()}
|
|
</TableCell>
|
|
);
|
|
})}
|
|
</TableRow>
|
|
{/* Toggle Row for expandable content */}
|
|
{toggleableColumn && hasToggleContent && (
|
|
<ToggleTableRow
|
|
row={row}
|
|
contentKey={toggleContentKey || ''}
|
|
contentLabel={toggleContentLabel}
|
|
colSpan={colSpan}
|
|
isExpanded={isRowExpanded}
|
|
onToggle={handleToggle}
|
|
/>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
{deleteModalConfig && (
|
|
<AlertModal
|
|
isOpen={deleteModal.isOpen}
|
|
onClose={() => setDeleteModal({ isOpen: false, items: [], isBulk: false, isLoading: false })}
|
|
onConfirm={handleDeleteConfirm}
|
|
title={deleteModalConfig.title}
|
|
message={
|
|
deleteModal.isBulk
|
|
? deleteModalConfig.multipleItemsMessage(deleteModal.items.length)
|
|
: deleteModalConfig.singleItemMessage
|
|
}
|
|
variant="danger"
|
|
isConfirmation={true}
|
|
confirmText="Delete"
|
|
cancelText="Cancel"
|
|
isLoading={deleteModal.isLoading}
|
|
itemsList={deleteModal.items.map(item => getItemDisplayName(item))}
|
|
/>
|
|
)}
|
|
|
|
{/* Bulk Export Confirmation Modal */}
|
|
{bulkActionModalConfig && (
|
|
<BulkExportModal
|
|
isOpen={exportModal.isOpen}
|
|
onClose={() => setExportModal({ isOpen: false, itemCount: 0, isLoading: false })}
|
|
onConfirm={handleExportConfirm}
|
|
title={bulkActionModalConfig.export.title}
|
|
message={bulkActionModalConfig.export.message(exportModal.itemCount)}
|
|
confirmText={bulkActionModalConfig.export.confirmText}
|
|
isLoading={exportModal.isLoading}
|
|
/>
|
|
)}
|
|
|
|
{/* Bulk Status Update Modal */}
|
|
{bulkActionModalConfig && (
|
|
<BulkStatusUpdateModal
|
|
isOpen={statusUpdateModal.isOpen}
|
|
onClose={() => setStatusUpdateModal({ isOpen: false, itemCount: 0, isLoading: false })}
|
|
onConfirm={handleStatusUpdateConfirm}
|
|
title={bulkActionModalConfig.updateStatus.title}
|
|
message={bulkActionModalConfig.updateStatus.message(statusUpdateModal.itemCount)}
|
|
confirmText={bulkActionModalConfig.updateStatus.confirmText}
|
|
statusOptions={bulkActionModalConfig.updateStatus.statusOptions}
|
|
isLoading={statusUpdateModal.isLoading}
|
|
/>
|
|
)}
|
|
|
|
{/* Pagination - Compact icon-based pagination */}
|
|
{pagination && (
|
|
<div className="mt-6 border-t border-gray-200 dark:border-gray-800 pt-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
Showing {data.length} of {pagination.totalCount} {selectionLabel || 'items'}
|
|
</div>
|
|
<CompactPagination
|
|
currentPage={pagination.currentPage}
|
|
totalPages={pagination.totalPages}
|
|
pageSize={pageSize}
|
|
onPageChange={(page) => {
|
|
pagination.onPageChange(page);
|
|
}}
|
|
onPageSizeChange={(size) => {
|
|
setPageSize(size);
|
|
// Reset to page 1 when page size changes
|
|
// The parent component should handle reloading via useEffect
|
|
pagination.onPageChange(1);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
}
|