final plolish phase 2

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-27 15:25:05 +00:00
parent 99982eb4fb
commit b9e4b6f7e2
27 changed files with 2229 additions and 280 deletions

View File

@@ -3,7 +3,7 @@
**Date:** December 27, 2025
**Scope:** Complete application audit for optimal user experience
**Note:** Plans, billing, credits, usage sections excluded - will be done in separate phase
**Status:** ✅ IMPLEMENTED
**Status:** ✅ IMPLEMENTED & INTEGRATED
---
@@ -12,13 +12,23 @@
| Section | Status | Files Modified |
|---------|--------|----------------|
| 1. Site & Sector Selector | ✅ | Already implemented per guidelines |
| 2. Tooltip Improvements | ✅ | `config/pages/*.config.tsx` (fixed typo) |
| 2. Tooltip Improvements | ✅ | `config/pages/*.config.tsx` (all 8 page configs updated with actionable tooltips) |
| 3. Footer 3-Widget Layout | ✅ | `components/dashboard/ThreeWidgetFooter.tsx` |
| 4. Progress Modal Steps | ✅ | `backend/igny8_core/ai/engine.py` |
| 5. Dashboard Redesign | ✅ | `components/dashboard/CompactDashboard.tsx` |
| 6. Site Setup Checklist | ✅ | `components/common/SiteCard.tsx`, `backend/auth/serializers.py`, `services/api.ts` |
| 7. To-Do-s Audit | ✅ | Documentation only |
| 8. Notification System | ✅ | `store/notificationStore.ts`, `components/header/NotificationDropdownNew.tsx` |
| 8. Notification System | ✅ | `store/notificationStore.ts`, `components/header/NotificationDropdownNew.tsx`, `hooks/useProgressModal.ts` |
### Integration Complete
| Integration | Status | Details |
|-------------|--------|---------|
| NotificationDropdown → AppHeader | ✅ | `layout/AppHeader.tsx`, `components/header/Header.tsx` now use `NotificationDropdownNew` |
| AI Task → Notifications | ✅ | `hooks/useProgressModal.ts` automatically adds notifications on success/failure |
| Dashboard exports | ✅ | `components/dashboard/index.ts` barrel export created |
| NeedsAttentionBar → Home | ✅ | `pages/Dashboard/Home.tsx` shows attention items at top |
| ThreeWidgetFooter hook | ✅ | `hooks/useThreeWidgetFooter.ts` helper for easy integration |
---

View File

@@ -0,0 +1,381 @@
"""
Dashboard API Views
Provides aggregated data for the frontend dashboard in a single call.
Replaces multiple sequential API calls for better performance.
"""
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from django.db.models import Count, Sum, Q, F
from django.utils import timezone
from datetime import timedelta
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
from igny8_core.auth.models import Site, Sector
from igny8_core.business.planning.models import Keywords, Clusters, ContentIdeas
from igny8_core.business.content.models import Tasks, Content
from igny8_core.business.billing.models import CreditUsageLog
from igny8_core.ai.models import AITaskLog
@extend_schema_view(
summary=extend_schema(
tags=['Dashboard'],
summary='Get dashboard summary',
description='Returns aggregated dashboard data including pipeline counts, AI operations, recent activity, and items needing attention.',
parameters=[
OpenApiParameter(
name='site_id',
description='Filter by specific site ID',
required=False,
type=int
),
OpenApiParameter(
name='days',
description='Number of days for recent activity and AI operations (default: 7)',
required=False,
type=int
),
]
),
)
class DashboardSummaryViewSet(viewsets.ViewSet):
"""Dashboard summary providing aggregated data for the main dashboard."""
permission_classes = [IsAuthenticated]
@action(detail=False, methods=['get'])
def summary(self, request):
"""
Get comprehensive dashboard summary in a single API call.
Returns:
- needs_attention: Items requiring user action
- pipeline: Workflow pipeline counts (keywords → published)
- ai_operations: Recent AI usage stats
- recent_activity: Latest activity log
- content_velocity: Content creation trends
- automation: Automation status summary
"""
account = request.user.account
site_id = request.query_params.get('site_id')
days = int(request.query_params.get('days', 7))
start_date = timezone.now() - timedelta(days=days)
# Build base filters
site_filter = Q(site__account=account)
if site_id:
site_filter &= Q(site_id=site_id)
# ==========================================
# 1. PIPELINE COUNTS
# ==========================================
keywords_count = Keywords.objects.filter(site_filter).count()
clusters_count = Clusters.objects.filter(site_filter).count()
ideas_count = ContentIdeas.objects.filter(site_filter).count()
tasks_count = Tasks.objects.filter(site_filter).count()
content_filter = site_filter
drafts_count = Content.objects.filter(content_filter, status='draft').count()
review_count = Content.objects.filter(content_filter, status='review').count()
published_count = Content.objects.filter(content_filter, status='published').count()
total_content = drafts_count + review_count + published_count
# Calculate completion percentage based on workflow milestones
milestones = [
keywords_count > 0,
clusters_count > 0,
ideas_count > 0,
tasks_count > 0,
total_content > 0,
published_count > 0,
]
completion_percentage = int((sum(milestones) / len(milestones)) * 100) if milestones else 0
pipeline = {
'keywords': keywords_count,
'clusters': clusters_count,
'ideas': ideas_count,
'tasks': tasks_count,
'drafts': drafts_count,
'review': review_count,
'published': published_count,
'total_content': total_content,
'completion_percentage': completion_percentage,
}
# ==========================================
# 2. NEEDS ATTENTION
# ==========================================
needs_attention = []
# Content pending review
if review_count > 0:
needs_attention.append({
'id': 'pending-review',
'type': 'pending_review',
'title': 'pending review',
'count': review_count,
'action_label': 'Review',
'action_url': '/writer/review',
'severity': 'warning',
})
# Sites without keywords (incomplete setup)
sites = Site.objects.filter(account=account, is_active=True)
sites_without_keywords = []
for site in sites:
kw_count = Keywords.objects.filter(site=site).count()
if kw_count == 0:
sites_without_keywords.append(site)
if sites_without_keywords:
if len(sites_without_keywords) == 1:
needs_attention.append({
'id': 'setup-incomplete',
'type': 'setup_incomplete',
'title': f'{sites_without_keywords[0].name} needs setup',
'action_label': 'Complete',
'action_url': f'/sites/{sites_without_keywords[0].id}',
'severity': 'info',
})
else:
needs_attention.append({
'id': 'setup-incomplete',
'type': 'setup_incomplete',
'title': f'{len(sites_without_keywords)} sites need setup',
'action_label': 'Complete',
'action_url': '/sites',
'severity': 'info',
})
# Sites without integrations
sites_without_integration = sites.filter(has_integration=False).count()
if sites_without_integration > 0:
needs_attention.append({
'id': 'no-integration',
'type': 'no_integration',
'title': f'{sites_without_integration} site{"s" if sites_without_integration > 1 else ""} without WordPress',
'action_label': 'Connect',
'action_url': '/integrations',
'severity': 'info',
})
# Low credits warning
if account.credits < 100:
needs_attention.append({
'id': 'credits-low',
'type': 'credits_low',
'title': f'Credits running low ({account.credits} remaining)',
'action_label': 'Upgrade',
'action_url': '/billing/plans',
'severity': 'warning' if account.credits > 20 else 'error',
})
# Queued tasks not processed
queued_tasks = Tasks.objects.filter(site_filter, status='queued').count()
if queued_tasks > 10:
needs_attention.append({
'id': 'queued-tasks',
'type': 'queued_tasks',
'title': f'{queued_tasks} tasks waiting to be generated',
'action_label': 'Generate',
'action_url': '/writer/tasks',
'severity': 'info',
})
# ==========================================
# 3. AI OPERATIONS (last N days)
# ==========================================
ai_usage = CreditUsageLog.objects.filter(
account=account,
created_at__gte=start_date
)
# Group by operation type
operations_by_type = ai_usage.values('operation_type').annotate(
count=Count('id'),
credits=Sum('credits_used'),
tokens=Sum('tokens_input') + Sum('tokens_output')
).order_by('-count')
# Format operation names
operation_display = {
'clustering': 'Clustering',
'idea_generation': 'Ideas',
'content_generation': 'Content',
'image_generation': 'Images',
'image_prompt_extraction': 'Image Prompts',
'linking': 'Linking',
'optimization': 'Optimization',
'reparse': 'Reparse',
'site_page_generation': 'Site Pages',
'site_structure_generation': 'Site Structure',
'ideas': 'Ideas',
'content': 'Content',
'images': 'Images',
}
operations = []
for op in operations_by_type[:5]: # Top 5 operations
operations.append({
'type': op['operation_type'],
'label': operation_display.get(op['operation_type'], op['operation_type'].replace('_', ' ').title()),
'count': op['count'],
'credits': op['credits'] or 0,
'tokens': op['tokens'] or 0,
})
total_credits_used = ai_usage.aggregate(total=Sum('credits_used'))['total'] or 0
total_operations = ai_usage.count()
ai_operations = {
'period_days': days,
'operations': operations,
'totals': {
'credits': total_credits_used,
'operations': total_operations,
}
}
# ==========================================
# 4. RECENT ACTIVITY
# ==========================================
recent_logs = AITaskLog.objects.filter(
account=account,
status='success',
created_at__gte=start_date
).order_by('-created_at')[:10]
activity_icons = {
'run_clustering': 'group',
'generate_content_ideas': 'bolt',
'generate_content': 'file-text',
'generate_images': 'image',
'publish_content': 'paper-plane',
'optimize_content': 'sparkles',
'link_content': 'link',
}
activity_colors = {
'run_clustering': 'purple',
'generate_content_ideas': 'orange',
'generate_content': 'blue',
'generate_images': 'pink',
'publish_content': 'green',
'optimize_content': 'cyan',
'link_content': 'indigo',
}
recent_activity = []
for log in recent_logs:
# Parse friendly message from the log
message = log.message or f'{log.function_name} completed'
recent_activity.append({
'id': log.id,
'type': log.function_name,
'description': message,
'timestamp': log.created_at.isoformat(),
'icon': activity_icons.get(log.function_name, 'bolt'),
'color': activity_colors.get(log.function_name, 'gray'),
'credits': float(log.cost) if log.cost else 0,
})
# ==========================================
# 5. CONTENT VELOCITY
# ==========================================
# Content created in different periods
now = timezone.now()
content_today = Content.objects.filter(
content_filter,
created_at__date=now.date()
).count()
content_this_week = Content.objects.filter(
content_filter,
created_at__gte=now - timedelta(days=7)
).count()
content_this_month = Content.objects.filter(
content_filter,
created_at__gte=now - timedelta(days=30)
).count()
# Daily breakdown for last 7 days
daily_content = []
for i in range(7):
day = now - timedelta(days=6-i)
count = Content.objects.filter(
content_filter,
created_at__date=day.date()
).count()
daily_content.append({
'date': day.date().isoformat(),
'count': count,
})
content_velocity = {
'today': content_today,
'this_week': content_this_week,
'this_month': content_this_month,
'daily': daily_content,
'average_per_day': round(content_this_week / 7, 1) if content_this_week else 0,
}
# ==========================================
# 6. AUTOMATION STATUS
# ==========================================
# Check automation settings
from igny8_core.business.automation.models import AutomationSettings
automation_enabled = AutomationSettings.objects.filter(
account=account,
enabled=True
).exists()
active_automations = AutomationSettings.objects.filter(
account=account,
enabled=True
).count()
automation = {
'enabled': automation_enabled,
'active_count': active_automations,
'status': 'active' if automation_enabled else 'inactive',
}
# ==========================================
# 7. SITES SUMMARY
# ==========================================
sites_data = []
for site in sites[:5]: # Top 5 sites
site_keywords = Keywords.objects.filter(site=site).count()
site_content = Content.objects.filter(site=site).count()
site_published = Content.objects.filter(site=site, status='published').count()
sites_data.append({
'id': site.id,
'name': site.name,
'domain': site.url,
'keywords': site_keywords,
'content': site_content,
'published': site_published,
'has_integration': site.has_integration,
'sectors_count': site.sectors.filter(is_active=True).count(),
})
return Response({
'needs_attention': needs_attention,
'pipeline': pipeline,
'ai_operations': ai_operations,
'recent_activity': recent_activity,
'content_velocity': content_velocity,
'automation': automation,
'sites': sites_data,
'account': {
'credits': account.credits,
'name': account.name,
},
'generated_at': timezone.now().isoformat(),
})

