master - part 2

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-30 09:47:58 +00:00
parent 2af7bb725f
commit 885158e152
13 changed files with 538 additions and 63 deletions

View File

@@ -887,9 +887,36 @@ class AutomationViewSet(viewsets.ViewSet):
stages.append(stage_data) stages.append(stage_data)
# Calculate global progress # 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 global_percentage = 0
if total_initial > 0: if run.status == 'completed':
global_percentage = round((total_processed / total_initial) * 100) # 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 # Calculate duration
duration_seconds = 0 duration_seconds = 0

View File

@@ -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),
]

View File

@@ -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),
]

View File

@@ -197,17 +197,18 @@ class BillingViewSet(viewsets.GenericViewSet):
Does not expose sensitive configuration details. Does not expose sensitive configuration details.
Query params: 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( methods = PaymentMethodConfig.objects.filter(
country_code=country,
is_enabled=True 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 # Serialize using the proper serializer
serializer = PaymentMethodConfigSerializer(methods, many=True) serializer = PaymentMethodConfigSerializer(methods, many=True)

View File

@@ -96,17 +96,15 @@ class ClustersAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
@admin.register(Keywords) @admin.register(Keywords)
class KeywordsAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin): class KeywordsAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = KeywordsResource 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_editable = ['status'] # Enable inline editing for status
list_select_related = ['site', 'sector', 'cluster', 'seed_keyword', 'seed_keyword__industry', 'seed_keyword__sector', 'account'] list_select_related = ['site', 'sector', 'cluster', 'seed_keyword', 'seed_keyword__industry', 'seed_keyword__sector', 'account']
list_filter = [ list_filter = [
('status', ChoicesDropdownFilter), ('status', ChoicesDropdownFilter),
('country', ChoicesDropdownFilter),
('site', RelatedDropdownFilter), ('site', RelatedDropdownFilter),
('sector', RelatedDropdownFilter), ('sector', RelatedDropdownFilter),
('cluster', RelatedDropdownFilter), ('cluster', RelatedDropdownFilter),
('volume', RangeNumericFilter),
('difficulty', RangeNumericFilter),
('created_at', RangeDateFilter), ('created_at', RangeDateFilter),
] ]
search_fields = ['seed_keyword__keyword'] search_fields = ['seed_keyword__keyword']
@@ -119,6 +117,30 @@ class KeywordsAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
'bulk_soft_delete', '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): def get_site_display(self, obj):
"""Safely get site name""" """Safely get site name"""
try: try:

View File

@@ -934,6 +934,45 @@ class ClusterViewSet(SiteSectorModelViewSet):
# Save with all required fields explicitly # Save with all required fields explicitly
serializer.save(account=account, site=site, sector=sector) 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') @action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete')
def bulk_delete(self, request): def bulk_delete(self, request):
"""Bulk delete clusters""" """Bulk delete clusters"""

View File

@@ -371,16 +371,16 @@ class GlobalModuleSettings(models.Model):
help_text="Enable Automation module platform-wide" help_text="Enable Automation module platform-wide"
) )
site_builder_enabled = models.BooleanField( site_builder_enabled = models.BooleanField(
default=True, default=False, # DEPRECATED: SiteBuilder module is disabled - Phase 2 feature
help_text="Enable Site Builder module platform-wide" help_text="Enable Site Builder module platform-wide (DEPRECATED)"
) )
linker_enabled = models.BooleanField( linker_enabled = models.BooleanField(
default=True, default=False, # Phase 2 feature - not active
help_text="Enable Linker module platform-wide" help_text="Enable Linker module platform-wide (Phase 2)"
) )
optimizer_enabled = models.BooleanField( optimizer_enabled = models.BooleanField(
default=True, default=False, # Phase 2 feature - not active
help_text="Enable Optimizer module platform-wide" help_text="Enable Optimizer module platform-wide (Phase 2)"
) )
publisher_enabled = models.BooleanField( publisher_enabled = models.BooleanField(
default=True, default=True,

View File

@@ -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),
]

View File

@@ -98,9 +98,9 @@ class ModuleEnableSettings(AccountBaseModel):
writer_enabled = models.BooleanField(default=True, help_text="Enable Writer module") writer_enabled = models.BooleanField(default=True, help_text="Enable Writer module")
thinker_enabled = models.BooleanField(default=True, help_text="Enable Thinker module") thinker_enabled = models.BooleanField(default=True, help_text="Enable Thinker module")
automation_enabled = models.BooleanField(default=True, help_text="Enable Automation 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") site_builder_enabled = models.BooleanField(default=False, help_text="Enable Site Builder module (DEPRECATED)")
linker_enabled = models.BooleanField(default=True, help_text="Enable Linker module") linker_enabled = models.BooleanField(default=False, help_text="Enable Linker module (Phase 2)")
optimizer_enabled = models.BooleanField(default=True, help_text="Enable Optimizer module") optimizer_enabled = models.BooleanField(default=False, help_text="Enable Optimizer module (Phase 2)")
publisher_enabled = models.BooleanField(default=True, help_text="Enable Publisher module") publisher_enabled = models.BooleanField(default=True, help_text="Enable Publisher module")
class Meta: class Meta:

