Files
igny8/frontend/src/templates/TablePageTemplate.tsx
IGNY8 VPS (Salman) d2f3f3ef97 seed keywords
2025-11-29 23:30:22 +00:00

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>
);
}