phase 4-6 - with buggy contetn calendar page

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-16 16:55:39 +00:00
parent 1f0a31fe79
commit 7e8d667e6e
11 changed files with 3664 additions and 44 deletions

View File

@@ -21,6 +21,7 @@ import { useToast } from '../../components/ui/toast/ToastContainer';
import { CalendarItemTooltip } from '../../components/ui/tooltip';
import { useSiteStore } from '../../store/siteStore';
import { fetchContent, Content, fetchAPI } from '../../services/api';
import ScheduleContentModal from '../../components/common/ScheduleContentModal';
import {
ClockIcon,
CheckCircleIcon,
@@ -67,10 +68,15 @@ export default function ContentCalendar() {
const [viewMode, setViewMode] = useState<ViewMode>('calendar'); // Default to calendar view
const [draggedItem, setDraggedItem] = useState<Content | null>(null);
const [currentMonth, setCurrentMonth] = useState(new Date()); // Track current month for calendar
// Schedule modal state
const [showScheduleModal, setShowScheduleModal] = useState(false);
const [scheduleContent, setScheduleContent] = useState<Content | null>(null);
const [isRescheduling, setIsRescheduling] = useState(false);
// Derived state: Queue items (scheduled or publishing - exclude already published)
const queueItems = useMemo(() => {
return allContent
const items = allContent
.filter((c: Content) =>
(c.site_status === 'scheduled' || c.site_status === 'publishing') &&
(!c.external_id || c.external_id === '') // Exclude already published items
@@ -80,6 +86,13 @@ export default function ContentCalendar() {
const dateB = b.scheduled_publish_at ? new Date(b.scheduled_publish_at).getTime() : 0;
return dateA - dateB;
});
console.log('[ContentCalendar] queueItems (derived):', items.length, 'items');
items.forEach(item => {
console.log(' Queue item:', item.id, item.title, 'scheduled:', item.scheduled_publish_at);
});
return items;
}, [allContent]);
// Derived state: Published items (have external_id - same logic as Content Approved page)
@@ -98,12 +111,40 @@ export default function ContentCalendar() {
);
}, [allContent]);
// Derived state: Failed items (publish failures)
const failedItems = useMemo(() => {
const items = allContent
.filter((c: Content) => c.site_status === 'failed')
.sort((a: Content, b: Content) => {
// Sort by failure time (most recent first)
const dateA = a.site_status_updated_at ? new Date(a.site_status_updated_at).getTime() : 0;
const dateB = b.site_status_updated_at ? new Date(b.site_status_updated_at).getTime() : 0;
return dateB - dateA;
});
console.log('[ContentCalendar] failedItems (derived):', items.length, 'items');
items.forEach(item => {
console.log(' Failed item:', item.id, item.title, 'error:', item.site_status_message);
});
return items;
}, [allContent]);
// Calculate stats from allContent
const stats = useMemo(() => {
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
// DEBUG: Check scheduled items in stats calculation
const scheduledItems = allContent.filter((c: Content) =>
c.site_status === 'scheduled' && (!c.external_id || c.external_id === '')
);
console.log('[ContentCalendar] STATS CALCULATION - Scheduled items:', scheduledItems.length);
scheduledItems.forEach(c => {
console.log(' Stats scheduled item:', c.id, c.title, 'external_id:', c.external_id);
});
// Published in last 30 days - check EITHER external_id OR site_status='published'
const publishedLast30Days = allContent.filter((c: Content) => {
const isPublished = (c.external_id && c.external_id !== '') || c.site_status === 'published';
@@ -124,14 +165,13 @@ export default function ContentCalendar() {
return {
// Scheduled count excludes items that are already published
scheduled: allContent.filter((c: Content) =>
c.site_status === 'scheduled' && (!c.external_id || c.external_id === '') && c.site_status !== 'published'
).length,
scheduled: scheduledItems.length,
publishing: allContent.filter((c: Content) => c.site_status === 'publishing').length,
// Published: check EITHER external_id OR site_status='published'
published: allContent.filter((c: Content) =>
(c.external_id && c.external_id !== '') || c.site_status === 'published'
).length,
failed: allContent.filter((c: Content) => c.site_status === 'failed').length,
review: allContent.filter((c: Content) => c.status === 'review').length,
approved: allContent.filter((c: Content) =>
c.status === 'approved' && (!c.external_id || c.external_id === '') && c.site_status !== 'published'
@@ -142,32 +182,99 @@ export default function ContentCalendar() {
}, [allContent]);
const loadQueue = useCallback(async () => {
if (!activeSite?.id) return;
if (!activeSite?.id) {
console.log('[ContentCalendar] No active site selected, skipping load');
return;
}
try {
setLoading(true);
// Fetch all content for this site
const response = await fetchContent({
site_id: activeSite.id,
page_size: 200,
// IMPORTANT: Since content is ordered by -created_at, we need to fetch items by specific site_status
// Otherwise old scheduled/failed items will be on later pages and won't load
console.log('[ContentCalendar] ========== SITE FILTERING DEBUG ==========');
console.log('[ContentCalendar] Active site ID:', activeSite.id);
console.log('[ContentCalendar] Active site name:', activeSite.name);
console.log('[ContentCalendar] Fetching content with multiple targeted queries...');
// Fetch scheduled items (all of them, regardless of page)
const scheduledResponse = await fetchAPI('/v1/writer/content/', {
params: {
site_id: activeSite.id,
page_size: 1000,
site_status: 'scheduled', // Filter specifically for scheduled
}
});
// Debug: Log content with external_id (published status)
console.log('[ContentCalendar] Total content items:', response.results?.length);
console.log('[ContentCalendar] Published items (with external_id):', response.results?.filter(c => c.external_id && c.external_id !== '').length);
console.log('[ContentCalendar] Scheduled items:', response.results?.filter(c => c.site_status === 'scheduled').length);
console.log('[ContentCalendar] Sample content:', response.results?.slice(0, 3).map(c => ({
id: c.id,
title: c.title,
status: c.status,
site_status: c.site_status,
external_id: c.external_id,
scheduled_publish_at: c.scheduled_publish_at,
updated_at: c.updated_at
})));
// Fetch failed items (all of them)
const failedResponse = await fetchAPI('/v1/writer/content/', {
params: {
site_id: activeSite.id,
page_size: 1000,
site_status: 'failed', // Filter specifically for failed
}
});
setAllContent(response.results || []);
// Fetch approved items (for sidebar drag-drop)
const approvedResponse = await fetchAPI('/v1/writer/content/', {
params: {
site_id: activeSite.id,
page_size: 100,
status: 'approved', // Approved workflow status
}
});
// Fetch published items (with external_id) for display
const publishedResponse = await fetchAPI('/v1/writer/content/', {
params: {
site_id: activeSite.id,
page_size: 100,
ordering: '-updated_at', // Most recently published first
}
});
// Combine all results, removing duplicates by ID
const allItems = [
...(scheduledResponse.results || []),
...(failedResponse.results || []),
...(approvedResponse.results || []),
...(publishedResponse.results || []),
];
// Remove duplicates by ID
const uniqueItems = Array.from(
new Map(allItems.map(item => [item.id, item])).values()
);
// Debug: Comprehensive logging
console.log('[ContentCalendar] ========== DATA LOAD DEBUG ==========');
console.log('[ContentCalendar] Scheduled query returned:', scheduledResponse.results?.length, 'items');
console.log('[ContentCalendar] Failed query returned:', failedResponse.results?.length, 'items');
console.log('[ContentCalendar] Approved query returned:', approvedResponse.results?.length, 'items');
console.log('[ContentCalendar] Published query returned:', publishedResponse.results?.length, 'items');
console.log('[ContentCalendar] Total unique items after deduplication:', uniqueItems.length);
console.log('[ContentCalendar] ALL SCHEDULED ITEMS DETAILS:');
scheduledResponse.results?.forEach(c => {
console.log(' - ID:', c.id, '| Title:', c.title);
console.log(' status:', c.status, '| site_status:', c.site_status);
console.log(' scheduled_publish_at:', c.scheduled_publish_at);
console.log(' external_id:', c.external_id);
console.log(' ---');
});
console.log('[ContentCalendar] ALL FAILED ITEMS DETAILS:');
failedResponse.results?.forEach(c => {
console.log(' - ID:', c.id, '| Title:', c.title);
console.log(' status:', c.status, '| site_status:', c.site_status);
console.log(' site_status_message:', c.site_status_message);
console.log(' scheduled_publish_at:', c.scheduled_publish_at);
console.log(' ---');
});
console.log('[ContentCalendar] ====================================');
setAllContent(uniqueItems);
} catch (error: any) {
toast.error(`Failed to load content: ${error.message}`);
} finally {
@@ -175,11 +282,54 @@ export default function ContentCalendar() {
}
}, [activeSite?.id, toast]);
// Load queue when active site changes
useEffect(() => {
if (activeSite?.id) {
console.log('[ContentCalendar] Site changed to:', activeSite.id, activeSite.name);
console.log('[ContentCalendar] Triggering loadQueue...');
loadQueue();
} else {
console.log('[ContentCalendar] No active site, clearing content');
setAllContent([]);
}
}, [activeSite?.id]); // Only depend on activeSite.id, loadQueue is stable
// Reschedule content
const handleRescheduleContent = useCallback(async (contentId: number, scheduledDate: string) => {
try {
await fetchAPI(`/v1/writer/content/${contentId}/reschedule/`, {
method: 'POST',
body: JSON.stringify({ scheduled_at: scheduledDate }),
});
toast.success('Rescheduled successfully');
loadQueue(); // Reload calendar
} catch (error: any) {
toast.error(`Failed to reschedule: ${error.message}`);
throw error;
}
}, [toast, loadQueue]);
// Open reschedule modal
const openRescheduleModal = useCallback((item: Content) => {
setScheduleContent(item);
setIsRescheduling(true);
setShowScheduleModal(true);
}, []);
// Handle schedule/reschedule from modal
const handleScheduleFromModal = useCallback(async (contentId: number, scheduledDate: string) => {
if (isRescheduling) {
await handleRescheduleContent(contentId, scheduledDate);
} else {
const response = await scheduleContent(contentId, scheduledDate);
toast.success('Scheduled successfully');
loadQueue();
}
}, [activeSite?.id]); // Removed loadQueue from dependencies to prevent reload loops
setShowScheduleModal(false);
setScheduleContent(null);
setIsRescheduling(false);
}, [isRescheduling, handleRescheduleContent, toast, loadQueue]);
// Drag and drop handlers for list view
const handleDragStart = (e: React.DragEvent, item: Content, source: 'queue' | 'approved') => {
@@ -589,6 +739,14 @@ export default function ContentCalendar() {
</div>
{getStatusBadge(item)}
<div className="flex items-center gap-1">
<IconButton
icon={<PencilIcon className="w-4 h-4" />}
variant="ghost"
tone="neutral"
size="sm"
onClick={() => openRescheduleModal(item)}
title="Edit schedule"
/>
<IconButton
icon={<EyeIcon className="w-4 h-4" />}
variant="ghost"
@@ -753,8 +911,9 @@ export default function ContentCalendar() {
)}
</div>
{/* Approved Content Sidebar - reduced width by 15% (80 -> 68) */}
<div className="w-68 flex-shrink-0">
{/* Right Sidebar - Contains Approved and Failed Items */}
<div className="w-68 flex-shrink-0 space-y-4">
{/* Approved Content Card */}
<ComponentCard title="Approved Content" desc="Drag to publishing queue to schedule">
<div className="space-y-2 max-h-[600px] overflow-y-auto">
{approvedItems.length === 0 ? (
@@ -797,8 +956,77 @@ export default function ContentCalendar() {
)}
</div>
</ComponentCard>
{/* Failed Items Card - Show below Approved if any exist */}
{failedItems.length > 0 && (
<ComponentCard
title="Failed Publishes"
desc="Items that failed to publish. Review and retry."
headerContent={
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/writer/approved?site_status=failed')}
>
View All
</Button>
}
>
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{failedItems.map(item => (
<div
key={item.id}
className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800"
>
<h4 className="font-medium text-gray-900 dark:text-white text-sm truncate">
{item.title}
</h4>
<div className="mt-2 space-y-1">
{item.site_status_message && (
<p className="text-xs text-red-600 dark:text-red-400 line-clamp-2">
{item.site_status_message}
</p>
)}
<div className="flex items-center gap-1 pt-1">
<Button
variant="outline"
size="xs"
onClick={() => openRescheduleModal(item)}
startIcon={<CalendarIcon className="w-3 h-3" />}
>
Reschedule
</Button>
<Button
variant="ghost"
size="xs"
onClick={() => navigate(`/writer/content/${item.id}`)}
>
View Details
</Button>
</div>
</div>
</div>
))}
</div>
</ComponentCard>
)}
</div>
</div>
{/* Schedule/Reschedule Modal */}
{showScheduleModal && scheduleContent && (
<ScheduleContentModal
isOpen={showScheduleModal}
onClose={() => {
setShowScheduleModal(false);
setScheduleContent(null);
setIsRescheduling(false);
}}
content={scheduleContent}
onSchedule={handleScheduleFromModal}
mode={isRescheduling ? 'reschedule' : 'schedule'}
/>
)}
</>
);
}

View File

@@ -32,6 +32,7 @@ import PublishLimitModal from '../../components/common/PublishLimitModal';
import ScheduleContentModal from '../../components/common/ScheduleContentModal';
import BulkScheduleModal from '../../components/common/BulkScheduleModal';
import BulkSchedulePreviewModal from '../../components/common/BulkSchedulePreviewModal';
import ErrorDetailsModal from '../../components/common/ErrorDetailsModal';
export default function Approved() {
const toast = useToast();
@@ -91,6 +92,10 @@ export default function Approved() {
const [bulkScheduleItems, setBulkScheduleItems] = useState<Content[]>([]);
const [bulkSchedulePreview, setBulkSchedulePreview] = useState<any>(null);
// Error details modal state
const [showErrorDetailsModal, setShowErrorDetailsModal] = useState(false);
const [errorContent, setErrorContent] = useState<Content | null>(null);
// Load dynamic filter options based on current site's data and applied filters
// This implements cascading filters - each filter's options reflect what's available
// given the other currently applied filters
@@ -482,6 +487,9 @@ export default function Approved() {
if (window.confirm(`Are you sure you want to unschedule "${row.title}"?`)) {
await handleUnscheduleContent(row.id);
}
} else if (action === 'view_error') {
setErrorContent(row);
setShowErrorDetailsModal(true);
} else if (action === 'edit') {
// Navigate to content editor
if (row.site_id) {
@@ -609,8 +617,14 @@ export default function Approved() {
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
if (action === 'bulk_publish_site') {
await handleBulkPublishToSite(ids);
} else if (action === 'bulk_schedule_manual') {
// Manual bulk scheduling (same time for all)
handleBulkScheduleManual(ids);
} else if (action === 'bulk_schedule_defaults') {
// Schedule with site defaults
handleBulkScheduleWithDefaults(ids);
}
}, [handleBulkPublishToSite]);
}, [handleBulkPublishToSite, handleBulkScheduleManual, handleBulkScheduleWithDefaults]);
// Bulk status update handler
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
@@ -886,6 +900,28 @@ export default function Approved() {
onChangeSettings={handleOpenSiteSettings}
siteId={activeSite?.id || 0}
/>
<ErrorDetailsModal
isOpen={showErrorDetailsModal}
onClose={() => {
setShowErrorDetailsModal(false);
setErrorContent(null);
}}
content={errorContent}
site={activeSite}
onPublishNow={() => {
if (errorContent) {
handleSinglePublish(errorContent);
}
}}
onReschedule={() => {
if (errorContent) {
setScheduleContent(errorContent);
setShowScheduleModal(true);
}
}}
onFixSettings={handleOpenSiteSettings}
/>
</>
);
}