widgets and other fixes

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-11 15:24:52 +00:00
parent 747770ac58
commit e9369df151
16 changed files with 761 additions and 277 deletions

View File

@@ -146,9 +146,9 @@ export default function WorkflowCompletionWidget({
{ label: 'Pages Published', value: writer.contentPublished, barColor: `var(${WORKFLOW_COLORS.writer.pagesPublished})` },
];
// Calculate max value for proportional bars (across both columns)
const allValues = [...plannerItems, ...writerItems].map(i => i.value);
const maxValue = Math.max(...allValues, 1);
// Since these are totals (not percentages), show full-width bars
// The value itself indicates the metric, not the bar width
const maxValue = 1; // Always show 100% filled bars
return (
<Card className={`p-5 bg-white dark:bg-gray-900 border border-[color:var(--color-stroke)] dark:border-gray-700 min-w-0 ${className}`}>

View File

@@ -247,6 +247,7 @@ export const WORKFLOW_COLORS = {
writer: {
tasksCreated: CSS_VAR_COLORS.grayBase, // Navy
contentPages: CSS_VAR_COLORS.primary, // Blue
imagesCreated: CSS_VAR_COLORS.purple, // Purple for images
pagesPublished: CSS_VAR_COLORS.success, // Green
},
} as const;

View File

@@ -146,6 +146,9 @@ export function useWorkflowStats(timeFilter: TimeFilter = 'all') {
return;
}
// Debug logging
console.log('[useWorkflowStats] Loading stats with timeFilter:', timeFilter, 'siteId:', activeSite.id);
setStats(prev => ({ ...prev, loading: true, error: null }));
try {
@@ -153,6 +156,8 @@ export function useWorkflowStats(timeFilter: TimeFilter = 'all') {
const dateFilter = getDateFilter(timeFilter);
const dateParam = dateFilter ? `&created_at__gte=${dateFilter.split('T')[0]}` : '';
console.log("[useWorkflowStats] Date filter:", { timeFilter, dateFilter, dateParam });
// IMPORTANT: Widget should always show site-wide stats for consistency
// Sector filtering removed to ensure widget shows same counts on all pages
const siteParam = `&site_id=${activeSite.id}`;
@@ -244,6 +249,7 @@ export function useWorkflowStats(timeFilter: TimeFilter = 'all') {
const plannerTotal = clusteringCredits + ideaCredits;
const writerTotal = contentCredits + imageCredits;
console.log("[useWorkflowStats] Results:", { keywordsCount: keywordsRes?.count, clustersCount: clustersRes?.count });
setStats({
planner: {
totalKeywords: keywordsRes?.count || 0,

View File

@@ -48,6 +48,7 @@ export default function Home() {
const [sites, setSites] = useState<Site[]>([]);
const [sitesLoading, setSitesLoading] = useState(true);
const [siteFilter, setSiteFilter] = useState<'all' | number>('all');
const [aiPeriod, setAIPeriod] = useState<'7d' | '30d' | '90d'>('7d');
const [showAddSite, setShowAddSite] = useState(false);
const [loading, setLoading] = useState(true);
const [subscription, setSubscription] = useState<Subscription | null>(null);
@@ -170,11 +171,12 @@ export default function Home() {
setLoading(true);
const siteId = siteFilter === 'all' ? undefined : siteFilter;
const periodDays = aiPeriod === '7d' ? 7 : aiPeriod === '30d' ? 30 : 90;
// Fetch real dashboard stats from API
const stats = await getDashboardStats({
site_id: siteId,
days: 7
days: periodDays
});
// Update pipeline data from real API data
@@ -274,7 +276,7 @@ export default function Home() {
}
setAIOperations({
period: stats.ai_operations.period,
period: aiPeriod,
operations: mappedOperations,
totals: stats.ai_operations.totals,
});
@@ -304,14 +306,14 @@ export default function Home() {
} finally {
setLoading(false);
}
}, [siteFilter, sites, toast]);
}, [siteFilter, sites, aiPeriod, toast]);
// Fetch dashboard data when filter changes
useEffect(() => {
if (!sitesLoading) {
fetchDashboardData();
}
}, [siteFilter, sitesLoading, fetchDashboardData]);
}, [siteFilter, aiPeriod, sitesLoading, fetchDashboardData]);
const handleAddSiteClick = () => {
setShowAddSite(true);
@@ -329,8 +331,7 @@ export default function Home() {
};
const handlePeriodChange = (period: '7d' | '30d' | '90d') => {
setAIOperations(prev => ({ ...prev, period }));
// In real implementation, would refetch data for new period
setAIPeriod(period);
};
const handleRunAutomation = () => {

View File

@@ -31,7 +31,7 @@ function WorkflowPipeline() {
{ name: "Content", color: "bg-brand-500", description: "AI-generate articles" },
{ name: "Images", color: "bg-purple-500", description: "Create visuals" },
{ name: "Review", color: "bg-warning-500", description: "Edit & approve" },
{ name: "Published", color: "bg-success-500", description: "Live on WordPress" },
{ name: "Published", color: "bg-success-500", description: "Live on your site" },
];
return (
@@ -230,7 +230,7 @@ export default function Help() {
},
{
question: "What is the complete workflow from keywords to published content?",
answer: "IGNY8 follows an 8-stage pipeline: 1) Add keywords from Opportunities or CSV, 2) Cluster related keywords, 3) Generate content ideas from clusters, 4) Create tasks from ideas, 5) Generate AI content from tasks, 6) Generate featured and in-article images, 7) Review and approve content, 8) Publish to WordPress. You can automate most of these steps in the Automation page."
answer: "IGNY8 follows an 8-stage pipeline: 1) Add keywords from Opportunities or CSV, 2) Cluster related keywords, 3) Generate content ideas from clusters, 4) Create tasks from ideas, 5) Generate AI content from tasks, 6) Generate featured and in-article images, 7) Review and approve content, 8) Publish to your site. You can automate most of these steps in the Automation page."
},
{
question: "How do I set up automation?",
@@ -242,7 +242,7 @@ export default function Help() {
},
{
question: "How are images generated?",
answer: "Images are generated using AI (DALL-E 3 for premium, Runware for basic) based on your content. Go to Writer → Images to see all generated images. You can generate featured images and in-article images, regenerate them if needed, and they automatically sync to WordPress when publishing."
answer: "Images are generated using IGNY8 AI (Premium quality or Basic quality) based on your content. Go to Writer → Images to see all generated images. You can generate featured images and in-article images, regenerate them if needed, and they automatically sync to your site when publishing."
},
{
question: "What is the difference between Tasks and Content?",
@@ -618,7 +618,7 @@ export default function Help() {
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">Publishing Tab</h4>
<ul className="list-disc list-inside text-sm text-gray-600 dark:text-gray-400 space-y-1">
<li><strong>Auto-Publish:</strong> Automatically publish approved content</li>
<li><strong>Auto-Sync:</strong> Keep WordPress in sync with changes</li>
<li><strong>Auto-Sync:</strong> Keep your site in sync with changes</li>
<li><strong>Default Post Status:</strong> Draft, Pending, or Publish</li>
</ul>
</div>
@@ -626,7 +626,7 @@ export default function Help() {
<div className="border-l-4 border-purple-500 pl-4">
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">Image Settings Tab</h4>
<ul className="list-disc list-inside text-sm text-gray-600 dark:text-gray-400 space-y-1">
<li><strong>Image Provider:</strong> Basic (Runware) or Premium (DALL-E 3)</li>
<li><strong>Image Quality:</strong> Basic or Premium (powered by IGNY8 AI)</li>
<li><strong>Image Style:</strong> Photorealistic, Illustrated, Abstract</li>
<li><strong>Default Size:</strong> 1024x1024, 1792x1024, 1024x1792</li>
</ul>
@@ -649,7 +649,7 @@ export default function Help() {
<li>Enter site name and domain</li>
<li>Select industry from 100+ categories</li>
<li>Add sectors for content organization</li>
<li>Configure WordPress integration (optional)</li>
<li>Configure site integration (WordPress or other platforms)</li>
<li>Save and start adding keywords</li>
</ol>
</div>
@@ -859,7 +859,7 @@ export default function Help() {
<div>
<h4 className="font-semibold text-gray-900 dark:text-white">Approve</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Change status to "Approved" when content is ready for publishing. Approved content can be published to WordPress.
Change status to "Approved" when content is ready for publishing. Approved content can be published to your site.
</p>
</div>
</div>
@@ -871,7 +871,7 @@ export default function Help() {
<li><strong>Draft:</strong> Initial AI-generated content</li>
<li><strong>In Review:</strong> Being edited/reviewed</li>
<li><strong>Approved:</strong> Ready for publishing</li>
<li><strong>Published:</strong> Live on WordPress</li>
<li><strong>Published:</strong> Live on your site</li>
</ul>
</div>
</div>
@@ -882,14 +882,14 @@ export default function Help() {
<div id="image-settings" ref={(el) => (sectionRefs.current["image-settings"] = el)}></div>
<div id="managing-images" ref={(el) => (sectionRefs.current["managing-images"] = el)}></div>
<p className="text-gray-700 dark:text-gray-300">
Generate AI images for your content using DALL-E 3 (premium) or Runware (basic).
Generate AI images for your content using IGNY8 AI (Premium or Basic quality).
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="border-l-4 border-brand-500 pl-4">
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">Featured Images</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Main image for each article. Automatically used as the WordPress featured image when publishing.
Main image for each article. Automatically used as the featured image when publishing to your site.
</p>
</div>
@@ -904,8 +904,8 @@ export default function Help() {
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg">
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">Image Generation Options:</h4>
<ul className="list-disc list-inside text-sm text-gray-600 dark:text-gray-400 space-y-1">
<li><strong>Basic (5 credits):</strong> Runware - fast, good quality</li>
<li><strong>Premium (25 credits):</strong> DALL-E 3 - highest quality</li>
<li><strong>Basic (5 credits):</strong> Fast generation, good quality</li>
<li><strong>Premium (25 credits):</strong> Highest quality</li>
<li><strong>Sizes:</strong> 1024x1024, 1792x1024, 1024x1792</li>
<li><strong>Styles:</strong> Photorealistic, Illustrated, Abstract</li>
</ul>
@@ -916,7 +916,7 @@ export default function Help() {
<AccordionItem title="Review & Publish" forceOpen={openAccordions.has('content-workflow')}>
<div id="content-workflow" ref={(el) => (sectionRefs.current["content-workflow"] = el)} className="space-y-4 scroll-mt-24">
<p className="text-gray-700 dark:text-gray-300">
Final review stage before publishing to WordPress.
Final review stage before publishing to your site.
</p>
<div className="space-y-3">
@@ -1030,7 +1030,7 @@ export default function Help() {
<div className="border-l-4 border-purple-500 pl-4">
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">Publishing History</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Track all published content with timestamps, WordPress post IDs, and sync status.
Track all published content with timestamps, post IDs, and sync status.
</p>
</div>
</div>
@@ -1080,7 +1080,7 @@ export default function Help() {
<div className="border-l-4 border-brand-500 pl-4">
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">Bidirectional Sync</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Changes in WordPress (post edits, status changes) sync back to IGNY8. Keep content in sync across platforms.
Changes made on your WordPress site (post edits, status changes) sync back to IGNY8. Keep content in sync across platforms.
</p>
</div>
</div>
@@ -1098,17 +1098,17 @@ export default function Help() {
<div className="border-l-4 border-brand-500 pl-4">
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">Content Generation</h4>
<ul className="list-disc list-inside text-sm text-gray-600 dark:text-gray-400 space-y-1">
<li><strong>OpenAI (GPT-4o):</strong> Primary content generation</li>
<li><strong>Anthropic (Claude):</strong> Alternative provider</li>
<li>Powered by IGNY8 AI</li>
<li>Advanced language models for high-quality content</li>
</ul>
</div>
<div className="border-l-4 border-purple-500 pl-4">
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">Image Generation</h4>
<ul className="list-disc list-inside text-sm text-gray-600 dark:text-gray-400 space-y-1">
<li><strong>DALL-E 3:</strong> Premium quality images</li>
<li><strong>Runware:</strong> Fast, cost-effective basic images</li>
<li><strong>Bria:</strong> Background removal & editing</li>
<li><strong>Premium Quality:</strong> Highest quality images</li>
<li><strong>Basic Quality:</strong> Fast, cost-effective images</li>
<li><strong>Image Editing:</strong> Background removal & editing</li>
</ul>
</div>
</div>
@@ -1146,7 +1146,7 @@ export default function Help() {
<div className="border-l-4 border-purple-500 pl-4">
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">Fixed-Cost Operations</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Image generation uses fixed credits: 5 for basic (Runware), 25 for premium (DALL-E 3).
Image generation uses fixed credits: 5 for Basic quality, 25 for Premium quality.
</p>
</div>
</div>

View File

@@ -26,6 +26,7 @@ import { useToast } from '../../components/ui/toast/ToastContainer';
import { GroupIcon, PlusIcon, DownloadIcon, ListIcon, BoltIcon } from '../../icons';
import { createClustersPageConfig } from '../../config/pages/clusters.config';
import { useSectorStore } from '../../store/sectorStore';
import { useSiteStore } from '../../store/siteStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
import PageHeader from '../../components/common/PageHeader';
@@ -33,6 +34,7 @@ import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeW
export default function Clusters() {
const toast = useToast();
const { activeSite } = useSiteStore();
const { activeSector } = useSectorStore();
const { pageSize } = usePageSizeStore();
@@ -84,39 +86,40 @@ export default function Clusters() {
const progressModal = useProgressModal();
const hasReloadedRef = useRef(false);
// Load total metrics for footer widget (not affected by pagination)
// Load total metrics for footer widget (site-wide totals, no sector filter)
const loadTotalMetrics = useCallback(async () => {
try {
// Fetch summary metrics in parallel with status counts
const [summaryRes, mappedRes, newRes, imagesRes] = await Promise.all([
fetchClustersSummary(activeSector?.id),
// Batch all API calls in parallel for better performance
const [allRes, mappedRes, newRes, imagesRes] = await Promise.all([
// Fetch all clusters (site-wide)
fetchClusters({
page_size: 1,
...(activeSector?.id && { sector_id: activeSector.id }),
site_id: activeSite?.id,
}),
// Fetch clusters with ideas (status='mapped')
fetchClusters({
page_size: 1,
site_id: activeSite?.id,
status: 'mapped',
}),
// Fetch clusters without ideas (status='new')
fetchClusters({
page_size: 1,
...(activeSector?.id && { sector_id: activeSector.id }),
site_id: activeSite?.id,
status: 'new',
}),
// Fetch images count
fetchImages({ page_size: 1 }),
]);
// Set summary metrics
setTotalVolume(summaryRes.total_volume || 0);
setTotalKeywords(summaryRes.total_keywords || 0);
// Set status counts
setTotalCount(allRes.count || 0);
setTotalWithIdeas(mappedRes.count || 0);
setTotalReady(newRes.count || 0);
// Set images count
setTotalImagesCount(imagesRes.count || 0);
} catch (error) {
console.error('Error loading total metrics:', error);
}
}, [activeSector]);
}, [activeSite]);
// Load total metrics when sector changes
useEffect(() => {

View File

@@ -28,12 +28,14 @@ import { BoltIcon, PlusIcon, DownloadIcon, ListIcon, GroupIcon, ArrowRightIcon }
import { LightBulbIcon } from '@heroicons/react/24/outline';
import { createIdeasPageConfig } from '../../config/pages/ideas.config';
import { useSectorStore } from '../../store/sectorStore';
import { useSiteStore } from '../../store/siteStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader';
import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
export default function Ideas() {
const toast = useToast();
const { activeSite } = useSiteStore();
const { activeSector } = useSectorStore();
const { pageSize } = usePageSizeStore();
@@ -96,37 +98,46 @@ export default function Ideas() {
loadClusters();
}, []);
// Load total metrics for footer widget (not affected by pagination)
// Load total metrics for footer widget (site-wide totals, no sector filter)
const loadTotalMetrics = useCallback(async () => {
try {
// Get ideas with status='queued' or 'completed' (those in tasks/writer)
const queuedRes = await fetchContentIdeas({
page_size: 1,
...(activeSector?.id && { sector_id: activeSector.id }),
status: 'queued',
});
const completedRes = await fetchContentIdeas({
page_size: 1,
...(activeSector?.id && { sector_id: activeSector.id }),
status: 'completed',
});
// Batch all API calls in parallel for better performance
const [allRes, queuedRes, completedRes, newRes, imagesRes] = await Promise.all([
// Get all ideas (site-wide)
fetchContentIdeas({
page_size: 1,
site_id: activeSite?.id,
}),
// Get ideas with status='queued'
fetchContentIdeas({
page_size: 1,
site_id: activeSite?.id,
status: 'queued',
}),
// Get ideas with status='completed'
fetchContentIdeas({
page_size: 1,
site_id: activeSite?.id,
status: 'completed',
}),
// Get ideas with status='new' (those ready to become tasks)
fetchContentIdeas({
page_size: 1,
site_id: activeSite?.id,
status: 'new',
}),
// Get actual total images count
fetchImages({ page_size: 1 }),
]);
setTotalCount(allRes.count || 0);
setTotalInTasks((queuedRes.count || 0) + (completedRes.count || 0));
// Get ideas with status='new' (those ready to become tasks)
const newRes = await fetchContentIdeas({
page_size: 1,
...(activeSector?.id && { sector_id: activeSector.id }),
status: 'new',
});
setTotalPending(newRes.count || 0);
// Get actual total images count
const imagesRes = await fetchImages({ page_size: 1 });
setTotalImagesCount(imagesRes.count || 0);
} catch (error) {
console.error('Error loading total metrics:', error);
}
}, [activeSector]);
}, [activeSite]);
// Load total metrics when sector changes
useEffect(() => {

View File

@@ -115,42 +115,47 @@ export default function Keywords() {
loadClusters();
}, []);
// Load total metrics for footer widget (not affected by pagination)
// Load total metrics for footer widget (site-wide totals, no sector filter)
const loadTotalMetrics = useCallback(async () => {
if (!activeSite) return;
try {
// Get all keywords (total count) - this is already in totalCount from main load
// Get keywords with status='mapped' (those that have been mapped to a cluster)
const mappedRes = await fetchKeywords({
page_size: 1,
site_id: activeSite.id,
...(activeSector?.id && { sector_id: activeSector.id }),
status: 'mapped',
});
// Batch all API calls in parallel for better performance
const [allRes, mappedRes, newRes, imagesRes] = await Promise.all([
// Get total keywords count (site-wide)
fetchKeywords({
page_size: 1,
site_id: activeSite.id,
}),
// Get keywords with status='mapped' (site-wide)
fetchKeywords({
page_size: 1,
site_id: activeSite.id,
status: 'mapped',
}),
// Get keywords with status='new' (site-wide)
fetchKeywords({
page_size: 1,
site_id: activeSite.id,
status: 'new',
}),
// Get actual total images count
fetchImages({ page_size: 1 }),
]);
setTotalCount(allRes.count || 0);
setTotalClustered(mappedRes.count || 0);
// Get keywords with status='new' (those that are ready to cluster but haven't been yet)
const newRes = await fetchKeywords({
page_size: 1,
site_id: activeSite.id,
...(activeSector?.id && { sector_id: activeSector.id }),
status: 'new',
});
setTotalUnmapped(newRes.count || 0);
setTotalImagesCount(imagesRes.count || 0);
// Get total volume across all keywords (we need to fetch all or rely on backend aggregation)
// For now, we'll just calculate from current data or set to 0
// TODO: Backend should provide total volume as an aggregated metric
setTotalVolume(0);
// Get actual total images count
const imagesRes = await fetchImages({ page_size: 1 });
setTotalImagesCount(imagesRes.count || 0);
} catch (error) {
console.error('Error loading total metrics:', error);
}
}, [activeSite, activeSector]);
}, [activeSite]);
// Load total metrics when site/sector changes
useEffect(() => {

View File

@@ -17,6 +17,7 @@ import InputField from '../../components/form/input/InputField';
import Select from '../../components/form/Select';
import ViewToggle from '../../components/common/ViewToggle';
import WorkflowGuide from '../../components/onboarding/WorkflowGuide';
import { useOnboardingStore } from '../../store/onboardingStore';
import {
PlusIcon,
PencilIcon,
@@ -64,11 +65,11 @@ type ViewType = 'table' | 'grid';
export default function SiteList() {
const navigate = useNavigate();
const toast = useToast();
const { toggleGuide, isGuideVisible } = useOnboardingStore();
const [sites, setSites] = useState<Site[]>([]);
const [filteredSites, setFilteredSites] = useState<Site[]>([]);
const [loading, setLoading] = useState(true);
const [viewType, setViewType] = useState<ViewType>('grid');
const [showWelcomeGuide, setShowWelcomeGuide] = useState(false);
const [showFilters, setShowFilters] = useState(false);
// Site Management Modals
@@ -413,6 +414,15 @@ export default function SiteList() {
</div>
{/* Status badge and toggle in top right */}
<div className="absolute top-0 right-0 flex items-center gap-2">
<Button
onClick={() => handleDeleteSite(site.id)}
variant="solid"
tone="danger"
size="xs"
className="bg-red-600 hover:bg-red-700 text-white"
startIcon={<TrashBinIcon className="w-3.5 h-3.5" />}
title="Delete site"
/>
<Switch
checked={site.is_active}
onChange={(enabled) => handleToggle(site.id, enabled)}
@@ -480,7 +490,7 @@ export default function SiteList() {
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Button
onClick={() => setShowWelcomeGuide(!showWelcomeGuide)}
onClick={toggleGuide}
variant="primary"
tone="brand"
size="md"
@@ -523,12 +533,11 @@ export default function SiteList() {
</div>
</div>
{/* Welcome Guide - Collapsible */}
{showWelcomeGuide && (
{/* Welcome Guide - Shows when button clicked OR when no sites exist */}
{(isGuideVisible || sites.length === 0) && (
<div className="mb-6">
<WorkflowGuide onSiteAdded={() => {
loadSites();
setShowWelcomeGuide(false);
}} />
</div>
)}
@@ -661,7 +670,7 @@ export default function SiteList() {
Clear Filters
</Button>
) : (
<Button onClick={() => setShowWelcomeGuide(true)} variant="primary" tone="success" startIcon={<PlusIcon className="w-5 h-5" />}>
<Button onClick={toggleGuide} variant="primary" tone="success" startIcon={<PlusIcon className="w-5 h-5" />}>
Add Your First Site
</Button>
)}

View File

@@ -1193,7 +1193,7 @@ export default function SiteSettings() {
? currentDays.filter((d: string) => d !== day.value)
: [...currentDays, day.value];
setPublishingSettings({ ...publishingSettings, publish_days: newDays });
savePublishingSettings({ publish_days: newDays });
// Don't auto-save - let user click Save button
}}
className="w-10 h-10 p-0"
>
@@ -1204,45 +1204,75 @@ export default function SiteSettings() {
</div>
<div>
<Label className="mb-2">Time Slots</Label>
<p className="text-xs text-gray-500 mb-3">In your local timezone</p>
<div className="flex items-center justify-between mb-2">
<Label>Time Slots</Label>
{(publishingSettings.publish_time_slots || []).length > 0 && (
<Button
variant="ghost"
tone="danger"
size="xs"
onClick={() => {
setPublishingSettings({ ...publishingSettings, publish_time_slots: [] });
}}
>
Clear All
</Button>
)}
</div>
<p className="text-xs text-gray-500 mb-3">In your local timezone. Content will be published at these times on selected days.</p>
<div className="space-y-2">
{(publishingSettings.publish_time_slots || ['09:00', '14:00', '18:00']).map((time: string, index: number) => (
<div key={index} className="flex items-center gap-2">
<InputField
type="time"
value={time}
onChange={(e) => {
const newSlots = [...(publishingSettings.publish_time_slots || [])];
newSlots[index] = e.target.value;
setPublishingSettings({ ...publishingSettings, publish_time_slots: newSlots });
}}
/>
{(publishingSettings.publish_time_slots || []).length > 1 && (
{(publishingSettings.publish_time_slots || ['09:00', '14:00', '18:00']).length === 0 ? (
<div className="text-center py-4 text-gray-500 text-sm border border-dashed border-gray-300 dark:border-gray-700 rounded-md">
No time slots configured. Add at least one time slot.
</div>
) : (
(publishingSettings.publish_time_slots || ['09:00', '14:00', '18:00']).map((time: string, index: number) => (
<div key={index} className="flex items-center gap-2">
<span className="text-xs text-gray-500 w-8">#{index + 1}</span>
<InputField
type="time"
value={time}
onChange={(e) => {
const newSlots = [...(publishingSettings.publish_time_slots || [])];
newSlots[index] = e.target.value;
setPublishingSettings({ ...publishingSettings, publish_time_slots: newSlots });
}}
className="flex-1"
/>
<IconButton
icon={<CloseIcon className="w-4 h-4" />}
variant="ghost"
tone="danger"
size="sm"
title="Remove time slot"
title="Remove this time slot"
onClick={() => {
const newSlots = (publishingSettings.publish_time_slots || []).filter((_: string, i: number) => i !== index);
setPublishingSettings({ ...publishingSettings, publish_time_slots: newSlots });
savePublishingSettings({ publish_time_slots: newSlots });
// Don't auto-save - let user click Save button
}}
/>
)}
</div>
))}
</div>
))
)}
<Button
variant="ghost"
tone="brand"
size="sm"
startIcon={<PlusIcon className="w-4 h-4" />}
onClick={() => {
const newSlots = [...(publishingSettings.publish_time_slots || []), '12:00'];
const lastSlot = (publishingSettings.publish_time_slots || [])[
(publishingSettings.publish_time_slots || []).length - 1
];
// Default new slot to 12:00 or 2 hours after last slot
let newTime = '12:00';
if (lastSlot) {
const [hours, mins] = lastSlot.split(':').map(Number);
const newHours = (hours + 2) % 24;
newTime = `${String(newHours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
const newSlots = [...(publishingSettings.publish_time_slots || []), newTime];
setPublishingSettings({ ...publishingSettings, publish_time_slots: newSlots });
savePublishingSettings({ publish_time_slots: newSlots });
// Don't auto-save - let user click Save button
}}
>
Add Time Slot

View File

@@ -20,6 +20,7 @@ import { useToast } from '../../components/ui/toast/ToastContainer';
import { FileIcon, TaskIcon, CheckCircleIcon, ArrowRightIcon } from '../../icons';
import { createContentPageConfig } from '../../config/pages/content.config';
import { useSectorStore } from '../../store/sectorStore';
import { useSiteStore } from '../../store/siteStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import ProgressModal from '../../components/common/ProgressModal';
import { useProgressModal } from '../../hooks/useProgressModal';
@@ -29,6 +30,7 @@ import { PencilSquareIcon } from '@heroicons/react/24/outline';
export default function Content() {
const toast = useToast();
const { activeSite } = useSiteStore();
const { activeSector } = useSectorStore();
const { pageSize } = usePageSizeStore();
@@ -62,40 +64,47 @@ export default function Content() {
const progressModal = useProgressModal();
const hasReloadedRef = useRef(false);
// Load total metrics for footer widget and header metrics (not affected by pagination)
// Load total metrics for footer widget and header metrics (site-wide totals, no sector filter)
const loadTotalMetrics = useCallback(async () => {
try {
// Get content with status='draft'
const draftRes = await fetchContent({
page_size: 1,
...(activeSector?.id && { sector_id: activeSector.id }),
status: 'draft',
});
// Batch all API calls in parallel for better performance
const [allRes, draftRes, reviewRes, publishedRes, imagesRes] = await Promise.all([
// Get all content (site-wide)
fetchContent({
page_size: 1,
site_id: activeSite?.id,
}),
// Get content with status='draft'
fetchContent({
page_size: 1,
site_id: activeSite?.id,
status: 'draft',
}),
// Get content with status='review'
fetchContent({
page_size: 1,
site_id: activeSite?.id,
status: 'review',
}),
// Get content with status='approved' or 'published' (ready for publishing or on site)
fetchContent({
page_size: 1,
site_id: activeSite?.id,
status__in: 'approved,published',
}),
// Get actual total images count
fetchImages({ page_size: 1 }),
]);
setTotalCount(allRes.count || 0);
setTotalDraft(draftRes.count || 0);
// Get content with status='review'
const reviewRes = await fetchContent({
page_size: 1,
...(activeSector?.id && { sector_id: activeSector.id }),
status: 'review',
});
setTotalReview(reviewRes.count || 0);
// Get content with status='approved' or 'published' (ready for publishing or on site)
const publishedRes = await fetchContent({
page_size: 1,
...(activeSector?.id && { sector_id: activeSector.id }),
status__in: 'approved,published',
});
setTotalPublished(publishedRes.count || 0);
// Get actual total images count
const imagesRes = await fetchImages({ page_size: 1 });
setTotalImagesCount(imagesRes.count || 0);
} catch (error) {
console.error('Error loading total metrics:', error);
}
}, [activeSector]);
}, [activeSite]);
// Load total metrics when sector changes
useEffect(() => {

View File

@@ -29,6 +29,7 @@ import { useToast } from '../../components/ui/toast/ToastContainer';
import { TaskIcon, PlusIcon, DownloadIcon, CheckCircleIcon } from '../../icons';
import { createTasksPageConfig } from '../../config/pages/tasks.config';
import { useSectorStore } from '../../store/sectorStore';
import { useSiteStore } from '../../store/siteStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader';
import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
@@ -36,6 +37,7 @@ import { DocumentTextIcon } from '@heroicons/react/24/outline';
export default function Tasks() {
const toast = useToast();
const { activeSite } = useSiteStore();
const { activeSector } = useSectorStore();
const { pageSize } = usePageSizeStore();
@@ -105,48 +107,54 @@ export default function Tasks() {
loadClusters();
}, []);
// Load total metrics for footer widget and header metrics (not affected by pagination)
// Load total metrics for footer widget and header metrics (site-wide totals, no sector filter)
const loadTotalMetrics = useCallback(async () => {
try {
// Get tasks with status='queued'
const queuedRes = await fetchTasks({
page_size: 1,
...(activeSector?.id && { sector_id: activeSector.id }),
status: 'queued',
});
// Batch all API calls in parallel for better performance
const [allRes, queuedRes, processingRes, completedRes, failedRes, imagesRes] = await Promise.all([
// Get all tasks (site-wide)
fetchTasks({
page_size: 1,
site_id: activeSite?.id,
}),
// Get tasks with status='queued'
fetchTasks({
page_size: 1,
site_id: activeSite?.id,
status: 'queued',
}),
// Get tasks with status='in_progress'
fetchTasks({
page_size: 1,
site_id: activeSite?.id,
status: 'in_progress',
}),
// Get tasks with status='completed'
fetchTasks({
page_size: 1,
site_id: activeSite?.id,
status: 'completed',
}),
// Get tasks with status='failed'
fetchTasks({
page_size: 1,
site_id: activeSite?.id,
status: 'failed',
}),
// Get actual total images count
fetchImages({ page_size: 1 }),
]);
setTotalCount(allRes.count || 0);
setTotalQueued(queuedRes.count || 0);
// Get tasks with status='in_progress'
const processingRes = await fetchTasks({
page_size: 1,
...(activeSector?.id && { sector_id: activeSector.id }),
status: 'in_progress',
});
setTotalProcessing(processingRes.count || 0);
// Get tasks with status='completed'
const completedRes = await fetchTasks({
page_size: 1,
...(activeSector?.id && { sector_id: activeSector.id }),
status: 'completed',
});
setTotalCompleted(completedRes.count || 0);
// Get tasks with status='failed'
const failedRes = await fetchTasks({
page_size: 1,
...(activeSector?.id && { sector_id: activeSector.id }),
status: 'failed',
});
setTotalFailed(failedRes.count || 0);
// Get actual total images count
const imagesRes = await fetchImages({ page_size: 1 });
setTotalImagesCount(imagesRes.count || 0);
} catch (error) {
console.error('Error loading total metrics:', error);
}
}, [activeSector]);
}, [activeSite]);
// Load total metrics when sector changes
useEffect(() => {

View File

@@ -16,6 +16,7 @@ import {
RefreshCwIcon,
DollarSignIcon,
TrendingUpIcon,
InfoIcon,
} from '../../icons';
import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
@@ -24,7 +25,7 @@ import { Card } from '../../components/ui/card';
import Button from '../../components/ui/button/Button';
import SelectDropdown from '../../components/form/SelectDropdown';
import Input from '../../components/form/input/InputField';
import { Pagination } from '../../components/ui/pagination/Pagination';
import { getCreditUsage, getCreditUsageSummary, type CreditUsageLog } from '../../services/billing.api';
import { getCreditUsage, type CreditUsageLog } from '../../services/billing.api';
// User-friendly operation names (no model/token details)
@@ -288,7 +289,7 @@ export default function UsageLogsPage() {
</div>
{/* Filters - Inline style like Planner pages */}
<div className="flex flex-wrap items-center gap-3">
<div className="grid grid-cols-2 gap-4">
<div className="w-44">
<SelectDropdown
options={OPERATION_OPTIONS}