diff --git a/backend/igny8_core/business/automation/services/automation_service.py b/backend/igny8_core/business/automation/services/automation_service.py index 1466e51c..87923471 100644 --- a/backend/igny8_core/business/automation/services/automation_service.py +++ b/backend/igny8_core/business/automation/services/automation_service.py @@ -1472,13 +1472,13 @@ class AutomationService: time.sleep(delay) def run_stage_7(self): - """Stage 7: Auto-Approve and Publish Review Content + """Stage 7: Auto-Approve Review Content This stage automatically approves content in 'review' status and - marks it as 'published' (or queues for WordPress sync). + marks it as 'approved' (ready for publishing to WordPress). """ stage_number = 7 - stage_name = "Review → Published" + stage_name = "Review → Approved" start_time = time.time() # Query content ready for review @@ -1538,7 +1538,7 @@ class AutomationService: 'review_total': total_count, 'approved_count': approved_count, 'content_ids': list(Content.objects.filter( - site=self.site, status='published', updated_at__gte=self.run.started_at + site=self.site, status='approved', updated_at__gte=self.run.started_at ).values_list('id', flat=True)), 'partial': True, 'stopped_reason': reason, @@ -1553,8 +1553,8 @@ class AutomationService: stage_number, f"Approving content {idx}/{total_count}: {content.title}" ) - # Approve content by changing status to 'published' - content.status = 'published' + # Approve content by changing status to 'approved' (ready for publishing) + content.status = 'approved' content.save(update_fields=['status', 'updated_at']) approved_count += 1 @@ -1593,7 +1593,7 @@ class AutomationService: time_elapsed = self._format_time_elapsed(start_time) content_ids = list(Content.objects.filter( site=self.site, - status='published', + status='approved', updated_at__gte=self.run.started_at ).values_list('id', flat=True)) @@ -1617,7 +1617,7 @@ class AutomationService: # Release lock cache.delete(f'automation_lock_{self.site.id}') - logger.info(f"[AutomationService] Stage 7 complete: {approved_count} content pieces approved and published") + logger.info(f"[AutomationService] Stage 7 complete: {approved_count} content pieces approved (ready for publishing)") def pause_automation(self): """Pause current automation run""" diff --git a/backend/igny8_core/business/content/models.py b/backend/igny8_core/business/content/models.py index 5e7f7085..8597d436 100644 --- a/backend/igny8_core/business/content/models.py +++ b/backend/igny8_core/business/content/models.py @@ -271,7 +271,8 @@ class Content(SoftDeletableModel, SiteSectorBaseModel): STATUS_CHOICES = [ ('draft', 'Draft'), ('review', 'Review'), - ('published', 'Published'), + ('approved', 'Approved'), # Ready for publishing to external site + ('published', 'Published'), # Actually published on external site ] status = models.CharField( max_length=50, diff --git a/backend/igny8_core/modules/writer/migrations/0014_add_approved_status.py b/backend/igny8_core/modules/writer/migrations/0014_add_approved_status.py new file mode 100644 index 00000000..2c9110a1 --- /dev/null +++ b/backend/igny8_core/modules/writer/migrations/0014_add_approved_status.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.9 on 2026-01-01 06:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('writer', '0013_add_caption_to_images'), + ] + + operations = [ + migrations.CreateModel( + name='ImagePrompts', + fields=[ + ], + options={ + 'verbose_name': 'Image Prompt', + 'verbose_name_plural': 'Image Prompts', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('writer.images',), + ), + migrations.AlterField( + model_name='content', + name='status', + field=models.CharField(choices=[('draft', 'Draft'), ('review', 'Review'), ('approved', 'Approved'), ('published', 'Published')], db_index=True, default='draft', help_text='Content status', max_length=50), + ), + ] diff --git a/backend/igny8_core/modules/writer/serializers.py b/backend/igny8_core/modules/writer/serializers.py index 756c52b5..6a4853f5 100644 --- a/backend/igny8_core/modules/writer/serializers.py +++ b/backend/igny8_core/modules/writer/serializers.py @@ -11,8 +11,8 @@ class TasksSerializer(serializers.ModelSerializer): """Serializer for Tasks model - Stage 1 refactored""" cluster_name = serializers.SerializerMethodField() sector_name = serializers.SerializerMethodField() - site_id = serializers.IntegerField(write_only=True, required=False) - sector_id = serializers.IntegerField(write_only=True, required=False) + site_id = serializers.IntegerField(read_only=True) + sector_id = serializers.IntegerField(read_only=True) class Meta: model = Tasks @@ -162,8 +162,8 @@ class ContentSerializer(serializers.ModelSerializer): has_image_prompts = serializers.SerializerMethodField() image_status = serializers.SerializerMethodField() has_generated_images = serializers.SerializerMethodField() - site_id = serializers.IntegerField(write_only=True, required=False) - sector_id = serializers.IntegerField(write_only=True, required=False) + site_id = serializers.IntegerField(read_only=True) + sector_id = serializers.IntegerField(read_only=True) class Meta: model = Content @@ -300,8 +300,8 @@ class ContentSerializer(serializers.ModelSerializer): class ContentTaxonomySerializer(serializers.ModelSerializer): """Serializer for ContentTaxonomy model - Stage 1 refactored""" content_count = serializers.SerializerMethodField() - site_id = serializers.IntegerField(write_only=True, required=False) - sector_id = serializers.IntegerField(write_only=True, required=False) + site_id = serializers.IntegerField(read_only=True) + sector_id = serializers.IntegerField(read_only=True) class Meta: model = ContentTaxonomy diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py index 169ffd49..7bd27b8a 100644 --- a/backend/igny8_core/modules/writer/views.py +++ b/backend/igny8_core/modules/writer/views.py @@ -751,6 +751,19 @@ class ContentViewSet(SiteSectorModelViewSet): 'source', ] + def get_queryset(self): + """Override to support status__in filtering for multiple statuses""" + queryset = super().get_queryset() + + # Support status__in query param (comma-separated list of statuses) + status_in = self.request.query_params.get('status__in', None) + if status_in: + statuses = [s.strip() for s in status_in.split(',') if s.strip()] + if statuses: + queryset = queryset.filter(status__in=statuses) + + return queryset + def perform_create(self, serializer): """Override to check monthly word limit and set account""" user = getattr(self.request, 'user', None) diff --git a/frontend/src/components/dashboard/CreditAvailabilityWidget.tsx b/frontend/src/components/dashboard/CreditAvailabilityWidget.tsx index 64a4b634..b284fc8f 100644 --- a/frontend/src/components/dashboard/CreditAvailabilityWidget.tsx +++ b/frontend/src/components/dashboard/CreditAvailabilityWidget.tsx @@ -15,6 +15,7 @@ import { interface CreditAvailabilityWidgetProps { availableCredits: number; totalCredits: number; + usedCredits?: number; // Actual credits used this month from API loading?: boolean; } @@ -29,10 +30,17 @@ const OPERATION_COSTS = { export default function CreditAvailabilityWidget({ availableCredits, totalCredits, + usedCredits: usedCreditsFromApi, loading = false }: CreditAvailabilityWidgetProps) { - const usedCredits = totalCredits - availableCredits; - const usagePercent = totalCredits > 0 ? Math.round((usedCredits / totalCredits) * 100) : 0; + // Use actual used credits from API if provided, otherwise calculate + // Note: availableCredits may include purchased credits beyond plan allocation + const usedCredits = usedCreditsFromApi ?? 0; + + // Calculate usage percentage based on plan allocation + // If available > plan, user has extra credits (purchased or carried over) + const usagePercent = totalCredits > 0 ? Math.min(Math.round((usedCredits / totalCredits) * 100), 100) : 0; + const remainingPercent = Math.max(100 - usagePercent, 0); // Calculate available operations const availableOps = Object.entries(OPERATION_COSTS).map(([key, config]) => ({ @@ -72,11 +80,11 @@ export default function CreditAvailabilityWidget({ className={`h-2 rounded-full transition-all ${ usagePercent > 90 ? 'bg-error-500' : usagePercent > 75 ? 'bg-warning-500' : 'bg-success-500' }`} - style={{ width: `${Math.max(100 - usagePercent, 0)}%` }} + style={{ width: `${remainingPercent}%` }} >

- {totalCredits > 0 ? `${usedCredits.toLocaleString()} of ${totalCredits.toLocaleString()} used (${usagePercent}%)` : 'No credits allocated'} + {totalCredits > 0 ? `${usedCredits.toLocaleString()} of ${totalCredits.toLocaleString()} used this month (${usagePercent}%)` : 'No credits allocated'}

diff --git a/frontend/src/components/dashboard/StandardizedModuleWidget.tsx b/frontend/src/components/dashboard/StandardizedModuleWidget.tsx index bf65f2fa..0d727ebf 100644 --- a/frontend/src/components/dashboard/StandardizedModuleWidget.tsx +++ b/frontend/src/components/dashboard/StandardizedModuleWidget.tsx @@ -87,8 +87,8 @@ export default function StandardizedModuleWidget({ ]; // Define Writer pipeline - using correct content structure - // Content has status: draft, review, published - // totalContent = drafts + review + published + // Content has status: draft, review, approved, published + // totalContent = drafts + review + approved + published // Get writer colors from config const writerColors = useMemo(() => getPipelineColors('writer'), []); diff --git a/frontend/src/components/dashboard/WorkflowCompletionWidget.tsx b/frontend/src/components/dashboard/WorkflowCompletionWidget.tsx index 30abbf28..152435c2 100644 --- a/frontend/src/components/dashboard/WorkflowCompletionWidget.tsx +++ b/frontend/src/components/dashboard/WorkflowCompletionWidget.tsx @@ -139,7 +139,7 @@ export default function WorkflowCompletionWidget({ ]; // Define writer items - using "Content Pages" not "Articles" - // Total content = drafts + review + published + // Total content = drafts + review + approved + published const totalContent = writer.contentDrafts + writer.contentReview + writer.contentPublished; const writerItems = [ { label: 'Content Pages', value: totalContent, barColor: `var(${WORKFLOW_COLORS.writer.contentPages})` }, diff --git a/frontend/src/config/pages/approved.config.tsx b/frontend/src/config/pages/approved.config.tsx index c07d5dd6..422c382c 100644 --- a/frontend/src/config/pages/approved.config.tsx +++ b/frontend/src/config/pages/approved.config.tsx @@ -90,6 +90,27 @@ export function createApprovedPageConfig(params: { ), }, + { + key: 'status', + label: 'Status', + sortable: true, + sortField: 'status', + width: '130px', + render: (value: string, row: Content) => { + // Map internal status to user-friendly labels + const statusConfig: Record = { + 'approved': { color: 'blue', label: 'Ready to Publish' }, + 'published': { color: 'success', label: row.external_id ? 'On Site' : 'Approved' }, + }; + const config = statusConfig[value] || { color: 'gray' as const, label: value || '-' }; + + return ( + + {config.label} + + ); + }, + }, { key: 'wordpress_status', label: 'Site Content Status', diff --git a/frontend/src/hooks/useModuleStats.ts b/frontend/src/hooks/useModuleStats.ts index d710715d..258c08db 100644 --- a/frontend/src/hooks/useModuleStats.ts +++ b/frontend/src/hooks/useModuleStats.ts @@ -6,7 +6,7 @@ * * IMPORTANT: Content table structure * - Tasks is separate table (has status: queued, processing, completed, failed) - * - Content table has status field: 'draft', 'review', 'published' (approved) + * - Content table has status field: 'draft', 'review', 'approved', 'published' * - Images is separate table linked to content * * Credits data comes from /v1/billing/credits/usage/summary/ endpoint @@ -43,7 +43,7 @@ export interface WriterModuleStats { tasksCompleted: number; // Tasks with status='completed' contentDrafts: number; // Content with status='draft' contentReview: number; // Content with status='review' - contentPublished: number; // Content with status='published' (approved) + contentPublished: number; // Content with status='approved' or 'published' (ready for publishing or on site) totalContent: number; // All content regardless of status totalImages: number; } @@ -158,8 +158,8 @@ export function useModuleStats() { fetchContent({ ...baseFilters, status: 'draft' }), // Content with status='review' fetchContent({ ...baseFilters, status: 'review' }), - // Content with status='published' (approved) - fetchContent({ ...baseFilters, status: 'published' }), + // Content with status='approved' or 'published' (ready for publishing or on site) + fetchContent({ ...baseFilters, status__in: 'approved,published' }), // Total content (all statuses) fetchContent({ ...baseFilters }), // Total images diff --git a/frontend/src/hooks/useWorkflowStats.ts b/frontend/src/hooks/useWorkflowStats.ts index 850eda2d..b6bc99e7 100644 --- a/frontend/src/hooks/useWorkflowStats.ts +++ b/frontend/src/hooks/useWorkflowStats.ts @@ -9,7 +9,7 @@ * * IMPORTANT: Content table structure * - Tasks is separate table - * - Content table has status field: 'draft', 'review', 'published' (approved) + * - Content table has status field: 'draft', 'review', 'approved', 'published' * - Images is separate table linked to content * * Credits data comes from /v1/billing/credits/usage/summary/ endpoint @@ -57,7 +57,7 @@ export interface WorkflowStats { tasksTotal: number; contentDrafts: number; // Content with status='draft' contentReview: number; // Content with status='review' - contentPublished: number; // Content with status='published' (approved) + contentPublished: number; // Content with status='approved' or 'published' (ready for publishing or on site) imagesCreated: number; }; // Credit consumption stats - detailed breakdown by operation @@ -208,10 +208,10 @@ export function useWorkflowStats(timeFilter: TimeFilter = 'all') { dateFilter ? fetchAPI(`/v1/writer/content/?page_size=1&status=review${baseParams}${dateParam}`) : fetchContent({ ...baseFilters, status: 'review' }), - // Content with status='published' (approved) + // Content with status='approved' or 'published' (ready for publishing or on site) dateFilter - ? fetchAPI(`/v1/writer/content/?page_size=1&status=published${baseParams}${dateParam}`) - : fetchContent({ ...baseFilters, status: 'published' }), + ? fetchAPI(`/v1/writer/content/?page_size=1&status__in=approved,published${baseParams}${dateParam}`) + : fetchContent({ ...baseFilters, status__in: 'approved,published' }), // Total images dateFilter ? fetchAPI(`/v1/writer/images/?page_size=1${baseParams}${dateParam}`) diff --git a/frontend/src/pages/Automation/AutomationPage.tsx b/frontend/src/pages/Automation/AutomationPage.tsx index 0ec9f27e..af048152 100644 --- a/frontend/src/pages/Automation/AutomationPage.tsx +++ b/frontend/src/pages/Automation/AutomationPage.tsx @@ -139,7 +139,7 @@ const AutomationPage: React.FC = () => { fetchContent({ page_size: 1, site_id: siteId }), fetchContent({ page_size: 1, site_id: siteId, status: 'draft' }), fetchContent({ page_size: 1, site_id: siteId, status: 'review' }), - fetchContent({ page_size: 1, site_id: siteId, status: 'published' }), + fetchContent({ page_size: 1, site_id: siteId, status__in: 'approved,published' }), fetchImages({ page_size: 1 }), fetchImages({ page_size: 1, status: 'pending' }), ]); @@ -258,7 +258,7 @@ const AutomationPage: React.FC = () => { fetchContent({ page_size: 1, site_id: siteId }), fetchContent({ page_size: 1, site_id: siteId, status: 'draft' }), fetchContent({ page_size: 1, site_id: siteId, status: 'review' }), - fetchContent({ page_size: 1, site_id: siteId, status: 'published' }), + fetchContent({ page_size: 1, site_id: siteId, status__in: 'approved,published' }), fetchImages({ page_size: 1 }), fetchImages({ page_size: 1, status: 'pending' }), ]); diff --git a/frontend/src/pages/Sites/Dashboard.tsx b/frontend/src/pages/Sites/Dashboard.tsx index 9fd742f5..9bf8625d 100644 --- a/frontend/src/pages/Sites/Dashboard.tsx +++ b/frontend/src/pages/Sites/Dashboard.tsx @@ -7,6 +7,8 @@ import React, { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import PageMeta from '../../components/common/PageMeta'; import PageHeader from '../../components/common/PageHeader'; +import ComponentCard from '../../components/common/ComponentCard'; +import { Card } from '../../components/ui/card'; import Button from '../../components/ui/button/Button'; import { useToast } from '../../components/ui/toast/ToastContainer'; import { fetchAPI, fetchSiteSectors } from '../../services/api'; @@ -23,6 +25,7 @@ import { BoltIcon, PageIcon, ArrowRightIcon, + ArrowUpIcon, } from '../../icons'; interface Site { @@ -251,6 +254,7 @@ export default function SiteDashboard() { diff --git a/frontend/src/pages/Writer/Approved.tsx b/frontend/src/pages/Writer/Approved.tsx index ffe487f1..960748c4 100644 --- a/frontend/src/pages/Writer/Approved.tsx +++ b/frontend/src/pages/Writer/Approved.tsx @@ -59,9 +59,9 @@ export default function Approved() { // Load total metrics for footer widget and header metrics (not affected by pagination) const loadTotalMetrics = useCallback(async () => { try { - // Fetch all approved content to calculate totals + // Fetch all approved+published content to calculate totals const data = await fetchContent({ - status: 'published', // Backend uses 'published' for approved content + status__in: 'approved,published', // Both approved and published content page_size: 1000, // Fetch enough to count }); @@ -86,7 +86,7 @@ export default function Approved() { loadTotalMetrics(); }, [loadTotalMetrics]); - // Load content - filtered for approved status (API still uses 'published' internally) + // Load content - filtered for approved+published status const loadContent = useCallback(async () => { setLoading(true); setShowContent(false); @@ -95,7 +95,7 @@ export default function Approved() { const filters: ContentFilters = { ...(searchTerm && { search: searchTerm }), - status: 'published', // Backend uses 'published' for approved content + status__in: 'approved,published', // Both approved and published content page: currentPage, page_size: pageSize, ordering, @@ -221,8 +221,13 @@ export default function Approved() { toast.warning('WordPress URL not available'); } } else if (action === 'edit') { - // Navigate to content editor (if exists) or show edit modal - navigate(`/writer/content?id=${row.id}`); + // Navigate to content editor + if (row.site_id) { + navigate(`/sites/${row.site_id}/posts/${row.id}/edit`); + } else { + // Fallback if site_id not available + toast.warning('Unable to edit: Site information not available'); + } } }, [toast, loadContent, navigate]); diff --git a/frontend/src/pages/Writer/Content.tsx b/frontend/src/pages/Writer/Content.tsx index dff366be..aadc427a 100644 --- a/frontend/src/pages/Writer/Content.tsx +++ b/frontend/src/pages/Writer/Content.tsx @@ -81,11 +81,11 @@ export default function Content() { }); setTotalReview(reviewRes.count || 0); - // Get content with status='published' + // Get content with status='approved' or 'published' (ready for publishing or on site) const publishedRes = await fetchContent({ page_size: 1, ...(activeSector?.id && { sector_id: activeSector.id }), - status: 'published', + status__in: 'approved,published', }); setTotalPublished(publishedRes.count || 0); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 4009f5c7..03c76f0c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1385,7 +1385,7 @@ export interface ContentImage { export interface ContentImagesGroup { content_id: number; content_title: string; - content_status: 'draft' | 'review' | 'publish'; + content_status: 'draft' | 'review' | 'approved' | 'published'; featured_image: ContentImage | null; in_article_images: ContentImage[]; overall_status: 'pending' | 'partial' | 'complete' | 'failed'; @@ -2197,6 +2197,7 @@ export async function deleteAuthorProfile(id: number): Promise { export interface ContentFilters { search?: string; status?: string; + status__in?: string; // Comma-separated list of statuses (e.g., 'approved,published') content_type?: string; content_structure?: string; source?: string; @@ -2215,9 +2216,11 @@ export interface Content { content_html: string; content_type: string; content_structure: string; - status: 'draft' | 'published'; + status: 'draft' | 'review' | 'approved' | 'published'; source: 'igny8' | 'wordpress'; // Relations + site_id?: number; + sector_id?: number; cluster_id: number; cluster_name?: string | null; sector_name?: string | null; @@ -2282,6 +2285,7 @@ export async function fetchContent(filters: ContentFilters = {}): Promise { + const statusLower = status.toLowerCase(); + // Map status to user-friendly labels + const statusLabels: Record = { + 'draft': 'Draft', + 'review': 'In Review', + 'approved': 'Ready to Publish', + 'published': hasExternalId ? 'On Site' : 'Approved', + }; + return statusLabels[statusLower] || status.charAt(0).toUpperCase() + status.slice(1); + }; + return (
@@ -824,7 +842,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
- {content.status} + {getStatusLabel(content.status, !!content.external_id)}