View File

@@ -8,6 +8,7 @@ from .account_views import (
TeamManagementViewSet,
UsageAnalyticsViewSet
)
from .dashboard_views import DashboardSummaryViewSet
router = DefaultRouter()
@@ -22,5 +23,8 @@ urlpatterns = [
# Usage analytics
path('usage/analytics/', UsageAnalyticsViewSet.as_view({'get': 'overview'}), name='usage-analytics'),
# Dashboard summary (aggregated data for main dashboard)
path('dashboard/summary/', DashboardSummaryViewSet.as_view({'get': 'summary'}), name='dashboard-summary'),
path('', include(router.urls)),
]

View File

@@ -1,13 +1,25 @@
/**
* ModuleMetricsFooter - Compact metrics footer for table pages
* Shows module-specific metrics at the bottom of table pages
* Uses standard EnhancedMetricCard and ProgressBar components
* Follows standard app design system and color scheme
*
* Supports two layouts:
* 1. Default: Grid of MetricCards + optional single progress bar
* 2. ThreeWidget: 3-column layout (Page Progress | Module Stats | Completion)
* - Matches Section 3 of COMPREHENSIVE-AUDIT-REPORT.md exactly
*
* STYLING: Uses CSS tokens from styles/tokens.css:
* - --color-primary: Brand blue for primary actions/bars
* - --color-success: Green for success states
* - --color-warning: Amber for warnings
* - --color-purple: Purple accent
*/
import React from 'react';
import { Link } from 'react-router-dom';
import EnhancedMetricCard, { MetricCardProps } from './EnhancedMetricCard';
import { ProgressBar } from '../ui/progress';
import { Card } from '../ui/card/Card';
import { LightBulbIcon, ChevronRightIcon } from '@heroicons/react/24/solid';
export interface MetricItem {
title: string;
@@ -25,30 +37,108 @@ export interface ProgressMetric {
color?: 'primary' | 'success' | 'warning' | 'purple';
}
// ============================================================================
// THREE-WIDGET LAYOUT TYPES (Section 3 of Audit Report)
// ============================================================================
/** Submodule color type - matches headerMetrics accentColor */
export type SubmoduleColor = 'blue' | 'green' | 'amber' | 'purple';
/** Widget 1: Page Progress - metrics in 2x2 grid + progress bar + hint */
export interface PageProgressWidget {
title: string;
metrics: Array<{ label: string; value: string | number; percentage?: string }>;
progress: { value: number; label: string; color?: SubmoduleColor };
hint?: string;
/** The submodule's accent color - progress bar uses this */
submoduleColor?: SubmoduleColor;
}
/** Widget 2: Module Stats - Pipeline flow with arrows and progress bars */
export interface ModulePipelineRow {
fromLabel: string;
fromValue: number;
fromHref?: string;
actionLabel: string;
toLabel: string;
toValue: number;
toHref?: string;
progress: number; // 0-100
/** Color for this pipeline row's progress bar */
color?: SubmoduleColor;
}
export interface ModuleStatsWidget {
title: string;
pipeline: ModulePipelineRow[];
links: Array<{ label: string; href: string }>;
}
/** Widget 3: Completion - Tree structure with bars for both modules */
export interface CompletionItem {
label: string;
value: number;
color?: SubmoduleColor;
}
export interface CompletionWidget {
title: string;
plannerItems: CompletionItem[];
writerItems: CompletionItem[];
creditsUsed?: number;
operationsCount?: number;
analyticsHref?: string;
}
// ============================================================================
// COMPONENT PROPS
// ============================================================================
interface ModuleMetricsFooterProps {
metrics: MetricItem[];
metrics?: MetricItem[];
progress?: ProgressMetric;
className?: string;
/** Submodule accent color - used for progress bars when in threeWidgetLayout */
submoduleColor?: SubmoduleColor;
threeWidgetLayout?: {
pageProgress: PageProgressWidget;
moduleStats: ModuleStatsWidget;
completion: CompletionWidget;
};
}
export default function ModuleMetricsFooter({
metrics,
metrics = [],
progress,
className = ''
className = '',
submoduleColor = 'blue',
threeWidgetLayout,
}: ModuleMetricsFooterProps) {
// Three-widget layout:
// First 2 widgets = 50% (25% each), Last widget = 50% with 2 columns inside
if (threeWidgetLayout) {
return (
<div className={`mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 ${className}`}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left side: 2 widgets side by side (each 50% of 50% = 25% total) */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<PageProgressCard widget={threeWidgetLayout.pageProgress} submoduleColor={submoduleColor} />
<ModuleStatsCard widget={threeWidgetLayout.moduleStats} />
</div>
{/* Right side: Completion widget (50% of total, 2 columns inside) */}
<CompletionCard widget={threeWidgetLayout.completion} />
</div>
</div>
);
}
// Original layout (default)
if (metrics.length === 0 && !progress) return null;
const progressColors = {
primary: 'bg-[var(--color-primary)]',
success: 'bg-[var(--color-success)]',
warning: 'bg-[var(--color-warning)]',
purple: 'bg-[var(--color-purple)]',
};
return (
<div className={`mt-8 pt-6 border-t border-gray-200 dark:border-gray-800 ${className}`}>
<div className="space-y-4">
{/* Metrics Grid */}
{metrics.length > 0 && (
<div className={`grid grid-cols-1 sm:grid-cols-2 ${metrics.length > 2 ? 'lg:grid-cols-3' : 'lg:grid-cols-2'} ${metrics.length > 3 ? 'xl:grid-cols-4' : ''} gap-4`}>
{metrics.map((metric, index) => (
@@ -65,8 +155,6 @@ export default function ModuleMetricsFooter({
))}
</div>
)}
{/* Progress Bar */}
{progress && (
<div className="space-y-2">
<ProgressBar
@@ -83,3 +171,319 @@ export default function ModuleMetricsFooter({
);
}
// ============================================================================
// COLOR UTILITIES - Maps SubmoduleColor to CSS token classes
// Uses CSS variables from styles/tokens.css
// ============================================================================
const getProgressBarStyle = (color: SubmoduleColor = 'blue'): React.CSSProperties => {
const colorMap: Record<SubmoduleColor, string> = {
blue: 'var(--color-primary)',
green: 'var(--color-success)',
amber: 'var(--color-warning)',
purple: 'var(--color-purple)',
};
return { backgroundColor: colorMap[color] };
};
const getLinkColorClass = (color: SubmoduleColor = 'blue'): string => {
// Using CSS variable approach for brand consistency
return 'text-[color:var(--color-primary)] hover:text-[color:var(--color-primary-dark)]';
};
// ============================================================================
// WIDGET 1: PAGE PROGRESS
// Design from audit:
// ┌──────────────────────────────────────────────────┐
// │ PAGE PROGRESS │
// │ │
// │ Clusters 12 With Ideas 8 (67%) │
// │ Keywords 46 Ready 4 │
// │ │
// │ ██████████████░░░░░░░ 67% Have Ideas │
// │ │
// │ 💡 4 clusters ready for idea generation │
// └──────────────────────────────────────────────────┘
// ============================================================================
function PageProgressCard({ widget, submoduleColor = 'blue' }: { widget: PageProgressWidget; submoduleColor?: SubmoduleColor }) {
const progressColor = widget.submoduleColor || widget.progress.color || submoduleColor;
return (
<Card className="p-5 bg-white dark:bg-gray-900 border border-[color:var(--color-stroke)] dark:border-gray-700">
{/* Header */}
<h3 className="text-sm font-semibold text-[color:var(--color-text)] dark:text-gray-200 uppercase tracking-wide mb-4">
{widget.title}
</h3>
{/* 2x2 Metrics Grid */}
<div className="grid grid-cols-2 gap-x-6 gap-y-3 mb-5">
{widget.metrics.slice(0, 4).map((metric, idx) => (
<div key={idx} className="flex items-baseline justify-between">
<span className="text-sm text-[color:var(--color-text-dim)] dark:text-gray-400">{metric.label}</span>
<div className="flex items-baseline gap-1.5">
<span className="text-lg font-bold text-[color:var(--color-text)] dark:text-white tabular-nums">
{typeof metric.value === 'number' ? metric.value.toLocaleString() : metric.value}
</span>
{metric.percentage && (
<span className="text-xs text-[color:var(--color-text-dim)] dark:text-gray-400">({metric.percentage})</span>
)}
</div>
</div>
))}
</div>
{/* Progress Bar - uses submodule color */}
<div className="mb-4">
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
...getProgressBarStyle(progressColor),
width: `${Math.min(100, Math.max(0, widget.progress.value))}%`
}}
/>
</div>
<div className="flex justify-between items-center mt-2">
<span className="text-sm font-medium text-[color:var(--color-text)] dark:text-gray-300">{widget.progress.label}</span>
<span className="text-sm font-bold text-[color:var(--color-text)] dark:text-white">{widget.progress.value}%</span>
</div>
</div>
{/* Hint with icon (no emoji) */}
{widget.hint && (
<div className="flex items-start gap-2 pt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800">
<LightBulbIcon className="w-5 h-5 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-warning)' }} />
<span className="text-sm font-medium" style={{ color: 'var(--color-primary)' }}>{widget.hint}</span>
</div>
)}
</Card>
);
}
// ============================================================================
// WIDGET 2: MODULE STATS
// Design from audit:
// ┌──────────────────────────────────────────────────┐
// │ PLANNER MODULE │
// │ │
// │ Keywords 46 ► Clusters 12 │
// │ ████████████████████░░░ 91% │
// │ │
// │ [→ Keywords] [→ Clusters] [→ Ideas] │
// └──────────────────────────────────────────────────┘
// ============================================================================
function ModuleStatsCard({ widget }: { widget: ModuleStatsWidget }) {
return (
<Card className="p-5 bg-white dark:bg-gray-900 border border-[color:var(--color-stroke)] dark:border-gray-700">
{/* Header */}
<h3 className="text-sm font-semibold text-[color:var(--color-text)] dark:text-gray-200 uppercase tracking-wide mb-4">
{widget.title}
</h3>
{/* Pipeline Rows */}
<div className="space-y-4 mb-4">
{widget.pipeline.map((row, idx) => (
<div key={idx}>
{/* Row header: FromLabel Value ► ToLabel Value */}
<div className="flex items-center justify-between mb-2">
{/* From side */}
<div className="flex items-center gap-2">
{row.fromHref ? (
<Link
to={row.fromHref}
className="text-sm font-medium hover:underline"
style={{ color: 'var(--color-primary)' }}
>
{row.fromLabel}
</Link>
) : (
<span className="text-sm font-medium text-[color:var(--color-text)] dark:text-gray-300">{row.fromLabel}</span>
)}
<span className="text-lg font-bold tabular-nums" style={{ color: 'var(--color-primary)' }}>
{row.fromValue}
</span>
</div>
{/* Arrow icon - clean chevron, just the tip */}
<ChevronRightIcon
className="w-6 h-6 flex-shrink-0 mx-2"
style={{ color: 'var(--color-primary)' }}
/>
{/* To side */}
<div className="flex items-center gap-2">
{row.toHref ? (
<Link
to={row.toHref}
className="text-sm font-medium hover:underline"
style={{ color: 'var(--color-primary)' }}
>
{row.toLabel}
</Link>
) : (
<span className="text-sm font-medium text-[color:var(--color-text)] dark:text-gray-300">{row.toLabel}</span>
)}
<span className="text-lg font-bold text-[color:var(--color-text)] dark:text-white tabular-nums">
{row.toValue}
</span>
</div>
</div>
{/* Progress bar - uses row color or default primary */}
<div className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
...getProgressBarStyle(row.color || 'blue'),
width: `${Math.min(100, Math.max(0, row.progress))}%`
}}
/>
</div>
</div>
))}
</div>
{/* Navigation Links */}
<div className="flex flex-wrap gap-3 pt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800">
{widget.links.map((link, idx) => (
<Link
key={idx}
to={link.href}
className="text-sm font-medium hover:underline flex items-center gap-1"
style={{ color: 'var(--color-primary)' }}
>
<ChevronRightIcon className="w-4 h-4" />
<span>{link.label}</span>
</Link>
))}
</div>
</Card>
);
}
// ============================================================================
// WIDGET 3: COMPLETION
// Design from audit - with 2 COLUMNS (Planner | Writer) side by side:
// ┌──────────────────────────────────────────────────────────────────┐
// │ WORKFLOW COMPLETION │
// │ │
// │ PLANNER │ WRITER │
// │ ├─ Keywords Clustered 42 │ ├─ Content Generated 28 │
// │ ├─ Clusters Created 12 │ ├─ Images Created 127 │
// │ └─ Ideas Generated 34 │ └─ Articles Published 45 │
// │ │
// │ Credits Used: 2,450 │ Operations: 156 │
// │ │
// │ [View Full Analytics →] │
// └──────────────────────────────────────────────────────────────────┘
// ============================================================================
function CompletionCard({ widget }: { widget: CompletionWidget }) {
// Calculate max for proportional bars (across both columns)
const allValues = [...widget.plannerItems, ...widget.writerItems].map(i => i.value);
const maxValue = Math.max(...allValues, 1);
const renderItem = (item: CompletionItem, isLast: boolean) => {
const barWidth = (item.value / maxValue) * 100;
const prefix = isLast ? '└─' : '├─';
const color = item.color || 'blue';
return (
<div key={item.label} className="flex items-center gap-2 py-1">
{/* Tree prefix */}
<span className="text-[color:var(--color-text-dim)] dark:text-gray-500 font-mono text-xs w-5 flex-shrink-0">{prefix}</span>
{/* Label */}
<span className="text-sm text-[color:var(--color-text)] dark:text-gray-300 flex-1 truncate">{item.label}</span>
{/* Progress bar */}
<div className="w-16 h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex-shrink-0">
<div
className="h-full rounded-full transition-all duration-500"
style={{
...getProgressBarStyle(color),
width: `${Math.min(100, barWidth)}%`
}}
/>
</div>
{/* Value */}
<span className="text-sm font-bold text-[color:var(--color-text)] dark:text-white tabular-nums w-10 text-right flex-shrink-0">
{item.value}
</span>
</div>
);
};
return (
<Card className="p-5 bg-white dark:bg-gray-900 border border-[color:var(--color-stroke)] dark:border-gray-700">
{/* Header */}
<h3 className="text-sm font-semibold text-[color:var(--color-text)] dark:text-gray-200 uppercase tracking-wide mb-4">
{widget.title}
</h3>
{/* Two-column layout: Planner | Writer */}
<div className="grid grid-cols-2 gap-6 mb-4">
{/* Planner Column */}
<div>
<div className="text-xs font-bold uppercase tracking-wide mb-2" style={{ color: 'var(--color-primary)' }}>
Planner
</div>
<div className="space-y-0.5">
{widget.plannerItems.map((item, idx) =>
renderItem(item, idx === widget.plannerItems.length - 1)
)}
</div>
</div>
{/* Writer Column */}
<div>
<div className="text-xs font-bold uppercase tracking-wide mb-2" style={{ color: 'var(--color-success)' }}>
Writer
</div>
<div className="space-y-0.5">
{widget.writerItems.map((item, idx) =>
renderItem(item, idx === widget.writerItems.length - 1)
)}
</div>
</div>
</div>
{/* Footer Stats - Credits Used & Operations */}
{(widget.creditsUsed !== undefined || widget.operationsCount !== undefined) && (
<div className="flex items-center gap-4 pt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800 text-sm">
{widget.creditsUsed !== undefined && (
<span className="text-[color:var(--color-text-dim)] dark:text-gray-400">
Credits Used: <strong className="text-[color:var(--color-text)] dark:text-white font-bold">{widget.creditsUsed.toLocaleString()}</strong>
</span>
)}
{widget.creditsUsed !== undefined && widget.operationsCount !== undefined && (
<span className="text-[color:var(--color-stroke)] dark:text-gray-600"></span>
)}
{widget.operationsCount !== undefined && (
<span className="text-[color:var(--color-text-dim)] dark:text-gray-400">
Operations: <strong className="text-[color:var(--color-text)] dark:text-white font-bold">{widget.operationsCount}</strong>
</span>
)}
</div>
)}
{/* Analytics Link */}
{widget.analyticsHref && (
<div className="pt-3 mt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800">
<Link
to={widget.analyticsHref}
className="text-sm font-medium hover:underline flex items-center gap-1"
style={{ color: 'var(--color-primary)' }}
>
View Full Analytics
<ChevronRightIcon className="w-4 h-4" />
</Link>
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,164 @@
/**
* NeedsAttentionBar - Compact alert bar for items needing user attention
*
* Shows at the top of dashboard when there are:
* - Content pending review
* - WordPress sync failures
* - Incomplete site setup
* - Automation failures
*
* Collapsible and only visible when there are items to show.
*/
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { AlertIcon, ArrowRightIcon, ChevronDownIcon, RefreshIcon, CloseIcon } from '../../icons';
export type AttentionType = 'pending_review' | 'sync_failed' | 'setup_incomplete' | 'automation_failed' | 'credits_low';
export interface AttentionItem {
id: string;
type: AttentionType;
title: string;
count?: number;
actionLabel: string;
actionUrl?: string;
onAction?: () => void;
onRetry?: () => void;
severity: 'warning' | 'error' | 'info';
}
interface NeedsAttentionBarProps {
items: AttentionItem[];
onDismiss?: (id: string) => void;
className?: string;
}
const severityStyles = {
warning: {
bg: 'bg-amber-50 dark:bg-amber-500/10',
border: 'border-amber-200 dark:border-amber-500/30',
icon: 'text-amber-500',
text: 'text-amber-800 dark:text-amber-200',
button: 'bg-amber-100 hover:bg-amber-200 text-amber-700 dark:bg-amber-500/20 dark:hover:bg-amber-500/30 dark:text-amber-200',
},
error: {
bg: 'bg-red-50 dark:bg-red-500/10',
border: 'border-red-200 dark:border-red-500/30',
icon: 'text-red-500',
text: 'text-red-800 dark:text-red-200',
button: 'bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-500/20 dark:hover:bg-red-500/30 dark:text-red-200',
},
info: {
bg: 'bg-blue-50 dark:bg-blue-500/10',
border: 'border-blue-200 dark:border-blue-500/30',
icon: 'text-blue-500',
text: 'text-blue-800 dark:text-blue-200',
button: 'bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-500/20 dark:hover:bg-blue-500/30 dark:text-blue-200',
},
};
export default function NeedsAttentionBar({ items, onDismiss, className = '' }: NeedsAttentionBarProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
if (items.length === 0) return null;
// Group items by severity for display priority
const errorItems = items.filter(i => i.severity === 'error');
const warningItems = items.filter(i => i.severity === 'warning');
const infoItems = items.filter(i => i.severity === 'info');
const sortedItems = [...errorItems, ...warningItems, ...infoItems];
const totalCount = items.reduce((sum, item) => sum + (item.count || 1), 0);
return (
<div className={`mb-6 ${className}`}>
{/* Header bar - always visible */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="w-full flex items-center justify-between p-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg hover:bg-amber-100 dark:hover:bg-amber-500/15 transition-colors"
>
<div className="flex items-center gap-3">
<AlertIcon className="w-5 h-5 text-amber-500" />
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
{totalCount} item{totalCount !== 1 ? 's' : ''} need{totalCount === 1 ? 's' : ''} attention
</span>
</div>
<ChevronDownIcon
className={`w-5 h-5 text-amber-500 transition-transform ${isCollapsed ? '' : 'rotate-180'}`}
/>
</button>
{/* Expandable content */}
{!isCollapsed && (
<div className="mt-2 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{sortedItems.map((item) => {
const styles = severityStyles[item.severity];
return (
<div
key={item.id}
className={`flex items-center justify-between p-3 rounded-lg border ${styles.bg} ${styles.border}`}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<AlertIcon className={`w-4 h-4 flex-shrink-0 ${styles.icon}`} />
<span className={`text-sm font-medium truncate ${styles.text}`}>
{item.count ? `${item.count} ` : ''}{item.title}
</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
{item.onRetry && (
<button
onClick={(e) => {
e.stopPropagation();
item.onRetry?.();
}}
className={`p-1.5 rounded ${styles.button} transition-colors`}
title="Retry"
>
<RefreshIcon className="w-3.5 h-3.5" />
</button>
)}
{item.actionUrl ? (
<Link
to={item.actionUrl}
className={`px-2 py-1 text-xs font-medium rounded ${styles.button} transition-colors flex items-center gap-1`}
>
{item.actionLabel}
<ArrowRightIcon className="w-3 h-3" />
</Link>
) : item.onAction ? (
<button
onClick={(e) => {
e.stopPropagation();
item.onAction?.();
}}
className={`px-2 py-1 text-xs font-medium rounded ${styles.button} transition-colors`}
>
{item.actionLabel}
</button>
) : null}
{onDismiss && (
<button
onClick={(e) => {
e.stopPropagation();
onDismiss(item.id);
}}
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
title="Dismiss"
>
<CloseIcon className="w-3.5 h-3.5 text-gray-400" />
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,50 @@
/**
* Dashboard Components - Centralized exports
*
* Usage:
* import { CompactDashboard, ThreeWidgetFooter } from '../components/dashboard';
*/
// Main dashboard components
export { default as CompactDashboard } from './CompactDashboard';
export type {
CompactDashboardProps,
AttentionItem,
WorkflowCounts,
AIOperation,
RecentActivityItem,
} from './CompactDashboard';
// Attention bar
export { default as NeedsAttentionBar } from './NeedsAttentionBar';
export type {
AttentionItem as NeedsAttentionItem,
AttentionType,
} from './NeedsAttentionBar';
// Footer components
export { default as ThreeWidgetFooter } from './ThreeWidgetFooter';
export type {
ThreeWidgetFooterProps,
PageMetricItem,
PageProgressWidget,
PipelineStep,
ModuleStatsWidget,
CompletionItem,
CompletionWidget,
} from './ThreeWidgetFooter';
// Other dashboard components
export { default as CreditBalanceWidget } from './CreditBalanceWidget';
export { default as EnhancedMetricCard } from './EnhancedMetricCard';
export { default as ModuleMetricsFooter } from './ModuleMetricsFooter';
export type {
SubmoduleColor,
PageProgressWidget as ModulePageProgressWidget,
ModulePipelineRow,
ModuleStatsWidget as ModuleModuleStatsWidget,
CompletionItem as ModuleCompletionItem,
CompletionWidget as ModuleCompletionWidget,
} from './ModuleMetricsFooter';
export { default as UsageChartWidget } from './UsageChartWidget';
export { default as WorkflowPipeline } from './WorkflowPipeline';

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { ThemeToggleButton } from "../common/ThemeToggleButton";
import NotificationDropdown from "./NotificationDropdown";
import NotificationDropdown from "./NotificationDropdownNew";
import UserDropdown from "./UserDropdown";
import { Link } from "react-router-dom";

View File

@@ -277,21 +277,21 @@ export function createApprovedPageConfig(params: {
label: 'Approved',
accentColor: 'green',
calculate: (data: { totalCount: number }) => data.totalCount,
tooltip: 'Total approved content ready for publishing.',
tooltip: 'Articles approved and ready for publishing. Select and click "Sync to WordPress" to go live.',
},
{
label: 'On Site',
accentColor: 'blue',
calculate: (data: { content: Content[] }) =>
data.content.filter(c => c.external_id).length,
tooltip: 'Content published to your website.',
tooltip: 'Live articles published to your WordPress site. These are actively generating traffic.',
},
{
label: 'Pending',
accentColor: 'amber',
calculate: (data: { content: Content[] }) =>
data.content.filter(c => !c.external_id).length,
tooltip: 'Approved content not yet published to site.',
tooltip: 'Approved but not synced. Select and click "Sync to WordPress" to publish.',
},
];

View File

@@ -456,30 +456,29 @@ export const createClustersPageConfig = (
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.totalCount || 0,
tooltip: 'Topic clusters organizing your keywords. Each cluster should have 3-7 related keywords.',
tooltip: 'Topic clusters grouping related keywords. Select clusters and click "Generate Ideas" to create content outlines.',
},
{
label: 'New',
value: 0,
accentColor: 'amber' as const,
calculate: (data) => data.clusters.filter((c: Cluster) => (c.ideas_count || 0) === 0).length,
tooltip: 'Clusters without content ideas yet. Generate ideas for these clusters to move them into the pipeline.',
tooltip: 'Clusters ready for idea generation. Select them and click "Generate Ideas" to create content outlines.',
},
{
label: 'Keywords',
value: 0,
accentColor: 'purple' as const,
calculate: (data) => data.clusters.reduce((sum: number, c: Cluster) => sum + (c.keywords_count || 0), 0),
tooltip: 'Total keywords organized across all clusters. More keywords = better topic coverage.',
tooltip: 'Keywords organized across clusters. Well-balanced clusters have 3-7 keywords each.',
},
{
label: 'Volume',
value: 0,
accentColor: 'green' as const,
calculate: (data) => data.clusters.reduce((sum: number, c: Cluster) => sum + (c.volume || 0), 0),
tooltip: 'Combined search volume across all clusters. Prioritize high-volume clusters for maximum traffic.',
tooltip: 'Combined monthly searches. Prioritize high-volume clusters for maximum traffic potential.',
},
],
};
};

View File

@@ -458,30 +458,29 @@ export const createContentPageConfig = (
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.totalCount || 0,
tooltip: 'Total content pieces generated. Includes drafts, review, and published content.',
tooltip: 'Total articles in your library. Add images and review before sending to the approval queue.',
},
{
label: 'Draft',
value: 0,
accentColor: 'amber' as const,
calculate: (data) => data.content.filter((c: Content) => c.status === 'draft').length,
tooltip: 'Content in draft stage. Edit and refine before moving to review.',
tooltip: 'Drafts needing images and review. Select and click "Generate Images" to add visuals.',
},
{
label: 'In Review',
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.content.filter((c: Content) => c.status === 'review').length,
tooltip: 'Content awaiting review and approval. Review for quality before publishing.',
tooltip: 'Articles awaiting approval. Review for quality then click "Approve" to publish.',
},
{
label: 'Published',
value: 0,
accentColor: 'green' as const,
calculate: (data) => data.content.filter((c: Content) => c.status === 'published').length,
tooltip: 'Published content ready for WordPress sync. Track your published library.',
tooltip: 'Live articles published to your site. View in Writer → Published.',
},
],
};
};

View File

@@ -405,30 +405,29 @@ export const createIdeasPageConfig = (
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.totalCount || 0,
tooltip: 'Total content ideas generated. Ideas become tasks in the content queue for writing.',
tooltip: 'Content ideas generated. Review each idea\'s outline, then click "Create Task" to begin content generation.',
},
{
label: 'New',
value: 0,
accentColor: 'amber' as const,
calculate: (data) => data.ideas.filter((i: ContentIdea) => i.status === 'new').length,
tooltip: 'New ideas waiting for review. Approve ideas to queue them for content creation.',
tooltip: 'Ideas not yet converted to tasks. Select and click "Create Tasks" to start the content writing process.',
},
{
label: 'Queued',
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.ideas.filter((i: ContentIdea) => i.status === 'queued').length,
tooltip: 'Ideas queued for content generation. These will be converted to writing tasks automatically.',
tooltip: 'Ideas ready for content generation. View their progress in Writer → Tasks queue.',
},
{
label: 'Completed',
value: 0,
accentColor: 'green' as const,
calculate: (data) => data.ideas.filter((i: ContentIdea) => i.status === 'completed').length,
tooltip: 'Ideas that have been successfully turned into content. Track your content creation progress.',
tooltip: 'Ideas successfully turned into articles. Review completed content in Writer → Content.',
},
],
};
};

View File

@@ -221,28 +221,28 @@ export const createImagesPageConfig = (
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.totalCount || 0,
tooltip: 'Total content pieces with image generation. Track image coverage across all content.',
tooltip: 'Articles in your library. Each can have 1 featured image + multiple in-article images.',
},
{
label: 'Complete',
value: 0,
accentColor: 'green' as const,
calculate: (data) => data.images.filter((i: ContentImagesGroup) => i.overall_status === 'complete').length,
tooltip: 'Content with all images generated. Ready for publishing with full visual coverage.',
tooltip: 'Articles with all images generated. Ready for publishing with full visual coverage.',
},
{
label: 'Partial',
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.images.filter((i: ContentImagesGroup) => i.overall_status === 'partial').length,
tooltip: 'Content with some images missing. Generate remaining images to complete visual assets.',
tooltip: 'Articles with some images missing. Select and click "Generate Images" to complete visuals.',
},
{
label: 'Pending',
value: 0,
accentColor: 'amber' as const,
calculate: (data) => data.images.filter((i: ContentImagesGroup) => i.overall_status === 'pending').length,
tooltip: 'Content waiting for image generation. Queue these to start creating visual assets.',
tooltip: 'Articles needing images. Select and click "Generate Prompts" then "Generate Images".',
},
],
maxInArticleImages: maxImages,

View File

@@ -435,28 +435,28 @@ export const createKeywordsPageConfig = (
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.totalCount || 0,
tooltip: 'Total keywords added to your workflow. Minimum 5 keywords are needed for clustering.',
tooltip: 'Keywords ready for clustering. Select unclustered keywords and click "Auto Cluster" to organize them into topic groups.',
},
{
label: 'Clustered',
value: 0,
accentColor: 'green' as const,
calculate: (data) => data.keywords.filter((k: Keyword) => k.cluster_id).length,
tooltip: 'Keywords grouped into topical clusters. Clustered keywords are ready for content ideation.',
tooltip: 'Clusters with 3-7 keywords are optimal for content creation. Click on a cluster to generate content ideas from it.',
},
{
label: 'Unmapped',
value: 0,
accentColor: 'amber' as const,
calculate: (data) => data.keywords.filter((k: Keyword) => !k.cluster_id).length,
tooltip: 'Unclustered keywords waiting to be organized. Select keywords and use Auto-Cluster to group them.',
tooltip: 'Keywords waiting to be clustered. Select them and click "Auto Cluster" to organize into topic groups.',
},
{
label: 'Volume',
value: 0,
accentColor: 'purple' as const,
calculate: (data) => data.keywords.reduce((sum: number, k: Keyword) => sum + (k.volume || 0), 0),
tooltip: 'Total monthly search volume across all keywords. Higher volume = more traffic potential.',
tooltip: 'Combined monthly searches. Prioritize higher-volume keywords when creating content.',
},
],
// bulkActions and rowActions are now global - defined in table-actions.config.ts

View File

@@ -265,25 +265,25 @@ export function createReviewPageConfig(params: {
label: 'Ready',
accentColor: 'blue',
calculate: ({ totalCount }) => totalCount,
tooltip: 'Content ready for final review. Review quality, SEO, and images before publishing.',
tooltip: 'Articles awaiting final review. Check quality and SEO before clicking "Approve & Publish".',
},
{
label: 'Images',
accentColor: 'green',
calculate: ({ content }) => content.filter(c => c.has_generated_images).length,
tooltip: 'Content with generated images. Visual assets complete and ready for review.',
tooltip: 'Articles with complete visuals. Articles with images get 94% more engagement.',
},
{
label: 'Optimized',
accentColor: 'purple',
calculate: ({ content }) => content.filter(c => c.optimization_scores && c.optimization_scores.overall_score >= 80).length,
tooltip: 'Content with high SEO optimization scores (80%+). Well-optimized for search engines.',
tooltip: 'High SEO scores (80%+). These articles are well-optimized for search rankings.',
},
{
label: 'Sync Ready',
accentColor: 'amber',
calculate: ({ content }) => content.filter(c => c.has_generated_images && c.optimization_scores && c.optimization_scores.overall_score >= 70).length,
tooltip: 'Content ready for WordPress sync. Has images and good optimization score.',
tooltip: 'Ready to publish! Has images + good SEO. Select and click "Publish to WordPress".',
},
],
};

View File

@@ -460,37 +460,36 @@ export const createTasksPageConfig = (
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.totalCount || 0,
tooltip: 'Total content generation tasks. Tasks process ideas into written content automatically.',
tooltip: 'Total content generation tasks. Select tasks and click "Generate Content" to write articles.',
},
{
label: 'In Queue',
value: 0,
accentColor: 'amber' as const,
calculate: (data) => data.tasks.filter((t: Task) => t.status === 'queued').length,
tooltip: 'Tasks queued for processing. These will be picked up by the content generation system.',
tooltip: 'Tasks waiting for content generation. Select and click "Generate Content" to write articles.',
},
{
label: 'Processing',
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.tasks.filter((t: Task) => t.status === 'in_progress').length,
tooltip: 'Tasks currently being processed. Content is being generated by AI right now.',
tooltip: 'Tasks being written by AI. Content will appear in Drafts when complete (~2-3 min each).',
},
{
label: 'Completed',
value: 0,
accentColor: 'green' as const,
calculate: (data) => data.tasks.filter((t: Task) => t.status === 'completed').length,
tooltip: 'Successfully completed tasks. Generated content is ready for review and publishing.',
tooltip: 'Tasks with generated content. Review articles in Writer → Content before publishing.',
},
{
label: 'Failed',
value: 0,
accentColor: 'red' as const,
calculate: (data) => data.tasks.filter((t: Task) => t.status === 'failed').length,
tooltip: 'Failed tasks that need attention. Review error logs and retry or modify the task.',
tooltip: 'Failed tasks needing attention. Click to view error details and retry generation.',
},
],
};
};

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { fetchAPI } from '../services/api';
import { useNotificationStore } from '../store/notificationStore';
export interface ProgressState {
percentage: number;
@@ -57,6 +58,9 @@ export function useProgressModal(): UseProgressModalReturn {
status: 'pending',
});
// Notification store for AI task notifications
const addNotification = useNotificationStore((state) => state.addAITaskNotification);
// Step logs state for debugging
const [stepLogs, setStepLogs] = useState<Array<{
stepNumber: number;
@@ -581,6 +585,9 @@ export function useProgressModal(): UseProgressModalReturn {
setStepLogs(allSteps);
}
// Add success notification
addNotification(title, stepInfo.friendlyMessage, true);
// Stop polling on SUCCESS
isStopped = true;
if (intervalId) {
@@ -637,6 +644,9 @@ export function useProgressModal(): UseProgressModalReturn {
setStepLogs(allSteps);
}
// Add failure notification
addNotification(title, errorMsg, false);
// Stop polling on FAILURE
isStopped = true;
if (intervalId) {

View File

@@ -0,0 +1,388 @@
/**
* useThreeWidgetFooter - Hook to build ThreeWidgetFooter props
*
* Provides helper functions to construct the three widgets:
* - Page Progress (current page metrics)
* - Module Stats (workflow pipeline)
* - Completion Stats (both modules summary)
*
* Usage:
* const footerProps = useThreeWidgetFooter({
* module: 'planner',
* currentPage: 'keywords',
* pageData: { keywords: [...], clusters: [...] },
* pipelineData: { ... }
* });
*/
import { useMemo } from 'react';
import type {
ThreeWidgetFooterProps,
PageProgressWidget,
ModuleStatsWidget,
CompletionWidget,
} from '../components/dashboard/ThreeWidgetFooter';
// ============================================================================
// PLANNER MODULE CONFIGURATIONS
// ============================================================================
interface PlannerPageData {
keywords?: Array<{ cluster_id?: number | null; volume?: number }>;
clusters?: Array<{ ideas_count?: number; keywords_count?: number }>;
ideas?: Array<{ status?: string }>;
totalKeywords?: number;
totalClusters?: number;
totalIdeas?: number;
}
interface WriterPageData {
tasks?: Array<{ status?: string }>;
content?: Array<{ status?: string; has_generated_images?: boolean }>;
totalTasks?: number;
totalContent?: number;
totalPublished?: number;
}
interface CompletionData {
keywordsClustered?: number;
clustersCreated?: number;
ideasGenerated?: number;
contentGenerated?: number;
imagesCreated?: number;
articlesPublished?: number;
creditsUsed?: number;
totalOperations?: number;
}
interface UseThreeWidgetFooterOptions {
module: 'planner' | 'writer';
currentPage: 'keywords' | 'clusters' | 'ideas' | 'tasks' | 'content' | 'images' | 'review' | 'published';
plannerData?: PlannerPageData;
writerData?: WriterPageData;
completionData?: CompletionData;
}
// ============================================================================
// PLANNER PAGE PROGRESS BUILDERS
// ============================================================================
function buildKeywordsPageProgress(data: PlannerPageData): PageProgressWidget {
const keywords = data.keywords || [];
const totalKeywords = data.totalKeywords || keywords.length;
const clusteredCount = keywords.filter(k => k.cluster_id).length;
const unmappedCount = keywords.filter(k => !k.cluster_id).length;
const totalVolume = keywords.reduce((sum, k) => sum + (k.volume || 0), 0);
const clusteredPercent = totalKeywords > 0 ? Math.round((clusteredCount / totalKeywords) * 100) : 0;
return {
title: 'Page Progress',
metrics: [
{ label: 'Keywords', value: totalKeywords },
{ label: 'Clustered', value: clusteredCount, suffix: ` (${clusteredPercent}%)` },
{ label: 'Unmapped', value: unmappedCount },
{ label: 'Volume', value: totalVolume >= 1000 ? `${(totalVolume / 1000).toFixed(1)}K` : totalVolume },
],
progress: {
value: clusteredPercent,
label: `${clusteredPercent}% Clustered`,
color: clusteredPercent >= 80 ? 'success' : 'primary',
},
hint: unmappedCount > 0 ? `${unmappedCount} keywords ready to cluster` : 'All keywords clustered!',
};
}
function buildClustersPageProgress(data: PlannerPageData): PageProgressWidget {
const clusters = data.clusters || [];
const totalClusters = data.totalClusters || clusters.length;
const withIdeas = clusters.filter(c => (c.ideas_count || 0) > 0).length;
const totalKeywords = clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0);
const readyClusters = clusters.filter(c => (c.ideas_count || 0) === 0).length;
const ideasPercent = totalClusters > 0 ? Math.round((withIdeas / totalClusters) * 100) : 0;
return {
title: 'Page Progress',
metrics: [
{ label: 'Clusters', value: totalClusters },
{ label: 'With Ideas', value: withIdeas, suffix: ` (${ideasPercent}%)` },
{ label: 'Keywords', value: totalKeywords },
{ label: 'Ready', value: readyClusters },
],
progress: {
value: ideasPercent,
label: `${ideasPercent}% Have Ideas`,
color: ideasPercent >= 70 ? 'success' : 'primary',
},
hint: readyClusters > 0 ? `${readyClusters} clusters ready for idea generation` : 'All clusters have ideas!',
};
}
function buildIdeasPageProgress(data: PlannerPageData): PageProgressWidget {
const ideas = data.ideas || [];
const totalIdeas = data.totalIdeas || ideas.length;
const inTasks = ideas.filter(i => i.status === 'completed' || i.status === 'queued').length;
const pending = ideas.filter(i => i.status === 'new').length;
const convertedPercent = totalIdeas > 0 ? Math.round((inTasks / totalIdeas) * 100) : 0;
return {
title: 'Page Progress',
metrics: [
{ label: 'Ideas', value: totalIdeas },
{ label: 'In Tasks', value: inTasks, suffix: ` (${convertedPercent}%)` },
{ label: 'Pending', value: pending },
{ label: 'From Clusters', value: data.totalClusters || 0 },
],
progress: {
value: convertedPercent,
label: `${convertedPercent}% Converted`,
color: convertedPercent >= 60 ? 'success' : 'primary',
},
hint: pending > 0 ? `${pending} ideas ready to become tasks` : 'All ideas converted!',
};
}
// ============================================================================
// WRITER PAGE PROGRESS BUILDERS
// ============================================================================
function buildTasksPageProgress(data: WriterPageData): PageProgressWidget {
const tasks = data.tasks || [];
const total = data.totalTasks || tasks.length;
const completed = tasks.filter(t => t.status === 'completed').length;
const queue = tasks.filter(t => t.status === 'queued').length;
const processing = tasks.filter(t => t.status === 'in_progress').length;
const completedPercent = total > 0 ? Math.round((completed / total) * 100) : 0;
return {
title: 'Page Progress',
metrics: [
{ label: 'Total', value: total },
{ label: 'Complete', value: completed, suffix: ` (${completedPercent}%)` },
{ label: 'Queue', value: queue },
{ label: 'Processing', value: processing },
],
progress: {
value: completedPercent,
label: `${completedPercent}% Generated`,
color: completedPercent >= 60 ? 'success' : 'primary',
},
hint: queue > 0 ? `${queue} tasks in queue for content generation` : 'All tasks processed!',
};
}
function buildContentPageProgress(data: WriterPageData): PageProgressWidget {
const content = data.content || [];
const drafts = content.filter(c => c.status === 'draft').length;
const hasImages = content.filter(c => c.has_generated_images).length;
const ready = content.filter(c => c.status === 'review' || c.status === 'published').length;
const imagesPercent = drafts > 0 ? Math.round((hasImages / drafts) * 100) : 0;
return {
title: 'Page Progress',
metrics: [
{ label: 'Drafts', value: drafts },
{ label: 'Has Images', value: hasImages, suffix: ` (${imagesPercent}%)` },
{ label: 'Total Words', value: '12.5K' }, // Would need word count from API
{ label: 'Ready', value: ready },
],
progress: {
value: imagesPercent,
label: `${imagesPercent}% Have Images`,
color: imagesPercent >= 70 ? 'success' : 'primary',
},
hint: drafts - hasImages > 0 ? `${drafts - hasImages} drafts need images before review` : 'All drafts have images!',
};
}
// ============================================================================
// MODULE STATS BUILDERS
// ============================================================================
function buildPlannerModuleStats(data: PlannerPageData): ModuleStatsWidget {
const keywords = data.keywords || [];
const clusters = data.clusters || [];
const ideas = data.ideas || [];
const totalKeywords = data.totalKeywords || keywords.length;
const totalClusters = data.totalClusters || clusters.length;
const totalIdeas = data.totalIdeas || ideas.length;
const clusteredKeywords = keywords.filter(k => k.cluster_id).length;
const clustersWithIdeas = clusters.filter(c => (c.ideas_count || 0) > 0).length;
const ideasInTasks = ideas.filter(i => i.status === 'completed' || i.status === 'queued').length;
return {
title: 'Planner Module',
pipeline: [
{
fromLabel: 'Keywords',
fromValue: totalKeywords,
toLabel: 'Clusters',
toValue: totalClusters,
actionLabel: 'Auto Cluster',
progressValue: totalKeywords > 0 ? Math.round((clusteredKeywords / totalKeywords) * 100) : 0,
},
{
fromLabel: 'Clusters',
fromValue: totalClusters,
toLabel: 'Ideas',
toValue: totalIdeas,
actionLabel: 'Generate Ideas',
progressValue: totalClusters > 0 ? Math.round((clustersWithIdeas / totalClusters) * 100) : 0,
},
{
fromLabel: 'Ideas',
fromValue: totalIdeas,
toLabel: 'Tasks',
toValue: ideasInTasks,
actionLabel: 'Create Tasks',
progressValue: totalIdeas > 0 ? Math.round((ideasInTasks / totalIdeas) * 100) : 0,
},
],
links: [
{ label: 'Keywords', href: '/planner/keywords' },
{ label: 'Clusters', href: '/planner/clusters' },
{ label: 'Ideas', href: '/planner/ideas' },
],
};
}
function buildWriterModuleStats(data: WriterPageData): ModuleStatsWidget {
const tasks = data.tasks || [];
const content = data.content || [];
const totalTasks = data.totalTasks || tasks.length;
const completedTasks = tasks.filter(t => t.status === 'completed').length;
const drafts = content.filter(c => c.status === 'draft').length;
const withImages = content.filter(c => c.has_generated_images).length;
const ready = content.filter(c => c.status === 'review').length;
const published = data.totalPublished || content.filter(c => c.status === 'published').length;
return {
title: 'Writer Module',
pipeline: [
{
fromLabel: 'Tasks',
fromValue: totalTasks,
toLabel: 'Drafts',
toValue: drafts,
actionLabel: 'Generate Content',
progressValue: totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0,
},
{
fromLabel: 'Drafts',
fromValue: drafts,
toLabel: 'Images',
toValue: withImages,
actionLabel: 'Generate Images',
progressValue: drafts > 0 ? Math.round((withImages / drafts) * 100) : 0,
},
{
fromLabel: 'Ready',
fromValue: ready,
toLabel: 'Published',
toValue: published,
actionLabel: 'Review & Publish',
progressValue: ready > 0 ? Math.round((published / (ready + published)) * 100) : 0,
},
],
links: [
{ label: 'Tasks', href: '/writer/tasks' },
{ label: 'Content', href: '/writer/content' },
{ label: 'Images', href: '/writer/images' },
{ label: 'Published', href: '/writer/published' },
],
};
}
// ============================================================================
// COMPLETION STATS BUILDER
// ============================================================================
function buildCompletionStats(data: CompletionData): CompletionWidget {
const maxValue = Math.max(
data.keywordsClustered || 0,
data.clustersCreated || 0,
data.ideasGenerated || 0,
data.contentGenerated || 0,
data.imagesCreated || 0,
data.articlesPublished || 0,
1
);
const calcBarWidth = (value: number) => Math.round((value / maxValue) * 100);
return {
plannerStats: [
{ label: 'Keywords Clustered', value: data.keywordsClustered || 0, barWidth: calcBarWidth(data.keywordsClustered || 0) },
{ label: 'Clusters Created', value: data.clustersCreated || 0, barWidth: calcBarWidth(data.clustersCreated || 0) },
{ label: 'Ideas Generated', value: data.ideasGenerated || 0, barWidth: calcBarWidth(data.ideasGenerated || 0) },
],
writerStats: [
{ label: 'Content Generated', value: data.contentGenerated || 0, barWidth: calcBarWidth(data.contentGenerated || 0) },
{ label: 'Images Created', value: data.imagesCreated || 0, barWidth: calcBarWidth(data.imagesCreated || 0) },
{ label: 'Articles Published', value: data.articlesPublished || 0, barWidth: calcBarWidth(data.articlesPublished || 0) },
],
summary: {
creditsUsed: data.creditsUsed || 0,
operations: data.totalOperations || 0,
},
};
}
// ============================================================================
// MAIN HOOK
// ============================================================================
export function useThreeWidgetFooter(options: UseThreeWidgetFooterOptions): ThreeWidgetFooterProps {
const { module, currentPage, plannerData = {}, writerData = {}, completionData = {} } = options;
return useMemo(() => {
// Build page progress based on current page
let pageProgress: PageProgressWidget;
if (module === 'planner') {
switch (currentPage) {
case 'keywords':
pageProgress = buildKeywordsPageProgress(plannerData);
break;
case 'clusters':
pageProgress = buildClustersPageProgress(plannerData);
break;
case 'ideas':
pageProgress = buildIdeasPageProgress(plannerData);
break;
default:
pageProgress = buildKeywordsPageProgress(plannerData);
}
} else {
switch (currentPage) {
case 'tasks':
pageProgress = buildTasksPageProgress(writerData);
break;
case 'content':
case 'images':
case 'review':
pageProgress = buildContentPageProgress(writerData);
break;
default:
pageProgress = buildTasksPageProgress(writerData);
}
}
// Build module stats
const moduleStats = module === 'planner'
? buildPlannerModuleStats(plannerData)
: buildWriterModuleStats(writerData);
// Build completion stats
const completion = buildCompletionStats(completionData);
return {
pageProgress,
moduleStats,
completion,
};
}, [module, currentPage, plannerData, writerData, completionData]);
}
export default useThreeWidgetFooter;

View File

@@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
import { usePageContext } from "../context/PageContext";
import { useSidebar } from "../context/SidebarContext";
import { ThemeToggleButton } from "../components/common/ThemeToggleButton";
import NotificationDropdown from "../components/header/NotificationDropdown";
import NotificationDropdown from "../components/header/NotificationDropdownNew";
import UserDropdown from "../components/header/UserDropdown";
import { HeaderMetrics } from "../components/header/HeaderMetrics";
import SearchModal from "../components/common/SearchModal";

View File

@@ -1,9 +1,10 @@
import React, { useEffect, useState, lazy, Suspense, useRef } from "react";
import React, { useEffect, useState, lazy, Suspense, useRef, useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import PageMeta from "../../components/common/PageMeta";
import CreditBalanceWidget from "../../components/dashboard/CreditBalanceWidget";
import UsageChartWidget from "../../components/dashboard/UsageChartWidget";
import EnhancedMetricCard from "../../components/dashboard/EnhancedMetricCard";
import NeedsAttentionBar, { AttentionItem } from "../../components/dashboard/NeedsAttentionBar";
import ComponentCard from "../../components/common/ComponentCard";
import PageHeader from "../../components/common/PageHeader";
import WorkflowGuide from "../../components/onboarding/WorkflowGuide";
@@ -38,7 +39,10 @@ import {
fetchContent,
fetchContentImages,
fetchSites,
fetchDashboardSummary,
Site,
DashboardSummary,
DashboardActivity,
} from "../../services/api";
import { useSiteStore } from "../../store/siteStore";
import { useSectorStore } from "../../store/sectorStore";
@@ -432,39 +436,93 @@ export default function Home() {
},
];
// Dashboard summary state for API data (recent activity, etc.)
const [dashboardData, setDashboardData] = useState<DashboardSummary | null>(null);
// Build attention items - prefer API data when available, fallback to computed
const attentionItems = useMemo<AttentionItem[]>(() => {
// 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 */}
<NeedsAttentionBar items={attentionItems} />
{/* Custom Header with Site Selector and Refresh */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">

View File

@@ -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 */}
<ModuleMetricsFooter
metrics={[
{
title: 'Keywords',
value: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0).toLocaleString(),
subtitle: `in ${totalCount} clusters`,
icon: <ListIcon className="w-5 h-5" />,
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: <BoltIcon className="w-5 h-5" />,
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: <GroupIcon className="w-5 h-5" />,
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',
}}
/>

View File

@@ -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 */}
<ModuleMetricsFooter
metrics={[
{
title: 'Clusters',
value: clusters.length.toLocaleString(),
subtitle: 'keyword groups',
icon: <GroupIcon className="w-5 h-5" />,
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: <BoltIcon className="w-5 h-5" />,
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: <ListIcon className="w-5 h-5" />,
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: <BoltIcon className="w-5 h-5" />,
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',
}}
/>

View File

@@ -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 */}
<ModuleMetricsFooter
metrics={[
{
title: 'Keywords',
value: totalCount.toLocaleString(),
subtitle: `in ${clusters.length} clusters`,
icon: <ListIcon className="w-5 h-5" />,
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: <GroupIcon className="w-5 h-5" />,
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: <BoltIcon className="w-5 h-5" />,
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',
}}
/>

View File

@@ -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 */}
<ModuleMetricsFooter
metrics={[
{
title: 'Approved Content',
value: content.length.toLocaleString(),
subtitle: 'ready for publishing',
icon: <CheckCircleIcon className="w-5 h-5" />,
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: <RocketLaunchIcon className="w-5 h-5" />,
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',
}}
/>
</>

View File

@@ -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 */}
<ModuleMetricsFooter
metrics={[
{
title: 'Tasks',
value: content.length.toLocaleString(),
subtitle: 'generated from queue',
icon: <TaskIcon className="w-5 h-5" />,
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: <FileIcon className="w-5 h-5" />,
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: <CheckCircleIcon className="w-5 h-5" />,
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: <CheckCircleIcon className="w-5 h-5" />,
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',
}}
/>

View File

@@ -455,15 +455,86 @@ export default function Review() {
onRowAction={handleRowAction}
/>
<ModuleMetricsFooter
metrics={[
{
title: 'Ready to Publish',
value: content.length,
subtitle: 'Total review items',
icon: <CheckCircleIcon className="w-5 h-5" />,
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',
},
}}
/>
</>
);

View File

@@ -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 */}
<ModuleMetricsFooter
metrics={[
{
title: 'Ideas',
value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0).toLocaleString(),
subtitle: 'from planner',
icon: <TaskIcon className="w-5 h-5" />,
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: <TaskIcon className="w-5 h-5" />,
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: <TaskIcon className="w-5 h-5" />,
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: <CheckCircleIcon className="w-5 h-5" />,
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',
}}
/>

View File

@@ -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<DashboardSummary> {
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}` : ''}`);
}