IMPROVEMENTS - Automation Page: - Page title: 'AI Automation Pipeline' → 'Automate Everything' - Page description updated to be more conversational - Status badge: 'Ready to Run' → 'Ready to Go!' with expanded explanation - Schedule display: More conversational format (e.g., 'Runs every day at 2:00 AM | Last run: Never | Uses about 5 credits per run') - Pipeline stage names completely rewritten with descriptions: - 'Keywords → Clusters' → 'ORGANIZE KEYWORDS' (Group related search terms into topic clusters) - 'Clusters → Ideas' → 'CREATE ARTICLE IDEAS' (Generate article titles and outlines for each cluster) - 'Ideas → Tasks' → 'PREPARE WRITING JOBS' (Convert ideas into tasks for the AI writer) - 'Tasks → Content' → 'WRITE ARTICLES' (AI generates full, complete articles) - 'Content → Image Prompts' → 'CREATE IMAGE DESCRIPTIONS' (Generate descriptions for AI to create images) - 'Image Prompts → Images' → 'GENERATE IMAGES' (AI creates custom images for your articles) - 'Manual Review Gate' → 'REVIEW & PUBLISH ⚠️' (Review articles before they go live) - Button updates: - 'Configure' → '⚙️ Adjust Settings' (with tooltip) - 'Run Now' now has tooltip explaining it starts immediately - Pipeline statistics section: - Added header: 'Here's what's in your automation pipeline:' - Metric labels updated with context: - 'Keywords' → 'Search Terms (waiting to organize)' - 'Clusters' → 'Topic Groups (ready for ideas)' - 'Ideas' → 'Article Ideas (waiting to write)' - 'Content' → 'Articles (in various stages)' - 'Images' → 'Images (created and waiting)' NO CODE CHANGES: Only visible user-facing text updates
1006 lines
50 KiB
TypeScript
1006 lines
50 KiB
TypeScript
/**
|
||
* Automation Dashboard Page
|
||
* Main page for managing AI automation pipeline
|
||
*/
|
||
import React, { useState, useEffect } from 'react';
|
||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||
import { useSiteStore } from '../../store/siteStore';
|
||
import { automationService, AutomationRun, AutomationConfig, PipelineStage } from '../../services/automationService';
|
||
import {
|
||
fetchKeywords,
|
||
fetchClusters,
|
||
fetchContentIdeas,
|
||
fetchTasks,
|
||
fetchContent,
|
||
fetchContentImages,
|
||
} from '../../services/api';
|
||
import ActivityLog from '../../components/Automation/ActivityLog';
|
||
import ConfigModal from '../../components/Automation/ConfigModal';
|
||
import RunHistory from '../../components/Automation/RunHistory';
|
||
import CurrentProcessingCard from '../../components/Automation/CurrentProcessingCard';
|
||
import PageMeta from '../../components/common/PageMeta';
|
||
import ComponentCard from '../../components/common/ComponentCard';
|
||
import EnhancedMetricCard from '../../components/dashboard/EnhancedMetricCard';
|
||
import DebugSiteSelector from '../../components/common/DebugSiteSelector';
|
||
import Button from '../../components/ui/button/Button';
|
||
import {
|
||
BoltIcon,
|
||
ListIcon,
|
||
GroupIcon,
|
||
FileTextIcon,
|
||
PencilIcon,
|
||
FileIcon,
|
||
CheckCircleIcon,
|
||
ClockIcon,
|
||
PaperPlaneIcon,
|
||
ArrowRightIcon
|
||
} from '../../icons';
|
||
|
||
const STAGE_CONFIG = [
|
||
{ icon: ListIcon, color: 'from-blue-500 to-blue-600', textColor: 'text-blue-600', hoverColor: 'hover:border-blue-500', name: 'ORGANIZE KEYWORDS', desc: 'Group related search terms into topic clusters' },
|
||
{ icon: GroupIcon, color: 'from-purple-500 to-purple-600', textColor: 'text-purple-600', hoverColor: 'hover:border-purple-500', name: 'CREATE ARTICLE IDEAS', desc: 'Generate article titles and outlines for each cluster' },
|
||
{ icon: CheckCircleIcon, color: 'from-indigo-500 to-indigo-600', textColor: 'text-indigo-600', hoverColor: 'hover:border-indigo-500', name: 'PREPARE WRITING JOBS', desc: 'Convert ideas into tasks for the AI writer' },
|
||
{ icon: PencilIcon, color: 'from-green-500 to-green-600', textColor: 'text-green-600', hoverColor: 'hover:border-green-500', name: 'WRITE ARTICLES', desc: 'AI generates full, complete articles' },
|
||
{ icon: FileIcon, color: 'from-amber-500 to-amber-600', textColor: 'text-amber-600', hoverColor: 'hover:border-amber-500', name: 'CREATE IMAGE DESCRIPTIONS', desc: 'Generate descriptions for AI to create images' },
|
||
{ icon: FileTextIcon, color: 'from-pink-500 to-pink-600', textColor: 'text-pink-600', hoverColor: 'hover:border-pink-500', name: 'GENERATE IMAGES', desc: 'AI creates custom images for your articles' },
|
||
{ icon: PaperPlaneIcon, color: 'from-teal-500 to-teal-600', textColor: 'text-teal-600', hoverColor: 'hover:border-teal-500', name: 'REVIEW & PUBLISH ⚠️', desc: 'Review articles before they go live (manual approval needed)' },
|
||
];
|
||
|
||
const AutomationPage: React.FC = () => {
|
||
const { activeSite } = useSiteStore();
|
||
const toast = useToast();
|
||
const [config, setConfig] = useState<AutomationConfig | null>(null);
|
||
const [currentRun, setCurrentRun] = useState<AutomationRun | null>(null);
|
||
const [pipelineOverview, setPipelineOverview] = useState<PipelineStage[]>([]);
|
||
const [metrics, setMetrics] = useState<any>(null);
|
||
const [showConfigModal, setShowConfigModal] = useState(false);
|
||
const [showProcessingCard, setShowProcessingCard] = useState<boolean>(true);
|
||
const [loading, setLoading] = useState(true);
|
||
const [estimate, setEstimate] = useState<{ estimated_credits: number; current_balance: number; sufficient: boolean } | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!activeSite) return;
|
||
loadData();
|
||
|
||
const interval = setInterval(() => {
|
||
if (currentRun && (currentRun.status === 'running' || currentRun.status === 'paused')) {
|
||
// When automation is running, refresh both run and metrics
|
||
loadCurrentRun();
|
||
loadPipelineOverview();
|
||
loadMetrics(); // Add metrics refresh during run
|
||
} else {
|
||
loadPipelineOverview();
|
||
}
|
||
}, 5000);
|
||
|
||
return () => clearInterval(interval);
|
||
}, [activeSite, currentRun?.status]);
|
||
|
||
const loadData = async () => {
|
||
if (!activeSite) return;
|
||
try {
|
||
setLoading(true);
|
||
const [configData, runData, estimateData, pipelineData] = await Promise.all([
|
||
automationService.getConfig(activeSite.id),
|
||
automationService.getCurrentRun(activeSite.id),
|
||
automationService.estimate(activeSite.id),
|
||
automationService.getPipelineOverview(activeSite.id),
|
||
]);
|
||
// Also fetch the same low-level metrics used by Home page so we can show authoritative totals
|
||
try {
|
||
const siteId = activeSite.id;
|
||
const [
|
||
keywordsTotalRes,
|
||
keywordsNewRes,
|
||
keywordsMappedRes,
|
||
clustersTotalRes,
|
||
clustersNewRes,
|
||
clustersMappedRes,
|
||
ideasTotalRes,
|
||
ideasNewRes,
|
||
ideasQueuedRes,
|
||
ideasCompletedRes,
|
||
tasksTotalRes,
|
||
contentTotalRes,
|
||
contentDraftRes,
|
||
contentReviewRes,
|
||
contentPublishedRes,
|
||
imagesTotalRes,
|
||
imagesPendingRes,
|
||
] = await Promise.all([
|
||
fetchKeywords({ page_size: 1, site_id: siteId }),
|
||
fetchKeywords({ page_size: 1, site_id: siteId, status: 'new' }),
|
||
fetchKeywords({ page_size: 1, site_id: siteId, status: 'mapped' }),
|
||
fetchClusters({ page_size: 1, site_id: siteId }),
|
||
fetchClusters({ page_size: 1, site_id: siteId, status: 'new' }),
|
||
fetchClusters({ page_size: 1, site_id: siteId, status: 'mapped' }),
|
||
fetchContentIdeas({ page_size: 1, site_id: siteId }),
|
||
fetchContentIdeas({ page_size: 1, site_id: siteId, status: 'new' }),
|
||
fetchContentIdeas({ page_size: 1, site_id: siteId, status: 'queued' }),
|
||
fetchContentIdeas({ page_size: 1, site_id: siteId, status: 'completed' }),
|
||
fetchTasks({ page_size: 1, site_id: siteId }),
|
||
fetchContent({ page_size: 1, site_id: siteId }),
|
||
fetchContent({ page_size: 1, site_id: siteId, status: 'draft' }),
|
||
fetchContent({ page_size: 1, site_id: siteId, status: 'review' }),
|
||
fetchContent({ page_size: 1, site_id: siteId, status: 'published' }),
|
||
fetchContentImages({ page_size: 1, site_id: siteId }),
|
||
fetchContentImages({ page_size: 1, site_id: siteId, status: 'pending' }),
|
||
]);
|
||
|
||
setMetrics({
|
||
keywords: { total: keywordsTotalRes.count || 0, new: keywordsNewRes.count || 0, mapped: keywordsMappedRes.count || 0 },
|
||
clusters: { total: clustersTotalRes.count || 0, new: clustersNewRes.count || 0, mapped: clustersMappedRes.count || 0 },
|
||
ideas: { total: ideasTotalRes.count || 0, new: ideasNewRes.count || 0, queued: ideasQueuedRes.count || 0, completed: ideasCompletedRes.count || 0 },
|
||
tasks: { total: tasksTotalRes.count || 0 },
|
||
content: {
|
||
total: contentTotalRes.count || 0,
|
||
draft: contentDraftRes.count || 0,
|
||
review: contentReviewRes.count || 0,
|
||
published: contentPublishedRes.count || 0,
|
||
},
|
||
images: { total: imagesTotalRes.count || 0, pending: imagesPendingRes.count || 0 },
|
||
});
|
||
} catch (e) {
|
||
// Non-fatal: keep metrics null if any of the low-level calls fail
|
||
console.warn('Failed to fetch low-level metrics for automation page', e);
|
||
}
|
||
setConfig(configData);
|
||
setCurrentRun(runData.run);
|
||
setEstimate(estimateData);
|
||
setPipelineOverview(pipelineData.stages);
|
||
// show processing card when there's a current run
|
||
if (runData.run) {
|
||
setShowProcessingCard(true);
|
||
}
|
||
} catch (error: any) {
|
||
toast.error('Failed to load automation data');
|
||
console.error(error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const loadCurrentRun = async () => {
|
||
if (!activeSite) return;
|
||
try {
|
||
const data = await automationService.getCurrentRun(activeSite.id);
|
||
setCurrentRun(data.run);
|
||
// ensure processing card is visible when a run exists
|
||
if (data.run) setShowProcessingCard(true);
|
||
} catch (error) {
|
||
console.error('Failed to poll current run', error);
|
||
}
|
||
};
|
||
|
||
const loadPipelineOverview = async () => {
|
||
if (!activeSite) return;
|
||
try {
|
||
const data = await automationService.getPipelineOverview(activeSite.id);
|
||
setPipelineOverview(data.stages);
|
||
} catch (error) {
|
||
console.error('Failed to poll pipeline overview', error);
|
||
}
|
||
};
|
||
|
||
const loadMetrics = async () => {
|
||
if (!activeSite) return;
|
||
try {
|
||
const siteId = activeSite.id;
|
||
const [
|
||
keywordsTotalRes,
|
||
keywordsNewRes,
|
||
keywordsMappedRes,
|
||
clustersTotalRes,
|
||
clustersNewRes,
|
||
clustersMappedRes,
|
||
ideasTotalRes,
|
||
ideasNewRes,
|
||
ideasQueuedRes,
|
||
ideasCompletedRes,
|
||
tasksTotalRes,
|
||
contentTotalRes,
|
||
contentDraftRes,
|
||
contentReviewRes,
|
||
contentPublishedRes,
|
||
imagesTotalRes,
|
||
imagesPendingRes,
|
||
] = await Promise.all([
|
||
fetchKeywords({ page_size: 1, site_id: siteId }),
|
||
fetchKeywords({ page_size: 1, site_id: siteId, status: 'new' }),
|
||
fetchKeywords({ page_size: 1, site_id: siteId, status: 'mapped' }),
|
||
fetchClusters({ page_size: 1, site_id: siteId }),
|
||
fetchClusters({ page_size: 1, site_id: siteId, status: 'new' }),
|
||
fetchClusters({ page_size: 1, site_id: siteId, status: 'mapped' }),
|
||
fetchContentIdeas({ page_size: 1, site_id: siteId }),
|
||
fetchContentIdeas({ page_size: 1, site_id: siteId, status: 'new' }),
|
||
fetchContentIdeas({ page_size: 1, site_id: siteId, status: 'queued' }),
|
||
fetchContentIdeas({ page_size: 1, site_id: siteId, status: 'completed' }),
|
||
fetchTasks({ page_size: 1, site_id: siteId }),
|
||
fetchContent({ page_size: 1, site_id: siteId }),
|
||
fetchContent({ page_size: 1, site_id: siteId, status: 'draft' }),
|
||
fetchContent({ page_size: 1, site_id: siteId, status: 'review' }),
|
||
fetchContent({ page_size: 1, site_id: siteId, status: 'published' }),
|
||
fetchContentImages({ page_size: 1, site_id: siteId }),
|
||
fetchContentImages({ page_size: 1, site_id: siteId, status: 'pending' }),
|
||
]);
|
||
|
||
setMetrics({
|
||
keywords: { total: keywordsTotalRes.count || 0, new: keywordsNewRes.count || 0, mapped: keywordsMappedRes.count || 0 },
|
||
clusters: { total: clustersTotalRes.count || 0, new: clustersNewRes.count || 0, mapped: clustersMappedRes.count || 0 },
|
||
ideas: { total: ideasTotalRes.count || 0, new: ideasNewRes.count || 0, queued: ideasQueuedRes.count || 0, completed: ideasCompletedRes.count || 0 },
|
||
tasks: { total: tasksTotalRes.count || 0 },
|
||
content: {
|
||
total: contentTotalRes.count || 0,
|
||
draft: contentDraftRes.count || 0,
|
||
review: contentReviewRes.count || 0,
|
||
published: contentPublishedRes.count || 0,
|
||
},
|
||
images: { total: imagesTotalRes.count || 0, pending: imagesPendingRes.count || 0 },
|
||
});
|
||
} catch (e) {
|
||
console.warn('Failed to fetch metrics', e);
|
||
}
|
||
};
|
||
|
||
const handleRunNow = async () => {
|
||
if (!activeSite) return;
|
||
if (estimate && !estimate.sufficient) {
|
||
toast.error(`Insufficient credits. Need ~${estimate.estimated_credits}, you have ${estimate.current_balance}`);
|
||
return;
|
||
}
|
||
try {
|
||
const result = await automationService.runNow(activeSite.id);
|
||
toast.success('Automation started successfully');
|
||
loadCurrentRun();
|
||
} catch (error: any) {
|
||
toast.error(error.response?.data?.error || 'Failed to start automation');
|
||
}
|
||
};
|
||
|
||
const handlePause = async () => {
|
||
if (!currentRun || !activeSite) return;
|
||
try {
|
||
await automationService.pause(activeSite.id, currentRun.run_id);
|
||
toast.success('Automation paused');
|
||
// refresh run and pipeline/metrics
|
||
await loadCurrentRun();
|
||
await loadPipelineOverview();
|
||
await loadMetrics();
|
||
} catch (error) {
|
||
toast.error('Failed to pause automation');
|
||
}
|
||
};
|
||
|
||
const handleResume = async () => {
|
||
if (!currentRun || !activeSite) return;
|
||
try {
|
||
await automationService.resume(activeSite.id, currentRun.run_id);
|
||
toast.success('Automation resumed');
|
||
await loadCurrentRun();
|
||
await loadPipelineOverview();
|
||
await loadMetrics();
|
||
} catch (error) {
|
||
toast.error('Failed to resume automation');
|
||
}
|
||
};
|
||
|
||
const handleSaveConfig = async (newConfig: Partial<AutomationConfig>) => {
|
||
if (!activeSite) return;
|
||
try {
|
||
await automationService.updateConfig(activeSite.id, newConfig);
|
||
toast.success('Configuration saved');
|
||
setShowConfigModal(false);
|
||
// Optimistically update config locally and refresh data
|
||
setConfig((prev) => ({ ...(prev as AutomationConfig), ...newConfig } as AutomationConfig));
|
||
await loadPipelineOverview();
|
||
await loadMetrics();
|
||
await loadCurrentRun();
|
||
} catch (error) {
|
||
console.error('Failed to save config:', error);
|
||
toast.error('Failed to save configuration');
|
||
}
|
||
};
|
||
|
||
const handlePublishAllWithoutReview = async () => {
|
||
if (!activeSite) return;
|
||
if (!confirm('Publish all content without review? This cannot be undone.')) return;
|
||
try {
|
||
await automationService.publishWithoutReview(activeSite.id);
|
||
toast.success('Publish job started (without review)');
|
||
// refresh metrics and pipeline
|
||
loadData();
|
||
loadPipelineOverview();
|
||
} catch (error: any) {
|
||
console.error('Failed to publish without review', error);
|
||
toast.error(error?.response?.data?.error || 'Failed to publish without review');
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-[60vh]">
|
||
<div className="text-lg text-gray-600 dark:text-gray-400">Loading automation...</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!activeSite) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-[60vh]">
|
||
<div className="text-lg text-gray-600 dark:text-gray-400">Please select a site to view automation</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const totalPending = pipelineOverview.reduce((sum, stage) => sum + stage.pending, 0);
|
||
|
||
const getStageResult = (stageNumber: number) => {
|
||
if (!currentRun) return null;
|
||
return (currentRun as any)[`stage_${stageNumber}_result`];
|
||
};
|
||
|
||
const renderMetricRow = (items: Array<{ label: string; value: any; colorCls?: string }>) => {
|
||
const visible = items.filter(i => i && (i.value !== undefined && i.value !== null));
|
||
if (visible.length === 0) {
|
||
return (
|
||
<div className="flex text-xs mt-1">
|
||
<div className="flex-1 text-center"></div>
|
||
<div className="flex-1 text-center"></div>
|
||
<div className="flex-1 text-center"></div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (visible.length === 2) {
|
||
return (
|
||
<div className="flex text-xs mt-1 justify-center gap-6">
|
||
{visible.map((it, idx) => (
|
||
<div key={idx} className="w-1/3 text-center">
|
||
<span className={`${it.colorCls ?? ''} block`}>{it.label}</span>
|
||
<div className="font-bold text-slate-900 dark:text-white">{Number(it.value) || 0}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// default to 3 columns (equally spaced)
|
||
return (
|
||
<div className="flex text-xs mt-1">
|
||
{items.concat(Array(Math.max(0, 3 - items.length)).fill({ label: '', value: '' })).slice(0,3).map((it, idx) => (
|
||
<div key={idx} className="flex-1 text-center">
|
||
<span className={`${it.colorCls ?? ''} block`}>{it.label}</span>
|
||
<div className="font-bold text-slate-900 dark:text-white">{it.value !== undefined && it.value !== null ? Number(it.value) : ''}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
return (
|
||
<>
|
||
<PageMeta title="Automate Everything | IGNY8" description="Set your content on automatic - Let our AI create and publish content on a schedule" />
|
||
|
||
<div className="space-y-6">
|
||
{/* Header */}
|
||
<div className="relative flex flex-col sm:flex-row items-center justify-between gap-4">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center justify-center size-10 rounded-xl bg-gradient-to-br from-teal-500 to-teal-600">
|
||
<BoltIcon className="text-white size-5" />
|
||
</div>
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">Automate Everything</h2>
|
||
{activeSite && (
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||
Site: <span className="font-medium text-brand-600 dark:text-brand-400">{activeSite.name}</span>
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Compact Ready-to-Run card (header) - absolutely centered in header */}
|
||
<div className="hidden sm:flex absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 z-10">
|
||
<div className={`w-full max-w-sm rounded-lg border-2 p-2 transition-all flex items-center gap-3 shadow-sm
|
||
${currentRun?.status === 'running' ? 'border-blue-500 bg-blue-50' : currentRun?.status === 'paused' ? 'border-amber-500 bg-amber-50' : totalPending > 0 ? 'border-success-500 bg-success-50' : 'border-slate-300 bg-slate-50'}`}>
|
||
<div className={`size-9 rounded-lg flex items-center justify-center flex-shrink-0
|
||
${currentRun?.status === 'running' ? 'bg-gradient-to-br from-blue-500 to-blue-600' : currentRun?.status === 'paused' ? 'bg-gradient-to-br from-amber-500 to-amber-600' : totalPending > 0 ? 'bg-gradient-to-br from-success-500 to-success-600' : 'bg-gradient-to-br from-slate-400 to-slate-500'}`}>
|
||
{!currentRun && totalPending > 0 ? <CheckCircleIcon className="size-4 text-white" /> : currentRun?.status === 'running' ? <BoltIcon className="size-4 text-white" /> : currentRun?.status === 'paused' ? <ClockIcon className="size-4 text-white" /> : <BoltIcon className="size-4 text-white" />}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-sm font-semibold text-slate-900 dark:text-white truncate">
|
||
{currentRun?.status === 'running' && `Running - Stage ${currentRun.current_stage}/7`}
|
||
{currentRun?.status === 'paused' && 'Paused'}
|
||
{!currentRun && totalPending > 0 && 'Ready to Go!'}
|
||
{!currentRun && totalPending === 0 && 'No Items Pending'}
|
||
</div>
|
||
<div className="text-xs text-slate-600 dark:text-gray-400 truncate">
|
||
{currentRun ? `Started: ${new Date(currentRun.started_at).toLocaleTimeString()}` : (totalPending > 0 ? `${totalPending} items waiting - Everything is queued up and ready for the next run` : 'All stages clear')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<DebugSiteSelector />
|
||
</div>
|
||
|
||
{/* Compact Schedule & Controls Panel */}
|
||
{config && (
|
||
<ComponentCard className="border-0 overflow-hidden rounded-2xl bg-gradient-to-br from-brand-600 to-brand-700">
|
||
<div className="flex flex-col lg:flex-row items-center lg:items-center justify-between gap-3 py-3 px-4">
|
||
<div className="flex items-center gap-4 flex-wrap">
|
||
<div className="flex items-center gap-2">
|
||
{config.is_enabled ? (
|
||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-success-500">
|
||
<div className="size-2 bg-white rounded-full"></div>
|
||
<span className="text-sm font-semibold text-white">Enabled</span>
|
||
</div>
|
||
) : (
|
||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/20">
|
||
<div className="size-2 bg-white/60 rounded-full"></div>
|
||
<span className="text-sm font-semibold text-white/90">Disabled</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="h-4 w-px bg-white/25"></div>
|
||
<div className="text-sm text-white/90">
|
||
Runs <span className="font-medium capitalize">{config.frequency === 'daily' ? 'every day' : config.frequency}</span> at <span className="font-medium">{config.scheduled_time}</span>
|
||
</div>
|
||
<div className="h-4 w-px bg-white/25"></div>
|
||
<div className="text-sm text-white/80">
|
||
Last run: <span className="font-medium">{config.last_run_at ? new Date(config.last_run_at).toLocaleDateString() : 'Never'}</span>
|
||
</div>
|
||
<div className="h-4 w-px bg-white/25"></div>
|
||
<div className="text-sm text-white/90">
|
||
Uses about <span className="font-semibold text-white">{estimate?.estimated_credits || 0} credits</span> per run
|
||
{estimate && !estimate.sufficient && (
|
||
<span className="ml-1 text-white/90 font-semibold">(Low)</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
onClick={() => setShowConfigModal(true)}
|
||
variant="outline"
|
||
tone="brand"
|
||
size="sm"
|
||
title="Change when this automation runs and how many credits it uses"
|
||
className="!border-white !text-white hover:!bg-white hover:!text-brand-700"
|
||
>
|
||
⚙️ Adjust Settings
|
||
</Button>
|
||
{currentRun?.status === 'running' && (
|
||
<Button
|
||
onClick={handlePause}
|
||
variant="primary"
|
||
tone="warning"
|
||
size="sm"
|
||
className="!bg-white/10 !text-white hover:!bg-white/20"
|
||
>
|
||
Pause
|
||
</Button>
|
||
)}
|
||
{currentRun?.status === 'paused' && (
|
||
<Button
|
||
onClick={handleResume}
|
||
variant="primary"
|
||
tone="brand"
|
||
size="sm"
|
||
className="!bg-white/10 !text-white hover:!bg-white/20"
|
||
>
|
||
Resume
|
||
</Button>
|
||
)}
|
||
{!currentRun && (
|
||
<Button
|
||
onClick={handleRunNow}
|
||
variant="primary"
|
||
tone="success"
|
||
size="sm"
|
||
disabled={!config?.is_enabled}
|
||
title="Start the automation immediately instead of waiting for the scheduled time"
|
||
className="!bg-white !text-brand-700 hover:!bg-success-600 hover:!text-white"
|
||
>
|
||
Run Now
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</ComponentCard>
|
||
)}
|
||
|
||
{/* Pipeline Statistics Header */}
|
||
<div className="mb-4">
|
||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">Here's what's in your automation pipeline:</h3>
|
||
</div>
|
||
|
||
{/* Metrics Summary Cards */}
|
||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||
{/* Keywords */}
|
||
<div className="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-xl p-4 border-2 border-blue-200 dark:border-blue-800">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="size-10 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center">
|
||
<ListIcon className="size-5 text-white" />
|
||
</div>
|
||
<div className="text-sm font-bold text-blue-900 dark:text-blue-100">Search Terms</div>
|
||
</div>
|
||
{(() => {
|
||
const res = getStageResult(1);
|
||
const total = res?.total ?? pipelineOverview[0]?.counts?.total ?? metrics?.keywords?.total ?? pipelineOverview[0]?.pending ?? 0;
|
||
return (
|
||
<div className="text-right">
|
||
<div className="text-3xl font-bold text-blue-900">{total}</div>
|
||
<div className="text-xs text-blue-700 dark:text-blue-300">waiting to organize</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
{(() => {
|
||
const res = getStageResult(1);
|
||
const newCount = res?.new ?? res?.new_items ?? pipelineOverview[0]?.counts?.new ?? metrics?.keywords?.new ?? 0;
|
||
const mapped = res?.mapped ?? pipelineOverview[0]?.counts?.mapped ?? metrics?.keywords?.mapped ?? 0;
|
||
return (
|
||
renderMetricRow([
|
||
{ label: 'New:', value: newCount, colorCls: 'text-blue-700' },
|
||
{ label: 'Mapped:', value: mapped, colorCls: 'text-blue-700' },
|
||
])
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{/* Clusters */}
|
||
<div className="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-xl p-4 border-2 border-purple-200 dark:border-purple-800">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="size-10 rounded-lg bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center">
|
||
<GroupIcon className="size-5 text-white" />
|
||
</div>
|
||
<div className="text-sm font-bold text-purple-900 dark:text-purple-100">Topic Groups</div>
|
||
</div>
|
||
{(() => {
|
||
const res = getStageResult(2);
|
||
const total = res?.total ?? pipelineOverview[1]?.counts?.total ?? metrics?.clusters?.total ?? pipelineOverview[1]?.pending ?? 0;
|
||
return (
|
||
<div className="text-right">
|
||
<div className="text-3xl font-bold text-purple-900">{total}</div>
|
||
<div className="text-xs text-purple-700 dark:text-purple-300">ready for ideas</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
{(() => {
|
||
const res = getStageResult(2);
|
||
const newCount = res?.new ?? res?.new_items ?? pipelineOverview[1]?.counts?.new ?? metrics?.clusters?.new ?? 0;
|
||
const mapped = res?.mapped ?? pipelineOverview[1]?.counts?.mapped ?? metrics?.clusters?.mapped ?? 0;
|
||
return (
|
||
renderMetricRow([
|
||
{ label: 'New:', value: newCount, colorCls: 'text-purple-700' },
|
||
{ label: 'Mapped:', value: mapped, colorCls: 'text-purple-700' },
|
||
])
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{/* Ideas */}
|
||
<div className="bg-gradient-to-br from-indigo-50 to-indigo-100 dark:from-indigo-900/20 dark:to-indigo-800/20 rounded-xl p-4 border-2 border-indigo-200 dark:border-indigo-800">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="size-10 rounded-lg bg-gradient-to-br from-indigo-500 to-indigo-600 flex items-center justify-center">
|
||
<CheckCircleIcon className="size-5 text-white" />
|
||
</div>
|
||
<div className="text-sm font-bold text-indigo-900 dark:text-indigo-100">Article Ideas</div>
|
||
</div>
|
||
{(() => {
|
||
const res = getStageResult(3);
|
||
const total = res?.total ?? pipelineOverview[2]?.counts?.total ?? metrics?.ideas?.total ?? pipelineOverview[2]?.pending ?? 0;
|
||
return (
|
||
<div className="text-right">
|
||
<div className="text-3xl font-bold text-indigo-900">{total}</div>
|
||
<div className="text-xs text-indigo-700 dark:text-indigo-300">waiting to write</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
{(() => {
|
||
const res = getStageResult(3);
|
||
const newCount = res?.new ?? res?.new_items ?? pipelineOverview[2]?.counts?.new ?? metrics?.ideas?.new ?? 0;
|
||
const queued = res?.queued ?? pipelineOverview[2]?.counts?.queued ?? metrics?.ideas?.queued ?? 0;
|
||
const completed = res?.completed ?? pipelineOverview[2]?.counts?.completed ?? metrics?.ideas?.completed ?? 0;
|
||
return (
|
||
renderMetricRow([
|
||
{ label: 'New:', value: newCount, colorCls: 'text-indigo-700' },
|
||
{ label: 'Queued:', value: queued, colorCls: 'text-indigo-700' },
|
||
{ label: 'Completed:', value: completed, colorCls: 'text-indigo-700' },
|
||
])
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 rounded-xl p-4 border-2 border-green-200 dark:border-green-800">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="size-10 rounded-lg bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center">
|
||
<FileTextIcon className="size-5 text-white" />
|
||
</div>
|
||
<div className="text-sm font-bold text-green-900 dark:text-green-100">Articles</div>
|
||
</div>
|
||
{(() => {
|
||
const res = getStageResult(4);
|
||
const total = res?.total ?? pipelineOverview[3]?.counts?.total ?? metrics?.content?.total ?? pipelineOverview[3]?.pending ?? 0;
|
||
return (
|
||
<div className="text-right">
|
||
<div className="text-3xl font-bold text-green-900">{total}</div>
|
||
<div className="text-xs text-green-700 dark:text-green-300">in various stages</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
{(() => {
|
||
const res = getStageResult(4);
|
||
const draft = res?.draft ?? res?.drafts ?? pipelineOverview[3]?.counts?.draft ?? metrics?.content?.draft ?? 0;
|
||
const review = res?.review ?? res?.in_review ?? pipelineOverview[3]?.counts?.review ?? metrics?.content?.review ?? 0;
|
||
const publish = res?.published ?? res?.publish ?? pipelineOverview[3]?.counts?.published ?? metrics?.content?.published ?? 0;
|
||
return (
|
||
renderMetricRow([
|
||
{ label: 'Draft:', value: draft, colorCls: 'text-green-700' },
|
||
{ label: 'Review:', value: review, colorCls: 'text-green-700' },
|
||
{ label: 'Publish:', value: publish, colorCls: 'text-green-700' },
|
||
])
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{/* Images */}
|
||
<div className="bg-gradient-to-br from-pink-50 to-pink-100 dark:from-pink-900/20 dark:to-pink-800/20 rounded-xl p-4 border-2 border-pink-200 dark:border-pink-800">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="size-10 rounded-lg bg-gradient-to-br from-pink-500 to-pink-600 flex items-center justify-center">
|
||
<FileIcon className="size-5 text-white" />
|
||
</div>
|
||
<div className="text-sm font-bold text-pink-900 dark:text-pink-100">Images</div>
|
||
</div>
|
||
{(() => {
|
||
const res = getStageResult(6);
|
||
const total = res?.total ?? pipelineOverview[5]?.counts?.total ?? metrics?.images?.total ?? pipelineOverview[5]?.pending ?? 0;
|
||
return (
|
||
<div className="text-right">
|
||
<div className="text-3xl font-bold text-pink-900">{total}</div>
|
||
<div className="text-xs text-pink-700 dark:text-pink-300">created and waiting</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
{(() => {
|
||
const res = getStageResult(6); // stage 6 is Image Prompts -> Images
|
||
if (res && typeof res === 'object') {
|
||
const entries = Object.entries(res);
|
||
const items = entries.slice(0,3).map(([k, v]) => ({ label: `${k.replace(/_/g, ' ')}:`, value: Number(v) || 0, colorCls: 'text-pink-700' }));
|
||
return renderMetricRow(items);
|
||
}
|
||
const counts = pipelineOverview[5]?.counts ?? metrics?.images ?? null;
|
||
if (counts && typeof counts === 'object') {
|
||
const entries = Object.entries(counts);
|
||
const items = entries.slice(0,3).map(([k, v]) => ({ label: `${k.replace(/_/g, ' ')}:`, value: Number(v) || 0, colorCls: 'text-pink-700' }));
|
||
return renderMetricRow(items);
|
||
}
|
||
return renderMetricRow([
|
||
{ label: 'Pending:', value: pipelineOverview[5]?.pending ?? metrics?.images?.pending ?? 0, colorCls: 'text-pink-700' },
|
||
]);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Current Processing Card - Shows real-time automation progress */}
|
||
{currentRun && showProcessingCard && activeSite && (
|
||
<CurrentProcessingCard
|
||
runId={currentRun.run_id}
|
||
siteId={activeSite.id}
|
||
currentRun={currentRun}
|
||
pipelineOverview={pipelineOverview}
|
||
onUpdate={async () => {
|
||
// Refresh current run status, pipeline overview and metrics (no full page reload)
|
||
await loadCurrentRun();
|
||
await loadPipelineOverview();
|
||
await loadMetrics();
|
||
}}
|
||
onClose={() => {
|
||
// hide the processing card until next run
|
||
setShowProcessingCard(false);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Pipeline Stages */}
|
||
<ComponentCard>
|
||
{/* Row 1: Stages 1-4 */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||
{pipelineOverview.slice(0, 4).map((stage, index) => {
|
||
const stageConfig = STAGE_CONFIG[index];
|
||
const StageIcon = stageConfig.icon;
|
||
const isActive = currentRun?.current_stage === stage.number;
|
||
const isComplete = currentRun && currentRun.current_stage > stage.number;
|
||
const result = currentRun ? (currentRun[`stage_${stage.number}_result` as keyof AutomationRun] as any) : null;
|
||
const processed = result ? Object.values(result).reduce((sum: number, val) => typeof val === 'number' ? sum + val : sum, 0) : 0;
|
||
const total = (stage.pending ?? 0) + processed;
|
||
const progressPercent = total > 0 ? Math.round((processed / total) * 100) : 0;
|
||
|
||
return (
|
||
<div
|
||
key={stage.number}
|
||
className={`
|
||
relative rounded-xl border-2 p-5 transition-all
|
||
${isActive
|
||
? 'border-blue-500 bg-blue-50 dark:bg-blue-500/10 shadow-lg'
|
||
: isComplete
|
||
? 'border-success-500 bg-success-50 dark:bg-success-500/10'
|
||
: stage.pending > 0
|
||
? `border-slate-200 bg-white dark:bg-white/[0.03] dark:border-gray-800 ${stageConfig.hoverColor} hover:shadow-lg`
|
||
: 'border-slate-200 bg-slate-50 dark:bg-white/[0.02] dark:border-gray-800'
|
||
}
|
||
`}
|
||
>
|
||
{/* Compact Header */}
|
||
<div className="flex items-start justify-between mb-3">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<div className="text-sm font-bold text-gray-900 dark:text-white">Stage {stage.number}</div>
|
||
{isActive && <span className="text-xs px-2 py-0.5 bg-blue-500 text-white rounded-full">● Active</span>}
|
||
{isComplete && <span className="text-xs px-2 py-0.5 bg-success-500 text-white rounded-full">✓</span>}
|
||
{!isActive && !isComplete && stage.pending > 0 && <span className="text-xs px-2 py-0.5 bg-gray-400 text-white rounded-full">Ready</span>}
|
||
</div>
|
||
<div className="text-xs font-medium text-gray-600 dark:text-gray-400">{stageConfig.name}</div>
|
||
</div>
|
||
<div className={`size-8 rounded-lg bg-gradient-to-br ${stageConfig.color} flex items-center justify-center shadow-md flex-shrink-0`}>
|
||
<StageIcon className="size-4 text-white" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Queue Metrics */}
|
||
<div className="space-y-1.5 text-xs mb-3">
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600 dark:text-gray-400">Total Queue:</span>
|
||
<span className="font-bold text-slate-900 dark:text-white">{stage.pending}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600 dark:text-gray-400">Processed:</span>
|
||
<span className="font-bold text-slate-900 dark:text-white">{processed}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600 dark:text-gray-400">Remaining:</span>
|
||
<span className={`font-bold ${stageConfig.textColor} dark:${stageConfig.textColor}`}>
|
||
{stage.pending}
|
||
</span>
|
||
</div>
|
||
{/* Credits and Time - Section 6 Enhancement */}
|
||
{result && result.credits_used !== undefined && (
|
||
<div className="flex justify-between pt-1.5 border-t border-slate-200 dark:border-gray-700">
|
||
<span className="text-gray-600 dark:text-gray-400">Credits Used:</span>
|
||
<span className="font-bold text-amber-600 dark:text-amber-400">{result.credits_used}</span>
|
||
</div>
|
||
)}
|
||
{result && result.time_elapsed && (
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600 dark:text-gray-400">Duration:</span>
|
||
<span className="font-semibold text-gray-700 dark:text-gray-300">{result.time_elapsed}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Progress Bar */}
|
||
{(isActive || isComplete || processed > 0) && (
|
||
<div className="mt-3 pt-3 border-t border-slate-200 dark:border-gray-700">
|
||
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400 mb-1.5">
|
||
<span>Progress</span>
|
||
<span>{isComplete ? '100' : progressPercent}%</span>
|
||
</div>
|
||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
|
||
<div
|
||
className={`bg-gradient-to-r ${stageConfig.color} h-2 rounded-full transition-all duration-500 ${isActive ? 'animate-pulse' : ''}`}
|
||
style={{ width: `${isComplete ? 100 : progressPercent}%` }}
|
||
></div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Row 2: Stages 5-7 + Status Summary */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
{/* Stages 5-6 */}
|
||
{pipelineOverview.slice(4, 6).map((stage, index) => {
|
||
const actualIndex = index + 4;
|
||
const stageConfig = STAGE_CONFIG[actualIndex];
|
||
const StageIcon = stageConfig.icon;
|
||
const isActive = currentRun?.current_stage === stage.number;
|
||
const isComplete = currentRun && currentRun.current_stage > stage.number;
|
||
const result = currentRun ? (currentRun[`stage_${stage.number}_result` as keyof AutomationRun] as any) : null;
|
||
const processed = result ? Object.values(result).reduce((sum: number, val) => typeof val === 'number' ? sum + val : sum, 0) : 0;
|
||
const total = (stage.pending ?? 0) + processed;
|
||
const progressPercent = total > 0 ? Math.round((processed / total) * 100) : 0;
|
||
|
||
return (
|
||
<div
|
||
key={stage.number}
|
||
className={`
|
||
relative rounded-xl border-2 p-5 transition-all
|
||
${isActive
|
||
? 'border-blue-500 bg-blue-50 dark:bg-blue-500/10 shadow-lg'
|
||
: isComplete
|
||
? 'border-success-500 bg-success-50 dark:bg-success-500/10'
|
||
: stage.pending > 0
|
||
? `border-slate-200 bg-white dark:bg-white/[0.03] dark:border-gray-800 ${stageConfig.hoverColor} hover:shadow-lg`
|
||
: 'border-slate-200 bg-slate-50 dark:bg-white/[0.02] dark:border-gray-800'
|
||
}
|
||
`}
|
||
>
|
||
<div className="flex items-start justify-between mb-3">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<div className="text-sm font-bold text-gray-900 dark:text-white">Stage {stage.number}</div>
|
||
{isActive && <span className="text-xs px-2 py-0.5 bg-blue-500 text-white rounded-full">● Active</span>}
|
||
{isComplete && <span className="text-xs px-2 py-0.5 bg-success-500 text-white rounded-full">✓</span>}
|
||
{!isActive && !isComplete && stage.pending > 0 && <span className="text-xs px-2 py-0.5 bg-gray-400 text-white rounded-full">Ready</span>}
|
||
</div>
|
||
<div className="text-xs font-medium text-gray-600 dark:text-gray-400">{stageConfig.name}</div>
|
||
</div>
|
||
<div className={`size-8 rounded-lg bg-gradient-to-br ${stageConfig.color} flex items-center justify-center shadow-md flex-shrink-0`}>
|
||
<StageIcon className="size-4 text-white" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5 text-xs mb-3">
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600 dark:text-gray-400">Total Queue:</span>
|
||
<span className="font-bold text-slate-900 dark:text-white">{stage.pending}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600 dark:text-gray-400">Processed:</span>
|
||
<span className="font-bold text-slate-900 dark:text-white">{processed}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600 dark:text-gray-400">Remaining:</span>
|
||
<span className={`font-bold ${stageConfig.textColor}`}>
|
||
{stage.pending}
|
||
</span>
|
||
</div>
|
||
{/* Credits and Time - Section 6 Enhancement */}
|
||
{result && result.credits_used !== undefined && (
|
||
<div className="flex justify-between pt-1.5 border-t border-slate-200 dark:border-gray-700">
|
||
<span className="text-gray-600 dark:text-gray-400">Credits Used:</span>
|
||
<span className="font-bold text-amber-600 dark:text-amber-400">{result.credits_used}</span>
|
||
</div>
|
||
)}
|
||
{result && result.time_elapsed && (
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-600 dark:text-gray-400">Duration:</span>
|
||
<span className="font-semibold text-gray-700 dark:text-gray-300">{result.time_elapsed}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{(isActive || isComplete || processed > 0) && (
|
||
<div className="mt-3 pt-3 border-t border-slate-200 dark:border-gray-700">
|
||
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400 mb-1.5">
|
||
<span>Progress</span>
|
||
<span>{isComplete ? '100' : progressPercent}%</span>
|
||
</div>
|
||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
|
||
<div
|
||
className={`bg-gradient-to-r ${stageConfig.color} h-2 rounded-full transition-all duration-500 ${isActive ? 'animate-pulse' : ''}`}
|
||
style={{ width: `${isComplete ? 100 : progressPercent}%` }}
|
||
></div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Stage 7 - Manual Review Gate */}
|
||
{pipelineOverview[6] && (() => {
|
||
const stage7 = pipelineOverview[6];
|
||
const isActive = currentRun?.current_stage === 7;
|
||
const isComplete = currentRun && currentRun.current_stage > 7;
|
||
|
||
return (
|
||
<div
|
||
className={`
|
||
relative rounded-xl border-3 p-5 transition-all
|
||
${isActive
|
||
? 'border-amber-500 bg-amber-50 dark:bg-amber-500/10 shadow-lg'
|
||
: isComplete
|
||
? 'border-success-500 bg-success-50 dark:bg-success-500/10'
|
||
: stage7.pending > 0
|
||
? 'border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700'
|
||
: 'border-slate-200 bg-slate-50 dark:bg-white/[0.02] dark:border-gray-800'
|
||
}
|
||
`}
|
||
>
|
||
<div className="flex items-start justify-between mb-3">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<div className="text-sm font-bold text-gray-900 dark:text-white">Stage 7</div>
|
||
<span className="text-xs px-2 py-0.5 bg-amber-500 text-white rounded-full">🚫 Stop</span>
|
||
</div>
|
||
<div className="text-xs font-medium text-amber-700 dark:text-amber-300">Manual Review Gate</div>
|
||
</div>
|
||
<div className="size-8 rounded-lg bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center shadow-md">
|
||
<PaperPlaneIcon className="size-4 text-white" />
|
||
</div>
|
||
</div>
|
||
|
||
{stage7.pending > 0 && (
|
||
<div className="text-center py-4">
|
||
<div className="text-3xl font-bold text-amber-600 dark:text-amber-400">{stage7.pending}</div>
|
||
<div className="text-xs text-amber-700 dark:text-amber-300 mt-1">ready for review</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-3 pt-3 border-t border-amber-200 dark:border-amber-700">
|
||
<Button
|
||
variant="primary"
|
||
tone="brand"
|
||
size="sm"
|
||
className="w-full text-xs"
|
||
disabled={stage7.pending === 0}
|
||
>
|
||
Go to Review →
|
||
</Button>
|
||
<div className="mt-2">
|
||
<Button
|
||
variant="outline"
|
||
tone="success"
|
||
size="sm"
|
||
className="w-full text-xs"
|
||
disabled={stage7.pending === 0}
|
||
onClick={handlePublishAllWithoutReview}
|
||
>
|
||
Publish all Without Review
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* Published summary card (placed after Stage 7 in the same row) */}
|
||
<div className="rounded-xl p-5 border-2 border-green-200 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/10 dark:to-green-800/10 flex flex-col h-full">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="size-10 rounded-lg bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center">
|
||
<FileTextIcon className="size-5 text-white" />
|
||
</div>
|
||
<div className="text-sm font-bold text-green-900 dark:text-green-100">Published</div>
|
||
</div>
|
||
<div className="text-right"> </div>
|
||
</div>
|
||
<div className="flex-1 flex items-center justify-center">
|
||
<div className="text-4xl md:text-5xl font-extrabold text-green-800 dark:text-green-300">{metrics?.content?.published ?? pipelineOverview[3]?.counts?.published ?? getStageResult(4)?.published ?? 0}</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</ComponentCard>
|
||
|
||
{/* Activity Log */}
|
||
{currentRun && <ActivityLog runId={currentRun.run_id} />}
|
||
|
||
{/* Run History */}
|
||
<RunHistory siteId={activeSite.id} />
|
||
|
||
{/* Config Modal */}
|
||
{showConfigModal && config && (
|
||
<ConfigModal config={config} onSave={handleSaveConfig} onCancel={() => setShowConfigModal(false)} />
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default AutomationPage;
|
||
|