more fixes

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-27 08:56:09 +00:00
parent 034c640601
commit efd7193951
8 changed files with 217 additions and 523 deletions

View File

@@ -1,183 +0,0 @@
/**
* StatusMetricsCard Component
* Displays status metrics in a card format with colored left border
* Used in table action rows to show page-specific status metrics
*/
import React from 'react';
import { Link } from 'react-router-dom';
export interface StatusMetricItem {
label: string;
value: string | number;
}
export interface StatusMetricsCardProps {
/** Title for the card */
title: string;
/** Subtitle text shown below the main count */
subtitle?: string;
/** Icon to display */
icon?: React.ReactNode;
/** Color variant - matches page badge colors */
color: 'blue' | 'orange' | 'pink' | 'emerald' | 'green' | 'purple' | 'amber' | 'red' | 'indigo' | 'cyan' | 'teal';
/** Main count value */
count: number;
/** Array of metric items for 2-column layout */
metrics?: StatusMetricItem[];
/** Review count to display */
reviewCount?: number;
/** Link to review page */
reviewLink?: string;
/** Custom action button */
actionButton?: {
label: string;
href: string;
};
}
const colorClasses: Record<string, { border: string; bg: string; text: string; iconBg: string }> = {
blue: {
border: 'border-l-blue-500',
bg: 'bg-blue-50 dark:bg-blue-500/10',
text: 'text-blue-700 dark:text-blue-300',
iconBg: 'bg-blue-100 dark:bg-blue-500/20'
},
orange: {
border: 'border-l-orange-500',
bg: 'bg-orange-50 dark:bg-orange-500/10',
text: 'text-orange-700 dark:text-orange-300',
iconBg: 'bg-orange-100 dark:bg-orange-500/20'
},
pink: {
border: 'border-l-pink-500',
bg: 'bg-pink-50 dark:bg-pink-500/10',
text: 'text-pink-700 dark:text-pink-300',
iconBg: 'bg-pink-100 dark:bg-pink-500/20'
},
emerald: {
border: 'border-l-emerald-500',
bg: 'bg-emerald-50 dark:bg-emerald-500/10',
text: 'text-emerald-700 dark:text-emerald-300',
iconBg: 'bg-emerald-100 dark:bg-emerald-500/20'
},
green: {
border: 'border-l-green-500',
bg: 'bg-green-50 dark:bg-green-500/10',
text: 'text-green-700 dark:text-green-300',
iconBg: 'bg-green-100 dark:bg-green-500/20'
},
purple: {
border: 'border-l-purple-500',
bg: 'bg-purple-50 dark:bg-purple-500/10',
text: 'text-purple-700 dark:text-purple-300',
iconBg: 'bg-purple-100 dark:bg-purple-500/20'
},
amber: {
border: 'border-l-amber-500',
bg: 'bg-amber-50 dark:bg-amber-500/10',
text: 'text-amber-700 dark:text-amber-300',
iconBg: 'bg-amber-100 dark:bg-amber-500/20'
},
red: {
border: 'border-l-red-500',
bg: 'bg-red-50 dark:bg-red-500/10',
text: 'text-red-700 dark:text-red-300',
iconBg: 'bg-red-100 dark:bg-red-500/20'
},
indigo: {
border: 'border-l-indigo-500',
bg: 'bg-indigo-50 dark:bg-indigo-500/10',
text: 'text-indigo-700 dark:text-indigo-300',
iconBg: 'bg-indigo-100 dark:bg-indigo-500/20'
},
cyan: {
border: 'border-l-cyan-500',
bg: 'bg-cyan-50 dark:bg-cyan-500/10',
text: 'text-cyan-700 dark:text-cyan-300',
iconBg: 'bg-cyan-100 dark:bg-cyan-500/20'
},
teal: {
border: 'border-l-teal-500',
bg: 'bg-teal-50 dark:bg-teal-500/10',
text: 'text-teal-700 dark:text-teal-300',
iconBg: 'bg-teal-100 dark:bg-teal-500/20'
},
};
export default function StatusMetricsCard({
title,
subtitle,
icon,
color,
count,
metrics,
reviewCount,
reviewLink = '/writer/review',
actionButton,
}: StatusMetricsCardProps) {
const colors = colorClasses[color] || colorClasses.blue;
return (
<div className={`${colors.bg} border-l-4 ${colors.border} rounded-lg px-4 py-3 min-w-[280px]`}>
<div className="flex items-start gap-3">
{/* Icon */}
{icon && (
<div className={`${colors.iconBg} rounded-lg p-2 flex-shrink-0`}>
<div className={`${colors.text} w-5 h-5`}>
{icon}
</div>
</div>
)}
{/* Content */}
<div className="flex-1 min-w-0">
{/* Title and Count Row */}
<div className="flex items-center justify-between gap-4 mb-1">
<h4 className={`text-sm font-semibold ${colors.text}`}>{title}</h4>
<span className="text-2xl font-bold text-gray-900 dark:text-white">{count}</span>
</div>
{/* Subtitle */}
{subtitle && (
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">{subtitle}</p>
)}
{/* 2-column metrics */}
{metrics && metrics.length > 0 && (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-2 text-sm">
{metrics.map((metric, index) => (
<div key={index} className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">{metric.label}</span>
<span className="font-medium text-gray-900 dark:text-white">{metric.value}</span>
</div>
))}
</div>
)}
{/* Review link/button */}
{(reviewCount !== undefined || actionButton) && (
<div className="mt-3 pt-2 border-t border-gray-200/50 dark:border-gray-700/50 flex items-center justify-between gap-2">
{reviewCount !== undefined && (
<span className="text-sm text-gray-600 dark:text-gray-400">
<span className="font-semibold text-gray-900 dark:text-white">{reviewCount}</span> awaiting review
</span>
)}
{actionButton && (
<Link
to={actionButton.href}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-md transition-colors"
>
{actionButton.label}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -139,10 +139,23 @@ const LayoutContent: React.FC = () => {
accentColor = 'purple'; accentColor = 'purple';
} }
// Set content balance - show as "X/Y Content" for simplicity // Format credit value with K/M suffix for large numbers
const formatCredits = (val: number): string => {
if (val >= 1000000) {
const millions = val / 1000000;
return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`;
}
if (val >= 1000) {
const thousands = val / 1000;
return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`;
}
return val.toString();
};
// Set credit balance - show as "used/total Credits"
setMetrics([{ setMetrics([{
label: 'Content', label: 'Credits',
value: total > 0 ? `${remaining}/${total}` : remaining, value: total > 0 ? `${formatCredits(remaining)}/${formatCredits(total)}` : formatCredits(remaining),
accentColor, accentColor,
}]); }]);
}, [balance, isAuthenticated, setMetrics]); }, [balance, isAuthenticated, setMetrics]);
@@ -160,8 +173,8 @@ const LayoutContent: React.FC = () => {
> >
<AppHeader /> <AppHeader />
{/* Pending Payment Banner - Shows when account status is 'pending_payment' */} {/* Pending Payment Banner - Shows when account status is 'pending_payment' */}
<PendingPaymentBanner className="mx-4 mt-4 md:mx-6 md:mt-6" /> <PendingPaymentBanner className="mx-4 mt-2 md:mx-6 md:mt-2" />
<div className="p-4 pb-20 md:p-6 md:pb-24"> <div className="px-4 pt-1.5 pb-20 md:px-6 md:pt-1.5 md:pb-24">
<Outlet /> <Outlet />
</div> </div>
</div> </div>

View File

@@ -25,7 +25,6 @@ import { useProgressModal } from '../../hooks/useProgressModal';
import PageHeader from '../../components/common/PageHeader'; import PageHeader from '../../components/common/PageHeader';
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter'; import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
import { PencilSquareIcon } from '@heroicons/react/24/outline'; import { PencilSquareIcon } from '@heroicons/react/24/outline';
import StatusMetricsCard from '../../components/common/StatusMetricsCard';
export default function Content() { export default function Content() {
const toast = useToast(); const toast = useToast();
@@ -56,22 +55,6 @@ export default function Content() {
const progressModal = useProgressModal(); const progressModal = useProgressModal();
const hasReloadedRef = useRef(false); const hasReloadedRef = useRef(false);
// Review count state
const [reviewCount, setReviewCount] = useState(0);
// Load review count
useEffect(() => {
const loadReviewCount = async () => {
try {
const data = await fetchContent({ status: 'review', page_size: 1 });
setReviewCount(data.count || 0);
} catch (error) {
console.error('Error fetching review count:', error);
}
};
loadReviewCount();
}, []);
// Load content - wrapped in useCallback // Load content - wrapped in useCallback
const loadContent = useCallback(async () => { const loadContent = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -290,24 +273,6 @@ export default function Content() {
onDelete={handleDelete} onDelete={handleDelete}
onBulkDelete={handleBulkDelete} onBulkDelete={handleBulkDelete}
getItemDisplayName={(row: ContentType) => row.title || `Content #${row.id}`} getItemDisplayName={(row: ContentType) => row.title || `Content #${row.id}`}
statusExplainer={
<StatusMetricsCard
title="Content Drafts"
color="orange"
icon={<PencilSquareIcon className="w-5 h-5" />}
count={totalCount}
subtitle="draft content items"
metrics={[
{ label: 'Image Prompts', value: `${content.filter(c => c.has_image_prompts).length}/${content.length}` },
{ label: 'Images Generated', value: `${content.filter(c => c.has_generated_images).length}/${content.length}` },
]}
reviewCount={reviewCount}
actionButton={{
label: 'Review',
href: '/writer/review',
}}
/>
}
/> />
{/* Module Metrics Footer - Pipeline Style with Cross-Module Links */} {/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}

View File

@@ -17,7 +17,6 @@ import {
fetchAPI, fetchAPI,
deleteContent, deleteContent,
bulkDeleteContent, bulkDeleteContent,
fetchContent,
} from '../../services/api'; } from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer'; import { useToast } from '../../components/ui/toast/ToastContainer';
import { FileIcon, DownloadIcon, ArrowRightIcon } from '../../icons'; import { FileIcon, DownloadIcon, ArrowRightIcon } from '../../icons';
@@ -27,7 +26,6 @@ import ImageQueueModal, { ImageQueueItem } from '../../components/common/ImageQu
import SingleRecordStatusUpdateModal from '../../components/common/SingleRecordStatusUpdateModal'; import SingleRecordStatusUpdateModal from '../../components/common/SingleRecordStatusUpdateModal';
import PageHeader from '../../components/common/PageHeader'; import PageHeader from '../../components/common/PageHeader';
import { Modal } from '../../components/ui/modal'; import { Modal } from '../../components/ui/modal';
import StatusMetricsCard from '../../components/common/StatusMetricsCard';
export default function Images() { export default function Images() {
const toast = useToast(); const toast = useToast();
@@ -70,22 +68,6 @@ export default function Images() {
const [isImageModalOpen, setIsImageModalOpen] = useState(false); const [isImageModalOpen, setIsImageModalOpen] = useState(false);
const [modalImageUrl, setModalImageUrl] = useState<string | null>(null); const [modalImageUrl, setModalImageUrl] = useState<string | null>(null);
// Review count state
const [reviewCount, setReviewCount] = useState(0);
// Load review count
useEffect(() => {
const loadReviewCount = async () => {
try {
const data = await fetchContent({ status: 'review', page_size: 1 });
setReviewCount(data.count || 0);
} catch (error) {
console.error('Error fetching review count:', error);
}
};
loadReviewCount();
}, []);
// Load images - wrapped in useCallback // Load images - wrapped in useCallback
const loadImages = useCallback(async () => { const loadImages = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -533,24 +515,6 @@ export default function Images() {
setCurrentPage(1); setCurrentPage(1);
}} }}
onRowAction={handleRowAction} onRowAction={handleRowAction}
statusExplainer={
<StatusMetricsCard
title="Content Images"
color="pink"
icon={<PhotoIcon className="w-5 h-5" />}
count={totalCount}
subtitle="content items with images"
metrics={[
{ label: 'Need Images', value: images.filter(i => i.overall_status === 'pending').length },
{ label: 'Images Complete', value: images.filter(i => i.overall_status === 'complete').length },
]}
reviewCount={reviewCount}
actionButton={{
label: 'Review',
href: '/writer/review',
}}
/>
}
/> />
<ImageQueueModal <ImageQueueModal
isOpen={isQueueModalOpen} isOpen={isQueueModalOpen}

View File

@@ -21,7 +21,6 @@ import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore'; import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader'; import PageHeader from '../../components/common/PageHeader';
import ModuleMetricsFooter from '../../components/dashboard/ModuleMetricsFooter'; import ModuleMetricsFooter from '../../components/dashboard/ModuleMetricsFooter';
import StatusMetricsCard from '../../components/common/StatusMetricsCard';
export default function Review() { export default function Review() {
const toast = useToast(); const toast = useToast();
@@ -454,19 +453,6 @@ export default function Review() {
setCurrentPage(1); setCurrentPage(1);
}} }}
onRowAction={handleRowAction} onRowAction={handleRowAction}
statusExplainer={
<StatusMetricsCard
title="In Review"
color="emerald"
icon={<ClipboardDocumentCheckIcon className="w-5 h-5" />}
count={totalCount}
subtitle="awaiting approval"
actionButton={{
label: 'Approved',
href: '/writer/approved',
}}
/>
}
/> />
<ModuleMetricsFooter <ModuleMetricsFooter
metrics={[ metrics={[

View File

@@ -20,7 +20,6 @@ import {
TaskCreateData, TaskCreateData,
fetchClusters, fetchClusters,
Cluster, Cluster,
fetchContent,
} from '../../services/api'; } from '../../services/api';
import FormModal from '../../components/common/FormModal'; import FormModal from '../../components/common/FormModal';
import ProgressModal from '../../components/common/ProgressModal'; import ProgressModal from '../../components/common/ProgressModal';
@@ -33,7 +32,6 @@ import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader'; import PageHeader from '../../components/common/PageHeader';
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter'; import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
import { DocumentTextIcon } from '@heroicons/react/24/outline'; import { DocumentTextIcon } from '@heroicons/react/24/outline';
import StatusMetricsCard from '../../components/common/StatusMetricsCard';
export default function Tasks() { export default function Tasks() {
const toast = useToast(); const toast = useToast();
@@ -82,27 +80,8 @@ export default function Tasks() {
// Progress modal for AI functions // Progress modal for AI functions
const progressModal = useProgressModal(); const progressModal = useProgressModal();
// Review count state
const [reviewCount, setReviewCount] = useState(0);
// AI Function Logs state
const hasReloadedRef = useRef<boolean>(false); const hasReloadedRef = useRef<boolean>(false);
// Load review count
useEffect(() => {
const loadReviewCount = async () => {
try {
const data = await fetchContent({ status: 'review', page_size: 1 });
setReviewCount(data.count || 0);
} catch (error) {
console.error('Error fetching review count:', error);
}
};
loadReviewCount();
}, []);
// Load clusters for filter dropdown // Load clusters for filter dropdown
@@ -486,24 +465,6 @@ export default function Tasks() {
setTypeFilter(''); setTypeFilter('');
setCurrentPage(1); setCurrentPage(1);
}} }}
statusExplainer={
<StatusMetricsCard
title="In Queue"
color="blue"
icon={<DocumentTextIcon className="w-5 h-5" />}
count={totalCount}
subtitle="content items queued"
metrics={[
{ label: 'Queued', value: tasks.filter(t => t.status === 'queued').length },
{ label: 'Processing', value: tasks.filter(t => t.status === 'in_progress').length },
]}
reviewCount={reviewCount}
actionButton={{
label: 'Review',
href: '/writer/review',
}}
/>
}
/> />
{/* Module Metrics Footer - Pipeline Style with Cross-Module Links */} {/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}

View File

@@ -240,7 +240,7 @@
text-align: left !important; text-align: left !important;
background-color: #f8fafc !important; /* Light gray background */ background-color: #f8fafc !important; /* Light gray background */
border-bottom: 2px solid #e2e8f0 !important; /* Thicker bottom border */ border-bottom: 2px solid #e2e8f0 !important; /* Thicker bottom border */
text-transform: uppercase; text-transform: capitalize;
letter-spacing: 0.3px; letter-spacing: 0.3px;
} }
@@ -365,10 +365,10 @@ select.igny8-select-styled option:checked {
} }
.igny8-header-metric-label { .igny8-header-metric-label {
font-size: 10px; font-size: 13px; /* increased from 10px by 25%+ */
font-weight: 500; font-weight: 500;
text-transform: uppercase; text-transform: capitalize;
letter-spacing: 0.5px; letter-spacing: 0.3px;
color: rgb(100 116 139); /* slate-500 */ color: rgb(100 116 139); /* slate-500 */
} }
@@ -377,7 +377,7 @@ select.igny8-select-styled option:checked {
} }
.igny8-header-metric-value { .igny8-header-metric-value {
font-size: 14px; font-size: 16px; /* increased from 14px */
font-weight: 700; font-weight: 700;
color: rgb(30 41 59); /* slate-800 */ color: rgb(30 41 59); /* slate-800 */
margin-left: 4px; margin-left: 4px;

View File

@@ -34,6 +34,20 @@ const formatColumnKey = (key: string): string => {
.join(' '); .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 Checkbox from '../components/form/input/Checkbox';
import Button from '../components/ui/button/Button'; import Button from '../components/ui/button/Button';
import Input from '../components/form/input/InputField'; 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 { Dropdown } from '../components/ui/dropdown/Dropdown';
import { DropdownItem } from '../components/ui/dropdown/DropdownItem'; import { DropdownItem } from '../components/ui/dropdown/DropdownItem';
import AlertModal from '../components/ui/alert/AlertModal'; 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 { FunnelIcon } from '@heroicons/react/24/outline';
import { useHeaderMetrics } from '../context/HeaderMetricsContext';
import { useToast } from '../components/ui/toast/ToastContainer'; import { useToast } from '../components/ui/toast/ToastContainer';
import { getDeleteModalConfig } from '../config/pages/delete-modal.config'; import { getDeleteModalConfig } from '../config/pages/delete-modal.config';
import { getBulkActionModalConfig } from '../config/pages/bulk-action-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 { useColumnVisibilityStore } from '../store/columnVisibilityStore';
import ToggleTableRow, { ToggleButton } from '../components/common/ToggleTableRow'; import ToggleTableRow, { ToggleButton } from '../components/common/ToggleTableRow';
import ColumnSelector from '../components/common/ColumnSelector'; import ColumnSelector from '../components/common/ColumnSelector';
import { Tooltip } from '../components/ui/tooltip/Tooltip';
interface ColumnConfig { interface ColumnConfig {
key: string; key: string;
@@ -94,6 +108,7 @@ interface HeaderMetrics {
label: string; label: string;
value: string | number; value: string | number;
accentColor: 'blue' | 'green' | 'amber' | 'purple'; accentColor: 'blue' | 'green' | 'amber' | 'purple';
tooltip?: string;
} }
interface TablePageTemplateProps { interface TablePageTemplateProps {
@@ -164,8 +179,6 @@ interface TablePageTemplateProps {
}; };
// Custom row highlight function (returns bg class based on row data) // Custom row highlight function (returns bg class based on row data)
getRowClassName?: (row: any) => string; getRowClassName?: (row: any) => string;
// Status explainer component to display on right side of table actions row
statusExplainer?: ReactNode;
} }
export default function TablePageTemplate({ export default function TablePageTemplate({
@@ -204,7 +217,6 @@ export default function TablePageTemplate({
bulkActions: customBulkActions, bulkActions: customBulkActions,
primaryAction, primaryAction,
getRowClassName, getRowClassName,
statusExplainer,
}: TablePageTemplateProps) { }: TablePageTemplateProps) {
const location = useLocation(); const location = useLocation();
const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false); const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false);
@@ -264,7 +276,6 @@ export default function TablePageTemplate({
isLoading: false, isLoading: false,
}); });
const { setMetrics } = useHeaderMetrics();
const toast = useToast(); const toast = useToast();
const { pageSize, setPageSize } = usePageSizeStore(); const { pageSize, setPageSize } = usePageSizeStore();
const { getVisibleColumns, setVisibleColumns: saveVisibleColumns } = useColumnVisibilityStore(); 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 // Check if any filters are applied
// When using renderFilters, check filterValues directly; otherwise check filters prop // When using renderFilters, check filterValues directly; otherwise check filters prop
const hasActiveFilters = (renderFilters || filters.length > 0) && Object.values(filterValues).some((value) => { const hasActiveFilters = (renderFilters || filters.length > 0) && Object.values(filterValues).some((value) => {
@@ -577,124 +544,193 @@ export default function TablePageTemplate({
return ( return (
<div className={className}> <div className={className}>
{/* Bulk Actions and Action Buttons Row - Fixed height container */} {/* Bulk Actions and Action Buttons Row - Reduced padding */}
<div className="flex items-center justify-between min-h-[65px] mb-4 mt-[10px]"> <div className="flex flex-col gap-2.5">
{/* Left side - Primary Action, Bulk Actions, and Filter Toggle */} {/* Main row with buttons and metrics */}
<div className="flex gap-2 items-center"> <div className="flex items-center justify-between py-1.5">
{/* Primary Workflow Action Button - Only enabled with selection */} {/* Left side - Create Button, Primary Action, Bulk Actions, Filter Toggle */}
{primaryAction && ( <div className="flex gap-1.5 items-center">
<Button {/* Create Button - moved to leftmost position */}
size="md" {onCreate && (
onClick={primaryAction.onClick} <Button
disabled={selectedIds.length === 0} variant="primary"
variant="primary" size="sm"
tone={primaryAction.variant === 'success' ? 'success' : primaryAction.variant === 'warning' ? 'warning' : 'brand'} endIcon={onCreateIcon}
startIcon={primaryAction.icon} onClick={onCreate}
className={selectedIds.length === 0 ? "opacity-50 cursor-not-allowed" : ""} >
> {createLabel}
{primaryAction.label} </Button>
{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">
{/* 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} {selectedIds.length}
</span> </span>
)} </Button>
</Button> )}
)}
{/* Bulk Actions - Only shown with selection */}
{/* Bulk Actions - Single button if only one action, dropdown if multiple */} {bulkActions.length > 0 && selectedIds.length > 0 && (
{bulkActions.length > 0 && ( <div className="inline-block">
<div className="inline-block"> {bulkActions.length === 1 ? (
{bulkActions.length === 1 ? ( <Button
// Single button for single action size="sm"
<Button onClick={() => handleBulkActionClick(bulkActions[0].key, selectedIds)}
size="md" variant="primary"
onClick={() => { tone={bulkActions[0].variant === 'success' ? 'success' : bulkActions[0].variant === 'danger' ? 'danger' : 'brand'}
if (selectedIds.length > 0) { startIcon={bulkActions[0].icon}
handleBulkActionClick(bulkActions[0].key, selectedIds); >
} {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">
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">
{selectedIds.length} {selectedIds.length}
</span> </span>
)} </Button>
</Button> ) : (
) : ( <>
// Dropdown for multiple actions <Button
<> ref={bulkActionsButtonRef}
<Button size="sm"
ref={bulkActionsButtonRef} onClick={() => setIsBulkActionsDropdownOpen(!isBulkActionsDropdownOpen)}
size="md" className="dropdown-toggle"
onClick={() => selectedIds.length > 0 && setIsBulkActionsDropdownOpen(!isBulkActionsDropdownOpen)} endIcon={<ChevronDownIcon className="w-3.5 h-3.5" />}
disabled={selectedIds.length === 0} >
className={`dropdown-toggle ${selectedIds.length === 0 ? "opacity-50 cursor-not-allowed" : ""}`} Bulk Actions
endIcon={<ChevronDownIcon className="w-4 h-4" />} <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">
>
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} {selectedIds.length}
</span> </span>
)} </Button>
</Button> <Dropdown
<Dropdown isOpen={isBulkActionsDropdownOpen}
isOpen={isBulkActionsDropdownOpen && selectedIds.length > 0} onClose={() => setIsBulkActionsDropdownOpen(false)}
onClose={() => setIsBulkActionsDropdownOpen(false)} anchorRef={bulkActionsButtonRef as React.RefObject<HTMLElement>}
anchorRef={bulkActionsButtonRef as React.RefObject<HTMLElement>} placement="bottom-left"
placement="bottom-left" className="w-48 p-2"
className="w-48 p-2" >
> {bulkActions.map((action, index) => {
{bulkActions.map((action, index) => { const isDelete = action.key === 'delete';
const isDelete = action.key === 'delete'; const showDivider = isDelete && index > 0;
const showDivider = isDelete && index > 0; return (
return ( <React.Fragment key={action.key}>
<React.Fragment key={action.key}> {showDivider && <div className="my-2 border-t border-gray-200 dark:border-gray-800"></div>}
{showDivider && <div className="my-2 border-t border-gray-200 dark:border-gray-800"></div>} <DropdownItem
<DropdownItem onItemClick={() => {
onItemClick={() => { handleBulkActionClick(action.key, selectedIds);
handleBulkActionClick(action.key, selectedIds); }}
}} className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${ isDelete
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-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"
: "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>}
{action.icon && <span className="flex-shrink-0 w-5 h-5">{action.icon}</span>} <span className="text-left">{action.label}</span>
<span className="text-left">{action.label}</span> </DropdownItem>
</DropdownItem> </React.Fragment>
</React.Fragment> );
); })}
})} </Dropdown>
</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> </div>
)} </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) && ( {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="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"> <div className="flex gap-3 items-center flex-wrap">
{renderFilters ? ( {renderFilters ? (
@@ -702,7 +738,6 @@ export default function TablePageTemplate({
) : ( ) : (
<> <>
{filters.map((filter) => { {filters.map((filter) => {
// Handle custom render filters (for complex filters like volume range)
if (filter.type === 'custom' && (filter as any).customRender) { if (filter.type === 'custom' && (filter as any).customRender) {
return <React.Fragment key={filter.key}>{(filter as any).customRender()}</React.Fragment>; return <React.Fragment key={filter.key}>{(filter as any).customRender()}</React.Fragment>;
} }
@@ -717,7 +752,7 @@ export default function TablePageTemplate({
onChange={(e) => { onChange={(e) => {
onFilterChange?.(filter.key, e.target.value); 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') { } else if (filter.type === 'select') {
@@ -729,7 +764,6 @@ export default function TablePageTemplate({
placeholder={filter.label} placeholder={filter.label}
value={currentValue} value={currentValue}
onChange={(value) => { onChange={(value) => {
// Ensure we pass the value even if it's an empty string
const newValue = value === null || value === undefined ? '' : String(value); const newValue = value === null || value === undefined ? '' : String(value);
onFilterChange?.(filter.key, newValue); onFilterChange?.(filter.key, newValue);
}} }}
@@ -754,52 +788,6 @@ export default function TablePageTemplate({
</div> </div>
</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> </div>
{/* Data Table - Match Keywords.tsx exact styling */} {/* Data Table - Match Keywords.tsx exact styling */}