This commit is contained in:
IGNY8 VPS (Salman)
2025-12-15 10:31:20 +00:00
parent 1ef4bb7db6
commit 7fb2a9309e
10 changed files with 352 additions and 248 deletions

View File

@@ -774,7 +774,7 @@ class ClusterViewSet(SiteSectorModelViewSet):
# Search configuration - search by name # Search configuration - search by name
search_fields = ['name'] search_fields = ['name']
# Ordering configuration # Ordering configuration - volume and difficulty are model fields kept in sync
ordering_fields = ['name', 'created_at', 'keywords_count', 'volume', 'difficulty'] ordering_fields = ['name', 'created_at', 'keywords_count', 'volume', 'difficulty']
ordering = ['name'] # Default ordering ordering = ['name'] # Default ordering

View File

@@ -11,6 +11,7 @@ interface ColumnSelectorProps {
visibleColumns: Set<string>; visibleColumns: Set<string>;
onToggleColumn: (columnKey: string) => void; onToggleColumn: (columnKey: string) => void;
className?: string; className?: string;
compact?: boolean; // New prop for compact mode (icon only)
} }
export default function ColumnSelector({ export default function ColumnSelector({
@@ -18,6 +19,7 @@ export default function ColumnSelector({
visibleColumns, visibleColumns,
onToggleColumn, onToggleColumn,
className = '', className = '',
compact = false,
}: ColumnSelectorProps) { }: ColumnSelectorProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@@ -53,7 +55,10 @@ export default function ColumnSelector({
ref={buttonRef} ref={buttonRef}
type="button" type="button"
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:bg-gray-700 transition-colors" className={`inline-flex items-center justify-center gap-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:bg-gray-700 transition-colors ${
compact ? 'w-8 h-8 p-0' : 'px-3 py-2'
}`}
title={compact ? `Columns (${visibleCount}/${totalCount})` : undefined}
> >
<svg <svg
className="w-4 h-4" className="w-4 h-4"
@@ -69,11 +74,20 @@ export default function ColumnSelector({
d="M4 6h16M4 12h16M4 18h16" d="M4 6h16M4 12h16M4 18h16"
/> />
</svg> </svg>
<span>Columns</span> {!compact && (
<span className="text-xs text-gray-500 dark:text-gray-400"> <>
({visibleCount}/{totalCount}) <span>Columns</span>
</span> <span className="text-xs text-gray-500 dark:text-gray-400">
<ChevronDownIcon className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} /> ({visibleCount}/{totalCount})
</span>
<ChevronDownIcon className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</>
)}
{compact && (
<span className="absolute -top-1 -right-1 inline-flex items-center justify-center w-4 h-4 text-[10px] font-bold text-white bg-brand-500 rounded-full">
{visibleCount}
</span>
)}
</button> </button>
{isOpen && ( {isOpen && (

View File

@@ -134,7 +134,7 @@ export const createClustersPageConfig = (
{ {
key: 'ideas_count', key: 'ideas_count',
label: 'Ideas', label: 'Ideas',
sortable: true, sortable: false, // Backend doesn't support sorting by ideas_count
sortField: 'ideas_count', sortField: 'ideas_count',
width: '120px', width: '120px',
render: (value: number) => value.toLocaleString(), render: (value: number) => value.toLocaleString(),
@@ -180,7 +180,7 @@ export const createClustersPageConfig = (
{ {
key: 'content_count', key: 'content_count',
label: 'Content', label: 'Content',
sortable: true, sortable: false, // Backend doesn't support sorting by content_count
sortField: 'content_count', sortField: 'content_count',
width: '120px', width: '120px',
render: (value: number) => value.toLocaleString(), render: (value: number) => value.toLocaleString(),
@@ -220,7 +220,7 @@ export const createClustersPageConfig = (
{ {
key: 'mapped_pages', key: 'mapped_pages',
label: 'Mapped Pages', label: 'Mapped Pages',
sortable: true, sortable: false, // Backend doesn't support sorting by mapped_pages
sortField: 'mapped_pages', sortField: 'mapped_pages',
defaultVisible: false, defaultVisible: false,
width: '120px', width: '120px',
@@ -229,7 +229,7 @@ export const createClustersPageConfig = (
{ {
key: 'updated_at', key: 'updated_at',
label: 'Updated', label: 'Updated',
sortable: true, sortable: false, // Backend doesn't support sorting by updated_at
sortField: 'updated_at', sortField: 'updated_at',
defaultVisible: false, defaultVisible: false,
render: (value: string) => formatRelativeDate(value), render: (value: string) => formatRelativeDate(value),

View File

@@ -116,7 +116,7 @@ export const createIdeasPageConfig = (
{ {
key: 'content_structure', key: 'content_structure',
label: 'Structure', label: 'Structure',
sortable: true, sortable: false, // Backend doesn't support sorting by content_structure
sortField: 'content_structure', sortField: 'content_structure',
width: '130px', width: '130px',
render: (value: string) => { render: (value: string) => {
@@ -134,7 +134,7 @@ export const createIdeasPageConfig = (
{ {
key: 'content_type', key: 'content_type',
label: 'Type', label: 'Type',
sortable: true, sortable: false, // Backend doesn't support sorting by content_type
sortField: 'content_type', sortField: 'content_type',
width: '110px', width: '110px',
render: (value: string) => { render: (value: string) => {
@@ -161,14 +161,14 @@ export const createIdeasPageConfig = (
{ {
key: 'keyword_cluster_name', key: 'keyword_cluster_name',
label: 'Cluster', label: 'Cluster',
sortable: true, sortable: false, // Backend doesn't support sorting by keyword_cluster_id
sortField: 'keyword_cluster_id', sortField: 'keyword_cluster_id',
width: '200px', width: '200px',
render: (_value: string, row: ContentIdea) => row.keyword_cluster_name || '-', render: (_value: string, row: ContentIdea) => row.keyword_cluster_name || '-',
}, },
{ {
...statusColumn, ...statusColumn,
sortable: true, sortable: false, // Backend doesn't support sorting by status
sortField: 'status', sortField: 'status',
render: (value: string) => { render: (value: string) => {
const statusColors: Record<string, 'success' | 'amber' | 'info'> = { const statusColors: Record<string, 'success' | 'amber' | 'info'> = {
@@ -202,7 +202,7 @@ export const createIdeasPageConfig = (
{ {
key: 'updated_at', key: 'updated_at',
label: 'Updated', label: 'Updated',
sortable: true, sortable: false, // Backend doesn't support sorting by updated_at
sortField: 'updated_at', sortField: 'updated_at',
defaultVisible: false, defaultVisible: false,
render: (value: string) => formatRelativeDate(value), render: (value: string) => formatRelativeDate(value),

View File

@@ -144,8 +144,8 @@ export const createKeywordsPageConfig = (
columns: [ columns: [
{ {
...keywordColumn, ...keywordColumn,
sortable: true, sortable: false, // Backend doesn't support sorting by keyword field
sortField: 'keyword', sortField: 'seed_keyword__keyword',
}, },
// Sector column - only show when viewing all sectors // Sector column - only show when viewing all sectors
...(showSectorColumn ? [{ ...(showSectorColumn ? [{
@@ -159,19 +159,19 @@ export const createKeywordsPageConfig = (
{ {
...volumeColumn, ...volumeColumn,
sortable: true, sortable: true,
sortField: 'volume', sortField: 'seed_keyword__volume', // Backend expects seed_keyword__volume
render: (value: number) => value.toLocaleString(), render: (value: number) => value.toLocaleString(),
}, },
{ {
...clusterColumn, ...clusterColumn,
sortable: true, sortable: false, // Backend doesn't support sorting by cluster_id
sortField: 'cluster_id', sortField: 'cluster_id',
render: (_value: string, row: Keyword) => row.cluster_name || '-', render: (_value: string, row: Keyword) => row.cluster_name || '-',
}, },
{ {
...difficultyColumn, ...difficultyColumn,
sortable: true, sortable: true,
sortField: 'difficulty', sortField: 'seed_keyword__difficulty', // Backend expects seed_keyword__difficulty
align: 'center' as const, align: 'center' as const,
render: (value: number) => { render: (value: number) => {
const difficultyNum = getDifficultyNumber(value); const difficultyNum = getDifficultyNumber(value);
@@ -198,8 +198,8 @@ export const createKeywordsPageConfig = (
}, },
{ {
...intentColumn, ...intentColumn,
sortable: true, sortable: false, // Backend doesn't support sorting by intent
sortField: 'intent', sortField: 'seed_keyword__intent',
render: (value: string) => { render: (value: string) => {
// Map intent values to badge colors // Map intent values to badge colors
// Transactional and Commercial → success (green, like active) // Transactional and Commercial → success (green, like active)

View File

@@ -149,7 +149,7 @@ export function createPublishedPageConfig(params: {
{ {
key: 'content_type', key: 'content_type',
label: 'Type', label: 'Type',
sortable: true, sortable: false, // Backend doesn't support sorting by content_type
sortField: 'content_type', sortField: 'content_type',
width: '110px', width: '110px',
render: (value: string) => { render: (value: string) => {
@@ -165,7 +165,7 @@ export function createPublishedPageConfig(params: {
{ {
key: 'content_structure', key: 'content_structure',
label: 'Structure', label: 'Structure',
sortable: true, sortable: false, // Backend doesn't support sorting by content_structure
sortField: 'content_structure', sortField: 'content_structure',
width: '130px', width: '130px',
render: (value: string) => { render: (value: string) => {
@@ -248,7 +248,7 @@ export function createPublishedPageConfig(params: {
{ {
key: 'word_count', key: 'word_count',
label: 'Words', label: 'Words',
sortable: true, sortable: false, // Backend doesn't support sorting by word_count
sortField: 'word_count', sortField: 'word_count',
numeric: true, numeric: true,
width: '100px', width: '100px',

View File

@@ -136,7 +136,7 @@ export const createTasksPageConfig = (
{ {
key: 'cluster_name', key: 'cluster_name',
label: 'Cluster', label: 'Cluster',
sortable: true, sortable: false, // Backend doesn't support sorting by cluster_id
sortField: 'cluster_id', sortField: 'cluster_id',
width: '200px', width: '200px',
render: (_value: string, row: Task) => row.cluster_name || '-', render: (_value: string, row: Task) => row.cluster_name || '-',
@@ -162,7 +162,7 @@ export const createTasksPageConfig = (
{ {
key: 'content_type', key: 'content_type',
label: 'Type', label: 'Type',
sortable: true, sortable: false, // Backend doesn't support sorting by content_type
sortField: 'content_type', sortField: 'content_type',
width: '110px', width: '110px',
render: (value: string) => { render: (value: string) => {

View File

@@ -559,7 +559,7 @@ export async function fetchKeywords(filters: KeywordFilters = {}): Promise<Keywo
if (filters.search) params.append('search', filters.search); if (filters.search) params.append('search', filters.search);
if (filters.status) params.append('status', filters.status); if (filters.status) params.append('status', filters.status);
if (filters.cluster_id) params.append('cluster_id', filters.cluster_id); if (filters.cluster_id) params.append('cluster_id', filters.cluster_id);
if (filters.intent) params.append('intent', filters.intent); if (filters.intent) params.append('seed_keyword__intent', filters.intent);
if (filters.difficulty_min !== undefined) params.append('difficulty_min', filters.difficulty_min.toString()); if (filters.difficulty_min !== undefined) params.append('difficulty_min', filters.difficulty_min.toString());
if (filters.difficulty_max !== undefined) params.append('difficulty_max', filters.difficulty_max.toString()); if (filters.difficulty_max !== undefined) params.append('difficulty_max', filters.difficulty_max.toString());
if (filters.volume_min !== undefined) params.append('volume_min', filters.volume_min.toString()); if (filters.volume_min !== undefined) params.append('volume_min', filters.volume_min.toString());

View File

@@ -3,14 +3,23 @@
* Manages column visibility settings per page with localStorage persistence * Manages column visibility settings per page with localStorage persistence
* Uses the same pattern as siteStore and sectorStore * Uses the same pattern as siteStore and sectorStore
* Stores preferences per user for multi-user support * Stores preferences per user for multi-user support
* Column preferences expire after 30 days
*/ */
import { create } from 'zustand'; import { create } from 'zustand';
import { persist, createJSONStorage, StateStorage } from 'zustand/middleware'; import { persist, createJSONStorage, StateStorage } from 'zustand/middleware';
import { useAuthStore } from './authStore';
interface ColumnPreference {
columns: string[];
timestamp: number;
}
interface ColumnVisibilityState { interface ColumnVisibilityState {
// Map of page pathname to Set of visible column keys // Map of page pathname to column preference with timestamp
pageColumns: Record<string, string[]>; pageColumns: Record<string, ColumnPreference>;
// Hydration flag to know when persist has loaded
_hasHydrated: boolean;
setHasHydrated: (state: boolean) => void;
// Actions // Actions
setPageColumns: (pathname: string, columnKeys: string[]) => void; setPageColumns: (pathname: string, columnKeys: string[]) => void;
@@ -19,50 +28,107 @@ interface ColumnVisibilityState {
resetPageColumns: (pathname: string) => void; resetPageColumns: (pathname: string) => void;
} }
// Helper function to get user ID directly from localStorage (synchronous, no race conditions)
// CRITICAL: We must read directly from localStorage, not from useAuthStore.getState().user
// because the auth store might not be hydrated yet when this storage is accessed
const getUserIdFromStorage = (): string => {
try {
const authData = localStorage.getItem('auth-storage');
if (authData) {
const parsed = JSON.parse(authData);
const userId = parsed?.state?.user?.id;
if (userId) {
return String(userId);
}
}
} catch (error) {
// Silent fail - will use anonymous
}
return 'anonymous';
};
// Custom storage that uses user-specific keys // Custom storage that uses user-specific keys
// IMPORTANT: The persist middleware passes the 'name' param, but we need to append user ID
// CRITICAL FIX: Get user ID directly from localStorage synchronously to avoid race conditions
const userSpecificStorage: StateStorage = { const userSpecificStorage: StateStorage = {
getItem: (name: string) => { getItem: (name: string) => {
const user = useAuthStore.getState().user; const userId = getUserIdFromStorage();
const userId = user?.id || 'anonymous'; const key = `${name}-user-${userId}`;
const key = `igny8-column-visibility-user-${userId}`;
const value = localStorage.getItem(key); const value = localStorage.getItem(key);
if (typeof window !== 'undefined' && window.location.pathname.includes('/writer/')) {
console.log('🔍 STORAGE GET:', { key, hasValue: !!value, valuePreview: value?.substring(0, 50) });
}
return value; return value;
}, },
setItem: (name: string, value: string) => { setItem: (name: string, value: string) => {
const user = useAuthStore.getState().user; const userId = getUserIdFromStorage();
const userId = user?.id || 'anonymous'; const key = `${name}-user-${userId}`;
const key = `igny8-column-visibility-user-${userId}`; if (typeof window !== 'undefined' && window.location.pathname.includes('/writer/')) {
console.log('💾 STORAGE SET:', { key, valuePreview: value?.substring(0, 50) });
}
localStorage.setItem(key, value); localStorage.setItem(key, value);
}, },
removeItem: (name: string) => { removeItem: (name: string) => {
const user = useAuthStore.getState().user; const userId = getUserIdFromStorage();
const userId = user?.id || 'anonymous'; const key = `${name}-user-${userId}`;
const key = `igny8-column-visibility-user-${userId}`;
localStorage.removeItem(key); localStorage.removeItem(key);
}, },
}; };
// 30 days in milliseconds
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
export const useColumnVisibilityStore = create<ColumnVisibilityState>()( export const useColumnVisibilityStore = create<ColumnVisibilityState>()(
persist<ColumnVisibilityState>( persist<ColumnVisibilityState>(
(set, get) => ({ (set, get) => ({
pageColumns: {}, pageColumns: {},
_hasHydrated: false,
setHasHydrated: (state: boolean) => {
set({ _hasHydrated: state });
},
setPageColumns: (pathname: string, columnKeys: string[]) => { setPageColumns: (pathname: string, columnKeys: string[]) => {
if (pathname.includes('/writer/')) {
console.log('📝 setPageColumns:', { pathname, columns: columnKeys, timestamp: new Date().toISOString() });
}
set((state) => ({ set((state) => ({
pageColumns: { pageColumns: {
...state.pageColumns, ...state.pageColumns,
[pathname]: columnKeys, [pathname]: {
columns: columnKeys,
timestamp: Date.now(),
},
}, },
})); }));
}, },
getPageColumns: (pathname: string) => { getPageColumns: (pathname: string) => {
return get().pageColumns[pathname] || []; const preference = get().pageColumns[pathname];
if (pathname.includes('/writer/')) {
console.log('📖 getPageColumns:', { pathname, hasPreference: !!preference, columns: preference?.columns });
}
if (!preference) return [];
// Check if preference has expired (older than 30 days)
const now = Date.now();
if (now - preference.timestamp > THIRTY_DAYS_MS) {
// Remove expired preference
set((state) => {
const newPageColumns = { ...state.pageColumns };
delete newPageColumns[pathname];
return { pageColumns: newPageColumns };
});
return [];
}
return preference.columns;
}, },
toggleColumn: (pathname: string, columnKey: string) => { toggleColumn: (pathname: string, columnKey: string) => {
set((state) => { set((state) => {
const currentColumns = state.pageColumns[pathname] || []; const preference = state.pageColumns[pathname];
const currentColumns = preference?.columns || [];
const newColumns = currentColumns.includes(columnKey) const newColumns = currentColumns.includes(columnKey)
? currentColumns.filter((key) => key !== columnKey) ? currentColumns.filter((key) => key !== columnKey)
: [...currentColumns, columnKey]; : [...currentColumns, columnKey];
@@ -70,7 +136,10 @@ export const useColumnVisibilityStore = create<ColumnVisibilityState>()(
return { return {
pageColumns: { pageColumns: {
...state.pageColumns, ...state.pageColumns,
[pathname]: newColumns, [pathname]: {
columns: newColumns,
timestamp: Date.now(),
},
}, },
}; };
}); });
@@ -90,6 +159,12 @@ export const useColumnVisibilityStore = create<ColumnVisibilityState>()(
partialize: (state) => ({ partialize: (state) => ({
pageColumns: state.pageColumns, pageColumns: state.pageColumns,
}), }),
onRehydrateStorage: () => (state) => {
if (state && typeof window !== 'undefined' && window.location.pathname.includes('/writer/')) {
console.log('💧 REHYDRATED:', { pageColumns: state.pageColumns });
}
state?.setHasHydrated(true);
},
} }
) )
); );

View File

@@ -29,7 +29,7 @@ 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, ListIcon } from '../icons';
import { useHeaderMetrics } from '../context/HeaderMetricsContext'; 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';
@@ -185,6 +185,9 @@ export default function TablePageTemplate({
const rowActionButtonRefs = React.useRef<Map<string | number, React.RefObject<HTMLButtonElement | null>>>(new Map()); const rowActionButtonRefs = React.useRef<Map<string | number, React.RefObject<HTMLButtonElement | null>>>(new Map());
const bulkActionsButtonRef = React.useRef<HTMLButtonElement>(null); const bulkActionsButtonRef = React.useRef<HTMLButtonElement>(null);
// Filter toggle state - hidden by default
const [showFilters, setShowFilters] = useState(false);
// Get notification config for current page // Get notification config for current page
const deleteModalConfig = getDeleteModalConfig(location.pathname); const deleteModalConfig = getDeleteModalConfig(location.pathname);
const bulkActionModalConfig = getBulkActionModalConfig(location.pathname); const bulkActionModalConfig = getBulkActionModalConfig(location.pathname);
@@ -237,72 +240,71 @@ export default function TablePageTemplate({
const { setMetrics } = useHeaderMetrics(); const { setMetrics } = useHeaderMetrics();
const toast = useToast(); const toast = useToast();
const { pageSize, setPageSize } = usePageSizeStore(); const { pageSize, setPageSize } = usePageSizeStore();
const { pageColumns, setPageColumns, getPageColumns } = useColumnVisibilityStore(); const { setPageColumns, getPageColumns, _hasHydrated } = useColumnVisibilityStore();
// Column visibility state management with Zustand store (same pattern as site/sector stores) // Column visibility state management with Zustand store
// Initialize visible columns from store or defaults // Start with defaults, then update once hydration completes
const initializeVisibleColumns = useMemo(() => { const [visibleColumns, setVisibleColumns] = useState<Set<string>>(() => {
const savedColumnKeys = getPageColumns(location.pathname); // Always start with defaults on initial render (before hydration)
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( return new Set(
columns columns
.filter(col => col.defaultVisible !== false) .filter(col => col.defaultVisible !== false)
.map(col => col.key) .map(col => col.key)
); );
}, [columns, location.pathname, getPageColumns]); });
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(initializeVisibleColumns); // Track if we've already loaded saved columns for this page to prevent loops
const hasLoadedSavedColumns = useRef(false);
// Update visible columns when columns prop or pathname changes // Load saved columns from store after hydration completes (one-time per page)
useEffect(() => { useEffect(() => {
const savedColumnKeys = getPageColumns(location.pathname); console.log('🔄 LOAD EFFECT:', { hasHydrated: _hasHydrated, hasLoaded: hasLoadedSavedColumns.current, pathname: location.pathname });
if (!_hasHydrated || hasLoadedSavedColumns.current) return;
if (savedColumnKeys.length > 0) { // Mark as loaded FIRST to prevent save effect from firing during this update
hasLoadedSavedColumns.current = true;
console.log('✅ Marked hasLoaded = true');
const savedColumnKeys = getPageColumns(location.pathname);
console.log('📥 LOADING saved columns:', savedColumnKeys);
if (savedColumnKeys && savedColumnKeys.length > 0) {
const savedSet = new Set(savedColumnKeys); const savedSet = new Set(savedColumnKeys);
// Validate that all saved columns still exist // Validate that all saved columns still exist in current columns config
const validColumns = columns.filter(col => savedSet.has(col.key)); const validColumns = columns.filter(col => savedSet.has(col.key));
if (validColumns.length > 0) { if (validColumns.length > 0) {
// Add any new columns with defaultVisible !== false // Add any new columns with defaultVisible !== false
const newColumns = columns const newColumns = columns
.filter(col => !savedSet.has(col.key) && col.defaultVisible !== false) .filter(col => !savedSet.has(col.key) && col.defaultVisible !== false)
.map(col => col.key); .map(col => col.key);
const newSet = new Set([...Array.from(validColumns.map(col => col.key)), ...newColumns]); const mergedColumns = [...Array.from(validColumns.map(col => col.key)), ...newColumns];
setVisibleColumns(newSet); console.log('✅ Setting visible columns to:', mergedColumns);
// Update store with validated columns setVisibleColumns(new Set(mergedColumns));
setPageColumns(location.pathname, Array.from(newSet));
return; return;
} }
} }
// Default: show all columns that have defaultVisible !== false console.log('⚠️ No valid saved columns, keeping defaults');
const defaultSet = new Set( }, [_hasHydrated, location.pathname]);
columns // NOTE: NOT including 'getPageColumns' or 'columns' - would cause unnecessary re-runs
.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) // Reset loaded flag when pathname changes (navigating to different page)
useEffect(() => { useEffect(() => {
hasLoadedSavedColumns.current = false;
}, [location.pathname]);
// Save to store whenever visibleColumns changes (only after hydration to avoid saving defaults)
useEffect(() => {
console.log('💾 SAVE EFFECT:', {
hasHydrated: _hasHydrated,
hasLoaded: hasLoadedSavedColumns.current,
willSave: _hasHydrated && hasLoadedSavedColumns.current,
columns: Array.from(visibleColumns)
});
if (!_hasHydrated || !hasLoadedSavedColumns.current) return; // Don't save until hydration completes AND saved columns have been loaded
setPageColumns(location.pathname, Array.from(visibleColumns)); setPageColumns(location.pathname, Array.from(visibleColumns));
}, [visibleColumns, location.pathname, setPageColumns]); }, [_hasHydrated, visibleColumns, location.pathname, setPageColumns]);
// Filter columns based on visibility // Filter columns based on visibility
const visibleColumnsList = useMemo(() => { const visibleColumnsList = useMemo(() => {
@@ -597,171 +599,167 @@ export default function TablePageTemplate({
return ( return (
<div className={className}> <div className={className}>
{/* Filters Row - 75% centered, container inside stretched to 100% */} {/* Bulk Actions and Action Buttons Row - Fixed height container */}
{(renderFilters || filters.length > 0) && ( <div className="flex items-center justify-between min-h-[65px] mb-4 mt-[10px]">
<div className="flex justify-center mb-4"> {/* Left side - Bulk Actions and Filter Toggle */}
<div <div className="flex gap-2 items-center">
className="w-[75%] igny8-filter-bar p-3 rounded-lg bg-transparent" {/* Bulk Actions - Single button if only one action, dropdown if multiple */}
style={{ boxShadow: '0 2px 6px 3px rgba(0, 0, 0, 0.08)' }} {bulkActions.length > 0 && (
> <div className="inline-block">
<div className="flex flex-nowrap gap-3 items-center justify-between w-full"> {bulkActions.length === 1 ? (
<div className="flex flex-nowrap gap-3 items-center flex-1 min-w-0 w-full"> // Single button for single action
{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 <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" size="md"
onClick={() => selectedIds.length > 0 && setIsBulkActionsDropdownOpen(!isBulkActionsDropdownOpen)} onClick={() => {
if (selectedIds.length > 0) {
handleBulkActionClick(bulkActions[0].key, selectedIds);
}
}}
disabled={selectedIds.length === 0} disabled={selectedIds.length === 0}
className={`dropdown-toggle ${selectedIds.length === 0 ? "opacity-50 cursor-not-allowed" : ""}`} variant={bulkActions[0].variant === 'success' ? 'success' : bulkActions[0].variant === 'danger' ? 'primary' : 'primary'}
endIcon={<ChevronDownIcon className="w-4 h-4" />} startIcon={bulkActions[0].icon}
className={selectedIds.length === 0 ? "opacity-50 cursor-not-allowed" : ""}
> >
Bulk Actions {bulkActions[0].label}
{selectedIds.length > 0 && ( {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"> <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 ) : (
isOpen={isBulkActionsDropdownOpen && selectedIds.length > 0} // Dropdown for multiple actions
onClose={() => setIsBulkActionsDropdownOpen(false)} <>
anchorRef={bulkActionsButtonRef as React.RefObject<HTMLElement>} <Button
placement="bottom-left" ref={bulkActionsButtonRef}
className="w-48 p-2" size="md"
> onClick={() => selectedIds.length > 0 && setIsBulkActionsDropdownOpen(!isBulkActionsDropdownOpen)}
{bulkActions.map((action, index) => { disabled={selectedIds.length === 0}
const isDelete = action.key === 'delete'; className={`dropdown-toggle ${selectedIds.length === 0 ? "opacity-50 cursor-not-allowed" : ""}`}
const showDivider = isDelete && index > 0; endIcon={<ChevronDownIcon className="w-4 h-4" />}
return ( >
<React.Fragment key={action.key}> Bulk Actions
{showDivider && <div className="my-2 border-t border-gray-200 dark:border-gray-800"></div>} {selectedIds.length > 0 && (
<DropdownItem <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">
onItemClick={() => { {selectedIds.length}
handleBulkActionClick(action.key, selectedIds); </span>
}} )}
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${ </Button>
isDelete <Dropdown
? "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" isOpen={isBulkActionsDropdownOpen && selectedIds.length > 0}
: "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" onClose={() => setIsBulkActionsDropdownOpen(false)}
}`} anchorRef={bulkActionsButtonRef as React.RefObject<HTMLElement>}
> placement="bottom-left"
{action.icon && <span className="flex-shrink-0 w-5 h-5">{action.icon}</span>} className="w-48 p-2"
<span className="text-left">{action.label}</span> >
</DropdownItem> {bulkActions.map((action, index) => {
</React.Fragment> const isDelete = action.key === 'delete';
); const showDivider = isDelete && index > 0;
})} return (
</Dropdown> <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="md"
onClick={() => setShowFilters(!showFilters)}
startIcon={<ListIcon className="w-4 h-4" />}
>
{showFilters ? 'Hide Filters' : 'Show Filters'}
</Button>
)}
</div>
{/* Center - Filters (when toggled on) */}
{showFilters && (renderFilters || filters.length > 0) && (
<div className="flex-1 mx-4">
<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 ? (
renderFilters
) : (
<>
{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="w-full sm:flex-1 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 || "w-full sm:flex-1"}
/>
);
}
return null;
})}
{hasActiveFilters && onFilterReset && (
<Button
variant="secondary"
size="sm"
onClick={onFilterReset}
>
Clear Filters
</Button>
)}
</>
)}
</div>
</div>
</div> </div>
)} )}
{/* Action Buttons - Right aligned */} {/* Right side - Action Buttons */}
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{/* Custom Actions */} {/* Custom Actions */}
{customActions} {customActions}
{/* Column Selector */}
<ColumnSelector
columns={columns.map(col => ({
key: col.key,
label: col.label,
defaultVisible: col.defaultVisible !== false,
}))}
visibleColumns={visibleColumns}
onToggleColumn={handleToggleColumn}
/>
{onExportCSV && ( {onExportCSV && (
<Button <Button
variant="secondary" variant="secondary"
@@ -827,17 +825,34 @@ export default function TablePageTemplate({
isHeader 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' : ''}`} 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 className="flex items-center justify-between gap-2">
<div onClick={() => handleSort(column)} className="flex items-center"> <div className="flex items-center flex-1">
{column.label} {column.sortable ? (
{getSortIcon(column)} <div onClick={() => handleSort(column)} className="flex items-center">
{column.label}
{getSortIcon(column)}
</div>
) : (
<>
{column.label}
{getSortIcon(column)}
</>
)}
</div> </div>
) : ( {/* Column Selector in last column heading */}
<> {isLastColumn && (
{column.label} <ColumnSelector
{getSortIcon(column)} columns={columns.map(col => ({
</> key: col.key,
)} label: col.label,
defaultVisible: col.defaultVisible !== false,
}))}
visibleColumns={visibleColumns}
onToggleColumn={handleToggleColumn}
compact={true}
/>
)}
</div>
</TableCell> </TableCell>
); );
})} })}