fitlers fixes

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-15 06:03:06 +00:00
parent 75785aa642
commit 51292bb1b3
17 changed files with 895 additions and 99 deletions

View File

@@ -776,9 +776,12 @@ class KeywordViewSet(SiteSectorModelViewSet):
cluster_filter = request.query_params.get('cluster_id')
difficulty_min = request.query_params.get('difficulty_min')
difficulty_max = request.query_params.get('difficulty_max')
volume_min = request.query_params.get('volume_min')
volume_max = request.query_params.get('volume_max')
search_term = request.query_params.get('search')
# Base queryset for each filter option calculation
# For countries: apply status, cluster, difficulty filters
# For countries: apply status, cluster, difficulty, volume, search filters
countries_qs = queryset
if status_filter:
countries_qs = countries_qs.filter(status=status_filter)
@@ -800,8 +803,23 @@ class KeywordViewSet(SiteSectorModelViewSet):
)
except (ValueError, TypeError):
pass
if volume_min is not None:
try:
countries_qs = countries_qs.filter(seed_keyword__volume__gte=int(volume_min))
except (ValueError, TypeError):
pass
if volume_max is not None:
try:
countries_qs = countries_qs.filter(seed_keyword__volume__lte=int(volume_max))
except (ValueError, TypeError):
pass
if search_term:
countries_qs = countries_qs.filter(
Q(seed_keyword__keyword__icontains=search_term) |
Q(cluster__name__icontains=search_term)
)
# For statuses: apply country, cluster, difficulty filters
# For statuses: apply country, cluster, difficulty, volume, search filters
statuses_qs = queryset
if country_filter:
statuses_qs = statuses_qs.filter(seed_keyword__country=country_filter)
@@ -823,8 +841,23 @@ class KeywordViewSet(SiteSectorModelViewSet):
)
except (ValueError, TypeError):
pass
if volume_min is not None:
try:
statuses_qs = statuses_qs.filter(seed_keyword__volume__gte=int(volume_min))
except (ValueError, TypeError):
pass
if volume_max is not None:
try:
statuses_qs = statuses_qs.filter(seed_keyword__volume__lte=int(volume_max))
except (ValueError, TypeError):
pass
if search_term:
statuses_qs = statuses_qs.filter(
Q(seed_keyword__keyword__icontains=search_term) |
Q(cluster__name__icontains=search_term)
)
# For clusters: apply status, country, difficulty filters
# For clusters: apply status, country, difficulty, volume, search filters
clusters_qs = queryset
if status_filter:
clusters_qs = clusters_qs.filter(status=status_filter)
@@ -846,8 +879,23 @@ class KeywordViewSet(SiteSectorModelViewSet):
)
except (ValueError, TypeError):
pass
if volume_min is not None:
try:
clusters_qs = clusters_qs.filter(seed_keyword__volume__gte=int(volume_min))
except (ValueError, TypeError):
pass
if volume_max is not None:
try:
clusters_qs = clusters_qs.filter(seed_keyword__volume__lte=int(volume_max))
except (ValueError, TypeError):
pass
if search_term:
clusters_qs = clusters_qs.filter(
Q(seed_keyword__keyword__icontains=search_term) |
Q(cluster__name__icontains=search_term)
)
# For difficulties: apply status, country, cluster filters
# For difficulties: apply status, country, cluster, volume, search filters
difficulties_qs = queryset
if status_filter:
difficulties_qs = difficulties_qs.filter(status=status_filter)
@@ -855,6 +903,21 @@ class KeywordViewSet(SiteSectorModelViewSet):
difficulties_qs = difficulties_qs.filter(seed_keyword__country=country_filter)
if cluster_filter:
difficulties_qs = difficulties_qs.filter(cluster_id=cluster_filter)
if volume_min is not None:
try:
difficulties_qs = difficulties_qs.filter(seed_keyword__volume__gte=int(volume_min))
except (ValueError, TypeError):
pass
if volume_max is not None:
try:
difficulties_qs = difficulties_qs.filter(seed_keyword__volume__lte=int(volume_max))
except (ValueError, TypeError):
pass
if search_term:
difficulties_qs = difficulties_qs.filter(
Q(seed_keyword__keyword__icontains=search_term) |
Q(cluster__name__icontains=search_term)
)
# Get distinct countries
countries = list(set(countries_qs.values_list('seed_keyword__country', flat=True)))
@@ -1259,18 +1322,56 @@ class ClusterViewSet(SiteSectorModelViewSet):
@action(detail=False, methods=['get'], url_path='filter_options', url_name='filter_options')
def filter_options(self, request):
"""
Get distinct filter values from current data.
Returns only statuses that exist in the current site's clusters.
Get distinct filter values from current data with cascading filter support.
Returns only statuses and difficulties that exist based on other active filters.
"""
import logging
from django.db.models import Q, Sum, Avg, Case, When, F, IntegerField
logger = logging.getLogger(__name__)
try:
# Start with base queryset (already has volume/difficulty annotations from get_queryset)
queryset = self.get_queryset()
# Get filter parameters for cascading
status_filter = request.query_params.get('status', '')
difficulty_min = request.query_params.get('difficulty_min', '')
difficulty_max = request.query_params.get('difficulty_max', '')
volume_min = request.query_params.get('volume_min', '')
volume_max = request.query_params.get('volume_max', '')
search = request.query_params.get('search', '')
# ===== GET STATUS OPTIONS =====
# Apply OTHER filters (exclude status) to get valid status options
status_qs = queryset
if difficulty_min:
try:
status_qs = status_qs.filter(_annotated_difficulty__gte=float(difficulty_min))
except ValueError:
pass
if difficulty_max:
try:
status_qs = status_qs.filter(_annotated_difficulty__lte=float(difficulty_max))
except ValueError:
pass
if volume_min:
try:
status_qs = status_qs.filter(_annotated_volume__gte=int(volume_min))
except ValueError:
pass
if volume_max:
try:
status_qs = status_qs.filter(_annotated_volume__lte=int(volume_max))
except ValueError:
pass
if search:
status_qs = status_qs.filter(
Q(name__icontains=search) | Q(description__icontains=search)
)
# Get distinct statuses
statuses = list(set(queryset.values_list('status', flat=True)))
statuses = list(set(status_qs.values_list('status', flat=True)))
statuses = sorted([s for s in statuses if s])
status_labels = {
'new': 'New',
@@ -1281,9 +1382,60 @@ class ClusterViewSet(SiteSectorModelViewSet):
for s in statuses
]
# ===== GET DIFFICULTY OPTIONS =====
# Apply OTHER filters (exclude difficulty) to get valid difficulty options
difficulty_qs = queryset
if status_filter:
difficulty_qs = difficulty_qs.filter(status=status_filter)
if volume_min:
try:
difficulty_qs = difficulty_qs.filter(_annotated_volume__gte=int(volume_min))
except ValueError:
pass
if volume_max:
try:
difficulty_qs = difficulty_qs.filter(_annotated_volume__lte=int(volume_max))
except ValueError:
pass
if search:
difficulty_qs = difficulty_qs.filter(
Q(name__icontains=search) | Q(description__icontains=search)
)
# Get raw difficulty values (0-100) from annotated field
difficulty_values = difficulty_qs.exclude(_annotated_difficulty__isnull=True).values_list('_annotated_difficulty', flat=True)
# Map raw difficulty (0-100) to 1-5 scale and find unique values
difficulty_levels = set()
for d in difficulty_values:
if d is not None:
if d <= 10:
difficulty_levels.add(1)
elif d <= 30:
difficulty_levels.add(2)
elif d <= 50:
difficulty_levels.add(3)
elif d <= 70:
difficulty_levels.add(4)
else:
difficulty_levels.add(5)
difficulty_labels = {
1: '1 - Very Easy',
2: '2 - Easy',
3: '3 - Medium',
4: '4 - Hard',
5: '5 - Very Hard',
}
difficulty_options = [
{'value': str(d), 'label': difficulty_labels[d]}
for d in sorted(difficulty_levels)
]
return success_response(
data={
'statuses': status_options,
'difficulties': difficulty_options,
},
request=request
)
@@ -1476,18 +1628,40 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
@action(detail=False, methods=['get'], url_path='filter_options', url_name='filter_options')
def filter_options(self, request):
"""
Get distinct filter values from current data.
Returns only values that exist in the current site's content ideas.
Get distinct filter values from current data with cascading filter support.
Returns only values that exist based on other active filters.
"""
import logging
from django.db.models import Q
logger = logging.getLogger(__name__)
try:
queryset = self.get_queryset()
# Get distinct statuses
statuses = list(set(queryset.values_list('status', flat=True)))
# Get filter parameters for cascading
status_filter = request.query_params.get('status', '')
content_type_filter = request.query_params.get('content_type', '')
content_structure_filter = request.query_params.get('content_structure', '')
cluster_filter = request.query_params.get('cluster', '')
search = request.query_params.get('search', '')
# Apply search filter to all options if provided
base_qs = queryset
if search:
base_qs = base_qs.filter(
Q(idea_title__icontains=search) | Q(description__icontains=search)
)
# Get statuses (filtered by type, structure, cluster)
status_qs = base_qs
if content_type_filter:
status_qs = status_qs.filter(content_type=content_type_filter)
if content_structure_filter:
status_qs = status_qs.filter(content_structure=content_structure_filter)
if cluster_filter:
status_qs = status_qs.filter(keyword_cluster_id=cluster_filter)
statuses = list(set(status_qs.values_list('status', flat=True)))
statuses = sorted([s for s in statuses if s])
status_labels = {
'new': 'New',
@@ -1499,8 +1673,15 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
for s in statuses
]
# Get distinct content_types
content_types = list(set(queryset.values_list('content_type', flat=True)))
# Get content_types (filtered by status, structure, cluster)
type_qs = base_qs
if status_filter:
type_qs = type_qs.filter(status=status_filter)
if content_structure_filter:
type_qs = type_qs.filter(content_structure=content_structure_filter)
if cluster_filter:
type_qs = type_qs.filter(keyword_cluster_id=cluster_filter)
content_types = list(set(type_qs.values_list('content_type', flat=True)))
content_types = sorted([t for t in content_types if t])
type_labels = {
'post': 'Post',
@@ -1513,8 +1694,15 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
for t in content_types
]
# Get distinct content_structures
structures = list(set(queryset.values_list('content_structure', flat=True)))
# Get content_structures (filtered by status, type, cluster)
structure_qs = base_qs
if status_filter:
structure_qs = structure_qs.filter(status=status_filter)
if content_type_filter:
structure_qs = structure_qs.filter(content_type=content_type_filter)
if cluster_filter:
structure_qs = structure_qs.filter(keyword_cluster_id=cluster_filter)
structures = list(set(structure_qs.values_list('content_structure', flat=True)))
structures = sorted([s for s in structures if s])
structure_labels = {
'article': 'Article', 'guide': 'Guide', 'comparison': 'Comparison',
@@ -1529,9 +1717,16 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
for s in structures
]
# Get distinct clusters with ideas
# Get distinct clusters (filtered by status, type, structure)
cluster_qs = base_qs
if status_filter:
cluster_qs = cluster_qs.filter(status=status_filter)
if content_type_filter:
cluster_qs = cluster_qs.filter(content_type=content_type_filter)
if content_structure_filter:
cluster_qs = cluster_qs.filter(content_structure=content_structure_filter)
cluster_ids = list(set(
queryset.exclude(keyword_cluster_id__isnull=True)
cluster_qs.exclude(keyword_cluster_id__isnull=True)
.values_list('keyword_cluster_id', flat=True)
))
clusters = Clusters.objects.filter(id__in=cluster_ids).values('id', 'name').order_by('name')