View File

@@ -189,6 +189,12 @@
/* Menu icon sizing - consistent across sidebar */ /* Menu icon sizing - consistent across sidebar */
@utility menu-item-icon-size { @utility menu-item-icon-size {
@apply w-5 h-5 flex-shrink-0; @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 { @utility menu-item-icon-active {

View File

@@ -7,6 +7,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate'; import TablePageTemplate from '../../templates/TablePageTemplate';
import { import {
fetchClusters, fetchClusters,
fetchClustersSummary,
fetchImages, fetchImages,
createCluster, createCluster,
updateCluster, updateCluster,
@@ -43,6 +44,8 @@ export default function Clusters() {
const [totalWithIdeas, setTotalWithIdeas] = useState(0); const [totalWithIdeas, setTotalWithIdeas] = useState(0);
const [totalReady, setTotalReady] = useState(0); const [totalReady, setTotalReady] = useState(0);
const [totalImagesCount, setTotalImagesCount] = useState(0); const [totalImagesCount, setTotalImagesCount] = useState(0);
const [totalVolume, setTotalVolume] = useState(0);
const [totalKeywords, setTotalKeywords] = useState(0);
// Filter state // Filter state
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@@ -84,24 +87,31 @@ export default function Clusters() {
// Load total metrics for footer widget (not affected by pagination) // Load total metrics for footer widget (not affected by pagination)
const loadTotalMetrics = useCallback(async () => { const loadTotalMetrics = useCallback(async () => {
try { try {
// Get clusters with status='mapped' (those that have ideas) // Fetch summary metrics in parallel with status counts
const mappedRes = await fetchClusters({ const [summaryRes, mappedRes, newRes, imagesRes] = await Promise.all([
page_size: 1, fetchClustersSummary(activeSector?.id),
...(activeSector?.id && { sector_id: activeSector.id }), fetchClusters({
status: 'mapped', page_size: 1,
}); ...(activeSector?.id && { sector_id: activeSector.id }),
setTotalWithIdeas(mappedRes.count || 0); status: 'mapped',
}),
fetchClusters({
page_size: 1,
...(activeSector?.id && { sector_id: activeSector.id }),
status: 'new',
}),
fetchImages({ page_size: 1 }),
]);
// Get clusters with status='new' (those that are ready for ideas) // Set summary metrics
const newRes = await fetchClusters({ setTotalVolume(summaryRes.total_volume || 0);
page_size: 1, setTotalKeywords(summaryRes.total_keywords || 0);
...(activeSector?.id && { sector_id: activeSector.id }),
status: 'new', // Set status counts
}); setTotalWithIdeas(mappedRes.count || 0);
setTotalReady(newRes.count || 0); setTotalReady(newRes.count || 0);
// Get actual total images count // Set images count
const imagesRes = await fetchImages({ page_size: 1 });
setTotalImagesCount(imagesRes.count || 0); setTotalImagesCount(imagesRes.count || 0);
} catch (error) { } catch (error) {
console.error('Error loading total metrics:', error); console.error('Error loading total metrics:', error);
@@ -403,12 +413,12 @@ export default function Clusters() {
value = totalReady; value = totalReady;
break; break;
case 'Keywords': case 'Keywords':
// Sum of keywords across all clusters on current page (this is acceptable for display) // Use totalKeywords from summary endpoint (aggregate across all clusters)
value = clusters.reduce((sum: number, c) => sum + (c.keywords_count || 0), 0); value = totalKeywords;
break; break;
case 'Volume': case 'Volume':
// Sum of volume across all clusters on current page (this is acceptable for display) // Use totalVolume from summary endpoint (aggregate across all clusters)
value = clusters.reduce((sum: number, c) => sum + (c.total_volume || 0), 0); value = totalVolume;
break; break;
default: default:
value = metric.calculate({ clusters, totalCount }); value = metric.calculate({ clusters, totalCount });
@@ -421,7 +431,7 @@ export default function Clusters() {
tooltip: (metric as any).tooltip, tooltip: (metric as any).tooltip,
}; };
}); });
}, [pageConfig?.headerMetrics, clusters, totalCount, totalReady, totalWithIdeas]); }, [pageConfig?.headerMetrics, clusters, totalCount, totalReady, totalWithIdeas, totalVolume, totalKeywords]);
const resetForm = useCallback(() => { const resetForm = useCallback(() => {
setFormData({ setFormData({

View File

@@ -34,6 +34,11 @@ export default function Review() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [totalImagesCount, setTotalImagesCount] = useState(0); 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 // Filter state - default to review status
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('review'); // Default to review const [statusFilter, setStatusFilter] = useState('review'); // Default to review
@@ -85,19 +90,30 @@ export default function Review() {
loadContent(); loadContent();
}, [loadContent]); }, [loadContent]);
// Load total images count // Load total images count and other metrics for footer widget
useEffect(() => { const loadTotalMetrics = useCallback(async () => {
const loadImageCount = async () => { try {
try { // Fetch counts in parallel for performance
const imagesRes = await fetchImages({ page_size: 1 }); const [imagesRes, draftsRes, approvedRes, tasksRes] = await Promise.all([
setTotalImagesCount(imagesRes.count || 0); fetchImages({ page_size: 1 }),
} catch (error) { fetchContent({ page_size: 1, status: 'draft' }),
console.error('Error loading image count:', error); fetchContent({ page_size: 1, status: 'approved' }),
} fetchAPI<{ count: number }>('/writer/tasks/?page_size=1'),
}; ]);
loadImageCount();
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 // Listen for site and sector changes and refresh data
useEffect(() => { useEffect(() => {
const handleSiteChange = () => { const handleSiteChange = () => {
@@ -494,24 +510,24 @@ export default function Review() {
pipeline: [ pipeline: [
{ {
fromLabel: 'Tasks', fromLabel: 'Tasks',
fromValue: 0, fromValue: totalTasks,
fromHref: '/writer/tasks', fromHref: '/writer/tasks',
actionLabel: 'Generate Content', actionLabel: 'Generate Content',
toLabel: 'Drafts', toLabel: 'Drafts',
toValue: 0, toValue: totalDrafts,
toHref: '/writer/content', toHref: '/writer/content',
progress: 0, progress: totalTasks > 0 ? Math.round((totalDrafts / totalTasks) * 100) : 0,
color: 'blue', color: 'blue',
}, },
{ {
fromLabel: 'Drafts', fromLabel: 'Drafts',
fromValue: 0, fromValue: totalDrafts,
fromHref: '/writer/content', fromHref: '/writer/content',
actionLabel: 'Generate Images', actionLabel: 'Generate Images',
toLabel: 'Images', toLabel: 'Images',
toValue: totalImagesCount, toValue: totalImagesCount,
toHref: '/writer/images', toHref: '/writer/images',
progress: 0, progress: totalDrafts > 0 ? Math.round((totalImagesCount / totalDrafts) * 100) : 0,
color: 'purple', color: 'purple',
}, },
{ {
@@ -520,9 +536,9 @@ export default function Review() {
fromHref: '/writer/review', fromHref: '/writer/review',
actionLabel: 'Review & Publish', actionLabel: 'Review & Publish',
toLabel: 'Published', toLabel: 'Published',
toValue: 0, toValue: totalApproved,
toHref: '/writer/published', toHref: '/writer/approved',
progress: 0, progress: totalCount > 0 ? Math.round((totalApproved / (totalCount + totalApproved)) * 100) : 0,
color: 'green', color: 'green',
}, },
], ],
@@ -530,7 +546,7 @@ export default function Review() {
{ label: 'Tasks', href: '/writer/tasks' }, { label: 'Tasks', href: '/writer/tasks' },
{ label: 'Content', href: '/writer/content' }, { label: 'Content', href: '/writer/content' },
{ label: 'Images', href: '/writer/images' }, { label: 'Images', href: '/writer/images' },
{ label: 'Published', href: '/writer/published' }, { label: 'Published', href: '/writer/approved' },
], ],
}} }}
completion={{ completion={{
@@ -541,9 +557,9 @@ export default function Review() {
{ label: 'Ideas Generated', value: 0, color: 'amber' }, { label: 'Ideas Generated', value: 0, color: 'amber' },
], ],
writerItems: [ 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: 'Images Created', value: totalImagesCount, color: 'purple' },
{ label: 'Articles Published', value: 0, color: 'green' }, { label: 'Articles Published', value: totalApproved, color: 'green' },
], ],
analyticsHref: '/account/usage', analyticsHref: '/account/usage',
}} }}

View File

@@ -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<ClustersSummary> {
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 }> { 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 endpoint = `/v1/planner/keywords/auto_cluster/`;
const requestBody = { ids: keywordIds, sector_id: sectorId }; const requestBody = { ids: keywordIds, sector_id: sectorId };