more fixes
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -139,10 +139,23 @@ const LayoutContent: React.FC = () => {
|
||||
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([{
|
||||
label: 'Content',
|
||||
value: total > 0 ? `${remaining}/${total}` : remaining,
|
||||
label: 'Credits',
|
||||
value: total > 0 ? `${formatCredits(remaining)}/${formatCredits(total)}` : formatCredits(remaining),
|
||||
accentColor,
|
||||
}]);
|
||||
}, [balance, isAuthenticated, setMetrics]);
|
||||
@@ -160,8 +173,8 @@ const LayoutContent: React.FC = () => {
|
||||
>
|
||||
<AppHeader />
|
||||
{/* Pending Payment Banner - Shows when account status is 'pending_payment' */}
|
||||
<PendingPaymentBanner className="mx-4 mt-4 md:mx-6 md:mt-6" />
|
||||
<div className="p-4 pb-20 md:p-6 md:pb-24">
|
||||
<PendingPaymentBanner className="mx-4 mt-2 md:mx-6 md:mt-2" />
|
||||
<div className="px-4 pt-1.5 pb-20 md:px-6 md:pt-1.5 md:pb-24">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,6 @@ import { useProgressModal } from '../../hooks/useProgressModal';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import { PencilSquareIcon } from '@heroicons/react/24/outline';
|
||||
import StatusMetricsCard from '../../components/common/StatusMetricsCard';
|
||||
|
||||
export default function Content() {
|
||||
const toast = useToast();
|
||||
@@ -56,22 +55,6 @@ export default function Content() {
|
||||
const progressModal = useProgressModal();
|
||||
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
|
||||
const loadContent = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -290,24 +273,6 @@ export default function Content() {
|
||||
onDelete={handleDelete}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
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 */}
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
fetchAPI,
|
||||
deleteContent,
|
||||
bulkDeleteContent,
|
||||
fetchContent,
|
||||
} from '../../services/api';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { FileIcon, DownloadIcon, ArrowRightIcon } from '../../icons';
|
||||
@@ -27,7 +26,6 @@ import ImageQueueModal, { ImageQueueItem } from '../../components/common/ImageQu
|
||||
import SingleRecordStatusUpdateModal from '../../components/common/SingleRecordStatusUpdateModal';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import { Modal } from '../../components/ui/modal';
|
||||
import StatusMetricsCard from '../../components/common/StatusMetricsCard';
|
||||
|
||||
export default function Images() {
|
||||
const toast = useToast();
|
||||
@@ -70,22 +68,6 @@ export default function Images() {
|
||||
const [isImageModalOpen, setIsImageModalOpen] = useState(false);
|
||||
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
|
||||
const loadImages = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -533,24 +515,6 @@ export default function Images() {
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
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
|
||||
isOpen={isQueueModalOpen}
|
||||
|
||||
@@ -21,7 +21,6 @@ import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleMetricsFooter from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import StatusMetricsCard from '../../components/common/StatusMetricsCard';
|
||||
|
||||
export default function Review() {
|
||||
const toast = useToast();
|
||||
@@ -454,19 +453,6 @@ export default function Review() {
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
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
|
||||
metrics={[
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
TaskCreateData,
|
||||
fetchClusters,
|
||||
Cluster,
|
||||
fetchContent,
|
||||
} from '../../services/api';
|
||||
import FormModal from '../../components/common/FormModal';
|
||||
import ProgressModal from '../../components/common/ProgressModal';
|
||||
@@ -33,7 +32,6 @@ import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import { DocumentTextIcon } from '@heroicons/react/24/outline';
|
||||
import StatusMetricsCard from '../../components/common/StatusMetricsCard';
|
||||
|
||||
export default function Tasks() {
|
||||
const toast = useToast();
|
||||
@@ -82,27 +80,8 @@ export default function Tasks() {
|
||||
// Progress modal for AI functions
|
||||
const progressModal = useProgressModal();
|
||||
|
||||
// Review count state
|
||||
const [reviewCount, setReviewCount] = useState(0);
|
||||
|
||||
// AI Function Logs state
|
||||
|
||||
|
||||
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
|
||||
@@ -486,24 +465,6 @@ export default function Tasks() {
|
||||
setTypeFilter('');
|
||||
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 */}
|
||||
|
||||
@@ -240,7 +240,7 @@
|
||||
text-align: left !important;
|
||||
background-color: #f8fafc !important; /* Light gray background */
|
||||
border-bottom: 2px solid #e2e8f0 !important; /* Thicker bottom border */
|
||||
text-transform: uppercase;
|
||||
text-transform: capitalize;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
@@ -365,10 +365,10 @@ select.igny8-select-styled option:checked {
|
||||
}
|
||||
|
||||
.igny8-header-metric-label {
|
||||
font-size: 10px;
|
||||
font-size: 13px; /* increased from 10px by 25%+ */
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: capitalize;
|
||||
letter-spacing: 0.3px;
|
||||
color: rgb(100 116 139); /* slate-500 */
|
||||
}
|
||||
|
||||
@@ -377,7 +377,7 @@ select.igny8-select-styled option:checked {
|
||||
}
|
||||
|
||||
.igny8-header-metric-value {
|
||||
font-size: 14px;
|
||||
font-size: 16px; /* increased from 14px */
|
||||
font-weight: 700;
|
||||
color: rgb(30 41 59); /* slate-800 */
|
||||
margin-left: 4px;
|
||||
|
||||
@@ -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