Planner Writer Dashboard
This commit is contained in:
@@ -1,51 +1,425 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
import ComponentCard from "../../components/common/ComponentCard";
|
||||
import { ProgressBar } from "../../components/ui/progress";
|
||||
import { ListIcon, GroupIcon, BoltIcon, PieChartIcon, ArrowRightIcon, CheckCircleIcon, TimeIcon } from "../../icons";
|
||||
import Chart from "react-apexcharts";
|
||||
import { ApexOptions } from "apexcharts";
|
||||
import {
|
||||
ListIcon,
|
||||
GroupIcon,
|
||||
BoltIcon,
|
||||
PieChartIcon,
|
||||
ArrowRightIcon,
|
||||
CheckCircleIcon,
|
||||
TimeIcon,
|
||||
ArrowUpIcon,
|
||||
ArrowDownIcon
|
||||
} from "../../icons";
|
||||
import {
|
||||
fetchKeywords,
|
||||
fetchClusters,
|
||||
fetchContentIdeas,
|
||||
fetchTasks
|
||||
} from "../../services/api";
|
||||
import { useSiteStore } from "../../store/siteStore";
|
||||
import { useSectorStore } from "../../store/sectorStore";
|
||||
|
||||
interface DashboardStats {
|
||||
keywords: {
|
||||
total: number;
|
||||
mapped: number;
|
||||
unmapped: number;
|
||||
byStatus: Record<string, number>;
|
||||
byIntent: Record<string, number>;
|
||||
};
|
||||
clusters: {
|
||||
total: number;
|
||||
withIdeas: number;
|
||||
withoutIdeas: number;
|
||||
totalVolume: number;
|
||||
avgKeywords: number;
|
||||
topClusters: Array<{ id: number; name: string; volume: number; keywords_count: number }>;
|
||||
};
|
||||
ideas: {
|
||||
total: number;
|
||||
queued: number;
|
||||
notQueued: number;
|
||||
byStatus: Record<string, number>;
|
||||
byContentType: Record<string, number>;
|
||||
};
|
||||
workflow: {
|
||||
keywordsReady: boolean;
|
||||
clustersBuilt: boolean;
|
||||
ideasGenerated: boolean;
|
||||
readyForWriter: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default function PlannerDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { activeSite } = useSiteStore();
|
||||
const { activeSector } = useSectorStore();
|
||||
|
||||
// Mock data - will be replaced with API calls
|
||||
const stats = {
|
||||
keywords: 245,
|
||||
clusters: 18,
|
||||
ideas: 52,
|
||||
mappedKeywords: 180,
|
||||
clustersWithIdeas: 12,
|
||||
queuedIdeas: 35,
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
|
||||
const [trends, setTrends] = useState<{
|
||||
keywords: number;
|
||||
clusters: number;
|
||||
ideas: number;
|
||||
}>({ keywords: 0, clusters: 0, ideas: 0 });
|
||||
|
||||
// Fetch real data
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch all data in parallel
|
||||
const [keywordsRes, clustersRes, ideasRes, tasksRes] = await Promise.all([
|
||||
fetchKeywords({ page_size: 1000, sector_id: activeSector?.id }),
|
||||
fetchClusters({ page_size: 1000, sector_id: activeSector?.id }),
|
||||
fetchContentIdeas({ page_size: 1000, sector_id: activeSector?.id }),
|
||||
fetchTasks({ page_size: 1000, sector_id: activeSector?.id })
|
||||
]);
|
||||
|
||||
// Process keywords
|
||||
const keywords = keywordsRes.results || [];
|
||||
const mappedKeywords = keywords.filter(k => k.cluster && k.cluster.length > 0);
|
||||
const unmappedKeywords = keywords.filter(k => !k.cluster || k.cluster.length === 0);
|
||||
|
||||
const keywordsByStatus: Record<string, number> = {};
|
||||
const keywordsByIntent: Record<string, number> = {};
|
||||
keywords.forEach(k => {
|
||||
keywordsByStatus[k.status || 'unknown'] = (keywordsByStatus[k.status || 'unknown'] || 0) + 1;
|
||||
if (k.intent) {
|
||||
keywordsByIntent[k.intent] = (keywordsByIntent[k.intent] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
// Process clusters
|
||||
const clusters = clustersRes.results || [];
|
||||
const clustersWithIdeas = clusters.filter(c => c.keywords_count > 0);
|
||||
const totalVolume = clusters.reduce((sum, c) => sum + (c.volume || 0), 0);
|
||||
const totalKeywordsInClusters = clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0);
|
||||
const avgKeywords = clusters.length > 0 ? Math.round(totalKeywordsInClusters / clusters.length) : 0;
|
||||
|
||||
// Top clusters by volume
|
||||
const topClusters = [...clusters]
|
||||
.sort((a, b) => (b.volume || 0) - (a.volume || 0))
|
||||
.slice(0, 5)
|
||||
.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name || 'Unnamed Cluster',
|
||||
volume: c.volume || 0,
|
||||
keywords_count: c.keywords_count || 0
|
||||
}));
|
||||
|
||||
// Process ideas
|
||||
const ideas = ideasRes.results || [];
|
||||
const ideaIds = new Set(ideas.map(i => i.id));
|
||||
const tasks = tasksRes.results || [];
|
||||
const queuedIdeas = tasks.filter(t => t.idea && ideaIds.has(t.idea)).length;
|
||||
const notQueuedIdeas = ideas.length - queuedIdeas;
|
||||
|
||||
const ideasByStatus: Record<string, number> = {};
|
||||
const ideasByContentType: Record<string, number> = {};
|
||||
ideas.forEach(i => {
|
||||
ideasByStatus[i.status || 'new'] = (ideasByStatus[i.status || 'new'] || 0) + 1;
|
||||
if (i.content_type) {
|
||||
ideasByContentType[i.content_type] = (ideasByContentType[i.content_type] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate trends (compare with previous state)
|
||||
if (stats) {
|
||||
setTrends({
|
||||
keywords: keywords.length - stats.keywords.total,
|
||||
clusters: clusters.length - stats.clusters.total,
|
||||
ideas: ideas.length - stats.ideas.total
|
||||
});
|
||||
}
|
||||
|
||||
setStats({
|
||||
keywords: {
|
||||
total: keywords.length,
|
||||
mapped: mappedKeywords.length,
|
||||
unmapped: unmappedKeywords.length,
|
||||
byStatus: keywordsByStatus,
|
||||
byIntent: keywordsByIntent
|
||||
},
|
||||
clusters: {
|
||||
total: clusters.length,
|
||||
withIdeas: clustersWithIdeas.length,
|
||||
withoutIdeas: clusters.length - clustersWithIdeas.length,
|
||||
totalVolume,
|
||||
avgKeywords,
|
||||
topClusters
|
||||
},
|
||||
ideas: {
|
||||
total: ideas.length,
|
||||
queued: queuedIdeas,
|
||||
notQueued: notQueuedIdeas,
|
||||
byStatus: ideasByStatus,
|
||||
byContentType: ideasByContentType
|
||||
},
|
||||
workflow: {
|
||||
keywordsReady: keywords.length > 0,
|
||||
clustersBuilt: clusters.length > 0,
|
||||
ideasGenerated: ideas.length > 0,
|
||||
readyForWriter: queuedIdeas > 0
|
||||
}
|
||||
});
|
||||
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const keywordMappingPct = stats.keywords > 0 ? Math.round((stats.mappedKeywords / stats.keywords) * 100) : 0;
|
||||
const clustersIdeasPct = stats.clusters > 0 ? Math.round((stats.clustersWithIdeas / stats.clusters) * 100) : 0;
|
||||
const ideasQueuedPct = stats.ideas > 0 ? Math.round((stats.queuedIdeas / stats.ideas) * 100) : 0;
|
||||
// Initial load and periodic refresh
|
||||
useEffect(() => {
|
||||
if (!activeSector?.id) return;
|
||||
|
||||
fetchDashboardData();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchDashboardData, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [activeSector?.id]);
|
||||
|
||||
// Calculate percentages
|
||||
const keywordMappingPct = useMemo(() => {
|
||||
if (!stats || stats.keywords.total === 0) return 0;
|
||||
return Math.round((stats.keywords.mapped / stats.keywords.total) * 100);
|
||||
}, [stats]);
|
||||
|
||||
const clustersIdeasPct = useMemo(() => {
|
||||
if (!stats || stats.clusters.total === 0) return 0;
|
||||
return Math.round((stats.clusters.withIdeas / stats.clusters.total) * 100);
|
||||
}, [stats]);
|
||||
|
||||
const ideasQueuedPct = useMemo(() => {
|
||||
if (!stats || stats.ideas.total === 0) return 0;
|
||||
return Math.round((stats.ideas.queued / stats.ideas.total) * 100);
|
||||
}, [stats]);
|
||||
|
||||
// Chart data for keywords by status
|
||||
const keywordsStatusChart = useMemo(() => {
|
||||
if (!stats) return null;
|
||||
|
||||
const options: ApexOptions = {
|
||||
chart: {
|
||||
type: 'donut',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
toolbar: { show: false }
|
||||
},
|
||||
labels: Object.keys(stats.keywords.byStatus),
|
||||
colors: ['#465FFF', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6'],
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
fontFamily: 'Outfit'
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
formatter: (val: number) => `${Math.round(val)}%`
|
||||
},
|
||||
plotOptions: {
|
||||
pie: {
|
||||
donut: {
|
||||
size: '65%'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const series = Object.values(stats.keywords.byStatus);
|
||||
|
||||
return { options, series };
|
||||
}, [stats]);
|
||||
|
||||
// Chart data for ideas by status
|
||||
const ideasStatusChart = useMemo(() => {
|
||||
if (!stats) return null;
|
||||
|
||||
const options: ApexOptions = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
toolbar: { show: false },
|
||||
height: 250
|
||||
},
|
||||
colors: ['#465FFF'],
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: false,
|
||||
columnWidth: '55%',
|
||||
borderRadius: 5
|
||||
}
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: true
|
||||
},
|
||||
xaxis: {
|
||||
categories: Object.keys(stats.ideas.byStatus),
|
||||
labels: {
|
||||
style: {
|
||||
fontFamily: 'Outfit'
|
||||
}
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
fontFamily: 'Outfit'
|
||||
}
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
strokeDashArray: 4
|
||||
}
|
||||
};
|
||||
|
||||
const series = [{
|
||||
name: 'Ideas',
|
||||
data: Object.values(stats.ideas.byStatus)
|
||||
}];
|
||||
|
||||
return { options, series };
|
||||
}, [stats]);
|
||||
|
||||
// Chart data for top clusters volume
|
||||
const topClustersChart = useMemo(() => {
|
||||
if (!stats || stats.clusters.topClusters.length === 0) return null;
|
||||
|
||||
const options: ApexOptions = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
toolbar: { show: false },
|
||||
height: 300
|
||||
},
|
||||
colors: ['#10B981'],
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: true,
|
||||
borderRadius: 5,
|
||||
dataLabels: {
|
||||
position: 'top'
|
||||
}
|
||||
}
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
formatter: (val: number) => val.toLocaleString(),
|
||||
offsetX: 10
|
||||
},
|
||||
xaxis: {
|
||||
categories: stats.clusters.topClusters.map(c => c.name),
|
||||
labels: {
|
||||
style: {
|
||||
fontFamily: 'Outfit',
|
||||
fontSize: '12px'
|
||||
}
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
fontFamily: 'Outfit'
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
y: {
|
||||
formatter: (val: number) => `${val.toLocaleString()} volume`
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const series = [{
|
||||
name: 'Search Volume',
|
||||
data: stats.clusters.topClusters.map(c => c.volume)
|
||||
}];
|
||||
|
||||
return { options, series };
|
||||
}, [stats]);
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Planner Dashboard - IGNY8" description="Content planning overview" />
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-brand-500 border-t-transparent"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading dashboard data...</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Planner Dashboard - IGNY8" description="Content planning overview" />
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600 dark:text-gray-400">No data available. Please select a sector.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const workflowSteps = [
|
||||
{ number: 1, title: "Add Keywords", status: "completed", count: stats.keywords, path: "/planner/keywords" },
|
||||
{ number: 2, title: "Select Sector", status: "completed", count: null, path: "/planner" },
|
||||
{ number: 3, title: "Auto Cluster", status: "pending", count: stats.clusters, path: "/planner/clusters" },
|
||||
{ number: 4, title: "Generate Ideas", status: "pending", count: stats.ideas, path: "/planner/ideas" },
|
||||
];
|
||||
|
||||
const topClusters = [
|
||||
{ name: "SEO Optimization", volume: 45800, keywords: 24 },
|
||||
{ name: "Content Marketing", volume: 32100, keywords: 18 },
|
||||
{ name: "Link Building", volume: 28700, keywords: 15 },
|
||||
{ name: "Keyword Research", volume: 24100, keywords: 12 },
|
||||
{ name: "Analytics", volume: 18900, keywords: 9 },
|
||||
];
|
||||
|
||||
const ideasByStatus = [
|
||||
{ status: "New", count: 20, color: "blue" },
|
||||
{ status: "Scheduled", count: 15, color: "amber" },
|
||||
{ status: "Published", count: 17, color: "green" },
|
||||
{
|
||||
number: 1,
|
||||
title: "Add Keywords",
|
||||
status: stats.workflow.keywordsReady ? "completed" : "pending",
|
||||
count: stats.keywords.total,
|
||||
path: "/planner/keywords"
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "Auto Cluster",
|
||||
status: stats.workflow.clustersBuilt ? "completed" : "pending",
|
||||
count: stats.clusters.total,
|
||||
path: "/planner/clusters"
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: "Generate Ideas",
|
||||
status: stats.workflow.ideasGenerated ? "completed" : "pending",
|
||||
count: stats.ideas.total,
|
||||
path: "/planner/ideas"
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
title: "Queue to Writer",
|
||||
status: stats.workflow.readyForWriter ? "completed" : "pending",
|
||||
count: stats.ideas.queued,
|
||||
path: "/writer/tasks"
|
||||
},
|
||||
];
|
||||
|
||||
const nextActions = [
|
||||
{ text: "65 keywords unmapped", action: "Map Keywords", path: "/planner/keywords" },
|
||||
{ text: "6 clusters without ideas", action: "Generate Ideas", path: "/planner/ideas" },
|
||||
{ text: "17 ideas not queued to writer", action: "Queue to Writer", path: "/writer/tasks" },
|
||||
...(stats.keywords.unmapped > 0 ? [{
|
||||
text: `${stats.keywords.unmapped} keywords unmapped`,
|
||||
action: "Map Keywords",
|
||||
path: "/planner/keywords"
|
||||
}] : []),
|
||||
...(stats.clusters.withoutIdeas > 0 ? [{
|
||||
text: `${stats.clusters.withoutIdeas} clusters without ideas`,
|
||||
action: "Generate Ideas",
|
||||
path: "/planner/clusters"
|
||||
}] : []),
|
||||
...(stats.ideas.notQueued > 0 ? [{
|
||||
text: `${stats.ideas.notQueued} ideas not queued to writer`,
|
||||
action: "Queue to Writer",
|
||||
path: "/planner/ideas"
|
||||
}] : [])
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -53,21 +427,45 @@ export default function PlannerDashboard() {
|
||||
<PageMeta title="Planner Dashboard - IGNY8" description="Content planning overview" />
|
||||
|
||||
<div className="space-y-5 sm:space-y-6">
|
||||
{/* Header with last updated */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">Planner Dashboard</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchDashboardData}
|
||||
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>
|
||||
|
||||
{/* Top Status Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4 md:gap-6">
|
||||
<Link
|
||||
to="/planner/keywords"
|
||||
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-md transition-shadow cursor-pointer group relative overflow-hidden"
|
||||
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-lg transition-all cursor-pointer group relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-brand-500"></div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Keywords Ready</p>
|
||||
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
|
||||
{stats.keywords.toLocaleString()}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<h4 className="font-bold text-gray-800 text-title-sm dark:text-white/90">
|
||||
{stats.keywords.total.toLocaleString()}
|
||||
</h4>
|
||||
{trends.keywords !== 0 && (
|
||||
<div className={`flex items-center gap-1 text-xs ${trends.keywords > 0 ? 'text-success-500' : 'text-error-500'}`}>
|
||||
{trends.keywords > 0 ? <ArrowUpIcon className="size-3" /> : <ArrowDownIcon className="size-3" />}
|
||||
<span>{Math.abs(trends.keywords)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Research, analyze, and manage keywords strategy
|
||||
{stats.keywords.mapped} mapped • {stats.keywords.unmapped} unmapped
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-blue-50 rounded-xl dark:bg-blue-500/10 group-hover:bg-blue-100 dark:group-hover:bg-blue-500/20 transition-colors">
|
||||
@@ -78,17 +476,25 @@ export default function PlannerDashboard() {
|
||||
|
||||
<Link
|
||||
to="/planner/clusters"
|
||||
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-md transition-shadow cursor-pointer group relative overflow-hidden"
|
||||
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-lg transition-all cursor-pointer group relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-success-500"></div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Clusters Built</p>
|
||||
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
|
||||
{stats.clusters.toLocaleString()}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<h4 className="font-bold text-gray-800 text-title-sm dark:text-white/90">
|
||||
{stats.clusters.total.toLocaleString()}
|
||||
</h4>
|
||||
{trends.clusters !== 0 && (
|
||||
<div className={`flex items-center gap-1 text-xs ${trends.clusters > 0 ? 'text-success-500' : 'text-error-500'}`}>
|
||||
{trends.clusters > 0 ? <ArrowUpIcon className="size-3" /> : <ArrowDownIcon className="size-3" />}
|
||||
<span>{Math.abs(trends.clusters)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Organize keywords into strategic topical clusters
|
||||
{stats.clusters.totalVolume.toLocaleString()} total volume • {stats.clusters.avgKeywords} avg keywords
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-green-50 rounded-xl dark:bg-green-500/10 group-hover:bg-green-100 dark:group-hover:bg-green-500/20 transition-colors">
|
||||
@@ -99,17 +505,25 @@ export default function PlannerDashboard() {
|
||||
|
||||
<Link
|
||||
to="/planner/ideas"
|
||||
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-md transition-shadow cursor-pointer group relative overflow-hidden"
|
||||
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-lg transition-all cursor-pointer group relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-warning-500"></div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Ideas Generated</p>
|
||||
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
|
||||
{stats.ideas.toLocaleString()}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<h4 className="font-bold text-gray-800 text-title-sm dark:text-white/90">
|
||||
{stats.ideas.total.toLocaleString()}
|
||||
</h4>
|
||||
{trends.ideas !== 0 && (
|
||||
<div className={`flex items-center gap-1 text-xs ${trends.ideas > 0 ? 'text-success-500' : 'text-error-500'}`}>
|
||||
{trends.ideas > 0 ? <ArrowUpIcon className="size-3" /> : <ArrowDownIcon className="size-3" />}
|
||||
<span>{Math.abs(trends.ideas)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Generate creative content ideas based on semantic strategy
|
||||
{stats.ideas.queued} queued • {stats.ideas.notQueued} pending
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-amber-50 rounded-xl dark:bg-amber-500/10 group-hover:bg-amber-100 dark:group-hover:bg-amber-500/20 transition-colors">
|
||||
@@ -120,17 +534,17 @@ export default function PlannerDashboard() {
|
||||
|
||||
<Link
|
||||
to="/planner/keywords"
|
||||
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-md transition-shadow cursor-pointer group relative overflow-hidden"
|
||||
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-lg transition-all cursor-pointer group relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-purple-500"></div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Mapped Keywords</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Mapping Progress</p>
|
||||
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
|
||||
{stats.mappedKeywords.toLocaleString()}
|
||||
{keywordMappingPct}%
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Keywords successfully mapped to content pages
|
||||
{stats.keywords.mapped} of {stats.keywords.total} keywords mapped
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-purple-50 rounded-xl dark:bg-purple-500/10 group-hover:bg-purple-100 dark:group-hover:bg-purple-500/20 transition-colors">
|
||||
@@ -147,15 +561,21 @@ export default function PlannerDashboard() {
|
||||
<Link
|
||||
key={step.number}
|
||||
to={step.path}
|
||||
className="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900/50 hover:border-brand-300 hover:bg-brand-50 dark:hover:bg-brand-500/10 transition-colors"
|
||||
className="rounded-xl border border-gray-200 bg-gradient-to-br from-gray-50 to-white p-4 dark:from-gray-900/50 dark:to-gray-800/50 dark:border-gray-800 hover:border-brand-300 hover:bg-gradient-to-br hover:from-brand-50 hover:to-white dark:hover:from-brand-500/10 dark:hover:to-gray-800/50 transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 bg-white border-2 border-gray-300 rounded-full text-sm font-semibold text-gray-600 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
|
||||
{step.number}
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-full text-sm font-bold ${
|
||||
step.status === "completed"
|
||||
? "bg-success-500 text-white"
|
||||
: step.status === "in_progress"
|
||||
? "bg-warning-500 text-white"
|
||||
: "bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
|
||||
}`}>
|
||||
{step.status === "completed" ? <CheckCircleIcon className="size-5" /> : step.number}
|
||||
</div>
|
||||
<h4 className="font-medium text-gray-800 dark:text-white/90">{step.title}</h4>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{step.status === "completed" ? (
|
||||
<>
|
||||
@@ -171,9 +591,8 @@ export default function PlannerDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
{step.count !== null && (
|
||||
<p className="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
{step.count} {step.title.includes("Keywords") ? "keywords" : step.title.includes("Clusters") ? "clusters" : "ideas"}{" "}
|
||||
{step.status === "completed" ? "added" : ""}
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mb-2">
|
||||
{step.count} {step.title.includes("Keywords") ? "keywords" : step.title.includes("Clusters") ? "clusters" : step.title.includes("Ideas") ? "ideas" : "items"}
|
||||
</p>
|
||||
)}
|
||||
{step.status === "pending" && (
|
||||
@@ -184,9 +603,9 @@ export default function PlannerDashboard() {
|
||||
e.stopPropagation();
|
||||
navigate(step.path);
|
||||
}}
|
||||
className="mt-3 inline-block text-xs font-medium text-brand-500 hover:text-brand-600 cursor-pointer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-brand-500 hover:text-brand-600 cursor-pointer group-hover:translate-x-1 transition-transform"
|
||||
>
|
||||
Start Now →
|
||||
Start Now <ArrowRightIcon className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</Link>
|
||||
@@ -205,7 +624,7 @@ export default function PlannerDashboard() {
|
||||
</div>
|
||||
<ProgressBar value={keywordMappingPct} color="primary" size="md" />
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{stats.mappedKeywords} of {stats.keywords} keywords mapped
|
||||
{stats.keywords.mapped} of {stats.keywords.total} keywords mapped
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -216,7 +635,7 @@ export default function PlannerDashboard() {
|
||||
</div>
|
||||
<ProgressBar value={clustersIdeasPct} color="success" size="md" />
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{stats.clustersWithIdeas} of {stats.clusters} clusters have ideas
|
||||
{stats.clusters.withIdeas} of {stats.clusters.total} clusters have ideas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -227,79 +646,68 @@ export default function PlannerDashboard() {
|
||||
</div>
|
||||
<ProgressBar value={ideasQueuedPct} color="warning" size="md" />
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{stats.queuedIdeas} of {stats.ideas} ideas queued
|
||||
{stats.ideas.queued} of {stats.ideas.total} ideas queued
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{/* Top 5 Clusters */}
|
||||
<ComponentCard title="Top 5 Clusters by Volume" desc="Highest volume keyword clusters" className="lg:col-span-1">
|
||||
<div className="space-y-4">
|
||||
{topClusters.map((cluster, index) => {
|
||||
const maxVolume = topClusters[0].volume;
|
||||
const percentage = Math.round((cluster.volume / maxVolume) * 100);
|
||||
return (
|
||||
<div key={index}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-white/90">{cluster.name}</span>
|
||||
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">
|
||||
{cluster.volume.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={percentage}
|
||||
color={index % 2 === 0 ? "primary" : "success"}
|
||||
size="sm"
|
||||
<ComponentCard title="Top 5 Clusters by Volume" desc="Highest volume keyword clusters" className="lg:col-span-2">
|
||||
{topClustersChart ? (
|
||||
<Chart
|
||||
options={topClustersChart.options}
|
||||
series={topClustersChart.series}
|
||||
type="bar"
|
||||
height={300}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No clusters data available
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ComponentCard>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Keywords by Status */}
|
||||
{keywordsStatusChart && (
|
||||
<ComponentCard title="Keywords by Status" desc="Distribution of keywords across statuses">
|
||||
<Chart
|
||||
options={keywordsStatusChart.options}
|
||||
series={keywordsStatusChart.series}
|
||||
type="donut"
|
||||
height={300}
|
||||
/>
|
||||
</ComponentCard>
|
||||
)}
|
||||
|
||||
{/* Ideas by Status */}
|
||||
<ComponentCard title="Ideas by Status" desc="Content ideas workflow status" className="lg:col-span-1">
|
||||
<div className="space-y-4">
|
||||
{ideasByStatus.map((item, index) => {
|
||||
const total = ideasByStatus.reduce((sum, i) => sum + i.count, 0);
|
||||
const percentage = Math.round((item.count / total) * 100);
|
||||
return (
|
||||
<div key={index}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-white/90">{item.status}</span>
|
||||
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">{item.count}</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={percentage}
|
||||
color={
|
||||
item.color === "blue"
|
||||
? "primary"
|
||||
: item.color === "amber"
|
||||
? "warning"
|
||||
: "success"
|
||||
}
|
||||
size="sm"
|
||||
{ideasStatusChart && (
|
||||
<ComponentCard title="Ideas by Status" desc="Content ideas workflow status">
|
||||
<Chart
|
||||
options={ideasStatusChart.options}
|
||||
series={ideasStatusChart.series}
|
||||
type="bar"
|
||||
height={300}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Next Actions */}
|
||||
{nextActions.length > 0 && (
|
||||
<ComponentCard title="Next Actions" desc="Actionable items requiring attention">
|
||||
<div className="space-y-3">
|
||||
{nextActions.map((action, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800"
|
||||
className="flex items-center justify-between p-4 rounded-lg bg-gradient-to-r from-gray-50 to-white dark:from-gray-900/50 dark:to-gray-800/50 border border-gray-200 dark:border-gray-800 hover:border-brand-300 dark:hover:border-brand-500/30 transition-all group"
|
||||
>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">{action.text}</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{action.text}</span>
|
||||
<Link
|
||||
to={action.path}
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-brand-500 hover:text-brand-600"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-brand-500 hover:text-brand-600 group-hover:translate-x-1 transition-transform"
|
||||
>
|
||||
{action.action}
|
||||
<ArrowRightIcon className="size-4" />
|
||||
@@ -308,6 +716,7 @@ export default function PlannerDashboard() {
|
||||
))}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,42 +1,841 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
import ComponentCard from "../../components/common/ComponentCard";
|
||||
import { ProgressBar } from "../../components/ui/progress";
|
||||
import Chart from "react-apexcharts";
|
||||
import { ApexOptions } from "apexcharts";
|
||||
import {
|
||||
FileTextIcon,
|
||||
BoxIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
PencilIcon,
|
||||
BoltIcon,
|
||||
ArrowUpIcon,
|
||||
ArrowDownIcon,
|
||||
ArrowRightIcon
|
||||
} from "../../icons";
|
||||
import {
|
||||
fetchTasks,
|
||||
fetchContent,
|
||||
fetchImages
|
||||
} from "../../services/api";
|
||||
import { useSiteStore } from "../../store/siteStore";
|
||||
import { useSectorStore } from "../../store/sectorStore";
|
||||
|
||||
interface WriterStats {
|
||||
tasks: {
|
||||
total: number;
|
||||
byStatus: Record<string, number>;
|
||||
pending: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
avgWordCount: number;
|
||||
totalWordCount: number;
|
||||
};
|
||||
content: {
|
||||
total: number;
|
||||
drafts: number;
|
||||
review: number;
|
||||
published: number;
|
||||
totalWordCount: number;
|
||||
avgWordCount: number;
|
||||
byContentType: Record<string, number>;
|
||||
};
|
||||
images: {
|
||||
total: number;
|
||||
generated: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
byType: Record<string, number>;
|
||||
};
|
||||
workflow: {
|
||||
tasksCreated: boolean;
|
||||
contentGenerated: boolean;
|
||||
imagesGenerated: boolean;
|
||||
readyToPublish: boolean;
|
||||
};
|
||||
productivity: {
|
||||
contentThisWeek: number;
|
||||
contentThisMonth: number;
|
||||
avgGenerationTime: number;
|
||||
publishRate: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function WriterDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { activeSite } = useSiteStore();
|
||||
const { activeSector } = useSectorStore();
|
||||
|
||||
const [stats, setStats] = useState<WriterStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
|
||||
const [trends, setTrends] = useState<{
|
||||
tasks: number;
|
||||
content: number;
|
||||
images: number;
|
||||
}>({ tasks: 0, content: 0, images: 0 });
|
||||
|
||||
// Fetch real data
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch all data in parallel
|
||||
const [tasksRes, contentRes, imagesRes] = await Promise.all([
|
||||
fetchTasks({ page_size: 1000, sector_id: activeSector?.id }),
|
||||
fetchContent({ page_size: 1000, sector_id: activeSector?.id }),
|
||||
fetchImages({ page_size: 1000, sector_id: activeSector?.id })
|
||||
]);
|
||||
|
||||
// Process tasks
|
||||
const tasks = tasksRes.results || [];
|
||||
const tasksByStatus: Record<string, number> = {};
|
||||
let totalWordCount = 0;
|
||||
let wordCountCount = 0;
|
||||
|
||||
tasks.forEach(t => {
|
||||
tasksByStatus[t.status || 'draft'] = (tasksByStatus[t.status || 'draft'] || 0) + 1;
|
||||
if (t.word_count) {
|
||||
totalWordCount += t.word_count;
|
||||
wordCountCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const pendingTasks = tasks.filter(t => t.status === 'draft' || t.status === 'pending').length;
|
||||
const inProgressTasks = tasks.filter(t => t.status === 'in_progress' || t.status === 'review').length;
|
||||
const completedTasks = tasks.filter(t => t.status === 'completed' || t.status === 'published').length;
|
||||
const avgWordCount = wordCountCount > 0 ? Math.round(totalWordCount / wordCountCount) : 0;
|
||||
|
||||
// Process content
|
||||
const content = contentRes.results || [];
|
||||
const contentByStatus: Record<string, number> = {};
|
||||
const contentByType: Record<string, number> = {};
|
||||
let contentTotalWords = 0;
|
||||
let contentWordCount = 0;
|
||||
|
||||
content.forEach(c => {
|
||||
contentByStatus[c.status || 'draft'] = (contentByStatus[c.status || 'draft'] || 0) + 1;
|
||||
if (c.word_count) {
|
||||
contentTotalWords += c.word_count;
|
||||
contentWordCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const drafts = content.filter(c => c.status === 'draft').length;
|
||||
const review = content.filter(c => c.status === 'review').length;
|
||||
const published = content.filter(c => c.status === 'published').length;
|
||||
const contentAvgWordCount = contentWordCount > 0 ? Math.round(contentTotalWords / contentWordCount) : 0;
|
||||
|
||||
// Process images
|
||||
const images = imagesRes.results || [];
|
||||
const imagesByStatus: Record<string, number> = {};
|
||||
const imagesByType: Record<string, number> = {};
|
||||
|
||||
images.forEach(img => {
|
||||
imagesByStatus[img.status || 'pending'] = (imagesByStatus[img.status || 'pending'] || 0) + 1;
|
||||
if (img.image_type) {
|
||||
imagesByType[img.image_type] = (imagesByType[img.image_type] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
const generatedImages = images.filter(img => img.status === 'generated' && img.image_url).length;
|
||||
const pendingImages = images.filter(img => (img.status === 'pending' || img.status === 'draft') || !img.image_url).length;
|
||||
const failedImages = images.filter(img => img.status === 'failed' || img.status === 'error').length;
|
||||
|
||||
// Calculate productivity metrics
|
||||
const now = new Date();
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const contentThisWeek = content.filter(c => {
|
||||
if (!c.generated_at) return false;
|
||||
const created = new Date(c.generated_at);
|
||||
return created >= weekAgo;
|
||||
}).length;
|
||||
|
||||
const contentThisMonth = content.filter(c => {
|
||||
if (!c.generated_at) return false;
|
||||
const created = new Date(c.generated_at);
|
||||
return created >= monthAgo;
|
||||
}).length;
|
||||
|
||||
const publishRate = content.length > 0 ? Math.round((published / content.length) * 100) : 0;
|
||||
|
||||
// Calculate trends
|
||||
if (stats) {
|
||||
setTrends({
|
||||
tasks: tasks.length - stats.tasks.total,
|
||||
content: content.length - stats.content.total,
|
||||
images: images.length - stats.images.total
|
||||
});
|
||||
}
|
||||
|
||||
setStats({
|
||||
tasks: {
|
||||
total: tasks.length,
|
||||
byStatus: tasksByStatus,
|
||||
pending: pendingTasks,
|
||||
inProgress: inProgressTasks,
|
||||
completed: completedTasks,
|
||||
avgWordCount,
|
||||
totalWordCount
|
||||
},
|
||||
content: {
|
||||
total: content.length,
|
||||
drafts,
|
||||
review,
|
||||
published,
|
||||
totalWordCount: contentTotalWords,
|
||||
avgWordCount: contentAvgWordCount,
|
||||
byContentType: contentByType
|
||||
},
|
||||
images: {
|
||||
total: images.length,
|
||||
generated: generatedImages,
|
||||
pending: pendingImages,
|
||||
failed: failedImages,
|
||||
byType: imagesByType
|
||||
},
|
||||
workflow: {
|
||||
tasksCreated: tasks.length > 0,
|
||||
contentGenerated: content.length > 0,
|
||||
imagesGenerated: generatedImages > 0,
|
||||
readyToPublish: published > 0
|
||||
},
|
||||
productivity: {
|
||||
contentThisWeek,
|
||||
contentThisMonth,
|
||||
avgGenerationTime: 0, // Would need task timestamps to calculate
|
||||
publishRate
|
||||
}
|
||||
});
|
||||
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial load and periodic refresh
|
||||
useEffect(() => {
|
||||
if (!activeSector?.id) return;
|
||||
|
||||
fetchDashboardData();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchDashboardData, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [activeSector?.id]);
|
||||
|
||||
// Chart data for tasks by status
|
||||
const tasksStatusChart = useMemo(() => {
|
||||
if (!stats) return null;
|
||||
|
||||
const options: ApexOptions = {
|
||||
chart: {
|
||||
type: 'donut',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
toolbar: { show: false }
|
||||
},
|
||||
labels: Object.keys(stats.tasks.byStatus),
|
||||
colors: ['#465FFF', '#F59E0B', '#10B981', '#EF4444', '#8B5CF6'],
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
fontFamily: 'Outfit'
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
formatter: (val: number) => `${Math.round(val)}%`
|
||||
},
|
||||
plotOptions: {
|
||||
pie: {
|
||||
donut: {
|
||||
size: '65%'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const series = Object.values(stats.tasks.byStatus);
|
||||
|
||||
return { options, series };
|
||||
}, [stats]);
|
||||
|
||||
// Chart data for content by status
|
||||
const contentStatusChart = useMemo(() => {
|
||||
if (!stats) return null;
|
||||
|
||||
const options: ApexOptions = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
toolbar: { show: false },
|
||||
height: 250
|
||||
},
|
||||
colors: ['#465FFF', '#F59E0B', '#10B981'],
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: false,
|
||||
columnWidth: '55%',
|
||||
borderRadius: 5
|
||||
}
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: true
|
||||
},
|
||||
xaxis: {
|
||||
categories: ['Drafts', 'In Review', 'Published'],
|
||||
labels: {
|
||||
style: {
|
||||
fontFamily: 'Outfit'
|
||||
}
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
fontFamily: 'Outfit'
|
||||
}
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
strokeDashArray: 4
|
||||
}
|
||||
};
|
||||
|
||||
const series = [{
|
||||
name: 'Content',
|
||||
data: [stats.content.drafts, stats.content.review, stats.content.published]
|
||||
}];
|
||||
|
||||
return { options, series };
|
||||
}, [stats]);
|
||||
|
||||
// Chart data for images by type
|
||||
const imagesTypeChart = useMemo(() => {
|
||||
if (!stats || Object.keys(stats.images.byType).length === 0) return null;
|
||||
|
||||
const options: ApexOptions = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
toolbar: { show: false },
|
||||
height: 250
|
||||
},
|
||||
colors: ['#10B981'],
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: true,
|
||||
borderRadius: 5
|
||||
}
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: true
|
||||
},
|
||||
xaxis: {
|
||||
categories: Object.keys(stats.images.byType),
|
||||
labels: {
|
||||
style: {
|
||||
fontFamily: 'Outfit',
|
||||
fontSize: '12px'
|
||||
}
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
fontFamily: 'Outfit'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const series = [{
|
||||
name: 'Images',
|
||||
data: Object.values(stats.images.byType)
|
||||
}];
|
||||
|
||||
return { options, series };
|
||||
}, [stats]);
|
||||
|
||||
// Productivity chart (content over time - simplified)
|
||||
const productivityChart = useMemo(() => {
|
||||
if (!stats) return null;
|
||||
|
||||
const options: ApexOptions = {
|
||||
chart: {
|
||||
type: 'area',
|
||||
fontFamily: 'Outfit, sans-serif',
|
||||
toolbar: { show: false },
|
||||
height: 200
|
||||
},
|
||||
colors: ['#465FFF'],
|
||||
stroke: {
|
||||
curve: 'smooth',
|
||||
width: 2
|
||||
},
|
||||
fill: {
|
||||
type: 'gradient',
|
||||
gradient: {
|
||||
opacityFrom: 0.6,
|
||||
opacityTo: 0.1
|
||||
}
|
||||
},
|
||||
xaxis: {
|
||||
categories: ['Week', 'Month'],
|
||||
labels: {
|
||||
style: {
|
||||
fontFamily: 'Outfit'
|
||||
}
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
fontFamily: 'Outfit'
|
||||
}
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
strokeDashArray: 4
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: true
|
||||
}
|
||||
};
|
||||
|
||||
const series = [{
|
||||
name: 'Content Created',
|
||||
data: [stats.productivity.contentThisWeek, stats.productivity.contentThisMonth]
|
||||
}];
|
||||
|
||||
return { options, series };
|
||||
}, [stats]);
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Writer Dashboard - IGNY8" description="Content creation overview" />
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-brand-500 border-t-transparent"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading dashboard data...</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Writer Dashboard - IGNY8" description="Content creation overview" />
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600 dark:text-gray-400">No data available. Please select a sector.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const workflowSteps = [
|
||||
{
|
||||
number: 1,
|
||||
title: "Create Tasks",
|
||||
status: stats.workflow.tasksCreated ? "completed" : "pending",
|
||||
count: stats.tasks.total,
|
||||
path: "/writer/tasks"
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "Generate Content",
|
||||
status: stats.workflow.contentGenerated ? "completed" : "pending",
|
||||
count: stats.content.total,
|
||||
path: "/writer/content"
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: "Generate Images",
|
||||
status: stats.workflow.imagesGenerated ? "completed" : "pending",
|
||||
count: stats.images.generated,
|
||||
path: "/writer/images"
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
title: "Publish",
|
||||
status: stats.workflow.readyToPublish ? "completed" : "pending",
|
||||
count: stats.content.published,
|
||||
path: "/writer/published"
|
||||
},
|
||||
];
|
||||
|
||||
const completionRate = stats.tasks.total > 0
|
||||
? Math.round((stats.tasks.completed / stats.tasks.total) * 100)
|
||||
: 0;
|
||||
|
||||
const nextActions = [
|
||||
...(stats.tasks.pending > 0 ? [{
|
||||
text: `${stats.tasks.pending} tasks pending content generation`,
|
||||
action: "Generate Content",
|
||||
path: "/writer/tasks"
|
||||
}] : []),
|
||||
...(stats.content.drafts > 0 ? [{
|
||||
text: `${stats.content.drafts} drafts ready for review`,
|
||||
action: "Review Content",
|
||||
path: "/writer/content"
|
||||
}] : []),
|
||||
...(stats.images.pending > 0 ? [{
|
||||
text: `${stats.images.pending} images pending generation`,
|
||||
action: "Generate Images",
|
||||
path: "/writer/images"
|
||||
}] : []),
|
||||
...(stats.content.review > 0 ? [{
|
||||
text: `${stats.content.review} content pieces ready to publish`,
|
||||
action: "Publish Content",
|
||||
path: "/writer/published"
|
||||
}] : [])
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Writer Dashboard - IGNY8" description="Content creation overview" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-6 mb-6">
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Tasks</span>
|
||||
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">-</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Queued tasks</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Drafts</span>
|
||||
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">-</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Draft content</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Published</span>
|
||||
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">-</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Published content</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ComponentCard title="Coming Soon" desc="Content creation overview">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Writer Dashboard - Coming Soon
|
||||
<div className="space-y-5 sm:space-y-6">
|
||||
{/* Header with last updated */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">Writer Dashboard</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Last updated: {lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
Overview of content tasks and workflow will be displayed here
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchDashboardData}
|
||||
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>
|
||||
|
||||
{/* Top Status Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4 md:gap-6">
|
||||
<Link
|
||||
to="/writer/tasks"
|
||||
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-lg transition-all cursor-pointer group relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-brand-500"></div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Total Tasks</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<h4 className="font-bold text-gray-800 text-title-sm dark:text-white/90">
|
||||
{stats.tasks.total.toLocaleString()}
|
||||
</h4>
|
||||
{trends.tasks !== 0 && (
|
||||
<div className={`flex items-center gap-1 text-xs ${trends.tasks > 0 ? 'text-success-500' : 'text-error-500'}`}>
|
||||
{trends.tasks > 0 ? <ArrowUpIcon className="size-3" /> : <ArrowDownIcon className="size-3" />}
|
||||
<span>{Math.abs(trends.tasks)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{stats.tasks.completed} completed • {stats.tasks.pending} pending
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-blue-50 rounded-xl dark:bg-blue-500/10 group-hover:bg-blue-100 dark:group-hover:bg-blue-500/20 transition-colors">
|
||||
<FileTextIcon className="text-brand-500 size-6" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/writer/content"
|
||||
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-lg transition-all cursor-pointer group relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-success-500"></div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Content Pieces</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<h4 className="font-bold text-gray-800 text-title-sm dark:text-white/90">
|
||||
{stats.content.total.toLocaleString()}
|
||||
</h4>
|
||||
{trends.content !== 0 && (
|
||||
<div className={`flex items-center gap-1 text-xs ${trends.content > 0 ? 'text-success-500' : 'text-error-500'}`}>
|
||||
{trends.content > 0 ? <ArrowUpIcon className="size-3" /> : <ArrowDownIcon className="size-3" />}
|
||||
<span>{Math.abs(trends.content)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{stats.content.published} published • {stats.content.drafts} drafts
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-green-50 rounded-xl dark:bg-green-500/10 group-hover:bg-green-100 dark:group-hover:bg-green-500/20 transition-colors">
|
||||
<PencilIcon className="text-success-500 size-6" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/writer/images"
|
||||
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-lg transition-all cursor-pointer group relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-warning-500"></div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Images Generated</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<h4 className="font-bold text-gray-800 text-title-sm dark:text-white/90">
|
||||
{stats.images.generated.toLocaleString()}
|
||||
</h4>
|
||||
{trends.images !== 0 && (
|
||||
<div className={`flex items-center gap-1 text-xs ${trends.images > 0 ? 'text-success-500' : 'text-error-500'}`}>
|
||||
{trends.images > 0 ? <ArrowUpIcon className="size-3" /> : <ArrowDownIcon className="size-3" />}
|
||||
<span>{Math.abs(trends.images)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{stats.images.total} total • {stats.images.pending} pending
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-amber-50 rounded-xl dark:bg-amber-500/10 group-hover:bg-amber-100 dark:group-hover:bg-amber-500/20 transition-colors">
|
||||
<BoxIcon className="text-warning-500 size-6" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/writer/published"
|
||||
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-lg transition-all cursor-pointer group relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-purple-500"></div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Publish Rate</p>
|
||||
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
|
||||
{stats.productivity.publishRate}%
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{stats.content.published} of {stats.content.total} published
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-purple-50 rounded-xl dark:bg-purple-500/10 group-hover:bg-purple-100 dark:group-hover:bg-purple-500/20 transition-colors">
|
||||
<BoltIcon className="text-purple-500 size-6" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Writer Workflow Steps */}
|
||||
<ComponentCard title="Writer Workflow Steps" desc="Track your content creation progress">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{workflowSteps.map((step) => (
|
||||
<Link
|
||||
key={step.number}
|
||||
to={step.path}
|
||||
className="rounded-xl border border-gray-200 bg-gradient-to-br from-gray-50 to-white p-4 dark:from-gray-900/50 dark:to-gray-800/50 dark:border-gray-800 hover:border-brand-300 hover:bg-gradient-to-br hover:from-brand-50 hover:to-white dark:hover:from-brand-500/10 dark:hover:to-gray-800/50 transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-full text-sm font-bold ${
|
||||
step.status === "completed"
|
||||
? "bg-success-500 text-white"
|
||||
: step.status === "in_progress"
|
||||
? "bg-warning-500 text-white"
|
||||
: "bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
|
||||
}`}>
|
||||
{step.status === "completed" ? <CheckCircleIcon className="size-5" /> : step.number}
|
||||
</div>
|
||||
<h4 className="font-medium text-gray-800 dark:text-white/90">{step.title}</h4>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{step.status === "completed" ? (
|
||||
<>
|
||||
<CheckCircleIcon className="size-4 text-success-500" />
|
||||
<span className="text-gray-600 dark:text-gray-300 font-medium">Completed</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ClockIcon className="size-4 text-amber-500" />
|
||||
<span className="text-gray-600 dark:text-gray-300 font-medium">Pending</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{step.count !== null && (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mb-2">
|
||||
{step.count} {step.title.includes("Tasks") ? "tasks" : step.title.includes("Content") ? "pieces" : step.title.includes("Images") ? "images" : "items"}
|
||||
</p>
|
||||
)}
|
||||
{step.status === "pending" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigate(step.path);
|
||||
}}
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-brand-500 hover:text-brand-600 cursor-pointer group-hover:translate-x-1 transition-transform"
|
||||
>
|
||||
Start Now <ArrowRightIcon className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Productivity Metrics */}
|
||||
<ComponentCard title="Productivity Metrics" desc="Content creation performance" className="lg:col-span-1">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Task Completion</span>
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-white/90">{completionRate}%</span>
|
||||
</div>
|
||||
<ProgressBar value={completionRate} color="primary" size="md" />
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{stats.tasks.completed} of {stats.tasks.total} tasks completed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Publish Rate</span>
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-white/90">{stats.productivity.publishRate}%</span>
|
||||
</div>
|
||||
<ProgressBar value={stats.productivity.publishRate} color="success" size="md" />
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{stats.content.published} of {stats.content.total} content published
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-800">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">This Week</p>
|
||||
<p className="text-lg font-bold text-gray-800 dark:text-white/90">{stats.productivity.contentThisWeek}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">This Month</p>
|
||||
<p className="text-lg font-bold text-gray-800 dark:text-white/90">{stats.productivity.contentThisMonth}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-800">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Avg Word Count</p>
|
||||
<p className="text-lg font-bold text-gray-800 dark:text-white/90">
|
||||
{stats.content.avgWordCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{stats.content.totalWordCount.toLocaleString()} total words
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{/* Content Status Chart */}
|
||||
{contentStatusChart && (
|
||||
<ComponentCard title="Content by Status" desc="Distribution across workflow stages" className="lg:col-span-2">
|
||||
<Chart
|
||||
options={contentStatusChart.options}
|
||||
series={contentStatusChart.series}
|
||||
type="bar"
|
||||
height={300}
|
||||
/>
|
||||
</ComponentCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Tasks by Status */}
|
||||
{tasksStatusChart && (
|
||||
<ComponentCard title="Tasks by Status" desc="Task distribution across statuses">
|
||||
<Chart
|
||||
options={tasksStatusChart.options}
|
||||
series={tasksStatusChart.series}
|
||||
type="donut"
|
||||
height={300}
|
||||
/>
|
||||
</ComponentCard>
|
||||
)}
|
||||
|
||||
{/* Images by Type */}
|
||||
{imagesTypeChart ? (
|
||||
<ComponentCard title="Images by Type" desc="Image generation breakdown">
|
||||
<Chart
|
||||
options={imagesTypeChart.options}
|
||||
series={imagesTypeChart.series}
|
||||
type="bar"
|
||||
height={300}
|
||||
/>
|
||||
</ComponentCard>
|
||||
) : (
|
||||
<ComponentCard title="Images Overview" desc="Image generation status">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-gray-50 dark:bg-gray-900/50">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Generated</span>
|
||||
<span className="text-lg font-bold text-success-500">{stats.images.generated}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-gray-50 dark:bg-gray-900/50">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Pending</span>
|
||||
<span className="text-lg font-bold text-warning-500">{stats.images.pending}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-gray-50 dark:bg-gray-900/50">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Failed</span>
|
||||
<span className="text-lg font-bold text-error-500">{stats.images.failed}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Productivity Chart */}
|
||||
{productivityChart && (
|
||||
<ComponentCard title="Content Creation Trend" desc="Content created this week and month">
|
||||
<Chart
|
||||
options={productivityChart.options}
|
||||
series={productivityChart.series}
|
||||
type="area"
|
||||
height={200}
|
||||
/>
|
||||
</ComponentCard>
|
||||
)}
|
||||
|
||||
{/* Next Actions */}
|
||||
{nextActions.length > 0 && (
|
||||
<ComponentCard title="Next Actions" desc="Actionable items requiring attention">
|
||||
<div className="space-y-3">
|
||||
{nextActions.map((action, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-4 rounded-lg bg-gradient-to-r from-gray-50 to-white dark:from-gray-900/50 dark:to-gray-800/50 border border-gray-200 dark:border-gray-800 hover:border-brand-300 dark:hover:border-brand-500/30 transition-all group"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{action.text}</span>
|
||||
<Link
|
||||
to={action.path}
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-brand-500 hover:text-brand-600 group-hover:translate-x-1 transition-transform"
|
||||
>
|
||||
{action.action}
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user