From 885158e152218ec526e72288c79529f15d1322ad Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Tue, 30 Dec 2025 09:47:58 +0000 Subject: [PATCH] master - part 2 --- .../igny8_core/business/automation/views.py | 31 ++- .../migrations/0008_global_payment_methods.py | 48 ++++ .../migrations/0009_seed_ai_model_configs.py | 218 ++++++++++++++++++ backend/igny8_core/business/billing/views.py | 13 +- backend/igny8_core/modules/planner/admin.py | 30 ++- backend/igny8_core/modules/planner/views.py | 39 ++++ .../modules/system/global_settings_models.py | 12 +- .../migrations/0011_disable_phase2_modules.py | 59 +++++ .../modules/system/settings_models.py | 6 +- frontend/src/index.css | 6 + frontend/src/pages/Planner/Clusters.tsx | 50 ++-- frontend/src/pages/Writer/Review.tsx | 60 +++-- frontend/src/services/api.ts | 29 +++ 13 files changed, 538 insertions(+), 63 deletions(-) create mode 100644 backend/igny8_core/business/billing/migrations/0008_global_payment_methods.py create mode 100644 backend/igny8_core/business/billing/migrations/0009_seed_ai_model_configs.py create mode 100644 backend/igny8_core/modules/system/migrations/0011_disable_phase2_modules.py diff --git a/backend/igny8_core/business/automation/views.py b/backend/igny8_core/business/automation/views.py index c9aa8995..812617f4 100644 --- a/backend/igny8_core/business/automation/views.py +++ b/backend/igny8_core/business/automation/views.py @@ -887,9 +887,36 @@ class AutomationViewSet(viewsets.ViewSet): stages.append(stage_data) # Calculate global progress + # Stages 1-6 are automation stages, Stage 7 is manual review (not counted) + # Progress = weighted average of stages 1-6 completion global_percentage = 0 - if total_initial > 0: - global_percentage = round((total_processed / total_initial) * 100) + if run.status == 'completed': + # If run is completed (after Stage 6), show 100% + global_percentage = 100 + elif run.status in ('cancelled', 'failed'): + # Keep current progress for cancelled/failed + if total_initial > 0: + global_percentage = round((total_processed / total_initial) * 100) + else: + # Calculate based on completed stages (1-6 only) + # Each of the 6 automation stages contributes ~16.67% to total + completed_stages = min(max(run.current_stage - 1, 0), 6) + stage_weight = 100 / 6 # Each stage is ~16.67% + + # Base progress from completed stages + base_progress = completed_stages * stage_weight + + # Add partial progress from current stage + current_stage_progress = 0 + if run.current_stage <= 6: + current_result = getattr(run, f'stage_{run.current_stage}_result', None) + current_initial = initial_snapshot.get(f'stage_{run.current_stage}_initial', 0) + if current_initial > 0 and current_result: + processed_key = processed_keys.get(run.current_stage, '') + current_processed = current_result.get(processed_key, 0) + current_stage_progress = (current_processed / current_initial) * stage_weight + + global_percentage = round(base_progress + current_stage_progress) # Calculate duration duration_seconds = 0 diff --git a/backend/igny8_core/business/billing/migrations/0008_global_payment_methods.py b/backend/igny8_core/business/billing/migrations/0008_global_payment_methods.py new file mode 100644 index 00000000..d56e8eb5 --- /dev/null +++ b/backend/igny8_core/business/billing/migrations/0008_global_payment_methods.py @@ -0,0 +1,48 @@ +""" +Migration: Simplify payment methods to global (remove country-specific filtering) + +This migration: +1. Updates existing PaymentMethodConfig records to use country_code='*' (global) +2. Removes duplicate payment methods per country, keeping only one global config per method +""" +from django.db import migrations + + +def migrate_to_global_payment_methods(apps, schema_editor): + """ + Convert country-specific payment methods to global. + For each payment_method type, keep only one configuration with country_code='*' + """ + PaymentMethodConfig = apps.get_model('billing', 'PaymentMethodConfig') + + # Get all unique payment methods + payment_methods = PaymentMethodConfig.objects.values_list('payment_method', flat=True).distinct() + + for method in payment_methods: + # Get all configs for this payment method + configs = PaymentMethodConfig.objects.filter(payment_method=method).order_by('sort_order', 'id') + + if configs.exists(): + # Keep the first one and make it global + first_config = configs.first() + first_config.country_code = '*' + first_config.save(update_fields=['country_code']) + + # Delete duplicates (other country-specific versions) + configs.exclude(id=first_config.id).delete() + + +def reverse_migration(apps, schema_editor): + """Reverse is a no-op - can't restore original country codes""" + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0007_simplify_payment_statuses'), + ] + + operations = [ + migrations.RunPython(migrate_to_global_payment_methods, reverse_migration), + ] diff --git a/backend/igny8_core/business/billing/migrations/0009_seed_ai_model_configs.py b/backend/igny8_core/business/billing/migrations/0009_seed_ai_model_configs.py new file mode 100644 index 00000000..3925a023 --- /dev/null +++ b/backend/igny8_core/business/billing/migrations/0009_seed_ai_model_configs.py @@ -0,0 +1,218 @@ +""" +Migration: Seed AIModelConfig from constants.py + +This migration populates the AIModelConfig table with the current models +from ai/constants.py, enabling database-driven model configuration. +""" +from decimal import Decimal +from django.db import migrations + + +def seed_ai_models(apps, schema_editor): + """ + Seed AIModelConfig with models from constants.py + """ + AIModelConfig = apps.get_model('billing', 'AIModelConfig') + + # Text Models (from MODEL_RATES) + text_models = [ + { + 'model_name': 'gpt-4.1', + 'display_name': 'GPT-4.1 - Balanced Performance', + 'model_type': 'text', + 'provider': 'openai', + 'input_cost_per_1m': Decimal('2.00'), + 'output_cost_per_1m': Decimal('8.00'), + 'context_window': 128000, + 'max_output_tokens': 16384, + 'supports_json_mode': True, + 'supports_vision': True, + 'supports_function_calling': True, + 'is_active': True, + 'is_default': True, # Default text model + 'sort_order': 1, + 'description': 'Default model - good balance of cost and capability', + }, + { + 'model_name': 'gpt-4o-mini', + 'display_name': 'GPT-4o Mini - Fast & Affordable', + 'model_type': 'text', + 'provider': 'openai', + 'input_cost_per_1m': Decimal('0.15'), + 'output_cost_per_1m': Decimal('0.60'), + 'context_window': 128000, + 'max_output_tokens': 16384, + 'supports_json_mode': True, + 'supports_vision': True, + 'supports_function_calling': True, + 'is_active': True, + 'is_default': False, + 'sort_order': 2, + 'description': 'Best for high-volume tasks where cost matters', + }, + { + 'model_name': 'gpt-4o', + 'display_name': 'GPT-4o - High Quality', + 'model_type': 'text', + 'provider': 'openai', + 'input_cost_per_1m': Decimal('2.50'), + 'output_cost_per_1m': Decimal('10.00'), + 'context_window': 128000, + 'max_output_tokens': 16384, + 'supports_json_mode': True, + 'supports_vision': True, + 'supports_function_calling': True, + 'is_active': True, + 'is_default': False, + 'sort_order': 3, + 'description': 'Premium model for complex tasks requiring best quality', + }, + { + 'model_name': 'gpt-5.1', + 'display_name': 'GPT-5.1 - Latest Generation', + 'model_type': 'text', + 'provider': 'openai', + 'input_cost_per_1m': Decimal('1.25'), + 'output_cost_per_1m': Decimal('10.00'), + 'context_window': 200000, + 'max_output_tokens': 32768, + 'supports_json_mode': True, + 'supports_vision': True, + 'supports_function_calling': True, + 'is_active': True, + 'is_default': False, + 'sort_order': 4, + 'description': 'Next-gen model with improved reasoning', + }, + { + 'model_name': 'gpt-5.2', + 'display_name': 'GPT-5.2 - Most Advanced', + 'model_type': 'text', + 'provider': 'openai', + 'input_cost_per_1m': Decimal('1.75'), + 'output_cost_per_1m': Decimal('14.00'), + 'context_window': 200000, + 'max_output_tokens': 65536, + 'supports_json_mode': True, + 'supports_vision': True, + 'supports_function_calling': True, + 'is_active': True, + 'is_default': False, + 'sort_order': 5, + 'description': 'Most capable model for enterprise-grade tasks', + }, + ] + + # Image Models (from IMAGE_MODEL_RATES) + image_models = [ + { + 'model_name': 'dall-e-3', + 'display_name': 'DALL-E 3 - Premium Images', + 'model_type': 'image', + 'provider': 'openai', + 'cost_per_image': Decimal('0.040'), + 'valid_sizes': ['1024x1024', '1024x1792', '1792x1024'], + 'supports_json_mode': False, + 'supports_vision': False, + 'supports_function_calling': False, + 'is_active': True, + 'is_default': True, # Default image model + 'sort_order': 1, + 'description': 'Best quality image generation, good for hero images and marketing', + }, + { + 'model_name': 'dall-e-2', + 'display_name': 'DALL-E 2 - Standard Images', + 'model_type': 'image', + 'provider': 'openai', + 'cost_per_image': Decimal('0.020'), + 'valid_sizes': ['256x256', '512x512', '1024x1024'], + 'supports_json_mode': False, + 'supports_vision': False, + 'supports_function_calling': False, + 'is_active': True, + 'is_default': False, + 'sort_order': 2, + 'description': 'Lower cost option for bulk image generation', + }, + { + 'model_name': 'gpt-image-1', + 'display_name': 'GPT Image 1 - Advanced', + 'model_type': 'image', + 'provider': 'openai', + 'cost_per_image': Decimal('0.042'), + 'valid_sizes': ['1024x1024', '1024x1792', '1792x1024'], + 'supports_json_mode': False, + 'supports_vision': False, + 'supports_function_calling': False, + 'is_active': True, + 'is_default': False, + 'sort_order': 3, + 'description': 'Advanced image model with enhanced capabilities', + }, + { + 'model_name': 'gpt-image-1-mini', + 'display_name': 'GPT Image 1 Mini - Fast', + 'model_type': 'image', + 'provider': 'openai', + 'cost_per_image': Decimal('0.011'), + 'valid_sizes': ['1024x1024'], + 'supports_json_mode': False, + 'supports_vision': False, + 'supports_function_calling': False, + 'is_active': True, + 'is_default': False, + 'sort_order': 4, + 'description': 'Fastest and most affordable image model', + }, + ] + + # Runware Image Models (from existing integration) + runware_models = [ + { + 'model_name': 'runware:100@1', + 'display_name': 'Runware Standard', + 'model_type': 'image', + 'provider': 'runware', + 'cost_per_image': Decimal('0.008'), + 'valid_sizes': ['512x512', '768x768', '1024x1024'], + 'supports_json_mode': False, + 'supports_vision': False, + 'supports_function_calling': False, + 'is_active': True, + 'is_default': False, + 'sort_order': 10, + 'description': 'Runware image generation - most affordable', + }, + ] + + # Create all models + all_models = text_models + image_models + runware_models + + for model_data in all_models: + AIModelConfig.objects.update_or_create( + model_name=model_data['model_name'], + defaults=model_data + ) + + +def reverse_migration(apps, schema_editor): + """Remove seeded models""" + AIModelConfig = apps.get_model('billing', 'AIModelConfig') + seeded_models = [ + 'gpt-4.1', 'gpt-4o-mini', 'gpt-4o', 'gpt-5.1', 'gpt-5.2', + 'dall-e-3', 'dall-e-2', 'gpt-image-1', 'gpt-image-1-mini', + 'runware:100@1' + ] + AIModelConfig.objects.filter(model_name__in=seeded_models).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0008_global_payment_methods'), + ] + + operations = [ + migrations.RunPython(seed_ai_models, reverse_migration), + ] diff --git a/backend/igny8_core/business/billing/views.py b/backend/igny8_core/business/billing/views.py index cb1359f6..5100816c 100644 --- a/backend/igny8_core/business/billing/views.py +++ b/backend/igny8_core/business/billing/views.py @@ -197,17 +197,18 @@ class BillingViewSet(viewsets.GenericViewSet): Does not expose sensitive configuration details. Query params: - country: ISO 2-letter country code (default: 'US') + country: ISO 2-letter country code (optional, defaults to global '*') - Returns payment methods filtered by country. + Returns payment methods - prioritizes global methods (country_code='*'). """ - country = request.GET.get('country', 'US').upper() + country = request.GET.get('country', '*').upper() - # Get country-specific methods + # Get global methods first (country_code='*'), then country-specific as fallback methods = PaymentMethodConfig.objects.filter( - country_code=country, is_enabled=True - ).order_by('sort_order') + ).filter( + Q(country_code='*') | Q(country_code=country) + ).order_by('sort_order').distinct() # Serialize using the proper serializer serializer = PaymentMethodConfigSerializer(methods, many=True) diff --git a/backend/igny8_core/modules/planner/admin.py b/backend/igny8_core/modules/planner/admin.py index 8aeb293e..e44805fa 100644 --- a/backend/igny8_core/modules/planner/admin.py +++ b/backend/igny8_core/modules/planner/admin.py @@ -96,17 +96,15 @@ class ClustersAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin): @admin.register(Keywords) class KeywordsAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin): resource_class = KeywordsResource - list_display = ['keyword', 'seed_keyword', 'site', 'sector', 'cluster', 'volume', 'difficulty', 'country', 'status', 'created_at'] + # Use actual DB fields and custom methods with @admin.display for computed values + list_display = ['get_keyword', 'seed_keyword', 'site', 'sector', 'cluster', 'get_volume', 'get_difficulty', 'get_country', 'status', 'created_at'] list_editable = ['status'] # Enable inline editing for status list_select_related = ['site', 'sector', 'cluster', 'seed_keyword', 'seed_keyword__industry', 'seed_keyword__sector', 'account'] list_filter = [ ('status', ChoicesDropdownFilter), - ('country', ChoicesDropdownFilter), ('site', RelatedDropdownFilter), ('sector', RelatedDropdownFilter), ('cluster', RelatedDropdownFilter), - ('volume', RangeNumericFilter), - ('difficulty', RangeNumericFilter), ('created_at', RangeDateFilter), ] search_fields = ['seed_keyword__keyword'] @@ -119,6 +117,30 @@ class KeywordsAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin): 'bulk_soft_delete', ] + @admin.display(description='Keyword') + def get_keyword(self, obj): + """Get keyword from seed_keyword""" + return obj.seed_keyword.keyword if obj.seed_keyword else '-' + + @admin.display(description='Volume') + def get_volume(self, obj): + """Get volume from override or seed_keyword""" + if obj.volume_override is not None: + return obj.volume_override + return obj.seed_keyword.volume if obj.seed_keyword else 0 + + @admin.display(description='Difficulty') + def get_difficulty(self, obj): + """Get difficulty from override or seed_keyword""" + if obj.difficulty_override is not None: + return obj.difficulty_override + return obj.seed_keyword.difficulty if obj.seed_keyword else 0 + + @admin.display(description='Country') + def get_country(self, obj): + """Get country from seed_keyword""" + return obj.seed_keyword.country if obj.seed_keyword else 'US' + def get_site_display(self, obj): """Safely get site name""" try: diff --git a/backend/igny8_core/modules/planner/views.py b/backend/igny8_core/modules/planner/views.py index fdc3bad2..a2ceebc7 100644 --- a/backend/igny8_core/modules/planner/views.py +++ b/backend/igny8_core/modules/planner/views.py @@ -934,6 +934,45 @@ class ClusterViewSet(SiteSectorModelViewSet): # Save with all required fields explicitly serializer.save(account=account, site=site, sector=sector) + @action(detail=False, methods=['get'], url_path='summary', url_name='summary') + def summary(self, request): + """ + Get aggregate summary metrics for clusters. + Returns total keywords count and total volume across all clusters (unfiltered). + Used for header metrics display. + """ + from django.db.models import Sum, Count, Case, When, F, IntegerField + + queryset = self.get_queryset() + + # Get cluster IDs + cluster_ids = list(queryset.values_list('id', flat=True)) + + # Aggregate keyword stats across all clusters + keyword_stats = ( + Keywords.objects + .filter(cluster_id__in=cluster_ids) + .aggregate( + total_keywords=Count('id'), + total_volume=Sum( + Case( + When(volume_override__isnull=False, then=F('volume_override')), + default=F('seed_keyword__volume'), + output_field=IntegerField() + ) + ) + ) + ) + + return success_response( + data={ + 'total_clusters': len(cluster_ids), + 'total_keywords': keyword_stats['total_keywords'] or 0, + 'total_volume': keyword_stats['total_volume'] or 0, + }, + request=request + ) + @action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete') def bulk_delete(self, request): """Bulk delete clusters""" diff --git a/backend/igny8_core/modules/system/global_settings_models.py b/backend/igny8_core/modules/system/global_settings_models.py index 6c0e5a7c..76ec3121 100644 --- a/backend/igny8_core/modules/system/global_settings_models.py +++ b/backend/igny8_core/modules/system/global_settings_models.py @@ -371,16 +371,16 @@ class GlobalModuleSettings(models.Model): help_text="Enable Automation module platform-wide" ) site_builder_enabled = models.BooleanField( - default=True, - help_text="Enable Site Builder module platform-wide" + default=False, # DEPRECATED: SiteBuilder module is disabled - Phase 2 feature + help_text="Enable Site Builder module platform-wide (DEPRECATED)" ) linker_enabled = models.BooleanField( - default=True, - help_text="Enable Linker module platform-wide" + default=False, # Phase 2 feature - not active + help_text="Enable Linker module platform-wide (Phase 2)" ) optimizer_enabled = models.BooleanField( - default=True, - help_text="Enable Optimizer module platform-wide" + default=False, # Phase 2 feature - not active + help_text="Enable Optimizer module platform-wide (Phase 2)" ) publisher_enabled = models.BooleanField( default=True, diff --git a/backend/igny8_core/modules/system/migrations/0011_disable_phase2_modules.py b/backend/igny8_core/modules/system/migrations/0011_disable_phase2_modules.py new file mode 100644 index 00000000..aa614c2e --- /dev/null +++ b/backend/igny8_core/modules/system/migrations/0011_disable_phase2_modules.py @@ -0,0 +1,59 @@ +""" +Migration: Disable SiteBuilder, Linker, Optimizer by default + +These modules are deprecated (SiteBuilder) or Phase 2 features (Linker, Optimizer). +This migration updates defaults and sets existing records to disabled. +""" +from django.db import migrations + + +def disable_phase2_modules(apps, schema_editor): + """ + Disable SiteBuilder, Linker, and Optimizer in existing settings records. + These are Phase 2 features not currently active. + """ + GlobalModuleSettings = apps.get_model('system', 'GlobalModuleSettings') + ModuleEnableSettings = apps.get_model('system', 'ModuleEnableSettings') + + # Update GlobalModuleSettings (singleton pk=1) + GlobalModuleSettings.objects.filter(pk=1).update( + site_builder_enabled=False, + linker_enabled=False, + optimizer_enabled=False + ) + + # Update all ModuleEnableSettings (per-account settings) + ModuleEnableSettings.objects.all().update( + site_builder_enabled=False, + linker_enabled=False, + optimizer_enabled=False + ) + + +def reverse_migration(apps, schema_editor): + """Re-enable modules (reverting to old defaults)""" + GlobalModuleSettings = apps.get_model('system', 'GlobalModuleSettings') + ModuleEnableSettings = apps.get_model('system', 'ModuleEnableSettings') + + GlobalModuleSettings.objects.filter(pk=1).update( + site_builder_enabled=True, + linker_enabled=True, + optimizer_enabled=True + ) + + ModuleEnableSettings.objects.all().update( + site_builder_enabled=True, + linker_enabled=True, + optimizer_enabled=True + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('system', '0010_globalmodulesettings_and_more'), + ] + + operations = [ + migrations.RunPython(disable_phase2_modules, reverse_migration), + ] diff --git a/backend/igny8_core/modules/system/settings_models.py b/backend/igny8_core/modules/system/settings_models.py index f2ec58d4..dd79a0cc 100644 --- a/backend/igny8_core/modules/system/settings_models.py +++ b/backend/igny8_core/modules/system/settings_models.py @@ -98,9 +98,9 @@ class ModuleEnableSettings(AccountBaseModel): writer_enabled = models.BooleanField(default=True, help_text="Enable Writer module") thinker_enabled = models.BooleanField(default=True, help_text="Enable Thinker module") automation_enabled = models.BooleanField(default=True, help_text="Enable Automation module") - site_builder_enabled = models.BooleanField(default=True, help_text="Enable Site Builder module") - linker_enabled = models.BooleanField(default=True, help_text="Enable Linker module") - optimizer_enabled = models.BooleanField(default=True, help_text="Enable Optimizer module") + site_builder_enabled = models.BooleanField(default=False, help_text="Enable Site Builder module (DEPRECATED)") + linker_enabled = models.BooleanField(default=False, help_text="Enable Linker module (Phase 2)") + optimizer_enabled = models.BooleanField(default=False, help_text="Enable Optimizer module (Phase 2)") publisher_enabled = models.BooleanField(default=True, help_text="Enable Publisher module") class Meta: diff --git a/frontend/src/index.css b/frontend/src/index.css index 1af9382b..2001afc3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -189,6 +189,12 @@ /* Menu icon sizing - consistent across sidebar */ @utility menu-item-icon-size { @apply w-5 h-5 flex-shrink-0; + + /* Force SVG icons to inherit parent size */ + & svg { + width: 100%; + height: 100%; + } } @utility menu-item-icon-active { diff --git a/frontend/src/pages/Planner/Clusters.tsx b/frontend/src/pages/Planner/Clusters.tsx index 5f128b13..c42d02c8 100644 --- a/frontend/src/pages/Planner/Clusters.tsx +++ b/frontend/src/pages/Planner/Clusters.tsx @@ -7,6 +7,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import TablePageTemplate from '../../templates/TablePageTemplate'; import { fetchClusters, + fetchClustersSummary, fetchImages, createCluster, updateCluster, @@ -43,6 +44,8 @@ export default function Clusters() { const [totalWithIdeas, setTotalWithIdeas] = useState(0); const [totalReady, setTotalReady] = useState(0); const [totalImagesCount, setTotalImagesCount] = useState(0); + const [totalVolume, setTotalVolume] = useState(0); + const [totalKeywords, setTotalKeywords] = useState(0); // Filter state const [searchTerm, setSearchTerm] = useState(''); @@ -84,24 +87,31 @@ export default function Clusters() { // Load total metrics for footer widget (not affected by pagination) const loadTotalMetrics = useCallback(async () => { try { - // Get clusters with status='mapped' (those that have ideas) - const mappedRes = await fetchClusters({ - page_size: 1, - ...(activeSector?.id && { sector_id: activeSector.id }), - status: 'mapped', - }); + // Fetch summary metrics in parallel with status counts + const [summaryRes, mappedRes, newRes, imagesRes] = await Promise.all([ + fetchClustersSummary(activeSector?.id), + fetchClusters({ + page_size: 1, + ...(activeSector?.id && { sector_id: activeSector.id }), + status: 'mapped', + }), + fetchClusters({ + page_size: 1, + ...(activeSector?.id && { sector_id: activeSector.id }), + status: 'new', + }), + fetchImages({ page_size: 1 }), + ]); + + // Set summary metrics + setTotalVolume(summaryRes.total_volume || 0); + setTotalKeywords(summaryRes.total_keywords || 0); + + // Set status counts setTotalWithIdeas(mappedRes.count || 0); - - // Get clusters with status='new' (those that are ready for ideas) - const newRes = await fetchClusters({ - page_size: 1, - ...(activeSector?.id && { sector_id: activeSector.id }), - status: 'new', - }); setTotalReady(newRes.count || 0); - // Get actual total images count - const imagesRes = await fetchImages({ page_size: 1 }); + // Set images count setTotalImagesCount(imagesRes.count || 0); } catch (error) { console.error('Error loading total metrics:', error); @@ -403,12 +413,12 @@ export default function Clusters() { value = totalReady; break; case 'Keywords': - // Sum of keywords across all clusters on current page (this is acceptable for display) - value = clusters.reduce((sum: number, c) => sum + (c.keywords_count || 0), 0); + // Use totalKeywords from summary endpoint (aggregate across all clusters) + value = totalKeywords; break; case 'Volume': - // Sum of volume across all clusters on current page (this is acceptable for display) - value = clusters.reduce((sum: number, c) => sum + (c.total_volume || 0), 0); + // Use totalVolume from summary endpoint (aggregate across all clusters) + value = totalVolume; break; default: value = metric.calculate({ clusters, totalCount }); @@ -421,7 +431,7 @@ export default function Clusters() { tooltip: (metric as any).tooltip, }; }); - }, [pageConfig?.headerMetrics, clusters, totalCount, totalReady, totalWithIdeas]); + }, [pageConfig?.headerMetrics, clusters, totalCount, totalReady, totalWithIdeas, totalVolume, totalKeywords]); const resetForm = useCallback(() => { setFormData({ diff --git a/frontend/src/pages/Writer/Review.tsx b/frontend/src/pages/Writer/Review.tsx index 720da34f..0c0ff50c 100644 --- a/frontend/src/pages/Writer/Review.tsx +++ b/frontend/src/pages/Writer/Review.tsx @@ -34,6 +34,11 @@ export default function Review() { const [loading, setLoading] = useState(true); const [totalImagesCount, setTotalImagesCount] = useState(0); + // Total metrics for footer widget (not page-filtered) + const [totalDrafts, setTotalDrafts] = useState(0); + const [totalApproved, setTotalApproved] = useState(0); + const [totalTasks, setTotalTasks] = useState(0); + // Filter state - default to review status const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState('review'); // Default to review @@ -85,19 +90,30 @@ export default function Review() { loadContent(); }, [loadContent]); - // Load total images count - useEffect(() => { - const loadImageCount = async () => { - try { - const imagesRes = await fetchImages({ page_size: 1 }); - setTotalImagesCount(imagesRes.count || 0); - } catch (error) { - console.error('Error loading image count:', error); - } - }; - loadImageCount(); + // Load total images count and other metrics for footer widget + const loadTotalMetrics = useCallback(async () => { + try { + // Fetch counts in parallel for performance + const [imagesRes, draftsRes, approvedRes, tasksRes] = await Promise.all([ + fetchImages({ page_size: 1 }), + fetchContent({ page_size: 1, status: 'draft' }), + fetchContent({ page_size: 1, status: 'approved' }), + fetchAPI<{ count: number }>('/writer/tasks/?page_size=1'), + ]); + + setTotalImagesCount(imagesRes.count || 0); + setTotalDrafts(draftsRes.count || 0); + setTotalApproved(approvedRes.count || 0); + setTotalTasks(tasksRes.count || 0); + } catch (error) { + console.error('Error loading metrics:', error); + } }, []); + useEffect(() => { + loadTotalMetrics(); + }, [loadTotalMetrics]); + // Listen for site and sector changes and refresh data useEffect(() => { const handleSiteChange = () => { @@ -494,24 +510,24 @@ export default function Review() { pipeline: [ { fromLabel: 'Tasks', - fromValue: 0, + fromValue: totalTasks, fromHref: '/writer/tasks', actionLabel: 'Generate Content', toLabel: 'Drafts', - toValue: 0, + toValue: totalDrafts, toHref: '/writer/content', - progress: 0, + progress: totalTasks > 0 ? Math.round((totalDrafts / totalTasks) * 100) : 0, color: 'blue', }, { fromLabel: 'Drafts', - fromValue: 0, + fromValue: totalDrafts, fromHref: '/writer/content', actionLabel: 'Generate Images', toLabel: 'Images', toValue: totalImagesCount, toHref: '/writer/images', - progress: 0, + progress: totalDrafts > 0 ? Math.round((totalImagesCount / totalDrafts) * 100) : 0, color: 'purple', }, { @@ -520,9 +536,9 @@ export default function Review() { fromHref: '/writer/review', actionLabel: 'Review & Publish', toLabel: 'Published', - toValue: 0, - toHref: '/writer/published', - progress: 0, + toValue: totalApproved, + toHref: '/writer/approved', + progress: totalCount > 0 ? Math.round((totalApproved / (totalCount + totalApproved)) * 100) : 0, color: 'green', }, ], @@ -530,7 +546,7 @@ export default function Review() { { label: 'Tasks', href: '/writer/tasks' }, { label: 'Content', href: '/writer/content' }, { label: 'Images', href: '/writer/images' }, - { label: 'Published', href: '/writer/published' }, + { label: 'Published', href: '/writer/approved' }, ], }} completion={{ @@ -541,9 +557,9 @@ export default function Review() { { label: 'Ideas Generated', value: 0, color: 'amber' }, ], writerItems: [ - { label: 'Content Generated', value: 0, color: 'blue' }, + { label: 'Content Generated', value: totalDrafts + totalCount + totalApproved, color: 'blue' }, { label: 'Images Created', value: totalImagesCount, color: 'purple' }, - { label: 'Articles Published', value: 0, color: 'green' }, + { label: 'Articles Published', value: totalApproved, color: 'green' }, ], analyticsHref: '/account/usage', }} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index eb7cac11..4009f5c7 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -852,6 +852,35 @@ export async function bulkUpdateClustersStatus(ids: number[], status: string): P }); } +export interface ClustersSummary { + total_clusters: number; + total_keywords: number; + total_volume: number; +} + +export async function fetchClustersSummary(sectorId?: number): Promise { + const params = new URLSearchParams(); + + // Auto-add site filter + const activeSiteId = getActiveSiteId(); + if (activeSiteId) { + params.append('site_id', activeSiteId.toString()); + } + + // Add sector filter if provided or get active + if (sectorId !== undefined) { + params.append('sector_id', sectorId.toString()); + } else { + const activeSectorId = getActiveSectorId(); + if (activeSectorId !== null && activeSectorId !== undefined) { + params.append('sector_id', activeSectorId.toString()); + } + } + + const queryString = params.toString(); + return fetchAPI(`/v1/planner/clusters/summary/${queryString ? `?${queryString}` : ''}`); +} + export async function autoClusterKeywords(keywordIds: number[], sectorId?: number): Promise<{ success: boolean; task_id?: string; clusters_created?: number; keywords_updated?: number; message?: string; error?: string }> { const endpoint = `/v1/planner/keywords/auto_cluster/`; const requestBody = { ids: keywordIds, sector_id: sectorId };