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)
# 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

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.
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)

View File

@@ -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:

View File

@@ -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"""

View File

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

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")
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:

View File

@@ -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 {

View File

@@ -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({

View File

@@ -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',
}}

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