Add ThreeWidgetFooter component and hook for 3-column table footer layout
- ThreeWidgetFooter.tsx: 3-column layout matching Section 3 of audit report - Widget 1: Page Progress (current page metrics + progress bar + hint) - Widget 2: Module Stats (workflow pipeline with progress bars) - Widget 3: Completion (both Planner/Writer stats + credits) - useThreeWidgetFooter.ts: Hook to build widget props from data - Builds page progress for Keywords, Clusters, Ideas, Tasks, Content - Builds Planner/Writer module pipelines - Calculates completion stats from data Uses CSS tokens from styles/tokens.css for consistent styling
This commit is contained in:
385
frontend/src/components/dashboard/ThreeWidgetFooter.tsx
Normal file
385
frontend/src/components/dashboard/ThreeWidgetFooter.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* ThreeWidgetFooter - 3-Column Layout for Table Page Footers
|
||||
*
|
||||
* Design from Section 3 of COMPREHENSIVE-AUDIT-REPORT.md:
|
||||
* ┌─────────────────────────────────────────────────────────────────────────────────────┐
|
||||
* │ WIDGET 1: PAGE METRICS │ WIDGET 2: MODULE STATS │ WIDGET 3: COMPLETION │
|
||||
* │ (Current Page Progress) │ (Full Module Overview) │ (Both Modules Stats) │
|
||||
* │ ~33.3% width │ ~33.3% width │ ~33.3% width │
|
||||
* └─────────────────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* 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 { Card } from '../ui/card/Card';
|
||||
import { LightBulbIcon, ChevronRightIcon } from '@heroicons/react/24/solid';
|
||||
|
||||
// ============================================================================
|
||||
// TYPE DEFINITIONS
|
||||
// ============================================================================
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Main component props */
|
||||
export interface ThreeWidgetFooterProps {
|
||||
pageProgress: PageProgressWidget;
|
||||
moduleStats: ModuleStatsWidget;
|
||||
completion: CompletionWidget;
|
||||
submoduleColor?: SubmoduleColor;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COLOR UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
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] };
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// WIDGET 1: PAGE PROGRESS
|
||||
// ============================================================================
|
||||
|
||||
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 */}
|
||||
<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 */}
|
||||
{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
|
||||
// ============================================================================
|
||||
|
||||
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 */}
|
||||
<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 */}
|
||||
<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
|
||||
// ============================================================================
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export default function ThreeWidgetFooter({
|
||||
pageProgress,
|
||||
moduleStats,
|
||||
completion,
|
||||
submoduleColor = 'blue',
|
||||
className = '',
|
||||
}: ThreeWidgetFooterProps) {
|
||||
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-3 gap-4">
|
||||
<PageProgressCard widget={pageProgress} submoduleColor={submoduleColor} />
|
||||
<ModuleStatsCard widget={moduleStats} />
|
||||
<CompletionCard widget={completion} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Also export sub-components for flexibility
|
||||
export { PageProgressCard, ModuleStatsCard, CompletionCard };
|
||||
390
frontend/src/hooks/useThreeWidgetFooter.ts
Normal file
390
frontend/src/hooks/useThreeWidgetFooter.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* 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',
|
||||
* plannerData: { keywords: [...], clusters: [...] },
|
||||
* completionData: { ... }
|
||||
* });
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type {
|
||||
ThreeWidgetFooterProps,
|
||||
PageProgressWidget,
|
||||
ModuleStatsWidget,
|
||||
CompletionWidget,
|
||||
SubmoduleColor,
|
||||
} from '../components/dashboard/ThreeWidgetFooter';
|
||||
|
||||
// ============================================================================
|
||||
// DATA INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
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, percentage: `${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 ? 'green' : 'blue',
|
||||
},
|
||||
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, percentage: `${ideasPercent}%` },
|
||||
{ label: 'Keywords', value: totalKeywords },
|
||||
{ label: 'Ready', value: readyClusters },
|
||||
],
|
||||
progress: {
|
||||
value: ideasPercent,
|
||||
label: `${ideasPercent}% Have Ideas`,
|
||||
color: ideasPercent >= 70 ? 'green' : 'blue',
|
||||
},
|
||||
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, percentage: `${convertedPercent}%` },
|
||||
{ label: 'Pending', value: pending },
|
||||
{ label: 'From Clusters', value: data.totalClusters || 0 },
|
||||
],
|
||||
progress: {
|
||||
value: convertedPercent,
|
||||
label: `${convertedPercent}% Converted`,
|
||||
color: convertedPercent >= 60 ? 'green' : 'blue',
|
||||
},
|
||||
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, percentage: `${completedPercent}%` },
|
||||
{ label: 'Queue', value: queue },
|
||||
{ label: 'Processing', value: processing },
|
||||
],
|
||||
progress: {
|
||||
value: completedPercent,
|
||||
label: `${completedPercent}% Generated`,
|
||||
color: completedPercent >= 60 ? 'green' : 'blue',
|
||||
},
|
||||
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, percentage: `${imagesPercent}%` },
|
||||
{ label: 'Total Words', value: '—' }, // Would need word count from API
|
||||
{ label: 'Ready', value: ready },
|
||||
],
|
||||
progress: {
|
||||
value: imagesPercent,
|
||||
label: `${imagesPercent}% Have Images`,
|
||||
color: imagesPercent >= 70 ? 'green' : 'blue',
|
||||
},
|
||||
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',
|
||||
progress: totalKeywords > 0 ? Math.round((clusteredKeywords / totalKeywords) * 100) : 0,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Clusters',
|
||||
fromValue: totalClusters,
|
||||
toLabel: 'Ideas',
|
||||
toValue: totalIdeas,
|
||||
actionLabel: 'Generate Ideas',
|
||||
progress: totalClusters > 0 ? Math.round((clustersWithIdeas / totalClusters) * 100) : 0,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ideas',
|
||||
fromValue: totalIdeas,
|
||||
toLabel: 'Tasks',
|
||||
toValue: ideasInTasks,
|
||||
actionLabel: 'Create Tasks',
|
||||
progress: totalIdeas > 0 ? Math.round((ideasInTasks / totalIdeas) * 100) : 0,
|
||||
color: 'amber',
|
||||
},
|
||||
],
|
||||
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',
|
||||
progress: totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Drafts',
|
||||
fromValue: drafts,
|
||||
toLabel: 'Images',
|
||||
toValue: withImages,
|
||||
actionLabel: 'Generate Images',
|
||||
progress: drafts > 0 ? Math.round((withImages / drafts) * 100) : 0,
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ready',
|
||||
fromValue: ready,
|
||||
toLabel: 'Published',
|
||||
toValue: published,
|
||||
actionLabel: 'Review & Publish',
|
||||
progress: ready > 0 ? Math.round((published / (ready + published)) * 100) : 0,
|
||||
color: 'green',
|
||||
},
|
||||
],
|
||||
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 {
|
||||
return {
|
||||
title: 'Workflow Completion',
|
||||
plannerItems: [
|
||||
{ label: 'Keywords Clustered', value: data.keywordsClustered || 0, color: 'blue' },
|
||||
{ label: 'Clusters Created', value: data.clustersCreated || 0, color: 'green' },
|
||||
{ label: 'Ideas Generated', value: data.ideasGenerated || 0, color: 'amber' },
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: data.contentGenerated || 0, color: 'blue' },
|
||||
{ label: 'Images Created', value: data.imagesCreated || 0, color: 'purple' },
|
||||
{ label: 'Articles Published', value: data.articlesPublished || 0, color: 'green' },
|
||||
],
|
||||
creditsUsed: data.creditsUsed,
|
||||
operationsCount: data.totalOperations,
|
||||
analyticsHref: '/account/usage',
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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);
|
||||
|
||||
// Determine submodule color based on current page
|
||||
let submoduleColor: SubmoduleColor = 'blue';
|
||||
if (currentPage === 'clusters') submoduleColor = 'green';
|
||||
if (currentPage === 'ideas') submoduleColor = 'amber';
|
||||
if (currentPage === 'images') submoduleColor = 'purple';
|
||||
|
||||
return {
|
||||
pageProgress,
|
||||
moduleStats,
|
||||
completion,
|
||||
submoduleColor,
|
||||
};
|
||||
}, [module, currentPage, plannerData, writerData, completionData]);
|
||||
}
|
||||
|
||||
export default useThreeWidgetFooter;
|
||||
Reference in New Issue
Block a user