/** * TablePageTemplate - Master template for CRUD table pages * Uses exact same styling as Keywords.tsx page * * Usage: * ...} * /> */ 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; // 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; onBulkDelete?: (ids: number[]) => Promise<{ deleted_count: number }>; // Export functionality onExport?: (row: any) => Promise | void; // Single record export // Bulk export functionality onBulkExport?: (ids: string[]) => Promise; // Bulk status update functionality onBulkUpdateStatus?: (ids: string[], status: string) => Promise; // Custom bulk actions handler (for actions not covered by delete/export/update_status) onBulkAction?: (actionKey: string, ids: string[]) => Promise; // Custom row actions handler (for actions that need the full row object) onRowAction?: (actionKey: string, row: any) => Promise; 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>(new Map()); const rowActionButtonRefs = React.useRef>>(new Map()); const bulkActionsButtonRef = React.useRef(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(selection?.selectedIds || []); const [expandedRows, setExpandedRows] = useState>(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>(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(''); 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 ( ); } if (sorting.sortDirection === 'asc') { return ( ); } else { return ( ); } }; 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 (
{/* Filters Row - 75% centered, container inside stretched to 100% */} {(renderFilters || filters.length > 0) && (
{renderFilters ? (
{renderFilters}
) : ( <> {filters.map((filter) => { // Handle custom render filters (for complex filters like volume range) if (filter.type === 'custom' && (filter as any).customRender) { return {(filter as any).customRender()}; } if (filter.type === 'text') { return ( { 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 ( { // 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; })} )}
{hasActiveFilters && onFilterReset && ( )}
)} {/* Bulk Actions and Action Buttons Row */}
{/* Bulk Actions - Single button if only one action, dropdown if multiple */} {bulkActions.length > 0 && (
{bulkActions.length === 1 ? ( // Single button for single action ) : ( // Dropdown for multiple actions <> 0} onClose={() => setIsBulkActionsDropdownOpen(false)} anchorRef={bulkActionsButtonRef as React.RefObject} placement="bottom-left" className="w-48 p-2" > {bulkActions.map((action, index) => { const isDelete = action.key === 'delete'; const showDivider = isDelete && index > 0; return ( {showDivider &&
} { 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 && {action.icon}} {action.label}
); })}
)}
)} {/* Action Buttons - Right aligned */}
{/* Custom Actions */} {customActions} {/* Column Selector */} ({ key: col.key, label: col.label, defaultVisible: col.defaultVisible !== false, }))} visibleColumns={visibleColumns} onToggleColumn={handleToggleColumn} /> {onExportCSV && ( )} {onImport && ( )} {onCreate && ( )}
{/* Data Table - Match Keywords.tsx exact styling */}
{selection && ( {showContent && ( { // 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" /> )} )} {visibleColumnsList.map((column, colIndex) => { const isLastColumn = colIndex === visibleColumnsList.length - 1; return ( 0 ? 'pr-16' : ''}`} > {column.sortable ? (
handleSort(column)} className="flex items-center"> {column.label} {getSortIcon(column)}
) : ( <> {column.label} {getSortIcon(column)} )}
); })}
{!showContent ? ( // Loading Skeleton Rows - Always show 10 rows to match page size Array.from({ length: 10 }).map((_, index) => ( {selection &&
} {visibleColumnsList.map((_, colIndex) => (
))}
)) ) : 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 ( {selection && ( handleSelectRow(row.id?.toString() || '', checked)} id={`checkbox-${row.id}`} disabled={isRowAdded} /> )} {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(); rowActionButtonRefs.current.set(rowId, ref); } } return ( 0 ? 'relative pr-16' : ''}`} >
{column.render ? ( column.render(row[column.key], row) ) : ( {row[column.key]?.toString() || '-'} )}
{column.toggleable && hasToggleContent && (
e.stopPropagation()}> { handleToggle(!isRowExpanded, rowKey); }} hasContent={hasToggleContent} />
)}
{/* 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 (
Added
); } // Otherwise show "Add +" button return (
); } // Multiple actions - use dropdown const buttonRef = rowActionButtonRefs.current.get(rowId); if (!buttonRef) return null; const isOpen = openRowActions.get(rowId) || false; return ( <> { setOpenRowActions(prev => { const newMap = new Map(prev); newMap.set(rowId, false); return newMap; }); }} anchorRef={buttonRef as React.RefObject} 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 ( 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" }`} > {getIconWithColor()} {action.label} {isExport && (
)}
); })}
); })()}
); })}
{/* Toggle Row for expandable content */} {toggleableColumn && hasToggleContent && ( )}
); }) )}
{/* Delete Confirmation Modal */} {deleteModalConfig && ( 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 && ( 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 && ( 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 && (
Showing {data.length} of {pagination.totalCount} {selectionLabel || 'items'}
{ 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); }} />
)}
); }