many changes for modules widgets and colors and styling
This commit is contained in:
252
frontend/src/hooks/useModuleStats.ts
Normal file
252
frontend/src/hooks/useModuleStats.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* useModuleStats Hook
|
||||
*
|
||||
* Centralized hook for fetching module-level statistics
|
||||
* used in the Module Stats widget (widget 2) of the footer.
|
||||
*
|
||||
* IMPORTANT: Content table structure
|
||||
* - Tasks is separate table (has status: queued, processing, completed, failed)
|
||||
* - Content table has status field: 'draft', 'review', 'published' (approved)
|
||||
* - Images is separate table linked to content
|
||||
*
|
||||
* Credits data comes from /v1/billing/credits/usage/summary/ endpoint
|
||||
* which returns by_operation with operation types:
|
||||
* - clustering: Keyword Clustering (Planner)
|
||||
* - idea_generation: Content Ideas Generation (Planner)
|
||||
* - content_generation: Content Generation (Writer)
|
||||
* - image_generation: Image Generation (Writer)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
fetchKeywords,
|
||||
fetchClusters,
|
||||
fetchContentIdeas,
|
||||
fetchTasks,
|
||||
fetchContent,
|
||||
fetchImages,
|
||||
fetchAPI,
|
||||
} from '../services/api';
|
||||
import { useSiteStore } from '../store/siteStore';
|
||||
import { useSectorStore } from '../store/sectorStore';
|
||||
|
||||
export interface PlannerModuleStats {
|
||||
totalKeywords: number;
|
||||
keywordsMapped: number; // Keywords with status='mapped' (assigned to clusters)
|
||||
totalClusters: number;
|
||||
totalIdeas: number;
|
||||
ideasConverted: number; // Ideas that have been converted to tasks
|
||||
}
|
||||
|
||||
export interface WriterModuleStats {
|
||||
totalTasks: number;
|
||||
tasksCompleted: number; // Tasks with status='completed'
|
||||
contentDrafts: number; // Content with status='draft'
|
||||
contentReview: number; // Content with status='review'
|
||||
contentPublished: number; // Content with status='published' (approved)
|
||||
totalContent: number; // All content regardless of status
|
||||
totalImages: number;
|
||||
}
|
||||
|
||||
export interface ModuleCredits {
|
||||
// Planner AI function credits
|
||||
clusteringCredits: number;
|
||||
ideaGenerationCredits: number;
|
||||
plannerTotal: number;
|
||||
// Writer AI function credits
|
||||
contentGenerationCredits: number;
|
||||
imageGenerationCredits: number;
|
||||
writerTotal: number;
|
||||
}
|
||||
|
||||
export interface ModuleStatsData {
|
||||
planner: PlannerModuleStats;
|
||||
writer: WriterModuleStats;
|
||||
credits: ModuleCredits;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const defaultStats: ModuleStatsData = {
|
||||
planner: {
|
||||
totalKeywords: 0,
|
||||
keywordsMapped: 0,
|
||||
totalClusters: 0,
|
||||
totalIdeas: 0,
|
||||
ideasConverted: 0,
|
||||
},
|
||||
writer: {
|
||||
totalTasks: 0,
|
||||
tasksCompleted: 0,
|
||||
contentDrafts: 0,
|
||||
contentReview: 0,
|
||||
contentPublished: 0,
|
||||
totalContent: 0,
|
||||
totalImages: 0,
|
||||
},
|
||||
credits: {
|
||||
clusteringCredits: 0,
|
||||
ideaGenerationCredits: 0,
|
||||
plannerTotal: 0,
|
||||
contentGenerationCredits: 0,
|
||||
imageGenerationCredits: 0,
|
||||
writerTotal: 0,
|
||||
},
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export function useModuleStats() {
|
||||
const [stats, setStats] = useState<ModuleStatsData>(defaultStats);
|
||||
const { activeSite } = useSiteStore();
|
||||
const { activeSector } = useSectorStore();
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
// Don't load if no active site - wait for site to be set
|
||||
if (!activeSite?.id) {
|
||||
setStats(prev => ({ ...prev, loading: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
setStats(prev => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
// Build common filters with explicit site/sector IDs
|
||||
const baseFilters = {
|
||||
page_size: 1,
|
||||
site_id: activeSite.id,
|
||||
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||
};
|
||||
|
||||
try {
|
||||
// Fetch all stats in parallel for performance
|
||||
const [
|
||||
// Planner stats
|
||||
keywordsRes,
|
||||
keywordsMappedRes,
|
||||
clustersRes,
|
||||
ideasRes,
|
||||
ideasQueuedRes,
|
||||
ideasCompletedRes,
|
||||
// Writer stats
|
||||
tasksRes,
|
||||
tasksCompletedRes,
|
||||
contentDraftsRes,
|
||||
contentReviewRes,
|
||||
contentPublishedRes,
|
||||
contentTotalRes,
|
||||
imagesRes,
|
||||
// Credits from billing summary
|
||||
creditsRes,
|
||||
] = await Promise.all([
|
||||
// Total keywords
|
||||
fetchKeywords({ ...baseFilters }),
|
||||
// Keywords that are mapped to clusters
|
||||
fetchKeywords({ ...baseFilters, status: 'mapped' }),
|
||||
// Total clusters
|
||||
fetchClusters({ ...baseFilters }),
|
||||
// Total ideas
|
||||
fetchContentIdeas({ ...baseFilters }),
|
||||
// Ideas that are queued (converted to tasks, waiting)
|
||||
fetchContentIdeas({ ...baseFilters, status: 'queued' }),
|
||||
// Ideas that are completed (converted to tasks, done)
|
||||
fetchContentIdeas({ ...baseFilters, status: 'completed' }),
|
||||
// Total tasks
|
||||
fetchTasks({ ...baseFilters }),
|
||||
// Completed tasks
|
||||
fetchTasks({ ...baseFilters, status: 'completed' }),
|
||||
// Content with status='draft'
|
||||
fetchContent({ ...baseFilters, status: 'draft' }),
|
||||
// Content with status='review'
|
||||
fetchContent({ ...baseFilters, status: 'review' }),
|
||||
// Content with status='published' (approved)
|
||||
fetchContent({ ...baseFilters, status: 'published' }),
|
||||
// Total content (all statuses)
|
||||
fetchContent({ ...baseFilters }),
|
||||
// Total images
|
||||
fetchImages({ ...baseFilters }),
|
||||
// Credits usage from billing summary
|
||||
fetchAPI('/v1/billing/credits/usage/summary/').catch(() => ({
|
||||
data: { by_operation: {} }
|
||||
})),
|
||||
]);
|
||||
|
||||
// Parse credits response
|
||||
const creditsData = creditsRes?.data || creditsRes || {};
|
||||
const byOperation = creditsData.by_operation || {};
|
||||
|
||||
// Extract credits by operation type
|
||||
const clusteringCredits = byOperation.clustering?.credits || 0;
|
||||
const ideaCredits = (byOperation.idea_generation?.credits || 0) + (byOperation.ideas?.credits || 0);
|
||||
const contentCredits = (byOperation.content_generation?.credits || 0) + (byOperation.content?.credits || 0);
|
||||
const imageCredits = (byOperation.image_generation?.credits || 0) +
|
||||
(byOperation.images?.credits || 0) +
|
||||
(byOperation.image_prompt_extraction?.credits || 0);
|
||||
|
||||
// Ideas converted = queued + completed (ideas that have tasks created)
|
||||
const ideasConverted = (ideasQueuedRes?.count || 0) + (ideasCompletedRes?.count || 0);
|
||||
|
||||
setStats({
|
||||
planner: {
|
||||
totalKeywords: keywordsRes?.count || 0,
|
||||
keywordsMapped: keywordsMappedRes?.count || 0,
|
||||
totalClusters: clustersRes?.count || 0,
|
||||
totalIdeas: ideasRes?.count || 0,
|
||||
ideasConverted: ideasConverted,
|
||||
},
|
||||
writer: {
|
||||
totalTasks: tasksRes?.count || 0,
|
||||
tasksCompleted: tasksCompletedRes?.count || 0,
|
||||
contentDrafts: contentDraftsRes?.count || 0,
|
||||
contentReview: contentReviewRes?.count || 0,
|
||||
contentPublished: contentPublishedRes?.count || 0,
|
||||
totalContent: contentTotalRes?.count || 0,
|
||||
totalImages: imagesRes?.count || 0,
|
||||
},
|
||||
credits: {
|
||||
clusteringCredits,
|
||||
ideaGenerationCredits: ideaCredits,
|
||||
plannerTotal: clusteringCredits + ideaCredits,
|
||||
contentGenerationCredits: contentCredits,
|
||||
imageGenerationCredits: imageCredits,
|
||||
writerTotal: contentCredits + imageCredits,
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error loading module stats:', error);
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: error.message || 'Failed to load module stats',
|
||||
}));
|
||||
}
|
||||
}, [activeSite?.id, activeSector?.id]);
|
||||
|
||||
// Load stats on mount and when dependencies change
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
// Listen for data changes to refresh
|
||||
useEffect(() => {
|
||||
const handleRefresh = () => loadStats();
|
||||
|
||||
window.addEventListener('site-changed', handleRefresh);
|
||||
window.addEventListener('sector-changed', handleRefresh);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('site-changed', handleRefresh);
|
||||
window.removeEventListener('sector-changed', handleRefresh);
|
||||
};
|
||||
}, [loadStats]);
|
||||
|
||||
// Expose refresh function
|
||||
const refresh = useCallback(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
return { ...stats, refresh };
|
||||
}
|
||||
|
||||
export default useModuleStats;
|
||||
@@ -1,390 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
294
frontend/src/hooks/useWorkflowStats.ts
Normal file
294
frontend/src/hooks/useWorkflowStats.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* useWorkflowStats Hook
|
||||
*
|
||||
* Centralized hook for fetching workflow statistics across
|
||||
* Planner and Writer modules with time-based filtering.
|
||||
*
|
||||
* This provides consistent data for the WorkflowCompletionWidget
|
||||
* across all pages.
|
||||
*
|
||||
* IMPORTANT: Content table structure
|
||||
* - Tasks is separate table
|
||||
* - Content table has status field: 'draft', 'review', 'published' (approved)
|
||||
* - Images is separate table linked to content
|
||||
*
|
||||
* Credits data comes from /v1/billing/credits/usage/summary/ endpoint
|
||||
* which returns by_operation with operation types:
|
||||
* - clustering: Keyword Clustering
|
||||
* - idea_generation: Content Ideas Generation
|
||||
* - content_generation: Content Generation
|
||||
* - image_generation: Image Generation
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
fetchKeywords,
|
||||
fetchClusters,
|
||||
fetchContentIdeas,
|
||||
fetchTasks,
|
||||
fetchContent,
|
||||
fetchImages,
|
||||
fetchAPI,
|
||||
} from '../services/api';
|
||||
import { useSiteStore } from '../store/siteStore';
|
||||
import { useSectorStore } from '../store/sectorStore';
|
||||
|
||||
// Time filter options (in days)
|
||||
export type TimeFilter = 'today' | '7' | '30' | '90' | 'all';
|
||||
|
||||
export interface CreditsBreakdown {
|
||||
clustering: number;
|
||||
ideaGeneration: number;
|
||||
contentGeneration: number;
|
||||
imageGeneration: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface WorkflowStats {
|
||||
// Planner Module Stats
|
||||
planner: {
|
||||
keywordsClustered: number;
|
||||
totalKeywords: number;
|
||||
clustersCreated: number;
|
||||
ideasGenerated: number;
|
||||
};
|
||||
// Writer Module Stats
|
||||
writer: {
|
||||
tasksTotal: number;
|
||||
contentDrafts: number; // Content with status='draft'
|
||||
contentReview: number; // Content with status='review'
|
||||
contentPublished: number; // Content with status='published' (approved)
|
||||
imagesCreated: number;
|
||||
};
|
||||
// Credit consumption stats - detailed breakdown by operation
|
||||
credits: {
|
||||
// Planner module credits
|
||||
plannerCreditsUsed: number; // clustering + idea_generation
|
||||
clusteringCredits: number; // Just clustering
|
||||
ideaGenerationCredits: number; // Just idea generation
|
||||
// Writer module credits
|
||||
writerCreditsUsed: number; // content_generation + image_generation
|
||||
contentGenerationCredits: number;
|
||||
imageGenerationCredits: number;
|
||||
// Total
|
||||
totalCreditsUsed: number;
|
||||
};
|
||||
// Loading state
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const defaultStats: WorkflowStats = {
|
||||
planner: {
|
||||
keywordsClustered: 0,
|
||||
totalKeywords: 0,
|
||||
clustersCreated: 0,
|
||||
ideasGenerated: 0,
|
||||
},
|
||||
writer: {
|
||||
tasksTotal: 0,
|
||||
contentDrafts: 0,
|
||||
contentReview: 0,
|
||||
contentPublished: 0,
|
||||
imagesCreated: 0,
|
||||
},
|
||||
credits: {
|
||||
plannerCreditsUsed: 0,
|
||||
clusteringCredits: 0,
|
||||
ideaGenerationCredits: 0,
|
||||
writerCreditsUsed: 0,
|
||||
contentGenerationCredits: 0,
|
||||
imageGenerationCredits: 0,
|
||||
totalCreditsUsed: 0,
|
||||
},
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Calculate the date filter based on time range
|
||||
function getDateFilter(timeFilter: TimeFilter): string | undefined {
|
||||
if (timeFilter === 'all') return undefined;
|
||||
|
||||
const now = new Date();
|
||||
let startDate: Date;
|
||||
|
||||
switch (timeFilter) {
|
||||
case 'today':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
break;
|
||||
case '7':
|
||||
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case '30':
|
||||
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case '90':
|
||||
startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return startDate.toISOString();
|
||||
}
|
||||
|
||||
export function useWorkflowStats(timeFilter: TimeFilter = 'all') {
|
||||
const [stats, setStats] = useState<WorkflowStats>(defaultStats);
|
||||
const { activeSite } = useSiteStore();
|
||||
const { activeSector } = useSectorStore();
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
// Don't load if no active site - wait for site to be set
|
||||
if (!activeSite?.id) {
|
||||
setStats(prev => ({ ...prev, loading: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
setStats(prev => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
// Build date filter query param
|
||||
const dateFilter = getDateFilter(timeFilter);
|
||||
const dateParam = dateFilter ? `&created_at__gte=${dateFilter.split('T')[0]}` : '';
|
||||
|
||||
// Build site/sector params for direct API calls
|
||||
const siteParam = `&site_id=${activeSite.id}`;
|
||||
const sectorParam = activeSector?.id ? `§or_id=${activeSector.id}` : '';
|
||||
const baseParams = `${siteParam}${sectorParam}`;
|
||||
|
||||
// Build common filters for fetch* functions
|
||||
const baseFilters = {
|
||||
page_size: 1,
|
||||
site_id: activeSite.id,
|
||||
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||
};
|
||||
|
||||
// Fetch all stats in parallel for performance
|
||||
// Note: page_size=1 is used to just get the count, not actual data
|
||||
const [
|
||||
// Planner stats - these APIs support created_at__gte filter
|
||||
keywordsRes,
|
||||
keywordsClusteredRes,
|
||||
clustersRes,
|
||||
ideasRes,
|
||||
// Writer stats
|
||||
tasksRes,
|
||||
contentDraftRes,
|
||||
contentReviewRes,
|
||||
contentPublishedRes,
|
||||
imagesRes,
|
||||
// Credits stats from billing summary endpoint
|
||||
creditsRes,
|
||||
] = await Promise.all([
|
||||
// Total keywords (with date filter via direct API call)
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/planner/keywords/?page_size=1${baseParams}${dateParam}`)
|
||||
: fetchKeywords({ ...baseFilters }),
|
||||
// Keywords that are clustered (status='mapped')
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/planner/keywords/?page_size=1&status=mapped${baseParams}${dateParam}`)
|
||||
: fetchKeywords({ ...baseFilters, status: 'mapped' }),
|
||||
// Total clusters
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/planner/clusters/?page_size=1${baseParams}${dateParam}`)
|
||||
: fetchClusters({ ...baseFilters }),
|
||||
// Total ideas
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/planner/ideas/?page_size=1${baseParams}${dateParam}`)
|
||||
: fetchContentIdeas({ ...baseFilters }),
|
||||
// Total tasks
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/writer/tasks/?page_size=1${baseParams}${dateParam}`)
|
||||
: fetchTasks({ ...baseFilters }),
|
||||
// Content with status='draft'
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/writer/content/?page_size=1&status=draft${baseParams}${dateParam}`)
|
||||
: fetchContent({ ...baseFilters, status: 'draft' }),
|
||||
// Content with status='review'
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/writer/content/?page_size=1&status=review${baseParams}${dateParam}`)
|
||||
: fetchContent({ ...baseFilters, status: 'review' }),
|
||||
// Content with status='published' (approved)
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/writer/content/?page_size=1&status=published${baseParams}${dateParam}`)
|
||||
: fetchContent({ ...baseFilters, status: 'published' }),
|
||||
// Total images
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/writer/images/?page_size=1${baseParams}${dateParam}`)
|
||||
: fetchImages({ ...baseFilters }),
|
||||
// Credits usage from billing summary endpoint - includes by_operation breakdown
|
||||
dateFilter
|
||||
? fetchAPI(`/v1/billing/credits/usage/summary/?start_date=${dateFilter}`)
|
||||
: fetchAPI('/v1/billing/credits/usage/summary/').catch(() => ({
|
||||
data: { total_credits_used: 0, by_operation: {} }
|
||||
})),
|
||||
]);
|
||||
|
||||
// Parse credits response - extract by_operation data
|
||||
const creditsData = creditsRes?.data || creditsRes || {};
|
||||
const byOperation = creditsData.by_operation || {};
|
||||
|
||||
// Extract credits by operation type
|
||||
// Planner operations: clustering, idea_generation (also 'ideas' legacy)
|
||||
const clusteringCredits = byOperation.clustering?.credits || 0;
|
||||
const ideaCredits = (byOperation.idea_generation?.credits || 0) + (byOperation.ideas?.credits || 0);
|
||||
|
||||
// Writer operations: content_generation, image_generation (also 'content', 'images' legacy)
|
||||
const contentCredits = (byOperation.content_generation?.credits || 0) + (byOperation.content?.credits || 0);
|
||||
const imageCredits = (byOperation.image_generation?.credits || 0) +
|
||||
(byOperation.images?.credits || 0) +
|
||||
(byOperation.image_prompt_extraction?.credits || 0);
|
||||
|
||||
const plannerTotal = clusteringCredits + ideaCredits;
|
||||
const writerTotal = contentCredits + imageCredits;
|
||||
|
||||
setStats({
|
||||
planner: {
|
||||
totalKeywords: keywordsRes?.count || 0,
|
||||
keywordsClustered: keywordsClusteredRes?.count || 0,
|
||||
clustersCreated: clustersRes?.count || 0,
|
||||
ideasGenerated: ideasRes?.count || 0,
|
||||
},
|
||||
writer: {
|
||||
tasksTotal: tasksRes?.count || 0,
|
||||
contentDrafts: contentDraftRes?.count || 0,
|
||||
contentReview: contentReviewRes?.count || 0,
|
||||
contentPublished: contentPublishedRes?.count || 0,
|
||||
imagesCreated: imagesRes?.count || 0,
|
||||
},
|
||||
credits: {
|
||||
plannerCreditsUsed: plannerTotal,
|
||||
clusteringCredits: clusteringCredits,
|
||||
ideaGenerationCredits: ideaCredits,
|
||||
writerCreditsUsed: writerTotal,
|
||||
contentGenerationCredits: contentCredits,
|
||||
imageGenerationCredits: imageCredits,
|
||||
totalCreditsUsed: creditsData.total_credits_used || plannerTotal + writerTotal,
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error loading workflow stats:', error);
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: error.message || 'Failed to load workflow stats',
|
||||
}));
|
||||
}
|
||||
}, [activeSite?.id, activeSector?.id, timeFilter]);
|
||||
|
||||
// Load stats on mount and when dependencies change
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
// Expose refresh function
|
||||
const refresh = useCallback(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
return { ...stats, refresh };
|
||||
}
|
||||
|
||||
export default useWorkflowStats;
|
||||
Reference in New Issue
Block a user