/** * Sync Control Panel Component * Controls for triggering sync operations */ 'use client'; import { useState } from 'react'; import { EntityType, SyncType } from '@/lib/types/sync'; import EntitySelector from './EntitySelector'; import ChunkedSyncProgress from './ChunkedSyncProgress'; import EntitySyncProgress from './EntitySyncProgress'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { toast } from 'sonner'; import { Loader2, RefreshCw, PlayCircle, Zap, Calendar, Layers } from 'lucide-react'; interface SyncControlPanelProps { selectedEntities: EntityType[]; onSelectedEntitiesChange: (entities: EntityType[]) => void; onSyncStart: () => void; onSyncComplete: () => void; isSyncing: boolean; } export default function SyncControlPanel({ selectedEntities, onSelectedEntitiesChange, onSyncStart, onSyncComplete, isSyncing, }: SyncControlPanelProps) { const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [pendingSyncType, setPendingSyncType] = useState<'full' | 'incremental' | 'entity' | 'chunked' | null>(null); const [yearsBack, setYearsBack] = useState(0.019); // Default to 7 days // Chunked sync progress state const [isChunkedSyncing, setIsChunkedSyncing] = useState(false); const [chunkedProgress, setChunkedProgress] = useState({ completedChunks: 0, totalChunks: 0, totalRecords: 0, currentChunk: undefined as any, failedChunks: [] as string[], }); // Entity sync progress tracking const [activeSyncEntity, setActiveSyncEntity] = useState(null); const [syncId, setSyncId] = useState(null); const handleSync = async (syncType: 'full' | 'incremental' | 'entity' | 'chunked') => { if (syncType === 'entity' && selectedEntities.length === 0) { toast.error('Please select at least one entity to sync'); return; } if (syncType === 'full') { setPendingSyncType('full'); setShowConfirmDialog(true); return; } if (syncType === 'chunked') { await executeChunkedSync(); return; } await executeSyncRequest(syncType); }; const executeChunkedSync = async () => { try { const estimatedChunks = Math.ceil(yearsBack * 12); // Monthly chunks setIsChunkedSyncing(true); setChunkedProgress({ completedChunks: 0, totalChunks: estimatedChunks, totalRecords: 0, currentChunk: { index: 1, total: estimatedChunks, description: 'Starting chunked sync...', recordsProcessed: 0, status: 'in_progress' as const, }, failedChunks: [], }); onSyncStart(); const response = await fetch('/api/sync/tickets-chunked', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ yearsBack, triggeredBy: 'admin-ui', }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Chunked sync failed'); } const result = await response.json(); toast.success(result.message || 'Chunked ticket sync started successfully'); // Use the estimated chunks from above let currentPollChunk = 0; // Poll for sync completion by checking sync history // The sync runs in background, so we check periodically for updates const pollInterval = setInterval(async () => { try { const historyResponse = await fetch('/api/sync/history?limit=1&entity=tickets'); if (historyResponse.ok) { const historyData = await historyResponse.json(); const latestSync = historyData.history?.[0]; // Update progress estimate based on time elapsed currentPollChunk = Math.min(currentPollChunk + 1, estimatedChunks); setChunkedProgress(prev => ({ ...prev, completedChunks: currentPollChunk, totalChunks: estimatedChunks, totalRecords: latestSync?.records_added + latestSync?.records_updated || prev.totalRecords, currentChunk: { index: currentPollChunk, total: estimatedChunks, description: `Processing... (${currentPollChunk}/${estimatedChunks})`, recordsProcessed: latestSync?.records_added + latestSync?.records_updated || 0, status: 'in_progress' as const, }, })); // Check if the latest sync is completed or failed if (latestSync && (latestSync.status === 'completed' || latestSync.status === 'failed')) { clearInterval(pollInterval); setIsChunkedSyncing(false); setChunkedProgress(prev => ({ ...prev, completedChunks: estimatedChunks, currentChunk: undefined, })); onSyncComplete(); if (latestSync.status === 'completed') { toast.success(`Chunked sync completed! ${latestSync.records_added + latestSync.records_updated} records processed`); } else { toast.error('Chunked sync failed. Check logs for details.'); } } } } catch (pollError) { console.error('Error polling sync status:', pollError); } }, 5000); // Poll every 5 seconds // Fallback: Stop polling after 30 minutes setTimeout(() => { clearInterval(pollInterval); setIsChunkedSyncing(false); onSyncComplete(); toast.info('Sync is still running. Check sync history for final status.'); }, 30 * 60 * 1000); } catch (error) { console.error('Chunked sync error:', error); toast.error(error instanceof Error ? error.message : 'Failed to start chunked sync'); setIsChunkedSyncing(false); onSyncComplete(); } }; const executeSyncRequest = async (syncType: 'full' | 'incremental' | 'entity') => { try { onSyncStart(); // Track entity sync if it's a single entity if (syncType === 'entity' && selectedEntities.length === 1) { setActiveSyncEntity(selectedEntities[0]); setSyncId(`${selectedEntities[0]}_${Date.now()}`); } let endpoint = '/api/sync/full'; let body: any = { triggeredBy: 'admin-ui' }; if (syncType === 'incremental') { endpoint = '/api/sync/incremental'; } else if (syncType === 'entity') { endpoint = '/api/sync/entity'; body.entities = selectedEntities; body.syncType = SyncType.ENTITY_SPECIFIC; } const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...body, yearsBack }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Sync failed'); } const result = await response.json(); toast.success(result.message || 'Sync started successfully'); // Note: Sync runs in background. Dashboard will auto-refresh to show progress. // onSyncComplete will be called when user manually refreshes or after checking status } catch (error) { console.error('Sync error:', error); toast.error(error instanceof Error ? error.message : 'Failed to start sync'); onSyncComplete(); } }; const confirmFullSync = async () => { setShowConfirmDialog(false); if (pendingSyncType && pendingSyncType !== 'chunked') { await executeSyncRequest(pendingSyncType); setPendingSyncType(null); } }; return ( <> {/* Entity Sync Progress */} {activeSyncEntity && syncId && ( { setActiveSyncEntity(null); setSyncId(null); onSyncComplete(); }} onError={(error) => { toast.error(error); setActiveSyncEntity(null); setSyncId(null); onSyncComplete(); }} /> )} {/* Chunked Sync Progress */} {(isChunkedSyncing || chunkedProgress.totalChunks > 0) && ( )} Sync Controls Trigger manual sync operations to update PostgreSQL database from Autotask {/* Entity Selector */} {/* Date Range Selector for Time-Based Entities */}

Limits tickets, tasks, and projects to reduce sync time and API usage. Use "All Time" during off-hours for historical data.

{/* Sync Buttons */}

Full Sync: Syncs all entities and soft-deletes missing records

Incremental Sync: Only syncs records modified since last sync

Chunked Tickets: Syncs tickets in monthly chunks to prevent timeouts (recommended for large date ranges)

Sync Selected: Syncs only the selected entities

{/* Confirmation Dialog */} Confirm Full Sync This will sync all entities from Autotask and may take several minutes. Records not found in Autotask will be soft-deleted. Are you sure you want to continue? Cancel Start Full Sync ); }