metrics adn insihigts
This commit is contained in:
@@ -9,6 +9,7 @@ import { useSectorStore } from '../../store/sectorStore';
|
||||
import SiteAndSectorSelector from './SiteAndSectorSelector';
|
||||
import { trackLoading } from './LoadingStateMonitor';
|
||||
import { useErrorHandler } from '../../hooks/useErrorHandler';
|
||||
import { WorkflowInsights, WorkflowInsight } from './WorkflowInsights';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
@@ -22,6 +23,7 @@ interface PageHeaderProps {
|
||||
};
|
||||
hideSiteSector?: boolean; // Hide site/sector selector and info for global pages
|
||||
navigation?: ReactNode; // Module navigation tabs
|
||||
workflowInsights?: WorkflowInsight[]; // Actionable insights for current page
|
||||
}
|
||||
|
||||
export default function PageHeader({
|
||||
@@ -33,6 +35,7 @@ export default function PageHeader({
|
||||
badge,
|
||||
hideSiteSector = false,
|
||||
navigation,
|
||||
workflowInsights,
|
||||
}: PageHeaderProps) {
|
||||
const { activeSite } = useSiteStore();
|
||||
const { activeSector, loadSectorsForSite } = useSectorStore();
|
||||
@@ -98,77 +101,87 @@ export default function PageHeader({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 ${className}`}>
|
||||
{/* Left side: Title, badge, and site/sector info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{badge && (
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-xl ${badgeColors[badge.color]} flex-shrink-0`}>
|
||||
{badge.icon && typeof badge.icon === 'object' && 'type' in badge.icon
|
||||
? React.cloneElement(badge.icon as React.ReactElement, { className: 'text-white size-5' })
|
||||
: badge.icon}
|
||||
<div className={`flex flex-col gap-3 ${className}`}>
|
||||
{/* Main header row - single row with 3 sections */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
{/* Left side: Title, badge, and site/sector info */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
{badge && (
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-xl ${badgeColors[badge.color]} flex-shrink-0`}>
|
||||
{badge.icon && typeof badge.icon === 'object' && 'type' in badge.icon
|
||||
? React.cloneElement(badge.icon as React.ReactElement, { className: 'text-white size-5' })
|
||||
: badge.icon}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">{title}</h2>
|
||||
</div>
|
||||
{!hideSiteSector && (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{lastUpdated && (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{activeSite && (
|
||||
<>
|
||||
{lastUpdated && <span className="text-sm text-gray-400 dark:text-gray-600">•</span>}
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Site: <span className="text-brand-600 dark:text-brand-400">{activeSite.name}</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{activeSector && (
|
||||
<>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-600">•</span>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Sector: <span className="text-brand-600 dark:text-brand-400">{activeSector.name}</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{!activeSector && activeSite && (
|
||||
<>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-600">•</span>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Sector: <span className="text-brand-600 dark:text-brand-400">All Sectors</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">{title}</h2>
|
||||
</div>
|
||||
{!hideSiteSector && (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{lastUpdated && (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{activeSite && (
|
||||
<>
|
||||
{lastUpdated && <span className="text-sm text-gray-400 dark:text-gray-600">•</span>}
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Site: <span className="text-brand-600 dark:text-brand-400">{activeSite.name}</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{activeSector && (
|
||||
<>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-600">•</span>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Sector: <span className="text-brand-600 dark:text-brand-400">{activeSector.name}</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{!activeSector && activeSite && (
|
||||
<>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-600">•</span>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Sector: <span className="text-brand-600 dark:text-brand-400">All Sectors</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hideSiteSector && lastUpdated && (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side: Navigation bar stacked above site/sector selector */}
|
||||
<div className="flex flex-col items-end gap-3">
|
||||
{navigation && <div>{navigation}</div>}
|
||||
<div className="flex items-center gap-3">
|
||||
{!hideSiteSector && <SiteAndSectorSelector />}
|
||||
{showRefresh && onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="px-4 py-2 text-sm font-medium text-brand-500 hover:text-brand-600 border border-brand-200 rounded-lg hover:bg-brand-50 dark:border-brand-800 dark:hover:bg-brand-500/10 transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
{hideSiteSector && lastUpdated && (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Middle: Workflow insights - takes available space */}
|
||||
{workflowInsights && workflowInsights.length > 0 && (
|
||||
<div className="flex-1 flex items-center justify-center px-6">
|
||||
<WorkflowInsights insights={workflowInsights} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right side: Navigation bar stacked above site/sector selector */}
|
||||
<div className="flex flex-col items-end gap-3 flex-shrink-0">
|
||||
{navigation && <div>{navigation}</div>}
|
||||
<div className="flex items-center gap-3">
|
||||
{!hideSiteSector && <SiteAndSectorSelector />}
|
||||
{showRefresh && onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="px-4 py-2 text-sm font-medium text-brand-500 hover:text-brand-600 border border-brand-200 rounded-lg hover:bg-brand-50 dark:border-brand-800 dark:hover:bg-brand-500/10 transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
92
frontend/src/components/common/WorkflowInsights.tsx
Normal file
92
frontend/src/components/common/WorkflowInsights.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* WorkflowInsights Component
|
||||
* Shows actionable insights in a collapsible notification box
|
||||
* Placed between page title and navigation tabs
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { CheckCircleIcon, AlertIcon, InfoIcon, BoltIcon, CloseIcon } from '../../icons';
|
||||
|
||||
export interface WorkflowInsight {
|
||||
type: 'success' | 'warning' | 'info' | 'action';
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface WorkflowInsightsProps {
|
||||
insights: WorkflowInsight[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const WorkflowInsights: React.FC<WorkflowInsightsProps> = ({ insights, className = '' }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
if (!insights || insights.length === 0 || isCollapsed) return null;
|
||||
|
||||
// Determine notification color based on most critical insight type
|
||||
const hasCritical = insights.some(i => i.type === 'warning' || i.type === 'action');
|
||||
const hasSuccess = insights.some(i => i.type === 'success');
|
||||
|
||||
const notificationColors = hasCritical
|
||||
? {
|
||||
bg: 'bg-amber-50 dark:bg-amber-500/10',
|
||||
border: 'border-amber-300 dark:border-amber-700',
|
||||
iconBg: 'bg-amber-100 dark:bg-amber-500/20',
|
||||
}
|
||||
: hasSuccess
|
||||
? {
|
||||
bg: 'bg-green-50 dark:bg-green-500/10',
|
||||
border: 'border-green-300 dark:border-green-700',
|
||||
iconBg: 'bg-green-100 dark:bg-green-500/20',
|
||||
}
|
||||
: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-500/10',
|
||||
border: 'border-blue-300 dark:border-blue-700',
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-500/20',
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircleIcon className="w-5 h-5 text-green-600 dark:text-green-400" />;
|
||||
case 'warning':
|
||||
return <AlertIcon className="w-5 h-5 text-amber-600 dark:text-amber-400" />;
|
||||
case 'action':
|
||||
return <BoltIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />;
|
||||
default:
|
||||
return <InfoIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<div className={`flex items-start gap-3 px-4 py-3 rounded-lg border ${notificationColors.bg} ${notificationColors.border} shadow-sm`}>
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 p-1.5 rounded-md ${notificationColors.iconBg}`}>
|
||||
{getIcon(insights[0].type)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 space-y-2">
|
||||
{insights.map((insight, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
{insights.length > 1 && (
|
||||
<span className="text-gray-400 dark:text-gray-500 text-base font-normal mt-0.5">•</span>
|
||||
)}
|
||||
<p className="text-base font-normal text-gray-700 dark:text-gray-300">
|
||||
{insight.message}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-gray-200/50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<CloseIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useHeaderMetrics } from '../../context/HeaderMetricsContext';
|
||||
import { Tooltip } from '../ui/tooltip/Tooltip';
|
||||
import { InfoIcon } from '../../icons';
|
||||
|
||||
export const HeaderMetrics: React.FC = () => {
|
||||
const { metrics } = useHeaderMetrics();
|
||||
@@ -8,18 +10,35 @@ export const HeaderMetrics: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="igny8-header-metrics flex">
|
||||
{metrics.map((metric, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<div className="igny8-header-metric">
|
||||
{metrics.map((metric, index) => {
|
||||
const metricElement = (
|
||||
<div className={`igny8-header-metric ${metric.tooltip ? 'cursor-help' : ''}`}>
|
||||
<div className={`igny8-header-metric-accent ${metric.accentColor}`}></div>
|
||||
<span className="igny8-header-metric-label">{metric.label}</span>
|
||||
<span className="igny8-header-metric-label text-xs flex items-center gap-1">
|
||||
{metric.label}
|
||||
{metric.tooltip && (
|
||||
<InfoIcon className="w-3 h-3 text-gray-400 dark:text-gray-500" />
|
||||
)}
|
||||
</span>
|
||||
<span className="igny8-header-metric-value">
|
||||
{typeof metric.value === 'number' ? metric.value.toLocaleString() : metric.value}
|
||||
</span>
|
||||
</div>
|
||||
{index < metrics.length - 1 && <div className="igny8-header-metric-separator"></div>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{metric.tooltip ? (
|
||||
<Tooltip text={metric.tooltip} placement="bottom">
|
||||
{metricElement}
|
||||
</Tooltip>
|
||||
) : (
|
||||
metricElement
|
||||
)}
|
||||
{index < metrics.length - 1 && <div className="igny8-header-metric-separator"></div>}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -420,28 +420,32 @@ export const createClustersPageConfig = (
|
||||
],
|
||||
headerMetrics: [
|
||||
{
|
||||
label: 'Total Clusters',
|
||||
label: 'Clusters',
|
||||
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.',
|
||||
},
|
||||
{
|
||||
label: 'Active',
|
||||
value: 0,
|
||||
accentColor: 'green' as const,
|
||||
calculate: (data) => data.clusters.filter((c: Cluster) => c.status === 'active').length,
|
||||
},
|
||||
{
|
||||
label: 'Total Keywords',
|
||||
label: 'New',
|
||||
value: 0,
|
||||
accentColor: 'amber' as const,
|
||||
calculate: (data) => data.clusters.reduce((sum: number, c: Cluster) => sum + (c.keywords_count || 0), 0),
|
||||
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.',
|
||||
},
|
||||
{
|
||||
label: 'Total Volume',
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -440,22 +440,32 @@ export const createContentPageConfig = (
|
||||
],
|
||||
headerMetrics: [
|
||||
{
|
||||
label: 'Total Content',
|
||||
label: 'Content',
|
||||
value: 0,
|
||||
accentColor: 'blue' as const,
|
||||
calculate: (data) => data.totalCount || 0,
|
||||
tooltip: 'Total content pieces generated. Includes drafts, review, and published content.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -387,28 +387,32 @@ export const createIdeasPageConfig = (
|
||||
],
|
||||
headerMetrics: [
|
||||
{
|
||||
label: 'Total Ideas',
|
||||
label: 'Ideas',
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
label: 'Completed',
|
||||
value: 0,
|
||||
accentColor: 'green' as const,
|
||||
calculate: (data) => data.ideas.filter((i: ContentIdea) => i.status === 'published').length,
|
||||
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.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -217,28 +217,32 @@ export const createImagesPageConfig = (
|
||||
],
|
||||
headerMetrics: [
|
||||
{
|
||||
label: 'Total Content',
|
||||
label: 'Content',
|
||||
value: 0,
|
||||
accentColor: 'blue' as const,
|
||||
calculate: (data) => data.totalCount || 0,
|
||||
tooltip: 'Total content pieces with image generation. Track image coverage across all content.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
label: 'Partial',
|
||||
value: 0,
|
||||
accentColor: 'info' as const,
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
],
|
||||
maxInArticleImages: maxImages,
|
||||
|
||||
@@ -476,28 +476,32 @@ export const createKeywordsPageConfig = (
|
||||
],
|
||||
headerMetrics: [
|
||||
{
|
||||
label: 'Total Keywords',
|
||||
label: 'Keywords',
|
||||
value: 0,
|
||||
accentColor: 'blue' as const,
|
||||
calculate: (data) => data.totalCount || 0,
|
||||
tooltip: 'Total keywords added to site wrokflow. Minimum 5 Keywords are needed for clustering.',
|
||||
},
|
||||
{
|
||||
label: 'Mapped',
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
label: 'Total Volume',
|
||||
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.',
|
||||
},
|
||||
],
|
||||
// bulkActions and rowActions are now global - defined in table-actions.config.ts
|
||||
|
||||
@@ -305,15 +305,38 @@ export function createPublishedPageConfig(params: {
|
||||
|
||||
const headerMetrics: HeaderMetricConfig[] = [
|
||||
{
|
||||
label: 'Total Published',
|
||||
label: 'Published',
|
||||
accentColor: 'green',
|
||||
calculate: (data: { totalCount: number }) => data.totalCount,
|
||||
tooltip: 'Total published content. Track your complete content library.',
|
||||
},
|
||||
{
|
||||
label: 'On WordPress',
|
||||
label: 'Synced',
|
||||
accentColor: 'blue',
|
||||
calculate: (data: { content: Content[] }) =>
|
||||
data.content.filter(c => c.external_id).length,
|
||||
tooltip: 'Content synced to WordPress. Successfully published on your website.',
|
||||
},
|
||||
{
|
||||
label: 'This Month',
|
||||
accentColor: 'purple',
|
||||
calculate: (data: { content: Content[] }) => {
|
||||
const now = new Date();
|
||||
const thisMonth = now.getMonth();
|
||||
const thisYear = now.getFullYear();
|
||||
return data.content.filter(c => {
|
||||
const date = new Date(c.created_at);
|
||||
return date.getMonth() === thisMonth && date.getFullYear() === thisYear;
|
||||
}).length;
|
||||
},
|
||||
tooltip: 'Content published this month. Track your monthly publishing velocity.',
|
||||
},
|
||||
{
|
||||
label: 'Pending Sync',
|
||||
accentColor: 'amber',
|
||||
calculate: (data: { content: Content[] }) =>
|
||||
data.content.filter(c => !c.external_id).length,
|
||||
tooltip: 'Published content not yet synced to WordPress. Sync these to make them live.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -262,19 +262,28 @@ export function createReviewPageConfig(params: {
|
||||
],
|
||||
headerMetrics: [
|
||||
{
|
||||
label: 'Total Ready',
|
||||
label: 'Ready',
|
||||
accentColor: 'blue',
|
||||
calculate: ({ totalCount }) => totalCount,
|
||||
tooltip: 'Content ready for final review. Review quality, SEO, and images before publishing.',
|
||||
},
|
||||
{
|
||||
label: 'Has Images',
|
||||
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.',
|
||||
},
|
||||
{
|
||||
label: 'Optimized',
|
||||
accentColor: 'purple',
|
||||
calculate: ({ content }) => content.filter(c => (c as any).optimization_score >= 80).length,
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -454,22 +454,39 @@ export const createTasksPageConfig = (
|
||||
],
|
||||
headerMetrics: [
|
||||
{
|
||||
label: 'Total Tasks',
|
||||
label: 'Tasks',
|
||||
value: 0,
|
||||
accentColor: 'blue' as const,
|
||||
calculate: (data) => data.totalCount || 0,
|
||||
tooltip: 'Total content generation tasks. Tasks process ideas into written content automatically.',
|
||||
},
|
||||
{
|
||||
label: 'Queued',
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ interface HeaderMetric {
|
||||
label: string;
|
||||
value: string | number;
|
||||
accentColor: 'blue' | 'green' | 'amber' | 'purple';
|
||||
tooltip?: string; // Actionable insight for this metric
|
||||
}
|
||||
|
||||
interface HeaderMetricsContextType {
|
||||
|
||||
@@ -28,6 +28,8 @@ import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
|
||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import { WorkflowInsight } from '../../components/common/WorkflowInsights';
|
||||
|
||||
export default function Clusters() {
|
||||
const toast = useToast();
|
||||
@@ -75,6 +77,61 @@ export default function Clusters() {
|
||||
const progressModal = useProgressModal();
|
||||
const hasReloadedRef = useRef(false);
|
||||
|
||||
// Calculate workflow insights
|
||||
const workflowInsights: WorkflowInsight[] = useMemo(() => {
|
||||
const insights: WorkflowInsight[] = [];
|
||||
const clustersWithIdeas = clusters.filter(c => (c.ideas_count || 0) > 0).length;
|
||||
const totalIdeas = clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0);
|
||||
const emptyClusters = clusters.filter(c => (c.keywords_count || 0) === 0).length;
|
||||
const thinClusters = clusters.filter(c => (c.keywords_count || 0) > 0 && (c.keywords_count || 0) < 3).length;
|
||||
const readyForGeneration = clustersWithIdeas;
|
||||
const generationRate = totalCount > 0 ? Math.round((readyForGeneration / totalCount) * 100) : 0;
|
||||
|
||||
if (totalCount === 0) {
|
||||
insights.push({
|
||||
type: 'info',
|
||||
message: 'Create clusters to organize keywords into topical groups for better content planning',
|
||||
});
|
||||
return insights;
|
||||
}
|
||||
|
||||
// Content generation readiness
|
||||
if (generationRate < 30) {
|
||||
insights.push({
|
||||
type: 'warning',
|
||||
message: `Only ${generationRate}% of clusters have content ideas - Generate ideas to unlock content pipeline`,
|
||||
});
|
||||
} else if (generationRate >= 70) {
|
||||
insights.push({
|
||||
type: 'success',
|
||||
message: `${generationRate}% of clusters have ideas (${totalIdeas} total) - Strong content pipeline ready`,
|
||||
});
|
||||
}
|
||||
|
||||
// Empty or thin clusters
|
||||
if (emptyClusters > 0) {
|
||||
insights.push({
|
||||
type: 'warning',
|
||||
message: `${emptyClusters} clusters have no keywords - Map keywords or delete unused clusters`,
|
||||
});
|
||||
} else if (thinClusters > 2) {
|
||||
insights.push({
|
||||
type: 'info',
|
||||
message: `${thinClusters} clusters have fewer than 3 keywords - Consider adding more related keywords for better coverage`,
|
||||
});
|
||||
}
|
||||
|
||||
// Actionable next step
|
||||
if (totalIdeas === 0) {
|
||||
insights.push({
|
||||
type: 'action',
|
||||
message: 'Select clusters and use Auto-Generate Ideas to create content briefs',
|
||||
});
|
||||
}
|
||||
|
||||
return insights;
|
||||
}, [clusters, totalCount]);
|
||||
|
||||
// Load clusters - wrapped in useCallback to prevent infinite loops
|
||||
const loadClusters = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -353,6 +410,7 @@ export default function Clusters() {
|
||||
label: metric.label,
|
||||
value: metric.calculate({ clusters, totalCount }),
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip,
|
||||
}));
|
||||
}, [pageConfig?.headerMetrics, clusters, totalCount]);
|
||||
|
||||
@@ -397,6 +455,7 @@ export default function Clusters() {
|
||||
title="Keyword Clusters"
|
||||
badge={{ icon: <GroupIcon />, color: 'purple' }}
|
||||
navigation={<ModuleNavigationTabs tabs={plannerTabs} />}
|
||||
workflowInsights={workflowInsights}
|
||||
/>
|
||||
<TablePageTemplate
|
||||
columns={pageConfig.columns}
|
||||
@@ -490,6 +549,40 @@ export default function Clusters() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}
|
||||
<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',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
]}
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Progress Modal for AI Functions */}
|
||||
<ProgressModal
|
||||
isOpen={progressModal.isOpen}
|
||||
|
||||
@@ -29,6 +29,8 @@ import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
|
||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import { WorkflowInsight } from '../../components/common/WorkflowInsights';
|
||||
|
||||
export default function Ideas() {
|
||||
const toast = useToast();
|
||||
@@ -76,6 +78,57 @@ export default function Ideas() {
|
||||
// Progress modal for AI functions
|
||||
const progressModal = useProgressModal();
|
||||
|
||||
// Calculate workflow insights
|
||||
const workflowInsights: WorkflowInsight[] = useMemo(() => {
|
||||
const insights: WorkflowInsight[] = [];
|
||||
const newCount = ideas.filter(i => i.status === 'new').length;
|
||||
const queuedCount = ideas.filter(i => i.status === 'queued').length;
|
||||
const completedCount = ideas.filter(i => i.status === 'completed').length;
|
||||
const queueActivationRate = totalCount > 0 ? Math.round((queuedCount / totalCount) * 100) : 0;
|
||||
const completionRate = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
|
||||
|
||||
if (totalCount === 0) {
|
||||
insights.push({
|
||||
type: 'info',
|
||||
message: 'Generate ideas from your keyword clusters to build your content pipeline',
|
||||
});
|
||||
return insights;
|
||||
}
|
||||
|
||||
// Queue activation insights
|
||||
if (newCount > 0 && queuedCount === 0) {
|
||||
insights.push({
|
||||
type: 'warning',
|
||||
message: `${newCount} new ideas waiting - Queue them to activate the content pipeline`,
|
||||
});
|
||||
} else if (queueActivationRate > 0 && queueActivationRate < 40) {
|
||||
insights.push({
|
||||
type: 'info',
|
||||
message: `${queueActivationRate}% of ideas queued (${queuedCount} ideas) - Queue more ideas to maintain steady content flow`,
|
||||
});
|
||||
} else if (queuedCount > 0) {
|
||||
insights.push({
|
||||
type: 'success',
|
||||
message: `${queuedCount} ideas in queue - Content pipeline is active and ready for task generation`,
|
||||
});
|
||||
}
|
||||
|
||||
// Completion velocity
|
||||
if (completionRate >= 50) {
|
||||
insights.push({
|
||||
type: 'success',
|
||||
message: `Strong completion rate (${completionRate}%) - ${completedCount} ideas converted to content`,
|
||||
});
|
||||
} else if (completionRate > 0) {
|
||||
insights.push({
|
||||
type: 'info',
|
||||
message: `${completedCount} ideas completed (${completionRate}%) - Continue queuing ideas to grow content library`,
|
||||
});
|
||||
}
|
||||
|
||||
return insights;
|
||||
}, [ideas, totalCount]);
|
||||
|
||||
// Load clusters for filter dropdown
|
||||
useEffect(() => {
|
||||
const loadClusters = async () => {
|
||||
@@ -258,6 +311,7 @@ export default function Ideas() {
|
||||
label: metric.label,
|
||||
value: metric.calculate({ ideas, totalCount }),
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip,
|
||||
}));
|
||||
}, [pageConfig?.headerMetrics, ideas, totalCount]);
|
||||
|
||||
@@ -307,6 +361,7 @@ export default function Ideas() {
|
||||
title="Content Ideas"
|
||||
badge={{ icon: <BoltIcon />, color: 'orange' }}
|
||||
navigation={<ModuleNavigationTabs tabs={plannerTabs} />}
|
||||
workflowInsights={workflowInsights}
|
||||
/>
|
||||
<TablePageTemplate
|
||||
columns={pageConfig.columns}
|
||||
@@ -413,6 +468,48 @@ export default function Ideas() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}
|
||||
<ModuleMetricsFooter
|
||||
metrics={[
|
||||
{
|
||||
title: 'Clusters',
|
||||
value: clusters.length.toLocaleString(),
|
||||
subtitle: 'topic groups',
|
||||
icon: <GroupIcon className="w-5 h-5" />,
|
||||
accentColor: 'purple',
|
||||
href: '/planner/clusters',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Progress Modal for AI Functions */}
|
||||
<ProgressModal
|
||||
isOpen={progressModal.isOpen}
|
||||
|
||||
@@ -651,9 +651,69 @@ export default function Keywords() {
|
||||
label: metric.label,
|
||||
value: metric.calculate({ keywords, totalCount, clusters }),
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip, // Add tooltip support
|
||||
}));
|
||||
}, [pageConfig?.headerMetrics, keywords, totalCount, clusters]);
|
||||
|
||||
// Calculate workflow insights based on UX doc principles
|
||||
const workflowInsights = useMemo(() => {
|
||||
const insights = [];
|
||||
const clusteredCount = keywords.filter(k => k.cluster_id).length;
|
||||
const unclusteredCount = totalCount - clusteredCount;
|
||||
const pipelineReadiness = totalCount > 0 ? Math.round((clusteredCount / totalCount) * 100) : 0;
|
||||
|
||||
if (totalCount === 0) {
|
||||
insights.push({
|
||||
type: 'info' as const,
|
||||
message: 'Import keywords to begin building your content strategy and unlock SEO opportunities',
|
||||
});
|
||||
return insights;
|
||||
}
|
||||
|
||||
// Pipeline Readiness Score insight
|
||||
if (pipelineReadiness < 30) {
|
||||
insights.push({
|
||||
type: 'warning' as const,
|
||||
message: `Pipeline readiness at ${pipelineReadiness}% - Most keywords need clustering before content ideation can begin`,
|
||||
});
|
||||
} else if (pipelineReadiness < 60) {
|
||||
insights.push({
|
||||
type: 'info' as const,
|
||||
message: `Pipeline readiness at ${pipelineReadiness}% - Clustering progress is moderate, continue organizing keywords`,
|
||||
});
|
||||
} else if (pipelineReadiness >= 85) {
|
||||
insights.push({
|
||||
type: 'success' as const,
|
||||
message: `Excellent pipeline readiness (${pipelineReadiness}%) - Ready for content ideation phase`,
|
||||
});
|
||||
}
|
||||
|
||||
// Clustering Potential (minimum batch size check)
|
||||
if (unclusteredCount >= 5) {
|
||||
insights.push({
|
||||
type: 'action' as const,
|
||||
message: `${unclusteredCount} keywords available for auto-clustering (minimum batch size met)`,
|
||||
});
|
||||
} else if (unclusteredCount > 0 && unclusteredCount < 5) {
|
||||
insights.push({
|
||||
type: 'info' as const,
|
||||
message: `${unclusteredCount} unclustered keywords - Need ${5 - unclusteredCount} more to run auto-cluster`,
|
||||
});
|
||||
}
|
||||
|
||||
// Coverage Gaps - thin clusters that need more research
|
||||
const thinClusters = clusters.filter(c => (c.keywords_count || 0) === 1);
|
||||
if (thinClusters.length > 3) {
|
||||
const thinVolume = thinClusters.reduce((sum, c) => sum + (c.volume || 0), 0);
|
||||
insights.push({
|
||||
type: 'warning' as const,
|
||||
message: `${thinClusters.length} clusters have only 1 keyword each (${thinVolume.toLocaleString()} monthly volume) - Consider expanding research`,
|
||||
});
|
||||
}
|
||||
|
||||
return insights;
|
||||
}, [keywords, totalCount, clusters]);
|
||||
|
||||
// Handle create/edit
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
@@ -736,6 +796,7 @@ export default function Keywords() {
|
||||
title="Keywords"
|
||||
badge={{ icon: <ListIcon />, color: 'green' }}
|
||||
navigation={<ModuleNavigationTabs tabs={plannerTabs} />}
|
||||
workflowInsights={workflowInsights}
|
||||
/>
|
||||
<TablePageTemplate
|
||||
columns={pageConfig.columns}
|
||||
@@ -857,9 +918,9 @@ export default function Keywords() {
|
||||
<ModuleMetricsFooter
|
||||
metrics={[
|
||||
{
|
||||
title: 'Total Keywords',
|
||||
title: 'Keywords',
|
||||
value: totalCount.toLocaleString(),
|
||||
subtitle: `${clusters.length} clusters`,
|
||||
subtitle: `in ${clusters.length} clusters`,
|
||||
icon: <ListIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
href: '/planner/keywords',
|
||||
@@ -867,21 +928,21 @@ export default function Keywords() {
|
||||
{
|
||||
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)}% coverage`,
|
||||
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',
|
||||
},
|
||||
{
|
||||
title: 'Active',
|
||||
value: keywords.filter(k => k.status === 'active').length.toLocaleString(),
|
||||
subtitle: `${keywords.filter(k => k.status === 'pending').length} pending`,
|
||||
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',
|
||||
},
|
||||
]}
|
||||
progress={{
|
||||
label: 'Keyword Clustering 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',
|
||||
}}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useProgressModal } from '../../hooks/useProgressModal';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
|
||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import { WorkflowInsight } from '../../components/common/WorkflowInsights';
|
||||
|
||||
export default function Content() {
|
||||
const toast = useToast();
|
||||
@@ -55,6 +56,59 @@ export default function Content() {
|
||||
const progressModal = useProgressModal();
|
||||
const hasReloadedRef = useRef(false);
|
||||
|
||||
// Calculate workflow insights
|
||||
const workflowInsights: WorkflowInsight[] = useMemo(() => {
|
||||
const insights: WorkflowInsight[] = [];
|
||||
const draftCount = content.filter(c => c.status === 'draft').length;
|
||||
const reviewCount = content.filter(c => c.status === 'review').length;
|
||||
const publishedCount = content.filter(c => c.status === 'published').length;
|
||||
const publishingRate = totalCount > 0 ? Math.round((publishedCount / totalCount) * 100) : 0;
|
||||
|
||||
if (totalCount === 0) {
|
||||
insights.push({
|
||||
type: 'info',
|
||||
message: 'No content yet - Generate content from tasks to build your content library',
|
||||
});
|
||||
return insights;
|
||||
}
|
||||
|
||||
// Draft vs Review status
|
||||
if (draftCount > reviewCount * 3 && draftCount >= 5) {
|
||||
insights.push({
|
||||
type: 'warning',
|
||||
message: `${draftCount} drafts waiting for review - Move content to review stage for quality assurance`,
|
||||
});
|
||||
} else if (draftCount > 0) {
|
||||
insights.push({
|
||||
type: 'info',
|
||||
message: `${draftCount} drafts in progress - Review and refine before moving to publish stage`,
|
||||
});
|
||||
}
|
||||
|
||||
// Review queue status
|
||||
if (reviewCount > 0) {
|
||||
insights.push({
|
||||
type: 'action',
|
||||
message: `${reviewCount} pieces awaiting final review - Approve and publish when ready`,
|
||||
});
|
||||
}
|
||||
|
||||
// Publishing readiness
|
||||
if (publishingRate >= 60 && publishedCount >= 10) {
|
||||
insights.push({
|
||||
type: 'success',
|
||||
message: `Strong publishing rate (${publishingRate}%) - ${publishedCount} articles ready for WordPress sync`,
|
||||
});
|
||||
} else if (publishedCount > 0) {
|
||||
insights.push({
|
||||
type: 'success',
|
||||
message: `${publishedCount} articles published (${publishingRate}%) - Continue moving content through the pipeline`,
|
||||
});
|
||||
}
|
||||
|
||||
return insights;
|
||||
}, [content, totalCount]);
|
||||
|
||||
// Load content - wrapped in useCallback
|
||||
const loadContent = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -167,6 +221,7 @@ export default function Content() {
|
||||
label: metric.label,
|
||||
value: metric.calculate({ content, totalCount }),
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip,
|
||||
}));
|
||||
}, [pageConfig?.headerMetrics, content, totalCount]);
|
||||
|
||||
@@ -238,6 +293,7 @@ export default function Content() {
|
||||
title="Content Drafts"
|
||||
badge={{ icon: <FileIcon />, color: 'purple' }}
|
||||
navigation={<ModuleNavigationTabs tabs={writerTabs} />}
|
||||
workflowInsights={workflowInsights}
|
||||
/>
|
||||
<TablePageTemplate
|
||||
columns={pageConfig.columns}
|
||||
@@ -283,27 +339,43 @@ export default function Content() {
|
||||
getItemDisplayName={(row: ContentType) => row.title || `Content #${row.id}`}
|
||||
/>
|
||||
|
||||
{/* Module Metrics Footer */}
|
||||
{/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}
|
||||
<ModuleMetricsFooter
|
||||
metrics={[
|
||||
{
|
||||
title: 'Total Content',
|
||||
value: totalCount.toLocaleString(),
|
||||
subtitle: `${content.filter(c => c.status === 'published').length} published`,
|
||||
icon: <FileIcon className="w-5 h-5" />,
|
||||
accentColor: 'green',
|
||||
href: '/writer/content',
|
||||
title: 'Tasks',
|
||||
value: content.length.toLocaleString(),
|
||||
subtitle: 'generated from queue',
|
||||
icon: <TaskIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
href: '/writer/tasks',
|
||||
},
|
||||
{
|
||||
title: 'Draft',
|
||||
value: content.filter(c => c.status === 'draft').length.toLocaleString(),
|
||||
subtitle: `${content.filter(c => c.status === 'published').length} published`,
|
||||
icon: <TaskIcon className="w-5 h-5" />,
|
||||
subtitle: 'needs editing',
|
||||
icon: <FileIcon className="w-5 h-5" />,
|
||||
accentColor: 'amber',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
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 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',
|
||||
}}
|
||||
|
||||
@@ -492,6 +492,7 @@ export default function Images() {
|
||||
label: metric.label,
|
||||
value: metric.calculate({ images, totalCount }),
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip,
|
||||
}));
|
||||
}, [pageConfig?.headerMetrics, images, totalCount]);
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ export default function Review() {
|
||||
pageConfig.headerMetrics.map(metric => ({
|
||||
...metric,
|
||||
value: metric.calculate({ content, totalCount }),
|
||||
tooltip: (metric as any).tooltip,
|
||||
})),
|
||||
[pageConfig.headerMetrics, content, totalCount]
|
||||
);
|
||||
|
||||
@@ -31,6 +31,8 @@ import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
|
||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import { WorkflowInsight } from '../../components/common/WorkflowInsights';
|
||||
|
||||
export default function Tasks() {
|
||||
const toast = useToast();
|
||||
@@ -79,6 +81,63 @@ export default function Tasks() {
|
||||
// Progress modal for AI functions
|
||||
const progressModal = useProgressModal();
|
||||
|
||||
// Calculate workflow insights
|
||||
const workflowInsights: WorkflowInsight[] = useMemo(() => {
|
||||
const insights: WorkflowInsight[] = [];
|
||||
const queuedCount = tasks.filter(t => t.status === 'queued').length;
|
||||
const processingCount = tasks.filter(t => t.status === 'in_progress').length;
|
||||
const completedCount = tasks.filter(t => t.status === 'completed').length;
|
||||
const failedCount = tasks.filter(t => t.status === 'failed').length;
|
||||
const completionRate = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
|
||||
|
||||
if (totalCount === 0) {
|
||||
insights.push({
|
||||
type: 'info',
|
||||
message: 'No tasks yet - Queue ideas from Planner to start generating content automatically',
|
||||
});
|
||||
return insights;
|
||||
}
|
||||
|
||||
// Queue status
|
||||
if (queuedCount > 10) {
|
||||
insights.push({
|
||||
type: 'warning',
|
||||
message: `Large queue detected (${queuedCount} tasks) - Content generation may take time, consider prioritizing`,
|
||||
});
|
||||
} else if (queuedCount > 0) {
|
||||
insights.push({
|
||||
type: 'info',
|
||||
message: `${queuedCount} tasks in queue - Content generation pipeline is active`,
|
||||
});
|
||||
}
|
||||
|
||||
// Processing status
|
||||
if (processingCount > 0) {
|
||||
insights.push({
|
||||
type: 'action',
|
||||
message: `${processingCount} tasks actively generating content - Check back soon for completed drafts`,
|
||||
});
|
||||
}
|
||||
|
||||
// Failed tasks
|
||||
if (failedCount > 0) {
|
||||
insights.push({
|
||||
type: 'warning',
|
||||
message: `${failedCount} tasks failed - Review errors and retry or adjust task parameters`,
|
||||
});
|
||||
}
|
||||
|
||||
// Completion success
|
||||
if (completionRate >= 70 && completedCount >= 5) {
|
||||
insights.push({
|
||||
type: 'success',
|
||||
message: `High completion rate (${completionRate}%) - ${completedCount} pieces of content ready for review`,
|
||||
});
|
||||
}
|
||||
|
||||
return insights;
|
||||
}, [tasks, totalCount]);
|
||||
|
||||
// AI Function Logs state
|
||||
const [aiLogs, setAiLogs] = useState<Array<{
|
||||
timestamp: string;
|
||||
@@ -522,6 +581,7 @@ export default function Tasks() {
|
||||
label: metric.label,
|
||||
value: metric.calculate({ tasks, totalCount }),
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip,
|
||||
}));
|
||||
}, [pageConfig?.headerMetrics, tasks, totalCount]);
|
||||
|
||||
@@ -573,6 +633,7 @@ export default function Tasks() {
|
||||
title="Content Queue"
|
||||
badge={{ icon: <TaskIcon />, color: 'indigo' }}
|
||||
navigation={<ModuleNavigationTabs tabs={writerTabs} />}
|
||||
workflowInsights={workflowInsights}
|
||||
/>
|
||||
<TablePageTemplate
|
||||
columns={pageConfig.columns}
|
||||
@@ -678,6 +739,47 @@ export default function Tasks() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}
|
||||
<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',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
title: 'Processing',
|
||||
value: tasks.filter(t => t.status === 'in_progress').length.toLocaleString(),
|
||||
subtitle: 'generating content',
|
||||
icon: <TaskIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
},
|
||||
{
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Progress Modal for AI Functions */}
|
||||
<ProgressModal
|
||||
isOpen={progressModal.isOpen}
|
||||
|
||||
@@ -334,7 +334,7 @@ select.igny8-select-styled option:checked {
|
||||
.igny8-header-metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
|
||||
Reference in New Issue
Block a user