more fixes
This commit is contained in:
@@ -34,6 +34,20 @@ const formatColumnKey = (key: string): string => {
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
// Helper function to format metric values with K/M suffixes
|
||||
// 3 digits show actual, 4+ digits show K, 1M+ show M
|
||||
const formatMetricValue = (value: number): string => {
|
||||
if (value >= 1000000) {
|
||||
const millions = value / 1000000;
|
||||
return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`;
|
||||
}
|
||||
if (value >= 1000) {
|
||||
const thousands = value / 1000;
|
||||
return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`;
|
||||
}
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
import Checkbox from '../components/form/input/Checkbox';
|
||||
import Button from '../components/ui/button/Button';
|
||||
import Input from '../components/form/input/InputField';
|
||||
@@ -41,9 +55,8 @@ import SelectDropdown from '../components/form/SelectDropdown';
|
||||
import { Dropdown } from '../components/ui/dropdown/Dropdown';
|
||||
import { DropdownItem } from '../components/ui/dropdown/DropdownItem';
|
||||
import AlertModal from '../components/ui/alert/AlertModal';
|
||||
import { ChevronDownIcon, MoreDotIcon, PlusIcon } from '../icons';
|
||||
import { ChevronDownIcon, MoreDotIcon, PlusIcon, InfoIcon } from '../icons';
|
||||
import { FunnelIcon } from '@heroicons/react/24/outline';
|
||||
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';
|
||||
@@ -55,6 +68,7 @@ import { usePageSizeStore } from '../store/pageSizeStore';
|
||||
import { useColumnVisibilityStore } from '../store/columnVisibilityStore';
|
||||
import ToggleTableRow, { ToggleButton } from '../components/common/ToggleTableRow';
|
||||
import ColumnSelector from '../components/common/ColumnSelector';
|
||||
import { Tooltip } from '../components/ui/tooltip/Tooltip';
|
||||
|
||||
interface ColumnConfig {
|
||||
key: string;
|
||||
@@ -94,6 +108,7 @@ interface HeaderMetrics {
|
||||
label: string;
|
||||
value: string | number;
|
||||
accentColor: 'blue' | 'green' | 'amber' | 'purple';
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
interface TablePageTemplateProps {
|
||||
@@ -164,8 +179,6 @@ interface TablePageTemplateProps {
|
||||
};
|
||||
// Custom row highlight function (returns bg class based on row data)
|
||||
getRowClassName?: (row: any) => string;
|
||||
// Status explainer component to display on right side of table actions row
|
||||
statusExplainer?: ReactNode;
|
||||
}
|
||||
|
||||
export default function TablePageTemplate({
|
||||
@@ -204,7 +217,6 @@ export default function TablePageTemplate({
|
||||
bulkActions: customBulkActions,
|
||||
primaryAction,
|
||||
getRowClassName,
|
||||
statusExplainer,
|
||||
}: TablePageTemplateProps) {
|
||||
const location = useLocation();
|
||||
const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false);
|
||||
@@ -264,7 +276,6 @@ export default function TablePageTemplate({
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const { setMetrics } = useHeaderMetrics();
|
||||
const toast = useToast();
|
||||
const { pageSize, setPageSize } = usePageSizeStore();
|
||||
const { getVisibleColumns, setVisibleColumns: saveVisibleColumns } = useColumnVisibilityStore();
|
||||
@@ -443,50 +454,6 @@ export default function TablePageTemplate({
|
||||
}
|
||||
};
|
||||
|
||||
// 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) => {
|
||||
@@ -577,124 +544,193 @@ export default function TablePageTemplate({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Bulk Actions and Action Buttons Row - Fixed height container */}
|
||||
<div className="flex items-center justify-between min-h-[65px] mb-4 mt-[10px]">
|
||||
{/* Left side - Primary Action, Bulk Actions, and Filter Toggle */}
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* Primary Workflow Action Button - Only enabled with selection */}
|
||||
{primaryAction && (
|
||||
<Button
|
||||
size="md"
|
||||
onClick={primaryAction.onClick}
|
||||
disabled={selectedIds.length === 0}
|
||||
variant="primary"
|
||||
tone={primaryAction.variant === 'success' ? 'success' : primaryAction.variant === 'warning' ? 'warning' : 'brand'}
|
||||
startIcon={primaryAction.icon}
|
||||
className={selectedIds.length === 0 ? "opacity-50 cursor-not-allowed" : ""}
|
||||
>
|
||||
{primaryAction.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">
|
||||
{/* Bulk Actions and Action Buttons Row - Reduced padding */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{/* Main row with buttons and metrics */}
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
{/* Left side - Create Button, Primary Action, Bulk Actions, Filter Toggle */}
|
||||
<div className="flex gap-1.5 items-center">
|
||||
{/* Create Button - moved to leftmost position */}
|
||||
{onCreate && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
endIcon={onCreateIcon}
|
||||
onClick={onCreate}
|
||||
>
|
||||
{createLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Primary Workflow Action Button - Only shown with selection */}
|
||||
{primaryAction && selectedIds.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={primaryAction.onClick}
|
||||
variant="primary"
|
||||
tone={primaryAction.variant === 'success' ? 'success' : primaryAction.variant === 'warning' ? 'warning' : 'brand'}
|
||||
startIcon={primaryAction.icon}
|
||||
>
|
||||
{primaryAction.label}
|
||||
<span className="ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-medium rounded-full bg-white/20 text-white">
|
||||
{selectedIds.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 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="primary"
|
||||
tone={bulkActions[0].variant === 'success' ? 'success' : bulkActions[0].variant === 'danger' ? 'danger' : 'brand'}
|
||||
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">
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Bulk Actions - Only shown with selection */}
|
||||
{bulkActions.length > 0 && selectedIds.length > 0 && (
|
||||
<div className="inline-block">
|
||||
{bulkActions.length === 1 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleBulkActionClick(bulkActions[0].key, selectedIds)}
|
||||
variant="primary"
|
||||
tone={bulkActions[0].variant === 'success' ? 'success' : bulkActions[0].variant === 'danger' ? 'danger' : 'brand'}
|
||||
startIcon={bulkActions[0].icon}
|
||||
>
|
||||
{bulkActions[0].label}
|
||||
<span className="ml-1.5 inline-flex items-center justify-center px-1.5 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">
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
ref={bulkActionsButtonRef}
|
||||
size="sm"
|
||||
onClick={() => setIsBulkActionsDropdownOpen(!isBulkActionsDropdownOpen)}
|
||||
className="dropdown-toggle"
|
||||
endIcon={<ChevronDownIcon className="w-3.5 h-3.5" />}
|
||||
>
|
||||
Bulk Actions
|
||||
<span className="ml-1.5 inline-flex items-center justify-center px-1.5 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>
|
||||
</>
|
||||
</Button>
|
||||
<Dropdown
|
||||
isOpen={isBulkActionsDropdownOpen}
|
||||
onClose={() => setIsBulkActionsDropdownOpen(false)}
|
||||
anchorRef={bulkActionsButtonRef as React.RefObject<HTMLElement>}
|
||||
placement="bottom-left"
|
||||
className="w-48 p-2"
|
||||
>
|
||||
{bulkActions.map((action, index) => {
|
||||
const isDelete = action.key === 'delete';
|
||||
const showDivider = isDelete && index > 0;
|
||||
return (
|
||||
<React.Fragment key={action.key}>
|
||||
{showDivider && <div className="my-2 border-t border-gray-200 dark:border-gray-800"></div>}
|
||||
<DropdownItem
|
||||
onItemClick={() => {
|
||||
handleBulkActionClick(action.key, selectedIds);
|
||||
}}
|
||||
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
|
||||
isDelete
|
||||
? "text-error-500 hover:bg-error-50 hover:text-error-600 dark:text-error-400 dark:hover:bg-error-500/15 dark:hover:text-error-300"
|
||||
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{action.icon && <span className="flex-shrink-0 w-5 h-5">{action.icon}</span>}
|
||||
<span className="text-left">{action.label}</span>
|
||||
</DropdownItem>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Dropdown>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Toggle Button */}
|
||||
{(renderFilters || filters.length > 0) && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
startIcon={<FunnelIcon className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{showFilters ? 'Hide Filters' : 'Show Filters'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side - Inline Metrics and Action Buttons */}
|
||||
<div className="flex gap-3 items-center">
|
||||
{/* Inline Metrics - with 25% larger label fonts */}
|
||||
{headerMetrics && headerMetrics.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-50 dark:bg-gray-800/30 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
{headerMetrics.map((metric, index) => {
|
||||
const metricElement = (
|
||||
<div className={`flex items-center gap-2 ${metric.tooltip ? 'cursor-help' : ''}`}>
|
||||
<div className={`w-1 h-5 rounded-full ${
|
||||
metric.accentColor === 'blue' ? 'bg-blue-500' :
|
||||
metric.accentColor === 'green' ? 'bg-green-500' :
|
||||
metric.accentColor === 'amber' ? 'bg-amber-500' :
|
||||
metric.accentColor === 'purple' ? 'bg-purple-500' : 'bg-gray-500'
|
||||
}`}></div>
|
||||
<span className="text-[13px] font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
{metric.label}
|
||||
{metric.tooltip && (
|
||||
<InfoIcon className="w-3 h-3 text-gray-400 dark:text-gray-500" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-base font-bold text-gray-900 dark:text-white">
|
||||
{typeof metric.value === 'number' ? formatMetricValue(metric.value) : metric.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{metric.tooltip ? (
|
||||
<Tooltip text={metric.tooltip} placement="bottom">
|
||||
{metricElement}
|
||||
</Tooltip>
|
||||
) : (
|
||||
metricElement
|
||||
)}
|
||||
{index < headerMetrics.length - 1 && (
|
||||
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-1"></div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-1.5 items-center">
|
||||
{/* Custom Actions */}
|
||||
{customActions}
|
||||
{onExportCSV && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
endIcon={onExportIcon}
|
||||
onClick={onExportCSV}
|
||||
>
|
||||
Export CSV
|
||||
</Button>
|
||||
)}
|
||||
{onImport && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
endIcon={onImportIcon}
|
||||
onClick={onImport}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Toggle Button */}
|
||||
{(renderFilters || filters.length > 0) && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
startIcon={<FunnelIcon className="w-4 h-4" />}
|
||||
>
|
||||
{showFilters ? 'Hide Filters' : 'Show Filters'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center - Filters (when toggled on) */}
|
||||
{/* Filters Row - Below action buttons, centered */}
|
||||
{showFilters && (renderFilters || filters.length > 0) && (
|
||||
<div className="flex-1 mx-4">
|
||||
<div className="flex justify-center py-1.5">
|
||||
<div className="bg-gray-50 dark:bg-gray-800/30 rounded-lg px-4 py-2 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex gap-3 items-center flex-wrap">
|
||||
{renderFilters ? (
|
||||
@@ -702,7 +738,6 @@ export default function TablePageTemplate({
|
||||
) : (
|
||||
<>
|
||||
{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>;
|
||||
}
|
||||
@@ -717,7 +752,7 @@ export default function TablePageTemplate({
|
||||
onChange={(e) => {
|
||||
onFilterChange?.(filter.key, e.target.value);
|
||||
}}
|
||||
className="w-full sm:flex-1 h-9"
|
||||
className="w-full sm:flex-1 h-8"
|
||||
/>
|
||||
);
|
||||
} else if (filter.type === 'select') {
|
||||
@@ -729,7 +764,6 @@ export default function TablePageTemplate({
|
||||
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);
|
||||
}}
|
||||
@@ -754,52 +788,6 @@ export default function TablePageTemplate({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right side - Status Explainer and Action Buttons */}
|
||||
<div className="flex gap-4 items-start">
|
||||
{/* Status Explainer */}
|
||||
{statusExplainer && (
|
||||
<div className="text-right">
|
||||
{statusExplainer}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* Custom Actions */}
|
||||
{customActions}
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{/* Data Table - Match Keywords.tsx exact styling */}
|
||||
|
||||
Reference in New Issue
Block a user