(() => {
+ // If we have dashboard API data, convert it to our AttentionItem format
+ if (dashboardData?.needs_attention && dashboardData.needs_attention.length > 0) {
+ return dashboardData.needs_attention.map(item => ({
+ id: item.id,
+ type: item.type as AttentionItem['type'],
+ title: item.title,
+ count: item.count,
+ actionLabel: item.action_label,
+ actionUrl: item.action_url,
+ severity: item.severity as AttentionItem['severity'],
+ }));
+ }
+
+ // Fallback: compute from local state
+ const items: AttentionItem[] = [];
+
+ // Check for content pending review
+ const reviewCount = progress.contentCount - progress.publishedCount;
+ if (reviewCount > 0 && reviewCount < 20) {
+ items.push({
+ id: 'pending-review',
+ type: 'pending_review',
+ title: 'pending review',
+ count: reviewCount,
+ actionLabel: 'Review',
+ actionUrl: '/writer/review',
+ severity: 'warning',
+ });
+ }
+
+ // Check for sites without setup (no keywords)
+ const sitesWithoutSetup = sites.filter(s => !s.keywords_count || s.keywords_count === 0);
+ if (sitesWithoutSetup.length > 0) {
+ items.push({
+ id: 'setup-incomplete',
+ type: 'setup_incomplete',
+ title: sitesWithoutSetup.length === 1
+ ? `${sitesWithoutSetup[0].name} needs setup`
+ : `${sitesWithoutSetup.length} sites need setup`,
+ actionLabel: 'Complete',
+ actionUrl: sitesWithoutSetup.length === 1 ? `/sites/${sitesWithoutSetup[0].id}` : '/sites',
+ severity: 'info',
+ });
+ }
+
+ // Check for low credits (if balance is low)
+ if (balance && balance.credits_remaining !== undefined) {
+ const creditsPercent = (balance.credits_remaining / (balance.credits || 1)) * 100;
+ if (creditsPercent < 20 && creditsPercent > 0) {
+ items.push({
+ id: 'credits-low',
+ type: 'credits_low',
+ title: `Credits running low (${balance.credits_remaining} remaining)`,
+ actionLabel: 'Upgrade',
+ actionUrl: '/billing/plans',
+ severity: 'warning',
+ });
+ }
+ }
+
+ return items;
+ }, [dashboardData, progress, sites, balance]);
+
const fetchAppInsights = async () => {
try {
setLoading(true);
- const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
// Determine site_id based on filter
const siteId = siteFilter === 'all' ? undefined : siteFilter;
- // Fetch sequentially with small delays to avoid burst throttling
- const keywordsRes = await fetchKeywords({ page_size: 1, site_id: siteId });
- await delay(120);
- const clustersRes = await fetchClusters({ page_size: 1, site_id: siteId });
- await delay(120);
- const ideasRes = await fetchContentIdeas({ page_size: 1, site_id: siteId });
- await delay(120);
- const tasksRes = await fetchTasks({ page_size: 1, site_id: siteId });
- await delay(120);
- const contentRes = await fetchContent({ page_size: 1, site_id: siteId });
- await delay(120);
- const imagesRes = await fetchContentImages({ page_size: 1, site_id: siteId });
+ // Use aggregated dashboard API - single call replaces 6 sequential calls
+ const summary = await fetchDashboardSummary({ site_id: siteId, days: 7 });
+ setDashboardData(summary);
- const totalKeywords = keywordsRes.count || 0;
- const totalClusters = clustersRes.count || 0;
- const totalIdeas = ideasRes.count || 0;
- const totalTasks = tasksRes.count || 0;
- const totalContent = contentRes.count || 0;
- const totalImages = imagesRes.count || 0;
-
- // Check for published content (status = 'published')
- const publishedContent = totalContent; // TODO: Filter by published status when API supports it
- const workflowCompletionRate = totalKeywords > 0
- ? Math.round((publishedContent / totalKeywords) * 100)
- : 0;
+ const totalKeywords = summary.pipeline.keywords;
+ const totalClusters = summary.pipeline.clusters;
+ const totalIdeas = summary.pipeline.ideas;
+ const totalTasks = summary.pipeline.tasks;
+ const totalContent = summary.pipeline.total_content;
+ const totalImages = 0; // Images count not in pipeline - fetch separately if needed
+ const publishedContent = summary.pipeline.published;
+ const workflowCompletionRate = summary.pipeline.completion_percentage;
// Check if site has industry and sectors (site with sectors means industry is set)
const hasSiteWithSectors = sites.some(site => site.active_sectors_count > 0);
@@ -478,9 +536,9 @@ export default function Home() {
totalImages,
publishedContent,
workflowCompletionRate,
- contentThisWeek: Math.floor(totalContent * 0.3),
- contentThisMonth: Math.floor(totalContent * 0.7),
- automationEnabled: false,
+ contentThisWeek: summary.content_velocity.this_week,
+ contentThisMonth: summary.content_velocity.this_month,
+ automationEnabled: summary.automation.enabled,
});
// Update progress
@@ -591,6 +649,9 @@ export default function Home() {
title="Dashboard - IGNY8"
description="IGNY8 AI-Powered Content Creation Dashboard"
/>
+
+ {/* Needs Attention Bar - Shows items requiring user action */}
+
{/* Custom Header with Site Selector and Refresh */}
diff --git a/frontend/src/pages/Planner/Clusters.tsx b/frontend/src/pages/Planner/Clusters.tsx
index a4cbc970..91d90cde 100644
--- a/frontend/src/pages/Planner/Clusters.tsx
+++ b/frontend/src/pages/Planner/Clusters.tsx
@@ -27,7 +27,11 @@ import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
import PageHeader from '../../components/common/PageHeader';
-import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
+import ModuleMetricsFooter, {
+ PageProgressWidget,
+ ModuleStatsWidget,
+ CompletionWidget
+} from '../../components/dashboard/ModuleMetricsFooter';
export default function Clusters() {
const toast = useToast();
@@ -486,37 +490,88 @@ export default function Clusters() {
}}
/>
- {/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}
+ {/* Module Metrics Footer - 3-Widget Layout */}
sum + (c.keywords_count || 0), 0).toLocaleString(),
- subtitle: `in ${totalCount} clusters`,
- icon: ,
- accentColor: 'blue',
- href: '/planner/keywords',
+ submoduleColor="green"
+ threeWidgetLayout={{
+ // Widget 1: Page Progress (Clusters)
+ pageProgress: {
+ title: 'Page Progress',
+ submoduleColor: 'green',
+ metrics: [
+ { label: 'Clusters', value: totalCount },
+ { label: 'With Ideas', value: clusters.filter(c => (c.ideas_count || 0) > 0).length, percentage: `${totalCount > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / totalCount) * 100) : 0}%` },
+ { label: 'Keywords', value: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0) },
+ { label: 'Ready', value: clusters.filter(c => (c.ideas_count || 0) === 0).length },
+ ],
+ progress: {
+ value: totalCount > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / totalCount) * 100) : 0,
+ label: 'Have Ideas',
+ color: 'green',
+ },
+ hint: clusters.filter(c => (c.ideas_count || 0) === 0).length > 0
+ ? `${clusters.filter(c => (c.ideas_count || 0) === 0).length} clusters ready for idea generation`
+ : 'All clusters have ideas!',
},
- {
- title: 'Content Ideas',
- value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0).toLocaleString(),
- subtitle: `across ${clusters.filter(c => (c.ideas_count || 0) > 0).length} clusters`,
- icon: ,
- accentColor: 'green',
- href: '/planner/ideas',
+ // Widget 2: Module Stats (Planner Pipeline)
+ moduleStats: {
+ title: 'Planner Module',
+ pipeline: [
+ {
+ fromLabel: 'Keywords',
+ fromValue: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0),
+ fromHref: '/planner/keywords',
+ actionLabel: 'Auto Cluster',
+ toLabel: 'Clusters',
+ toValue: totalCount,
+ progress: 100,
+ color: 'blue',
+ },
+ {
+ fromLabel: 'Clusters',
+ fromValue: totalCount,
+ actionLabel: 'Generate Ideas',
+ toLabel: 'Ideas',
+ toValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0),
+ toHref: '/planner/ideas',
+ progress: totalCount > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / totalCount) * 100) : 0,
+ color: 'green',
+ },
+ {
+ fromLabel: 'Ideas',
+ fromValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0),
+ fromHref: '/planner/ideas',
+ actionLabel: 'Create Tasks',
+ toLabel: 'Tasks',
+ toValue: 0,
+ toHref: '/writer/tasks',
+ progress: 0,
+ color: 'amber',
+ },
+ ],
+ links: [
+ { label: 'Keywords', href: '/planner/keywords' },
+ { label: 'Clusters', href: '/planner/clusters' },
+ { label: 'Ideas', href: '/planner/ideas' },
+ ],
},
- {
- title: 'Ready to Write',
- value: clusters.filter(c => (c.ideas_count || 0) > 0 && c.status === 'active').length.toLocaleString(),
- subtitle: 'clusters with approved ideas',
- icon: ,
- accentColor: 'purple',
+ // Widget 3: Completion Stats
+ completion: {
+ title: 'Workflow Completion',
+ plannerItems: [
+ { label: 'Keywords', value: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0), color: 'blue' },
+ { label: 'Clusters', value: totalCount, color: 'green' },
+ { label: 'Ideas', value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0), color: 'amber' },
+ ],
+ writerItems: [
+ { label: 'Content', value: 0, color: 'blue' },
+ { label: 'Images', value: 0, color: 'purple' },
+ { label: 'Published', value: 0, color: 'green' },
+ ],
+ creditsUsed: 0,
+ operationsCount: 0,
+ analyticsHref: '/analytics',
},
- ]}
- progress={{
- label: 'Idea Generation Pipeline: Clusters with content ideas generated (ready for downstream content creation)',
- value: totalCount > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / totalCount) * 100) : 0,
- color: 'purple',
}}
/>
diff --git a/frontend/src/pages/Planner/Ideas.tsx b/frontend/src/pages/Planner/Ideas.tsx
index cc8f492c..63aef441 100644
--- a/frontend/src/pages/Planner/Ideas.tsx
+++ b/frontend/src/pages/Planner/Ideas.tsx
@@ -29,7 +29,11 @@ import { createIdeasPageConfig } from '../../config/pages/ideas.config';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader';
-import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
+import ModuleMetricsFooter, {
+ PageProgressWidget,
+ ModuleStatsWidget,
+ CompletionWidget
+} from '../../components/dashboard/ModuleMetricsFooter';
export default function Ideas() {
const toast = useToast();
@@ -414,45 +418,88 @@ export default function Ideas() {
}}
/>
- {/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}
+ {/* Module Metrics Footer - 3-Widget Layout */}
,
- accentColor: 'purple',
- href: '/planner/clusters',
+ submoduleColor="amber"
+ threeWidgetLayout={{
+ // Widget 1: Page Progress (Ideas)
+ pageProgress: {
+ title: 'Page Progress',
+ submoduleColor: 'amber',
+ metrics: [
+ { label: 'Ideas', value: totalCount },
+ { label: 'In Tasks', value: ideas.filter(i => i.status === 'queued' || i.status === 'completed').length, percentage: `${totalCount > 0 ? Math.round((ideas.filter(i => i.status !== 'new').length / totalCount) * 100) : 0}%` },
+ { label: 'Pending', value: ideas.filter(i => i.status === 'new').length },
+ { label: 'Clusters', value: clusters.length },
+ ],
+ progress: {
+ value: totalCount > 0 ? Math.round((ideas.filter(i => i.status !== 'new').length / totalCount) * 100) : 0,
+ label: 'Converted',
+ color: 'amber',
+ },
+ hint: ideas.filter(i => i.status === 'new').length > 0
+ ? `${ideas.filter(i => i.status === 'new').length} ideas ready to become tasks`
+ : 'All ideas converted to tasks!',
},
- {
- title: 'Ready to Queue',
- value: ideas.filter(i => i.status === 'new').length.toLocaleString(),
- subtitle: 'awaiting approval',
- icon: ,
- accentColor: 'orange',
+ // Widget 2: Module Stats (Planner Pipeline)
+ moduleStats: {
+ title: 'Planner Module',
+ pipeline: [
+ {
+ fromLabel: 'Keywords',
+ fromValue: 0,
+ fromHref: '/planner/keywords',
+ actionLabel: 'Auto Cluster',
+ toLabel: 'Clusters',
+ toValue: clusters.length,
+ toHref: '/planner/clusters',
+ progress: 100,
+ color: 'blue',
+ },
+ {
+ fromLabel: 'Clusters',
+ fromValue: clusters.length,
+ fromHref: '/planner/clusters',
+ actionLabel: 'Generate Ideas',
+ toLabel: 'Ideas',
+ toValue: totalCount,
+ progress: 100,
+ color: 'green',
+ },
+ {
+ fromLabel: 'Ideas',
+ fromValue: totalCount,
+ actionLabel: 'Create Tasks',
+ toLabel: 'Tasks',
+ toValue: ideas.filter(i => i.status === 'queued' || i.status === 'completed').length,
+ toHref: '/writer/tasks',
+ progress: totalCount > 0 ? Math.round((ideas.filter(i => i.status !== 'new').length / totalCount) * 100) : 0,
+ color: 'amber',
+ },
+ ],
+ links: [
+ { label: 'Keywords', href: '/planner/keywords' },
+ { label: 'Clusters', href: '/planner/clusters' },
+ { label: 'Ideas', href: '/planner/ideas' },
+ ],
},
- {
- title: 'In Queue',
- value: ideas.filter(i => i.status === 'queued').length.toLocaleString(),
- subtitle: 'ready for tasks',
- icon: ,
- accentColor: 'blue',
- href: '/writer/tasks',
+ // Widget 3: Completion Stats
+ completion: {
+ title: 'Workflow Completion',
+ plannerItems: [
+ { label: 'Clusters', value: clusters.length, color: 'green' },
+ { label: 'Ideas', value: totalCount, color: 'amber' },
+ { label: 'In Tasks', value: ideas.filter(i => i.status !== 'new').length, color: 'purple' },
+ ],
+ writerItems: [
+ { label: 'Content', value: ideas.filter(i => i.status === 'completed').length, color: 'blue' },
+ { label: 'Images', value: 0, color: 'purple' },
+ { label: 'Published', value: 0, color: 'green' },
+ ],
+ creditsUsed: 0,
+ operationsCount: 0,
+ analyticsHref: '/analytics',
},
- {
- title: 'Content Created',
- value: ideas.filter(i => i.status === 'completed').length.toLocaleString(),
- subtitle: `${totalCount > 0 ? Math.round((ideas.filter(i => i.status === 'completed').length / totalCount) * 100) : 0}% completion`,
- icon: ,
- accentColor: 'green',
- href: '/writer/content',
- },
- ]}
- progress={{
- label: 'Idea-to-Content Pipeline: Ideas successfully converted into written content',
- value: totalCount > 0 ? Math.round((ideas.filter(i => i.status === 'completed').length / totalCount) * 100) : 0,
- color: 'success',
}}
/>
diff --git a/frontend/src/pages/Planner/Keywords.tsx b/frontend/src/pages/Planner/Keywords.tsx
index 6b98f5cb..82a1bdf6 100644
--- a/frontend/src/pages/Planner/Keywords.tsx
+++ b/frontend/src/pages/Planner/Keywords.tsx
@@ -25,7 +25,7 @@ import { useSiteStore } from '../../store/siteStore';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader';
-import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
+import ModuleMetricsFooter from '../../components/dashboard/ModuleMetricsFooter';
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
import FormModal from '../../components/common/FormModal';
import ProgressModal from '../../components/common/ProgressModal';
@@ -704,37 +704,89 @@ export default function Keywords() {
}}
/>
- {/* Module Metrics Footer */}
+ {/* Module Metrics Footer - 3-Widget Layout */}
,
- accentColor: 'blue',
- href: '/planner/keywords',
+ submoduleColor="blue"
+ threeWidgetLayout={{
+ // Widget 1: Page Progress
+ pageProgress: {
+ title: 'Page Progress',
+ submoduleColor: 'blue',
+ metrics: [
+ { label: 'Keywords', value: totalCount },
+ { label: 'Clustered', value: keywords.filter(k => k.cluster_id).length, percentage: `${totalCount > 0 ? Math.round((keywords.filter(k => k.cluster_id).length / totalCount) * 100) : 0}%` },
+ { label: 'Unmapped', value: keywords.filter(k => !k.cluster_id).length },
+ { label: 'Volume', value: `${(keywords.reduce((sum, k) => sum + (k.volume || 0), 0) / 1000).toFixed(1)}K` },
+ ],
+ progress: {
+ value: totalCount > 0 ? Math.round((keywords.filter(k => k.cluster_id).length / totalCount) * 100) : 0,
+ label: 'Clustered',
+ color: 'blue',
+ },
+ hint: keywords.filter(k => !k.cluster_id).length > 0
+ ? `${keywords.filter(k => !k.cluster_id).length} keywords ready to cluster`
+ : 'All keywords clustered!',
},
- {
- title: 'Clustered',
- value: keywords.filter(k => k.cluster_id).length.toLocaleString(),
- subtitle: `${Math.round((keywords.filter(k => k.cluster_id).length / Math.max(totalCount, 1)) * 100)}% organized`,
- icon: ,
- accentColor: 'purple',
- href: '/planner/clusters',
+ // Widget 2: Module Stats (Planner Pipeline)
+ moduleStats: {
+ title: 'Planner Module',
+ pipeline: [
+ {
+ fromLabel: 'Keywords',
+ fromValue: totalCount,
+ actionLabel: 'Auto Cluster',
+ toLabel: 'Clusters',
+ toValue: clusters.length,
+ toHref: '/planner/clusters',
+ progress: totalCount > 0 ? Math.round((keywords.filter(k => k.cluster_id).length / totalCount) * 100) : 0,
+ color: 'blue',
+ },
+ {
+ fromLabel: 'Clusters',
+ fromValue: clusters.length,
+ fromHref: '/planner/clusters',
+ actionLabel: 'Generate Ideas',
+ toLabel: 'Ideas',
+ toValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0),
+ toHref: '/planner/ideas',
+ progress: clusters.length > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / clusters.length) * 100) : 0,
+ color: 'green',
+ },
+ {
+ fromLabel: 'Ideas',
+ fromValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0),
+ fromHref: '/planner/ideas',
+ actionLabel: 'Create Tasks',
+ toLabel: 'Tasks',
+ toValue: 0,
+ toHref: '/writer/tasks',
+ progress: 0,
+ color: 'amber',
+ },
+ ],
+ links: [
+ { label: 'Keywords', href: '/planner/keywords' },
+ { label: 'Clusters', href: '/planner/clusters' },
+ { label: 'Ideas', href: '/planner/ideas' },
+ ],
},
- {
- title: 'Easy Wins',
- value: keywords.filter(k => k.difficulty && k.difficulty <= 3 && (k.volume || 0) > 0).length.toLocaleString(),
- subtitle: `Low difficulty with ${keywords.filter(k => k.difficulty && k.difficulty <= 3).reduce((sum, k) => sum + (k.volume || 0), 0).toLocaleString()} volume`,
- icon: ,
- accentColor: 'green',
+ // Widget 3: Completion Stats
+ completion: {
+ title: 'Workflow Completion',
+ plannerItems: [
+ { label: 'Keywords', value: keywords.filter(k => k.cluster_id).length, color: 'blue' },
+ { label: 'Clusters', value: clusters.length, color: 'green' },
+ { label: 'Ideas', value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0), color: 'amber' },
+ ],
+ writerItems: [
+ { label: 'Content', value: 0, color: 'blue' },
+ { label: 'Images', value: 0, color: 'purple' },
+ { label: 'Published', value: 0, color: 'green' },
+ ],
+ creditsUsed: 0,
+ operationsCount: 0,
+ analyticsHref: '/analytics',
},
- ]}
- progress={{
- label: 'Keyword Clustering Pipeline: Keywords organized into topical clusters',
- value: totalCount > 0 ? Math.round((keywords.filter(k => k.cluster_id).length / totalCount) * 100) : 0,
- color: 'primary',
}}
/>
diff --git a/frontend/src/pages/Writer/Approved.tsx b/frontend/src/pages/Writer/Approved.tsx
index d1aaf2ad..57aa4832 100644
--- a/frontend/src/pages/Writer/Approved.tsx
+++ b/frontend/src/pages/Writer/Approved.tsx
@@ -17,8 +17,7 @@ import {
bulkDeleteContent,
} from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer';
-import { FileIcon, CheckCircleIcon, BoltIcon } from '../../icons';
-import { RocketLaunchIcon } from '@heroicons/react/24/outline';
+import { CheckCircleIcon, BoltIcon } from '../../icons';
import { createApprovedPageConfig } from '../../config/pages/approved.config';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
@@ -358,29 +357,87 @@ export default function Approved() {
getItemDisplayName={(row: Content) => row.title || `Content #${row.id}`}
/>
- {/* Module Metrics Footer */}
+ {/* Module Metrics Footer - 3-Widget Layout */}
,
- accentColor: 'green',
+ submoduleColor="green"
+ threeWidgetLayout={{
+ pageProgress: {
+ title: 'Page Progress',
+ submoduleColor: 'green',
+ metrics: [
+ { label: 'Total Approved', value: totalCount },
+ { label: 'On Site', value: content.filter(c => c.external_id).length, percentage: `${totalCount > 0 ? Math.round((content.filter(c => c.external_id).length / totalCount) * 100) : 0}%` },
+ { label: 'Pending Publish', value: content.filter(c => !c.external_id).length },
+ { label: 'This Page', value: content.length },
+ ],
+ progress: {
+ label: 'Published to Site',
+ value: totalCount > 0 ? Math.round((content.filter(c => c.external_id).length / totalCount) * 100) : 0,
+ color: 'green',
+ },
+ hint: content.filter(c => !c.external_id).length > 0
+ ? `${content.filter(c => !c.external_id).length} items ready for site publishing`
+ : 'All approved content published!',
},
- {
- title: 'Published to Site',
- value: content.filter(c => c.external_id).length.toLocaleString(),
- subtitle: 'on WordPress',
- icon: ,
- accentColor: 'blue',
- href: '/writer/approved',
+ moduleStats: {
+ title: 'Writer Module',
+ pipeline: [
+ {
+ fromLabel: 'Tasks',
+ fromValue: 0,
+ fromHref: '/writer/tasks',
+ actionLabel: 'Generate Content',
+ toLabel: 'Drafts',
+ toValue: 0,
+ toHref: '/writer/content',
+ progress: 100,
+ color: 'blue',
+ },
+ {
+ fromLabel: 'Drafts',
+ fromValue: 0,
+ fromHref: '/writer/content',
+ actionLabel: 'Generate Images',
+ toLabel: 'Images',
+ toValue: 0,
+ toHref: '/writer/images',
+ progress: 100,
+ color: 'purple',
+ },
+ {
+ fromLabel: 'Ready',
+ fromValue: 0,
+ fromHref: '/writer/review',
+ actionLabel: 'Review & Publish',
+ toLabel: 'Published',
+ toValue: totalCount,
+ progress: totalCount > 0 ? Math.round((content.filter(c => c.external_id).length / totalCount) * 100) : 0,
+ color: 'green',
+ },
+ ],
+ links: [
+ { label: 'Tasks', href: '/writer/tasks' },
+ { label: 'Content', href: '/writer/content' },
+ { label: 'Images', href: '/writer/images' },
+ { label: 'Published', href: '/writer/approved' },
+ ],
+ },
+ completion: {
+ title: 'Workflow Completion',
+ plannerItems: [
+ { label: 'Keywords', value: 0, color: 'blue' },
+ { label: 'Clusters', value: 0, color: 'green' },
+ { label: 'Ideas', value: 0, color: 'amber' },
+ ],
+ writerItems: [
+ { label: 'Content', value: 0, color: 'purple' },
+ { label: 'Images', value: 0, color: 'amber' },
+ { label: 'Published', value: content.filter(c => c.external_id).length, color: 'green' },
+ ],
+ creditsUsed: 0,
+ operationsCount: 0,
+ analyticsHref: '/analytics',
},
- ]}
- progress={{
- label: 'Site Publishing Progress',
- value: totalCount > 0 ? Math.round((content.filter(c => c.external_id).length / totalCount) * 100) : 0,
- color: 'success',
}}
/>
>
diff --git a/frontend/src/pages/Writer/Content.tsx b/frontend/src/pages/Writer/Content.tsx
index 56318c68..61543aa4 100644
--- a/frontend/src/pages/Writer/Content.tsx
+++ b/frontend/src/pages/Writer/Content.tsx
@@ -4,7 +4,7 @@
*/
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
-import { Link, useNavigate } from 'react-router-dom';
+import { useNavigate } from 'react-router-dom';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchContent,
@@ -16,14 +16,13 @@ import {
} from '../../services/api';
import { optimizerApi } from '../../api/optimizer.api';
import { useToast } from '../../components/ui/toast/ToastContainer';
-import { FileIcon, TaskIcon, CheckCircleIcon, ArrowRightIcon } from '../../icons';
import { createContentPageConfig } from '../../config/pages/content.config';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import ProgressModal from '../../components/common/ProgressModal';
import { useProgressModal } from '../../hooks/useProgressModal';
import PageHeader from '../../components/common/PageHeader';
-import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
+import ModuleMetricsFooter from '../../components/dashboard/ModuleMetricsFooter';
import { PencilSquareIcon } from '@heroicons/react/24/outline';
export default function Content() {
@@ -275,45 +274,86 @@ export default function Content() {
getItemDisplayName={(row: ContentType) => row.title || `Content #${row.id}`}
/>
- {/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}
+ {/* Module Metrics Footer - 3-Widget Layout */}
,
- accentColor: 'blue',
- href: '/writer/tasks',
+ submoduleColor="purple"
+ threeWidgetLayout={{
+ pageProgress: {
+ title: 'Page Progress',
+ submoduleColor: 'purple',
+ metrics: [
+ { label: 'Total Content', value: totalCount },
+ { label: 'Draft', value: content.filter(c => c.status === 'draft').length },
+ { label: 'In Review', value: content.filter(c => c.status === 'review').length },
+ { label: 'Published', value: content.filter(c => c.status === 'published').length, percentage: `${totalCount > 0 ? Math.round((content.filter(c => c.status === 'published').length / totalCount) * 100) : 0}%` },
+ ],
+ progress: {
+ label: 'Published',
+ value: totalCount > 0 ? Math.round((content.filter(c => c.status === 'published').length / totalCount) * 100) : 0,
+ color: 'green',
+ },
+ hint: content.filter(c => c.status === 'draft').length > 0
+ ? `${content.filter(c => c.status === 'draft').length} drafts need images before review`
+ : 'All content processed!',
},
- {
- title: 'Draft',
- value: content.filter(c => c.status === 'draft').length.toLocaleString(),
- subtitle: 'needs editing',
- icon: ,
- accentColor: 'amber',
+ moduleStats: {
+ title: 'Writer Module',
+ pipeline: [
+ {
+ fromLabel: 'Tasks',
+ fromValue: totalCount,
+ fromHref: '/writer/tasks',
+ actionLabel: 'Generate Content',
+ toLabel: 'Drafts',
+ toValue: content.filter(c => c.status === 'draft').length,
+ progress: 100,
+ color: 'blue',
+ },
+ {
+ fromLabel: 'Drafts',
+ fromValue: content.filter(c => c.status === 'draft').length,
+ actionLabel: 'Generate Images',
+ toLabel: 'Images',
+ toValue: 0,
+ toHref: '/writer/images',
+ progress: totalCount > 0 ? Math.round((content.filter(c => c.status !== 'draft').length / totalCount) * 100) : 0,
+ color: 'purple',
+ },
+ {
+ fromLabel: 'Ready',
+ fromValue: content.filter(c => c.status === 'review').length,
+ fromHref: '/writer/review',
+ actionLabel: 'Review & Publish',
+ toLabel: 'Published',
+ toValue: content.filter(c => c.status === 'published').length,
+ toHref: '/writer/approved',
+ progress: totalCount > 0 ? Math.round((content.filter(c => c.status === 'published').length / totalCount) * 100) : 0,
+ color: 'green',
+ },
+ ],
+ links: [
+ { label: 'Tasks', href: '/writer/tasks' },
+ { label: 'Content', href: '/writer/content' },
+ { label: 'Images', href: '/writer/images' },
+ { label: 'Published', href: '/writer/approved' },
+ ],
},
- {
- title: 'In Review',
- value: content.filter(c => c.status === 'review').length.toLocaleString(),
- subtitle: 'awaiting approval',
- icon: ,
- accentColor: 'blue',
- href: '/writer/review',
+ completion: {
+ title: 'Workflow Completion',
+ plannerItems: [
+ { label: 'Keywords', value: 0, color: 'blue' },
+ { label: 'Clusters', value: 0, color: 'green' },
+ { label: 'Ideas', value: 0, color: 'amber' },
+ ],
+ writerItems: [
+ { label: 'Content', value: totalCount, color: 'purple' },
+ { label: 'Images', value: 0, color: 'amber' },
+ { label: 'Published', value: content.filter(c => c.status === 'published').length, color: 'green' },
+ ],
+ creditsUsed: 0,
+ operationsCount: 0,
+ analyticsHref: '/analytics',
},
- {
- title: 'Published',
- value: content.filter(c => c.status === 'published').length.toLocaleString(),
- subtitle: 'ready for sync',
- icon: ,
- accentColor: 'green',
- href: '/writer/published',
- },
- ]}
- progress={{
- label: 'Content Publishing Pipeline: Content moved from draft through review to published (Draft \u2192 Review \u2192 Published)',
- value: totalCount > 0 ? Math.round((content.filter(c => c.status === 'published').length / totalCount) * 100) : 0,
- color: 'success',
}}
/>
diff --git a/frontend/src/pages/Writer/Review.tsx b/frontend/src/pages/Writer/Review.tsx
index 92e6b4e7..9e667fee 100644
--- a/frontend/src/pages/Writer/Review.tsx
+++ b/frontend/src/pages/Writer/Review.tsx
@@ -455,15 +455,86 @@ export default function Review() {
onRowAction={handleRowAction}
/>
,
- accentColor: 'blue',
+ submoduleColor="amber"
+ threeWidgetLayout={{
+ pageProgress: {
+ title: 'Page Progress',
+ submoduleColor: 'amber',
+ metrics: [
+ { label: 'In Review', value: totalCount },
+ { label: 'This Page', value: content.length },
+ { label: 'Ready', value: content.filter(c => c.word_count && c.word_count > 0).length, percentage: `${totalCount > 0 ? Math.round((content.filter(c => c.word_count && c.word_count > 0).length / totalCount) * 100) : 0}%` },
+ { label: 'Pending', value: content.filter(c => !c.word_count || c.word_count === 0).length },
+ ],
+ progress: {
+ label: 'Ready for Approval',
+ value: totalCount > 0 ? Math.round((content.filter(c => c.word_count && c.word_count > 0).length / totalCount) * 100) : 0,
+ color: 'amber',
+ },
+ hint: totalCount > 0
+ ? `${totalCount} items in review queue awaiting approval`
+ : 'No items in review queue',
},
- ]}
+ moduleStats: {
+ title: 'Writer Module',
+ pipeline: [
+ {
+ fromLabel: 'Tasks',
+ fromValue: 0,
+ fromHref: '/writer/tasks',
+ actionLabel: 'Generate Content',
+ toLabel: 'Drafts',
+ toValue: 0,
+ toHref: '/writer/content',
+ progress: 100,
+ color: 'blue',
+ },
+ {
+ fromLabel: 'Drafts',
+ fromValue: 0,
+ fromHref: '/writer/content',
+ actionLabel: 'Generate Images',
+ toLabel: 'Images',
+ toValue: 0,
+ toHref: '/writer/images',
+ progress: 100,
+ color: 'purple',
+ },
+ {
+ fromLabel: 'Ready',
+ fromValue: totalCount,
+ actionLabel: 'Review & Publish',
+ toLabel: 'Published',
+ toValue: 0,
+ toHref: '/writer/approved',
+ progress: 0,
+ color: 'green',
+ },
+ ],
+ links: [
+ { label: 'Tasks', href: '/writer/tasks' },
+ { label: 'Content', href: '/writer/content' },
+ { label: 'Images', href: '/writer/images' },
+ { label: 'Published', href: '/writer/approved' },
+ ],
+ },
+ completion: {
+ title: 'Workflow Completion',
+ plannerItems: [
+ { label: 'Keywords', value: 0, color: 'blue' },
+ { label: 'Clusters', value: 0, color: 'green' },
+ { label: 'Ideas', value: 0, color: 'amber' },
+ ],
+ writerItems: [
+ { label: 'Content', value: 0, color: 'purple' },
+ { label: 'In Review', value: totalCount, color: 'amber' },
+ { label: 'Published', value: 0, color: 'green' },
+ ],
+ creditsUsed: 0,
+ operationsCount: 0,
+ analyticsHref: '/analytics',
+ },
+ }}
/>
>
);
diff --git a/frontend/src/pages/Writer/Tasks.tsx b/frontend/src/pages/Writer/Tasks.tsx
index 04e00be1..2c7c54c9 100644
--- a/frontend/src/pages/Writer/Tasks.tsx
+++ b/frontend/src/pages/Writer/Tasks.tsx
@@ -30,7 +30,7 @@ import { createTasksPageConfig } from '../../config/pages/tasks.config';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader';
-import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
+import ModuleMetricsFooter from '../../components/dashboard/ModuleMetricsFooter';
import { DocumentTextIcon } from '@heroicons/react/24/outline';
export default function Tasks() {
@@ -467,44 +467,89 @@ export default function Tasks() {
}}
/>
- {/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}
+ {/* Module Metrics Footer - 3-Widget Layout */}
sum + (c.ideas_count || 0), 0).toLocaleString(),
- subtitle: 'from planner',
- icon: ,
- accentColor: 'orange',
- href: '/planner/ideas',
+ submoduleColor="blue"
+ threeWidgetLayout={{
+ // Widget 1: Page Progress (Tasks)
+ pageProgress: {
+ title: 'Page Progress',
+ submoduleColor: 'blue',
+ metrics: [
+ { label: 'Total', value: totalCount },
+ { label: 'Complete', value: tasks.filter(t => t.status === 'completed').length, percentage: `${totalCount > 0 ? Math.round((tasks.filter(t => t.status === 'completed').length / totalCount) * 100) : 0}%` },
+ { label: 'Queue', value: tasks.filter(t => t.status === 'queued').length },
+ { label: 'Processing', value: tasks.filter(t => t.status === 'in_progress').length },
+ ],
+ progress: {
+ value: totalCount > 0 ? Math.round((tasks.filter(t => t.status === 'completed').length / totalCount) * 100) : 0,
+ label: 'Generated',
+ color: 'blue',
+ },
+ hint: tasks.filter(t => t.status === 'queued').length > 0
+ ? `${tasks.filter(t => t.status === 'queued').length} tasks in queue for content generation`
+ : 'All tasks processed!',
},
- {
- title: 'In Queue',
- value: tasks.filter(t => t.status === 'queued').length.toLocaleString(),
- subtitle: 'waiting for processing',
- icon: ,
- accentColor: 'amber',
+ // Widget 2: Module Stats (Writer Pipeline)
+ moduleStats: {
+ title: 'Writer Module',
+ pipeline: [
+ {
+ fromLabel: 'Tasks',
+ fromValue: totalCount,
+ actionLabel: 'Generate Content',
+ toLabel: 'Drafts',
+ toValue: tasks.filter(t => t.status === 'completed').length,
+ toHref: '/writer/content',
+ progress: totalCount > 0 ? Math.round((tasks.filter(t => t.status === 'completed').length / totalCount) * 100) : 0,
+ color: 'blue',
+ },
+ {
+ fromLabel: 'Drafts',
+ fromValue: tasks.filter(t => t.status === 'completed').length,
+ fromHref: '/writer/content',
+ actionLabel: 'Generate Images',
+ toLabel: 'Images',
+ toValue: 0,
+ toHref: '/writer/images',
+ progress: 0,
+ color: 'purple',
+ },
+ {
+ fromLabel: 'Ready',
+ fromValue: 0,
+ fromHref: '/writer/review',
+ actionLabel: 'Review & Publish',
+ toLabel: 'Published',
+ toValue: 0,
+ toHref: '/writer/approved',
+ progress: 0,
+ color: 'green',
+ },
+ ],
+ links: [
+ { label: 'Tasks', href: '/writer/tasks' },
+ { label: 'Content', href: '/writer/content' },
+ { label: 'Images', href: '/writer/images' },
+ { label: 'Published', href: '/writer/approved' },
+ ],
},
- {
- title: 'Processing',
- value: tasks.filter(t => t.status === 'in_progress').length.toLocaleString(),
- subtitle: 'generating content',
- icon: ,
- accentColor: 'blue',
+ // Widget 3: Completion Stats
+ completion: {
+ title: 'Workflow Completion',
+ plannerItems: [
+ { label: 'Clusters', value: clusters.length, color: 'green' },
+ { label: 'Ideas', value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0), color: 'amber' },
+ ],
+ writerItems: [
+ { label: 'Tasks', value: totalCount, color: 'blue' },
+ { label: 'Content', value: tasks.filter(t => t.status === 'completed').length, color: 'purple' },
+ { label: 'Published', value: 0, color: 'green' },
+ ],
+ creditsUsed: 0,
+ operationsCount: 0,
+ analyticsHref: '/analytics',
},
- {
- title: 'Ready for Review',
- value: tasks.filter(t => t.status === 'completed').length.toLocaleString(),
- subtitle: 'content generated',
- icon: ,
- accentColor: 'green',
- href: '/writer/content',
- },
- ]}
- progress={{
- label: 'Content Generation Pipeline: Tasks successfully completed (Queued → Processing → Completed)',
- value: totalCount > 0 ? Math.round((tasks.filter(t => t.status === 'completed').length / totalCount) * 100) : 0,
- color: 'success',
}}
/>
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index eb7cac11..4ba7c0b7 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -2619,3 +2619,117 @@ export async function generatePageContent(
});
}
+// ==========================================
+// Dashboard Summary API
+// ==========================================
+
+export interface DashboardAttentionItem {
+ id: string;
+ type: 'pending_review' | 'setup_incomplete' | 'credits_low' | 'no_integration' | 'queued_tasks' | 'sync_failed';
+ title: string;
+ count?: number;
+ action_label: string;
+ action_url: string;
+ severity: 'info' | 'warning' | 'error';
+}
+
+export interface DashboardPipeline {
+ keywords: number;
+ clusters: number;
+ ideas: number;
+ tasks: number;
+ drafts: number;
+ review: number;
+ published: number;
+ total_content: number;
+ completion_percentage: number;
+}
+
+export interface DashboardAIOperation {
+ type: string;
+ label: string;
+ count: number;
+ credits: number;
+ tokens: number;
+}
+
+export interface DashboardAIOperations {
+ period_days: number;
+ operations: DashboardAIOperation[];
+ totals: {
+ credits: number;
+ operations: number;
+ };
+}
+
+export interface DashboardActivity {
+ id: number;
+ type: string;
+ description: string;
+ timestamp: string;
+ icon: string;
+ color: string;
+ credits: number;
+}
+
+export interface DashboardContentVelocity {
+ today: number;
+ this_week: number;
+ this_month: number;
+ daily: Array<{ date: string; count: number }>;
+ average_per_day: number;
+}
+
+export interface DashboardAutomation {
+ enabled: boolean;
+ active_count: number;
+ status: 'active' | 'inactive';
+}
+
+export interface DashboardSite {
+ id: number;
+ name: string;
+ domain: string;
+ keywords: number;
+ content: number;
+ published: number;
+ has_integration: boolean;
+ sectors_count: number;
+}
+
+export interface DashboardSummary {
+ needs_attention: DashboardAttentionItem[];
+ pipeline: DashboardPipeline;
+ ai_operations: DashboardAIOperations;
+ recent_activity: DashboardActivity[];
+ content_velocity: DashboardContentVelocity;
+ automation: DashboardAutomation;
+ sites: DashboardSite[];
+ account: {
+ credits: number;
+ name: string;
+ };
+ generated_at: string;
+}
+
+export interface DashboardSummaryFilters {
+ site_id?: number;
+ days?: number;
+}
+
+/**
+ * Fetch aggregated dashboard summary in a single API call.
+ * Replaces multiple sequential calls for better performance.
+ */
+export async function fetchDashboardSummary(
+ filters: DashboardSummaryFilters = {}
+): Promise {
+ const params = new URLSearchParams();
+ if (filters.site_id) params.append('site_id', String(filters.site_id));
+ if (filters.days) params.append('days', String(filters.days));
+
+ const queryString = params.toString();
+ return fetchAPI(`/v1/account/dashboard/summary/${queryString ? `?${queryString}` : ''}`);
+}
+
+