View File

@@ -272,6 +272,167 @@ class TasksViewSet(SiteSectorModelViewSet):
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
@action(detail=False, methods=['get'], url_path='filter_options', url_name='filter_options')
def filter_options(self, request):
"""
Get distinct filter values from current data with cascading support.
Returns only values that exist based on other active filters.
"""
import logging
from django.db.models import Q
logger = logging.getLogger(__name__)
try:
queryset = self.get_queryset()
# Get filter parameters for cascading
status_filter = request.query_params.get('status', '')
content_type_filter = request.query_params.get('content_type', '')
content_structure_filter = request.query_params.get('content_structure', '')
cluster_filter = request.query_params.get('cluster', '')
source_filter = request.query_params.get('source', '')
search = request.query_params.get('search', '')
# Apply search to base queryset
base_qs = queryset
if search:
base_qs = base_qs.filter(
Q(title__icontains=search) | Q(keywords__icontains=search)
)
# Get statuses (filtered by other fields)
status_qs = base_qs
if content_type_filter:
status_qs = status_qs.filter(content_type=content_type_filter)
if content_structure_filter:
status_qs = status_qs.filter(content_structure=content_structure_filter)
if cluster_filter:
status_qs = status_qs.filter(cluster_id=cluster_filter)
if source_filter:
status_qs = status_qs.filter(source=source_filter)
statuses = list(set(status_qs.values_list('status', flat=True)))
statuses = sorted([s for s in statuses if s])
status_labels = {
'queued': 'Queued',
'processing': 'Processing',
'completed': 'Completed',
'failed': 'Failed',
}
status_options = [
{'value': s, 'label': status_labels.get(s, s.title())}
for s in statuses
]
# Get content types (filtered by other fields)
type_qs = base_qs
if status_filter:
type_qs = type_qs.filter(status=status_filter)
if content_structure_filter:
type_qs = type_qs.filter(content_structure=content_structure_filter)
if cluster_filter:
type_qs = type_qs.filter(cluster_id=cluster_filter)
if source_filter:
type_qs = type_qs.filter(source=source_filter)
content_types = list(set(type_qs.values_list('content_type', flat=True)))
content_types = sorted([t for t in content_types if t])
type_labels = {
'post': 'Post',
'page': 'Page',
'product': 'Product',
'taxonomy': 'Taxonomy',
}
content_type_options = [
{'value': t, 'label': type_labels.get(t, t.title())}
for t in content_types
]
# Get content structures (filtered by other fields)
structure_qs = base_qs
if status_filter:
structure_qs = structure_qs.filter(status=status_filter)
if content_type_filter:
structure_qs = structure_qs.filter(content_type=content_type_filter)
if cluster_filter:
structure_qs = structure_qs.filter(cluster_id=cluster_filter)
if source_filter:
structure_qs = structure_qs.filter(source=source_filter)
structures = list(set(structure_qs.values_list('content_structure', flat=True)))
structures = sorted([s for s in structures if s])
structure_labels = {
'article': 'Article', 'guide': 'Guide', 'comparison': 'Comparison',
'review': 'Review', 'listicle': 'Listicle', 'landing_page': 'Landing Page',
'business_page': 'Business Page', 'service_page': 'Service Page',
'general': 'General', 'cluster_hub': 'Cluster Hub',
'product_page': 'Product Page', 'category_archive': 'Category Archive',
'tag_archive': 'Tag Archive', 'attribute_archive': 'Attribute Archive',
}
content_structure_options = [
{'value': s, 'label': structure_labels.get(s, s.replace('_', ' ').title())}
for s in structures
]
# Get clusters (filtered by other fields)
cluster_qs = base_qs
if status_filter:
cluster_qs = cluster_qs.filter(status=status_filter)
if content_type_filter:
cluster_qs = cluster_qs.filter(content_type=content_type_filter)
if content_structure_filter:
cluster_qs = cluster_qs.filter(content_structure=content_structure_filter)
if source_filter:
cluster_qs = cluster_qs.filter(source=source_filter)
from igny8_core.modules.planner.models import Clusters
cluster_ids = list(set(
cluster_qs.exclude(cluster_id__isnull=True)
.values_list('cluster_id', flat=True)
))
clusters = Clusters.objects.filter(id__in=cluster_ids).values('id', 'name').order_by('name')
cluster_options = [
{'value': str(c['id']), 'label': c['name']}
for c in clusters
]
# Get sources (filtered by other fields)
source_qs = base_qs
if status_filter:
source_qs = source_qs.filter(status=status_filter)
if content_type_filter:
source_qs = source_qs.filter(content_type=content_type_filter)
if content_structure_filter:
source_qs = source_qs.filter(content_structure=content_structure_filter)
if cluster_filter:
source_qs = source_qs.filter(cluster_id=cluster_filter)
sources = list(set(source_qs.values_list('source', flat=True)))
sources = sorted([s for s in sources if s])
source_labels = {
'manual': 'Manual',
'planner': 'Planner',
'ai': 'AI',
}
source_options = [
{'value': s, 'label': source_labels.get(s, s.title())}
for s in sources
]
return success_response(
data={
'statuses': status_options,
'content_types': content_type_options,
'content_structures': content_structure_options,
'clusters': cluster_options,
'sources': source_options,
},
request=request
)
except Exception as e:
logger.error(f"Error in filter_options: {str(e)}", exc_info=True)
return error_response(
error=f'Failed to fetch filter options: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
@extend_schema_view(

View File

@@ -50,6 +50,17 @@ const SelectDropdown: React.FC<SelectDropdownProps> = ({
const displayText = selectedOption ? selectedOption.label : placeholder;
const isPlaceholder = !selectedOption;
// Calculate minimum width based on longest option text
// This ensures dropdown is wide enough for all options from the start
const getLongestOptionLength = () => {
if (options.length === 0) return placeholder.length;
const allTexts = [placeholder, ...options.map(opt => opt.label)];
return Math.max(...allTexts.map(text => text.length));
};
// Estimate width: ~8px per character + padding (this is approximate)
const estimatedMinWidth = Math.min(360, Math.max(120, getLongestOptionLength() * 8 + 40));
// Handle click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -102,7 +113,8 @@ const SelectDropdown: React.FC<SelectDropdownProps> = ({
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
onKeyDown={handleKeyDown}
className={`igny8-select-styled w-auto min-w-[120px] max-w-[360px] appearance-none rounded-lg border border-gray-300 bg-transparent px-3 pr-10 shadow-theme-xs focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-800 ${
style={{ minWidth: `${estimatedMinWidth}px` }}
className={`igny8-select-styled w-auto max-w-[360px] appearance-none rounded-lg border border-gray-300 bg-transparent px-3 pr-10 shadow-theme-xs focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-800 ${
className.includes('text-base') ? 'h-11 py-2.5 text-base' : 'h-9 py-2 text-sm'
} ${
isPlaceholder

View File

@@ -282,8 +282,15 @@ export const createClustersPageConfig = (
type: 'select',
options: [
{ value: '', label: 'All Status' },
{ value: 'new', label: 'New' },
{ value: 'mapped', label: 'Mapped' },
// Use dynamic options if loaded (even if empty array)
// Only fall back to defaults if statusOptions is undefined (not loaded yet)
...(handlers.statusOptions !== undefined
? handlers.statusOptions
: [
{ value: 'new', label: 'New' },
{ value: 'mapped', label: 'Mapped' },
]
),
],
},
{
@@ -292,11 +299,18 @@ export const createClustersPageConfig = (
type: 'select',
options: [
{ value: '', label: 'All Difficulty' },
{ value: '1', label: '1 - Very Easy' },
{ value: '2', label: '2 - Easy' },
{ value: '3', label: '3 - Medium' },
{ value: '4', label: '4 - Hard' },
{ value: '5', label: '5 - Very Hard' },
// Use dynamic options if loaded (even if empty array)
// Only fall back to defaults if difficultyOptions is undefined (not loaded yet)
...(handlers.difficultyOptions !== undefined
? handlers.difficultyOptions
: [
{ value: '1', label: '1 - Very Easy' },
{ value: '2', label: '2 - Easy' },
{ value: '3', label: '3 - Medium' },
{ value: '4', label: '4 - Hard' },
{ value: '5', label: '5 - Very Hard' },
]
),
],
},
{
@@ -304,7 +318,7 @@ export const createClustersPageConfig = (
label: 'Volume Range',
type: 'custom',
customRender: () => (
<div className="relative flex-1 min-w-[140px]">
<div className="relative" style={{ minWidth: '180px' }}>
<button
ref={handlers.volumeButtonRef}
type="button"
@@ -323,7 +337,7 @@ export const createClustersPageConfig = (
: ""
}`}
>
<span className="block text-left truncate">
<span className="block text-left truncate whitespace-nowrap">
{handlers.volumeMin || handlers.volumeMax
? `Vol: ${handlers.volumeMin || 'Min'} - ${handlers.volumeMax || 'Max'}`
: 'Volume Range'}
@@ -384,16 +398,14 @@ export const createClustersPageConfig = (
<Button
size="sm"
variant="primary"
onClick={async () => {
onClick={() => {
const newMin = handlers.tempVolumeMin === '' ? '' : Number(handlers.tempVolumeMin);
const newMax = handlers.tempVolumeMax === '' ? '' : Number(handlers.tempVolumeMax);
handlers.setIsVolumeDropdownOpen(false);
handlers.setVolumeMin(newMin);
handlers.setVolumeMax(newMax);
handlers.setCurrentPage(1);
setTimeout(() => {
handlers.loadClusters();
}, 0);
// Remove manual loadClusters call - let useEffect handle it automatically
}}
className="flex-1"
>

View File

@@ -83,6 +83,18 @@ export const createContentPageConfig = (
setStatusFilter: (value: string) => void;
setCurrentPage: (page: number) => void;
onRowClick?: (row: Content) => void;
// Dynamic filter options
statusOptions?: Array<{ value: string; label: string }>;
sourceOptions?: Array<{ value: string; label: string }>;
contentTypeOptions?: Array<{ value: string; label: string }>;
contentStructureOptions?: Array<{ value: string; label: string }>;
// New filter setters
contentTypeFilter?: string;
setContentTypeFilter?: (value: string) => void;
contentStructureFilter?: string;
setContentStructureFilter?: (value: string) => void;
sourceFilter?: string;
setSourceFilter?: (value: string) => void;
}
): ContentPageConfig => {
const showSectorColumn = !handlers.activeSector;
@@ -404,8 +416,15 @@ export const createContentPageConfig = (
type: 'select',
options: [
{ value: '', label: 'All Status' },
{ value: 'draft', label: 'Draft' },
{ value: 'published', label: 'Published' },
...(handlers.statusOptions !== undefined
? handlers.statusOptions
: [
{ value: 'draft', label: 'Draft' },
{ value: 'review', label: 'Review' },
{ value: 'approved', label: 'Approved' },
{ value: 'published', label: 'Published' },
]
),
],
},
{
@@ -445,10 +464,16 @@ export const createContentPageConfig = (
type: 'select',
options: [
{ value: '', label: 'All Sources' },
{ value: 'igny8', label: 'IGNY8' },
{ value: 'wordpress', label: 'WordPress' },
...(handlers.sourceOptions !== undefined
? handlers.sourceOptions
: [
{ value: 'igny8', label: 'IGNY8' },
{ value: 'wordpress', label: 'WordPress' },
]
),
],
},
},
],
headerMetrics: [
{

View File

@@ -238,7 +238,9 @@ export const createIdeasPageConfig = (
type: 'select',
options: [
{ value: '', label: 'All Status' },
...(handlers.statusOptions && handlers.statusOptions.length > 0
// Use dynamic options if loaded (even if empty array)
// Only fall back to defaults if statusOptions is undefined (not loaded yet)
...(handlers.statusOptions !== undefined
? handlers.statusOptions
: [
{ value: 'new', label: 'New' },
@@ -254,7 +256,9 @@ export const createIdeasPageConfig = (
type: 'select',
options: [
{ value: '', label: 'All Structures' },
...(handlers.contentStructureOptions && handlers.contentStructureOptions.length > 0
// Use dynamic options if loaded (even if empty array)
// Only fall back to defaults if contentStructureOptions is undefined (not loaded yet)
...(handlers.contentStructureOptions !== undefined
? handlers.contentStructureOptions
: [
{ value: 'article', label: 'Article' },
@@ -281,7 +285,9 @@ export const createIdeasPageConfig = (
type: 'select',
options: [
{ value: '', label: 'All Types' },
...(handlers.contentTypeOptions && handlers.contentTypeOptions.length > 0
// Use dynamic options if loaded (even if empty array)
// Only fall back to defaults if contentTypeOptions is undefined (not loaded yet)
...(handlers.contentTypeOptions !== undefined
? handlers.contentTypeOptions
: [
{ value: 'post', label: 'Post' },
@@ -299,7 +305,9 @@ export const createIdeasPageConfig = (
dynamicOptions: 'clusters',
options: [
{ value: '', label: 'All Clusters' },
...(handlers.clusterOptions && handlers.clusterOptions.length > 0
// Use dynamic cluster options if loaded (even if empty array)
// Only fall back to full clusters list if clusterOptions is undefined (not loaded yet)
...(handlers.clusterOptions !== undefined
? handlers.clusterOptions
: handlers.clusters.map((c) => ({ value: c.id.toString(), label: c.name }))
),

View File

@@ -272,8 +272,9 @@ export const createKeywordsPageConfig = (
type: 'select',
options: [
{ value: '', label: 'All Status' },
// Use dynamic options if available, otherwise show default options
...(handlers.statusOptions && handlers.statusOptions.length > 0
// Use dynamic options if loaded (even if empty array)
// Only fall back to defaults if statusOptions is undefined (not loaded yet)
...(handlers.statusOptions !== undefined
? handlers.statusOptions
: [
{ value: 'new', label: 'New' },
@@ -288,8 +289,9 @@ export const createKeywordsPageConfig = (
type: 'select',
options: [
{ value: '', label: 'All Countries' },
// Use dynamic options if available, otherwise show default options
...(handlers.countryOptions && handlers.countryOptions.length > 0
// Use dynamic options if loaded (even if empty array)
// Only fall back to defaults if countryOptions is undefined (not loaded yet)
...(handlers.countryOptions !== undefined
? handlers.countryOptions
: [
{ value: 'US', label: 'United States' },
@@ -309,7 +311,9 @@ export const createKeywordsPageConfig = (
type: 'select',
options: [
{ value: '', label: 'All Difficulty' },
...(handlers.difficultyOptions && handlers.difficultyOptions.length > 0
// Use dynamic options if loaded (even if empty array)
// Only fall back to defaults if difficultyOptions is undefined (not loaded yet)
...(handlers.difficultyOptions !== undefined
? handlers.difficultyOptions
: [
{ value: '1', label: '1 - Very Easy' },
@@ -328,8 +332,9 @@ export const createKeywordsPageConfig = (
dynamicOptions: 'clusters', // Flag for dynamic option loading
options: [
{ value: '', label: 'All Clusters' },
// Use dynamic cluster options if available
...(handlers.clusterOptions && handlers.clusterOptions.length > 0
// Use dynamic cluster options if loaded (even if empty array)
// Only fall back to full clusters list if clusterOptions is undefined (not loaded yet)
...(handlers.clusterOptions !== undefined
? handlers.clusterOptions
: handlers.clusters.map(c => ({ value: String(c.id), label: c.name }))
),
@@ -340,7 +345,7 @@ export const createKeywordsPageConfig = (
label: 'Volume Range',
type: 'custom',
customRender: () => (
<div className="relative flex-1 min-w-[140px]">
<div className="relative" style={{ minWidth: '180px' }}>
<button
ref={handlers.volumeButtonRef}
type="button"
@@ -359,7 +364,7 @@ export const createKeywordsPageConfig = (
: ""
}`}
>
<span className="block text-left truncate">
<span className="block text-left truncate whitespace-nowrap">
{handlers.volumeMin || handlers.volumeMax
? `Vol: ${handlers.volumeMin || 'Min'} - ${handlers.volumeMax || 'Max'}`
: 'Volume Range'}
@@ -420,16 +425,14 @@ export const createKeywordsPageConfig = (
<Button
size="sm"
variant="primary"
onClick={async () => {
onClick={() => {
const newMin = handlers.tempVolumeMin === '' ? '' : Number(handlers.tempVolumeMin);
const newMax = handlers.tempVolumeMax === '' ? '' : Number(handlers.tempVolumeMax);
handlers.setIsVolumeDropdownOpen(false);
handlers.setVolumeMin(newMin);
handlers.setVolumeMax(newMax);
handlers.setCurrentPage(1);
setTimeout(() => {
handlers.loadKeywords();
}, 0);
// Remove manual loadKeywords call - let useEffect handle it automatically
}}
className="flex-1"
>

View File

@@ -53,6 +53,18 @@ export function createReviewPageConfig(params: {
setCurrentPage: (page: number) => void;
activeSector: { id: number; name: string } | null;
onRowClick?: (row: Content) => void;
// Dynamic filter options
statusOptions?: Array<{ value: string; label: string }>;
siteStatusOptions?: Array<{ value: string; label: string }>;
contentTypeOptions?: Array<{ value: string; label: string }>;
contentStructureOptions?: Array<{ value: string; label: string }>;
// Filter values and setters
siteStatusFilter?: string;
setSiteStatusFilter?: (value: string) => void;
contentTypeFilter?: string;
setContentTypeFilter?: (value: string) => void;
contentStructureFilter?: string;
setContentStructureFilter?: (value: string) => void;
}): ReviewPageConfig {
const showSectorColumn = !params.activeSector;

View File

@@ -18,6 +18,7 @@ import {
Cluster,
ClusterFilters,
ClusterCreateData,
FilterOption,
} from '../../services/api';
import FormModal from '../../components/common/FormModal';
import ProgressModal from '../../components/common/ProgressModal';
@@ -28,7 +29,6 @@ import { createClustersPageConfig } from '../../config/pages/clusters.config';
import { useSectorStore } from '../../store/sectorStore';
import { useSiteStore } from '../../store/siteStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
import PageHeader from '../../components/common/PageHeader';
import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
@@ -49,6 +49,11 @@ export default function Clusters() {
const [totalVolume, setTotalVolume] = useState(0);
const [totalKeywords, setTotalKeywords] = useState(0);
// Dynamic filter options (loaded from backend based on current data)
// Initialize as undefined to distinguish "not loaded yet" from "loaded but empty array"
const [statusOptions, setStatusOptions] = useState<FilterOption[] | undefined>(undefined);
const [difficultyOptions, setDifficultyOptions] = useState<FilterOption[] | undefined>(undefined);
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
@@ -86,6 +91,74 @@ export default function Clusters() {
const progressModal = useProgressModal();
const hasReloadedRef = useRef(false);
// Load dynamic filter options based on current site's data
const loadFilterOptions = useCallback(async () => {
if (!activeSite) return;
try {
const response = await fetch(`/api/v1/planner/clusters/filter_options/?site_id=${activeSite.id}`, {
credentials: 'include',
});
const result = await response.json();
if (result.success && result.data) {
setStatusOptions(result.data.statuses || []);
setDifficultyOptions(result.data.difficulties || []);
}
} catch (error) {
console.error('Error loading filter options:', error);
}
}, [activeSite]);
// Load filter options when site changes
useEffect(() => {
loadFilterOptions();
}, [loadFilterOptions]);
// Reload filter options when filters change (cascading)
useEffect(() => {
if (!activeSite) return;
const loadCascadingOptions = async () => {
try {
const params = new URLSearchParams();
params.append('site_id', activeSite.id.toString());
if (statusFilter) params.append('status', statusFilter);
if (difficultyFilter) {
// Map difficulty level to min/max range for backend
const difficultyNum = parseInt(difficultyFilter);
const ranges: Record<number, { min: number; max: number }> = {
1: { min: 0, max: 10 },
2: { min: 11, max: 30 },
3: { min: 31, max: 50 },
4: { min: 51, max: 70 },
5: { min: 71, max: 100 },
};
const range = ranges[difficultyNum];
if (range) {
params.append('difficulty_min', range.min.toString());
params.append('difficulty_max', range.max.toString());
}
}
if (volumeMin) params.append('volume_min', volumeMin.toString());
if (volumeMax) params.append('volume_max', volumeMax.toString());
if (searchTerm) params.append('search', searchTerm);
const response = await fetch(`/api/v1/planner/clusters/filter_options/?${params.toString()}`, {
credentials: 'include',
});
const result = await response.json();
if (result.success && result.data) {
setStatusOptions(result.data.statuses || []);
setDifficultyOptions(result.data.difficulties || []);
}
} catch (error) {
console.error('Error loading cascading filter options:', error);
}
};
loadCascadingOptions();
}, [activeSite, statusFilter, difficultyFilter, volumeMin, volumeMax, searchTerm]);
// Load total metrics for footer widget (site-wide totals, no sector filter)
const loadTotalMetrics = useCallback(async () => {
try {
@@ -145,13 +218,18 @@ export default function Clusters() {
// Add difficulty range filter
if (difficultyFilter) {
const difficultyNum = parseInt(difficultyFilter);
const label = getDifficultyLabelFromNumber(difficultyNum);
if (label !== null) {
const range = getDifficultyRange(label);
if (range) {
filters.difficulty_min = range.min;
filters.difficulty_max = range.max;
}
// Map difficulty level (1-5) directly to raw difficulty range (0-100)
const ranges: Record<number, { min: number; max: number }> = {
1: { min: 0, max: 10 },
2: { min: 11, max: 30 },
3: { min: 31, max: 50 },
4: { min: 51, max: 70 },
5: { min: 71, max: 100 },
};
const range = ranges[difficultyNum];
if (range) {
filters.difficulty_min = range.min;
filters.difficulty_max = range.max;
}
}
@@ -382,6 +460,9 @@ export default function Clusters() {
setCurrentPage,
loadClusters,
onGenerateIdeas: (clusterId: number) => handleRowAction('generate_ideas', { id: clusterId } as Cluster),
// Dynamic filter options
statusOptions,
difficultyOptions,
});
}, [
activeSector,
@@ -395,6 +476,8 @@ export default function Clusters() {
tempVolumeMin,
tempVolumeMax,
loadClusters,
statusOptions,
difficultyOptions,
handleRowAction,
]);

View File

@@ -52,10 +52,11 @@ export default function Ideas() {
const [totalImagesCount, setTotalImagesCount] = useState(0);
// Dynamic filter options
const [statusOptions, setStatusOptions] = useState<FilterOption[]>([]);
const [contentTypeOptions, setContentTypeOptions] = useState<FilterOption[]>([]);
const [contentStructureOptions, setContentStructureOptions] = useState<FilterOption[]>([]);
const [clusterOptions, setClusterOptions] = useState<FilterOption[]>([]);
// Initialize as undefined to distinguish "not loaded yet" from "loaded but empty array"
const [statusOptions, setStatusOptions] = useState<FilterOption[] | undefined>(undefined);
const [contentTypeOptions, setContentTypeOptions] = useState<FilterOption[] | undefined>(undefined);
const [contentStructureOptions, setContentStructureOptions] = useState<FilterOption[] | undefined>(undefined);
const [clusterOptions, setClusterOptions] = useState<FilterOption[] | undefined>(undefined);
// Filter state
const [searchTerm, setSearchTerm] = useState('');
@@ -126,6 +127,38 @@ export default function Ideas() {
loadFilterOptions();
}, [loadFilterOptions]);
// Reload filter options when filters change (cascading)
useEffect(() => {
if (!activeSite) return;
const loadCascadingOptions = async () => {
try {
const params = new URLSearchParams();
params.append('site_id', activeSite.id.toString());
if (statusFilter) params.append('status', statusFilter);
if (typeFilter) params.append('content_type', typeFilter);
if (structureFilter) params.append('content_structure', structureFilter);
if (clusterFilter) params.append('cluster', clusterFilter);
if (searchTerm) params.append('search', searchTerm);
const response = await fetch(`/api/v1/planner/ideas/filter_options/?${params.toString()}`, {
credentials: 'include',
});
const result = await response.json();
if (result.success && result.data) {
setStatusOptions(result.data.statuses || []);
setContentTypeOptions(result.data.content_types || []);
setContentStructureOptions(result.data.content_structures || []);
setClusterOptions(result.data.clusters || []);
}
} catch (error) {
console.error('Error loading cascading filter options:', error);
}
};
loadCascadingOptions();
}, [activeSite, statusFilter, typeFilter, structureFilter, clusterFilter, searchTerm]);
// Load total metrics for footer widget (site-wide totals, no sector filter)
const loadTotalMetrics = useCallback(async () => {
try {
@@ -185,6 +218,7 @@ export default function Ideas() {
...(clusterFilter && { keyword_cluster_id: clusterFilter }),
...(structureFilter && { content_structure: structureFilter }),
...(typeFilter && { content_type: typeFilter }),
...(activeSector?.id && { sector_id: activeSector.id }),
page: currentPage,
page_size: pageSize,
ordering,

View File

@@ -30,7 +30,7 @@ import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader';
import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
import { getDifficultyNumber } from '../../utils/difficulty';
import FormModal from '../../components/common/FormModal';
import ProgressModal from '../../components/common/ProgressModal';
import { useProgressModal } from '../../hooks/useProgressModal';
@@ -57,10 +57,11 @@ export default function Keywords() {
const [totalImagesCount, setTotalImagesCount] = useState(0);
// Dynamic filter options (loaded from backend based on current data)
const [countryOptions, setCountryOptions] = useState<FilterOption[]>([]);
const [statusOptions, setStatusOptions] = useState<FilterOption[]>([]);
const [clusterOptions, setClusterOptions] = useState<FilterOption[]>([]);
const [difficultyOptions, setDifficultyOptions] = useState<FilterOption[]>([]);
// Initialize as undefined to distinguish "not loaded yet" from "loaded but empty array"
const [countryOptions, setCountryOptions] = useState<FilterOption[] | undefined>(undefined);
const [statusOptions, setStatusOptions] = useState<FilterOption[] | undefined>(undefined);
const [clusterOptions, setClusterOptions] = useState<FilterOption[] | undefined>(undefined);
const [difficultyOptions, setDifficultyOptions] = useState<FilterOption[] | undefined>(undefined);
// Filter state - match Keywords.tsx
const [searchTerm, setSearchTerm] = useState('');
@@ -133,6 +134,9 @@ export default function Keywords() {
cluster_id?: string;
difficulty_min?: number;
difficulty_max?: number;
volume_min?: number;
volume_max?: number;
search?: string;
}) => {
if (!activeSite) return;
@@ -178,8 +182,11 @@ export default function Keywords() {
cluster_id: clusterFilter || undefined,
difficulty_min: difficultyMin,
difficulty_max: difficultyMax,
volume_min: volumeMin !== '' ? Number(volumeMin) : undefined,
volume_max: volumeMax !== '' ? Number(volumeMax) : undefined,
search: searchTerm || undefined,
});
}, [statusFilter, countryFilter, clusterFilter, difficultyFilter, loadFilterOptions, getDifficultyRange]);
}, [statusFilter, countryFilter, clusterFilter, difficultyFilter, volumeMin, volumeMax, searchTerm, loadFilterOptions, getDifficultyRange]);
// Load total metrics for footer widget (site-wide totals, no sector filter)
const loadTotalMetrics = useCallback(async () => {
@@ -251,13 +258,18 @@ export default function Keywords() {
// Add difficulty range filter
if (difficultyFilter) {
const difficultyNum = parseInt(difficultyFilter);
const label = getDifficultyLabelFromNumber(difficultyNum);
if (label !== null) {
const range = getDifficultyRange(label);
if (range) {
filters.difficulty_min = range.min;
filters.difficulty_max = range.max;
}
// Map difficulty level (1-5) directly to raw difficulty range (0-100)
const ranges: Record<number, { min: number; max: number }> = {
1: { min: 0, max: 10 },
2: { min: 11, max: 30 },
3: { min: 31, max: 50 },
4: { min: 51, max: 70 },
5: { min: 71, max: 100 },
};
const range = ranges[difficultyNum];
if (range) {
filters.difficulty_min = range.min;
filters.difficulty_max = range.max;
}
}

View File

@@ -45,6 +45,12 @@ export default function Approved() {
const [totalPublished, setTotalPublished] = useState(0);
const [totalImagesCount, setTotalImagesCount] = useState(0);
// Dynamic filter options (loaded from backend)
const [statusOptions, setStatusOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
const [siteStatusOptions, setSiteStatusOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
const [contentTypeOptions, setContentTypeOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
const [contentStructureOptions, setContentStructureOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState(''); // Status filter (draft/review/approved/published)
@@ -63,6 +69,39 @@ export default function Approved() {
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [showContent, setShowContent] = useState(false);
// Load dynamic filter options with cascading
const loadFilterOptions = useCallback(async () => {
if (!activeSite) return;
try {
const params = new URLSearchParams();
params.append('site_id', activeSite.id.toString());
if (statusFilter) params.append('status', statusFilter);
if (siteStatusFilter) params.append('site_status', siteStatusFilter);
if (contentTypeFilter) params.append('content_type', contentTypeFilter);
if (contentStructureFilter) params.append('content_structure', contentStructureFilter);
if (searchTerm) params.append('search', searchTerm);
const response = await fetch(`/api/v1/writer/content/filter_options/?${params.toString()}`, {
credentials: 'include',
});
const result = await response.json();
if (result.success && result.data) {
setStatusOptions(result.data.statuses || []);
setSiteStatusOptions(result.data.site_statuses || []);
setContentTypeOptions(result.data.content_types || []);
setContentStructureOptions(result.data.content_structures || []);
}
} catch (error) {
console.error('Error loading filter options:', error);
}
}, [activeSite, statusFilter, siteStatusFilter, contentTypeFilter, contentStructureFilter, searchTerm]);
// Load filter options when dependencies change
useEffect(() => {
loadFilterOptions();
}, [loadFilterOptions]);
// Load total metrics for footer widget and header metrics (not affected by pagination)
const loadTotalMetrics = useCallback(async () => {
try {

View File

@@ -46,10 +46,18 @@ export default function Content() {
const [totalPublished, setTotalPublished] = useState(0);
const [totalImagesCount, setTotalImagesCount] = useState(0);
// Dynamic filter options (loaded from backend)
const [statusOptions, setStatusOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
const [sourceOptions, setSourceOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
const [contentTypeOptions, setContentTypeOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
const [contentStructureOptions, setContentStructureOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('draft');
const [sourceFilter, setSourceFilter] = useState('');
const [contentTypeFilter, setContentTypeFilter] = useState('');
const [contentStructureFilter, setContentStructureFilter] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Pagination state
@@ -66,6 +74,39 @@ export default function Content() {
const progressModal = useProgressModal();
const hasReloadedRef = useRef(false);
// Load dynamic filter options
const loadFilterOptions = useCallback(async () => {
if (!activeSite) return;
try {
const params = new URLSearchParams();
params.append('site_id', activeSite.id.toString());
if (statusFilter) params.append('status', statusFilter);
if (sourceFilter) params.append('source', sourceFilter);
if (contentTypeFilter) params.append('content_type', contentTypeFilter);
if (contentStructureFilter) params.append('content_structure', contentStructureFilter);
if (searchTerm) params.append('search', searchTerm);
const response = await fetch(`/api/v1/writer/content/filter_options/?${params.toString()}`, {
credentials: 'include',
});
const result = await response.json();
if (result.success && result.data) {
setStatusOptions(result.data.statuses || []);
setSourceOptions(result.data.sources || []);
setContentTypeOptions(result.data.content_types || []);
setContentStructureOptions(result.data.content_structures || []);
}
} catch (error) {
console.error('Error loading filter options:', error);
}
}, [activeSite, statusFilter, sourceFilter, contentTypeFilter, contentStructureFilter, searchTerm]);
// Load filter options when dependencies change
useEffect(() => {
loadFilterOptions();
}, [loadFilterOptions]);
// Load total metrics for footer widget and header metrics (site-wide totals, no sector filter)
const loadTotalMetrics = useCallback(async () => {
try {
@@ -131,6 +172,8 @@ export default function Content() {
...(searchTerm && { search: searchTerm }),
...(statusFilter && { status: statusFilter }),
...(sourceFilter && { source: sourceFilter }),
...(contentTypeFilter && { content_type: contentTypeFilter }),
...(contentStructureFilter && { content_structure: contentStructureFilter }),
page: currentPage,
page_size: pageSize,
ordering,
@@ -151,7 +194,7 @@ export default function Content() {
setShowContent(true);
setLoading(false);
}
}, [currentPage, statusFilter, sortBy, sortDirection, searchTerm, activeSector, pageSize, toast]);
}, [currentPage, statusFilter, sourceFilter, contentTypeFilter, contentStructureFilter, sortBy, sortDirection, searchTerm, activeSector, pageSize, toast]);
useEffect(() => {
loadContent();
@@ -216,13 +259,32 @@ export default function Content() {
setStatusFilter,
setCurrentPage,
onRowClick: handleRowClick,
// Dynamic filter options
statusOptions,
sourceOptions,
contentTypeOptions,
contentStructureOptions,
// Filter values and setters
contentTypeFilter,
setContentTypeFilter,
contentStructureFilter,
setContentStructureFilter,
sourceFilter,
setSourceFilter,
});
}, [
activeSector,
searchTerm,
statusFilter,
handleRowClick,
]);
statusOptions,
sourceOptions,
contentTypeOptions,
contentStructureOptions,
contentTypeFilter,
contentStructureFilter,
sourceFilter,
});
// Calculate header metrics - use totals from API calls (not page data)
// This ensures metrics show correct totals across all pages, not just current page

View File

@@ -43,9 +43,18 @@ export default function Review() {
const [totalPublished, setTotalPublished] = useState(0);
const [totalImagesCount, setTotalImagesCount] = useState(0);
// Dynamic filter options (loaded from backend)
const [statusOptions, setStatusOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
const [siteStatusOptions, setSiteStatusOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
const [contentTypeOptions, setContentTypeOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
const [contentStructureOptions, setContentStructureOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
// Filter state - default to review status
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('review'); // Default to review
const [siteStatusFilter, setSiteStatusFilter] = useState('');
const [contentTypeFilter, setContentTypeFilter] = useState('');
const [contentStructureFilter, setContentStructureFilter] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Pagination state
@@ -58,6 +67,39 @@ export default function Review() {
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [showContent, setShowContent] = useState(false);
// Load dynamic filter options
const loadFilterOptions = useCallback(async () => {
if (!activeSite) return;
try {
const params = new URLSearchParams();
params.append('site_id', activeSite.id.toString());
params.append('status', 'review'); // Always review status
if (siteStatusFilter) params.append('site_status', siteStatusFilter);
if (contentTypeFilter) params.append('content_type', contentTypeFilter);
if (contentStructureFilter) params.append('content_structure', contentStructureFilter);
if (searchTerm) params.append('search', searchTerm);
const response = await fetch(`/api/v1/writer/content/filter_options/?${params.toString()}`, {
credentials: 'include',
});
const result = await response.json();
if (result.success && result.data) {
setStatusOptions(result.data.statuses || []);
setSiteStatusOptions(result.data.site_statuses || []);
setContentTypeOptions(result.data.content_types || []);
setContentStructureOptions(result.data.content_structures || []);
}
} catch (error) {
console.error('Error loading filter options:', error);
}
}, [activeSite, siteStatusFilter, contentTypeFilter, contentStructureFilter, searchTerm]);
// Load filter options when dependencies change
useEffect(() => {
loadFilterOptions();
}, [loadFilterOptions]);
// Load content - filtered for review status
const loadContent = useCallback(async () => {
setLoading(true);
@@ -68,6 +110,9 @@ export default function Review() {
const filters: ContentFilters = {
...(searchTerm && { search: searchTerm }),
status: 'review', // Always filter for review status
...(siteStatusFilter && { site_status: siteStatusFilter }),
...(contentTypeFilter && { content_type: contentTypeFilter }),
...(contentStructureFilter && { content_structure: contentStructureFilter }),
page: currentPage,
page_size: pageSize,
ordering,
@@ -88,7 +133,7 @@ export default function Review() {
setShowContent(true);
setLoading(false);
}
}, [currentPage, sortBy, sortDirection, searchTerm, pageSize, toast]);
}, [currentPage, siteStatusFilter, contentTypeFilter, contentStructureFilter, sortBy, sortDirection, searchTerm, pageSize, toast]);
useEffect(() => {
loadContent();
@@ -163,8 +208,32 @@ export default function Review() {
setStatusFilter,
setCurrentPage,
onRowClick: handleRowClick,
// Dynamic filter options
statusOptions,
siteStatusOptions,
contentTypeOptions,
contentStructureOptions,
// Filter values and setters
siteStatusFilter,
setSiteStatusFilter,
contentTypeFilter,
setContentTypeFilter,
contentStructureFilter,
setContentStructureFilter,
}),
[activeSector, searchTerm, statusFilter, handleRowClick]
[
activeSector,
searchTerm,
statusFilter,
handleRowClick,
statusOptions,
siteStatusOptions,
contentTypeOptions,
contentStructureOptions,
siteStatusFilter,
contentTypeFilter,
contentStructureFilter,
]
);
// Header metrics (calculated from loaded data)

View File

@@ -60,6 +60,13 @@ export default function Tasks() {
const [totalProcessing, setTotalProcessing] = useState(0);
const [totalCompleted, setTotalCompleted] = useState(0);
// Dynamic filter options (loaded from backend)
const [statusOptions, setStatusOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
const [contentTypeOptions, setContentTypeOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
const [contentStructureOptions, setContentStructureOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
const [clusterOptions, setClusterOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
const [sourceOptions, setSourceOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
@@ -99,6 +106,41 @@ export default function Tasks() {
const hasReloadedRef = useRef<boolean>(false);
// Load dynamic filter options with cascading
const loadFilterOptions = useCallback(async () => {
if (!activeSite) return;
try {
const params = new URLSearchParams();
params.append('site_id', activeSite.id.toString());
if (statusFilter) params.append('status', statusFilter);
if (typeFilter) params.append('content_type', typeFilter);
if (structureFilter) params.append('content_structure', structureFilter);
if (clusterFilter) params.append('cluster', clusterFilter);
if (sourceFilter) params.append('source', sourceFilter);
if (searchTerm) params.append('search', searchTerm);
const response = await fetch(`/api/v1/writer/tasks/filter_options/?${params.toString()}`, {
credentials: 'include',
});
const result = await response.json();
if (result.success && result.data) {
setStatusOptions(result.data.statuses || []);
setContentTypeOptions(result.data.content_types || []);
setContentStructureOptions(result.data.content_structures || []);
setClusterOptions(result.data.clusters || []);
setSourceOptions(result.data.sources || []);
}
} catch (error) {
console.error('Error loading filter options:', error);
}
}, [activeSite, statusFilter, typeFilter, structureFilter, clusterFilter, sourceFilter, searchTerm]);
// Load filter options when dependencies change
useEffect(() => {
loadFilterOptions();
}, [loadFilterOptions]);
// Load clusters for filter dropdown

View File

@@ -762,6 +762,9 @@ export interface KeywordFilterOptionsRequest {
difficulty_min?: number;
difficulty_max?: number;
cluster_id?: string;
volume_min?: number;
volume_max?: number;
search?: string;
}
export async function fetchPlannerKeywordFilterOptions(
@@ -775,6 +778,9 @@ export async function fetchPlannerKeywordFilterOptions(
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?.cluster_id) params.append('cluster_id', filters.cluster_id);
if (filters?.volume_min !== undefined) params.append('volume_min', filters.volume_min.toString());
if (filters?.volume_max !== undefined) params.append('volume_max', filters.volume_max.toString());
if (filters?.search) params.append('search', filters.search);
const queryString = params.toString();
return fetchAPI(`/v1/planner/keywords/filter_options/${queryString ? `?${queryString}` : ''}`);

View File

@@ -648,16 +648,46 @@ export default function TablePageTemplate({
</div>
)}
{/* Filter Toggle Button */}
{/* Filter Toggle Button - with active filter count badge */}
{(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 className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setShowFilters(!showFilters)}
startIcon={<FunnelIcon className="w-3.5 h-3.5" />}
>
{showFilters ? 'Hide Filters' : 'Show Filters'}
</Button>
{hasActiveFilters && (
<>
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gradient-to-r from-brand-50 to-brand-100 dark:from-brand-900/20 dark:to-brand-800/20 border border-brand-200 dark:border-brand-700/50 rounded-md">
<div className="w-1.5 h-1.5 rounded-full bg-brand-500 animate-pulse"></div>
<span className="text-xs font-semibold text-brand-700 dark:text-brand-300">
{Object.values(filterValues).filter(value => {
if (value === '' || value === null || value === undefined) return false;
if (typeof value === 'object' && ('min' in value || 'max' in value)) {
return value.min !== '' && value.min !== null && value.min !== undefined ||
value.max !== '' && value.max !== null && value.max !== undefined;
}
return true;
}).length} active
</span>
</div>
{onFilterReset && (
<Button
variant="primary"
tone="danger"
size="xs"
onClick={onFilterReset}
className="shadow-sm hover:shadow-md transition-shadow"
>
Clear
</Button>
)}
</>
)}
</div>
)}
</div>
@@ -777,15 +807,6 @@ export default function TablePageTemplate({
}
return null;
})}
{hasActiveFilters && onFilterReset && (
<Button
variant="secondary"
size="sm"
onClick={onFilterReset}
>
Clear Filters
</Button>
)}
</>
)}
</div>