Add comprehensive admin features and multi-system integration
- Add admin dashboard with sync controls and data browser - Implement RMM, Auvik, and Addigy organization mappings - Add chunked ticket sync with progress tracking - Implement entity sync service with rate limiting - Add analytics engine and performance optimizer - Create data browser for all PSA entities - Add navigation components and UI improvements - Implement background processing and sync services - Add comprehensive documentation and migration scripts - Update configuration items with multi-system support - Enhance contact management and purchase history - Add issue type assignment and LLM analyzer - Improve error handling and logging utilities
This commit is contained in:
parent
e8462ef301
commit
6eee14f8af
171 changed files with 32671 additions and 621 deletions
174
components/admin/ChunkedSyncProgress.tsx
Normal file
174
components/admin/ChunkedSyncProgress.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* Chunked Sync Progress Component
|
||||
* Displays animated progress bar for chunked ticket sync operations
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle2, XCircle, Loader2, Calendar } from 'lucide-react';
|
||||
|
||||
interface ChunkInfo {
|
||||
index: number;
|
||||
total: number;
|
||||
description: string;
|
||||
recordsProcessed: number;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
interface ChunkedSyncProgressProps {
|
||||
isActive: boolean;
|
||||
currentChunk?: ChunkInfo;
|
||||
completedChunks?: number;
|
||||
totalChunks?: number;
|
||||
totalRecords?: number;
|
||||
failedChunks?: string[];
|
||||
}
|
||||
|
||||
export default function ChunkedSyncProgress({
|
||||
isActive,
|
||||
currentChunk,
|
||||
completedChunks = 0,
|
||||
totalChunks = 0,
|
||||
totalRecords = 0,
|
||||
failedChunks = [],
|
||||
}: ChunkedSyncProgressProps) {
|
||||
const [animatedProgress, setAnimatedProgress] = useState(0);
|
||||
|
||||
// Animate progress bar
|
||||
useEffect(() => {
|
||||
if (totalChunks === 0) return;
|
||||
|
||||
const targetProgress = (completedChunks / totalChunks) * 100;
|
||||
|
||||
// Smooth animation
|
||||
const step = (targetProgress - animatedProgress) / 10;
|
||||
const interval = setInterval(() => {
|
||||
setAnimatedProgress(prev => {
|
||||
const next = prev + step;
|
||||
if (Math.abs(next - targetProgress) < 0.5) {
|
||||
clearInterval(interval);
|
||||
return targetProgress;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 50);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [completedChunks, totalChunks]);
|
||||
|
||||
if (!isActive && totalChunks === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const progressPercentage = totalChunks > 0 ? (completedChunks / totalChunks) * 100 : 0;
|
||||
const hasFailures = failedChunks.length > 0;
|
||||
|
||||
return (
|
||||
<Card className="border-blue-200 bg-blue-50/50 dark:border-blue-800 dark:bg-blue-950/20">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-blue-600" />
|
||||
<CardTitle className="text-lg">Chunked Ticket Sync Progress</CardTitle>
|
||||
</div>
|
||||
{isActive ? (
|
||||
<Badge variant="default" className="bg-blue-600">
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||
Syncing
|
||||
</Badge>
|
||||
) : hasFailures ? (
|
||||
<Badge variant="destructive">
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
Completed with Errors
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
Completed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
Processing tickets in monthly chunks to prevent timeouts
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Progress Bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">
|
||||
{currentChunk?.description || 'Preparing...'}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{completedChunks} / {totalChunks} chunks
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={animatedProgress}
|
||||
className="h-3 transition-all duration-300"
|
||||
aria-label={`Sync progress: ${Math.round(progressPercentage)}% complete`}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{Math.round(progressPercentage)}% complete</span>
|
||||
<span>{totalRecords.toLocaleString()} records processed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chunk Details */}
|
||||
{currentChunk && isActive && (
|
||||
<div className="p-3 bg-white dark:bg-card rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Loader2 className="w-4 h-4 text-blue-600 dark:text-blue-400 animate-spin" />
|
||||
<span className="font-medium text-sm">
|
||||
Processing: {currentChunk.description}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Chunk {currentChunk.index} of {currentChunk.total} • {currentChunk.recordsProcessed.toLocaleString()} records so far
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Failed Chunks */}
|
||||
{failedChunks.length > 0 && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<XCircle className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
<span className="font-medium text-sm text-red-900 dark:text-red-100">
|
||||
{failedChunks.length} chunk{failedChunks.length > 1 ? 's' : ''} failed
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{failedChunks.slice(0, 3).map((chunk, idx) => (
|
||||
<li key={idx} className="text-xs text-red-800 dark:text-red-200">
|
||||
• {chunk}
|
||||
</li>
|
||||
))}
|
||||
{failedChunks.length > 3 && (
|
||||
<li className="text-xs text-red-600 dark:text-red-400 italic">
|
||||
... and {failedChunks.length - 3} more
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completion Summary */}
|
||||
{!isActive && totalChunks > 0 && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-950/20 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
<span className="font-medium text-sm text-green-900 dark:text-green-100">
|
||||
Sync completed: {totalRecords.toLocaleString()} records processed across {completedChunks} chunks
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
212
components/admin/DataTable.tsx
Normal file
212
components/admin/DataTable.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Search, ArrowUpDown, ArrowUp, ArrowDown, Loader2 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface Column {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
render?: (value: any, row: any) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface DataTableProps {
|
||||
columns: Column[];
|
||||
data: any[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onSort?: (column: string, direction: 'asc' | 'desc') => void;
|
||||
onSearch?: (query: string) => void;
|
||||
onRowClick?: (row: any) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function DataTable({
|
||||
columns,
|
||||
data,
|
||||
totalCount,
|
||||
page,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
onSort,
|
||||
onSearch,
|
||||
onRowClick,
|
||||
isLoading = false,
|
||||
}: DataTableProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
|
||||
const handleSort = (columnKey: string) => {
|
||||
if (!onSort) return;
|
||||
|
||||
const newDirection = sortColumn === columnKey && sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
setSortColumn(columnKey);
|
||||
setSortDirection(newDirection);
|
||||
onSort(columnKey, newDirection);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
if (onSearch) {
|
||||
onSearch(searchQuery);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search Bar */}
|
||||
{onSearch && (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch} disabled={isLoading}>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||
<span className="ml-2">Search</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="border rounded-lg overflow-hidden bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column.key} className="font-semibold">
|
||||
{column.sortable ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSort(column.key)}
|
||||
className="h-8 -ml-3 hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
{column.label}
|
||||
{sortColumn === column.key ? (
|
||||
sortDirection === 'asc' ? (
|
||||
<ArrowUp className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="ml-2 h-4 w-4" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
column.label
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, index) => (
|
||||
<TableRow key={index}>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.key}>
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<Search className="w-8 h-8 opacity-50" />
|
||||
<p className="text-sm font-medium">No data found</p>
|
||||
<p className="text-xs">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((row, index) => (
|
||||
<TableRow
|
||||
key={row.id || index}
|
||||
className={onRowClick ? 'cursor-pointer hover:bg-muted/50 transition-colors' : ''}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.key}>
|
||||
{column.render ? column.render(row[column.key], row) : row[column.key]}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-2">
|
||||
<div className="text-sm text-muted-foreground font-medium">
|
||||
Showing <span className="font-semibold text-foreground">{Math.min((page - 1) * pageSize + 1, totalCount)}</span> to{' '}
|
||||
<span className="font-semibold text-foreground">{Math.min(page * pageSize, totalCount)}</span> of{' '}
|
||||
<span className="font-semibold text-foreground">{totalCount}</span> results
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={page === 1 || isLoading}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page === 1 || isLoading}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 px-3">
|
||||
<span className="text-sm font-medium">
|
||||
Page {page} of {totalPages || 1}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page === totalPages || isLoading}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={page === totalPages || isLoading}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
components/admin/DetailModal.tsx
Normal file
130
components/admin/DetailModal.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
'use client';
|
||||
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Calendar, Check, X, FileText, Copy, CheckCircle2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface DetailModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
data: Record<string, any> | null;
|
||||
fields?: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
render?: (value: any) => React.ReactNode;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function DetailModal({ open, onOpenChange, title, data, fields }: DetailModalProps) {
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const copyToClipboard = (text: string, fieldKey: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedField(fieldKey);
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
};
|
||||
|
||||
const renderValue = (value: any): React.ReactNode => {
|
||||
if (value === null || value === undefined) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-muted-foreground italic text-xs">
|
||||
<X className="w-3 h-3" />
|
||||
null
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<Badge variant={value ? 'default' : 'secondary'} className="gap-1">
|
||||
{value ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />}
|
||||
{value ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (value instanceof Date || (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/))) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm">
|
||||
<Calendar className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{new Date(value).toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return (
|
||||
<pre className="text-xs bg-muted p-3 rounded-md overflow-x-auto border">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
return <span className="text-sm">{String(value)}</span>;
|
||||
};
|
||||
|
||||
const displayFields = fields || Object.keys(data).map(key => ({ key, label: key, render: undefined }));
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
||||
<DialogDescription className="mt-1.5">
|
||||
Detailed view of record • {displayFields.length} fields
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
ID: {data.id}
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 -mr-2">
|
||||
<div className="space-y-1 py-4">
|
||||
{displayFields.map((field, index) => {
|
||||
const value = data[field.key];
|
||||
const stringValue = value !== null && value !== undefined ? String(value) : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
className="group grid grid-cols-[200px_1fr] gap-6 py-3 px-4 rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText className="w-4 h-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div className="font-medium text-sm text-muted-foreground">
|
||||
{field.label}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 break-words min-w-0">
|
||||
{field.render ? field.render(value) : renderValue(value)}
|
||||
</div>
|
||||
{stringValue && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={() => copyToClipboard(stringValue, field.key)}
|
||||
>
|
||||
{copiedField === field.key ? (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
97
components/admin/EntitySelector.tsx
Normal file
97
components/admin/EntitySelector.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Entity Selector Component
|
||||
* Checkbox grid for selecting entities to sync
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { EntityType } from '@/lib/types/sync';
|
||||
import { getEntityDisplayName } from '@/lib/utils/sync-helpers';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
interface EntitySelectorProps {
|
||||
selectedEntities: EntityType[];
|
||||
onChange: (entities: EntityType[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ALL_ENTITIES: EntityType[] = [
|
||||
EntityType.COMPANIES,
|
||||
EntityType.RESOURCES,
|
||||
EntityType.STATUSES,
|
||||
EntityType.ISSUE_TYPES,
|
||||
EntityType.SUB_ISSUE_TYPES,
|
||||
EntityType.WORK_TYPES,
|
||||
EntityType.CONTACTS,
|
||||
EntityType.PROJECTS,
|
||||
EntityType.TICKETS,
|
||||
EntityType.TASKS,
|
||||
EntityType.CONFIGURATION_ITEMS,
|
||||
EntityType.CONTRACTS,
|
||||
EntityType.BILLING_ITEMS,
|
||||
EntityType.TIME_ENTRIES,
|
||||
];
|
||||
|
||||
export default function EntitySelector({
|
||||
selectedEntities,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: EntitySelectorProps) {
|
||||
const handleToggle = (entity: EntityType) => {
|
||||
if (selectedEntities.includes(entity)) {
|
||||
onChange(selectedEntities.filter(e => e !== entity));
|
||||
} else {
|
||||
onChange([...selectedEntities, entity]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedEntities.length === ALL_ENTITIES.length) {
|
||||
onChange([]);
|
||||
} else {
|
||||
onChange([...ALL_ENTITIES]);
|
||||
}
|
||||
};
|
||||
|
||||
const allSelected = selectedEntities.length === ALL_ENTITIES.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base font-semibold">Select Entities</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAll}
|
||||
disabled={disabled}
|
||||
className="text-sm text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
{allSelected ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 md:gap-4">
|
||||
{ALL_ENTITIES.map((entity) => (
|
||||
<div key={entity} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={entity}
|
||||
checked={selectedEntities.includes(entity)}
|
||||
onCheckedChange={() => handleToggle(entity)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={entity}
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
{getEntityDisplayName(entity)}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedEntities.length} of {ALL_ENTITIES.length} entities selected
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
246
components/admin/EntitySyncProgress.tsx
Normal file
246
components/admin/EntitySyncProgress.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, CheckCircle2, XCircle, Database, ArrowDownToLine, FileEdit, Trash2 } from 'lucide-react';
|
||||
|
||||
interface SyncProgressState {
|
||||
syncId: string;
|
||||
entityType: string;
|
||||
status: 'idle' | 'running' | 'completed' | 'failed';
|
||||
currentPage: number;
|
||||
totalRecords: number;
|
||||
estimatedTotal?: number;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
error?: string;
|
||||
phase: 'fetching' | 'mapping' | 'upserting' | 'deleting' | 'completed';
|
||||
}
|
||||
|
||||
interface EntitySyncProgressProps {
|
||||
entityType: string;
|
||||
syncId?: string;
|
||||
onComplete?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
const PHASE_LABELS = {
|
||||
fetching: 'Fetching from Autotask',
|
||||
mapping: 'Mapping records',
|
||||
upserting: 'Saving to database',
|
||||
deleting: 'Cleaning up',
|
||||
completed: 'Completed',
|
||||
};
|
||||
|
||||
const PHASE_ICONS = {
|
||||
fetching: ArrowDownToLine,
|
||||
mapping: FileEdit,
|
||||
upserting: Database,
|
||||
deleting: Trash2,
|
||||
completed: CheckCircle2,
|
||||
};
|
||||
|
||||
export default function EntitySyncProgress({
|
||||
entityType,
|
||||
syncId,
|
||||
onComplete,
|
||||
onError,
|
||||
}: EntitySyncProgressProps) {
|
||||
const [progress, setProgress] = useState<SyncProgressState | null>(null);
|
||||
const [animatedProgress, setAnimatedProgress] = useState(0);
|
||||
|
||||
// Poll for progress updates
|
||||
useEffect(() => {
|
||||
let pollInterval: NodeJS.Timeout;
|
||||
let mounted = true;
|
||||
|
||||
const fetchProgress = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (syncId) {
|
||||
params.append('syncId', syncId);
|
||||
} else {
|
||||
params.append('entityType', entityType);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/sync/progress?${params}`);
|
||||
if (!response.ok) {
|
||||
// No progress found yet
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const progressData = data.progress;
|
||||
|
||||
if (mounted && progressData) {
|
||||
setProgress(progressData);
|
||||
|
||||
// Handle completion
|
||||
if (progressData.status === 'completed' && onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (progressData.status === 'failed' && onError) {
|
||||
onError(progressData.error || 'Sync failed');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching sync progress:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
fetchProgress();
|
||||
|
||||
// Poll every 2 seconds while sync is running
|
||||
pollInterval = setInterval(() => {
|
||||
if (progress?.status === 'running') {
|
||||
fetchProgress();
|
||||
} else if (progress?.status === 'completed' || progress?.status === 'failed') {
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearInterval(pollInterval);
|
||||
};
|
||||
}, [entityType, syncId, progress?.status, onComplete, onError]);
|
||||
|
||||
// Animate progress bar
|
||||
useEffect(() => {
|
||||
if (!progress) return;
|
||||
|
||||
let targetProgress = 0;
|
||||
|
||||
// Calculate progress based on phase
|
||||
switch (progress.phase) {
|
||||
case 'fetching':
|
||||
targetProgress = 25;
|
||||
break;
|
||||
case 'mapping':
|
||||
targetProgress = 50;
|
||||
break;
|
||||
case 'upserting':
|
||||
targetProgress = 75;
|
||||
break;
|
||||
case 'deleting':
|
||||
targetProgress = 90;
|
||||
break;
|
||||
case 'completed':
|
||||
targetProgress = 100;
|
||||
break;
|
||||
}
|
||||
|
||||
// Smooth animation
|
||||
const step = (targetProgress - animatedProgress) / 10;
|
||||
const interval = setInterval(() => {
|
||||
setAnimatedProgress((prev) => {
|
||||
const next = prev + step;
|
||||
if (Math.abs(next - targetProgress) < 1) {
|
||||
clearInterval(interval);
|
||||
return targetProgress;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 50);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [progress?.phase]);
|
||||
|
||||
if (!progress || progress.status === 'idle') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const PhaseIcon = PHASE_ICONS[progress.phase];
|
||||
const duration = progress.endTime
|
||||
? Math.round((progress.endTime - progress.startTime) / 1000)
|
||||
: Math.round((Date.now() - progress.startTime) / 1000);
|
||||
|
||||
return (
|
||||
<Card className="border-2 dark:border-gray-700">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{progress.status === 'running' && (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-blue-500" />
|
||||
)}
|
||||
{progress.status === 'completed' && (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
)}
|
||||
{progress.status === 'failed' && (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
<CardTitle className="text-lg">
|
||||
{entityType.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} Sync
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
progress.status === 'running' ? 'default' :
|
||||
progress.status === 'completed' ? 'secondary' :
|
||||
'destructive'
|
||||
}
|
||||
className={
|
||||
progress.status === 'running' ? 'bg-blue-500 hover:bg-blue-600' : ''
|
||||
}
|
||||
>
|
||||
{progress.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="flex items-center gap-2 mt-2">
|
||||
<PhaseIcon className="h-4 w-4" />
|
||||
<span>{PHASE_LABELS[progress.phase]}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Progress Bar */}
|
||||
<div className="space-y-2">
|
||||
<Progress
|
||||
value={animatedProgress}
|
||||
className="h-2"
|
||||
aria-label={`Sync progress: ${Math.round(animatedProgress)}%`}
|
||||
/>
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{Math.round(animatedProgress)}% complete</span>
|
||||
<span>{duration}s elapsed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{progress.totalRecords > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4 pt-2 border-t dark:border-gray-700">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-muted-foreground">Records Processed</p>
|
||||
<p className="text-2xl font-bold">{progress.totalRecords.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-muted-foreground">Current Phase</p>
|
||||
<p className="text-lg font-semibold capitalize">{progress.phase}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{progress.status === 'failed' && progress.error && (
|
||||
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-md p-3">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{progress.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completion Message */}
|
||||
{progress.status === 'completed' && (
|
||||
<div className="bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800 rounded-md p-3">
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
✓ Successfully synced {progress.totalRecords.toLocaleString()} records in {duration}s
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
402
components/admin/SyncControlPanel.tsx
Normal file
402
components/admin/SyncControlPanel.tsx
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
/**
|
||||
* 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<number>(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<EntityType | null>(null);
|
||||
const [syncId, setSyncId] = useState<string | null>(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 && (
|
||||
<EntitySyncProgress
|
||||
entityType={activeSyncEntity}
|
||||
syncId={syncId}
|
||||
onComplete={() => {
|
||||
setActiveSyncEntity(null);
|
||||
setSyncId(null);
|
||||
onSyncComplete();
|
||||
}}
|
||||
onError={(error) => {
|
||||
toast.error(error);
|
||||
setActiveSyncEntity(null);
|
||||
setSyncId(null);
|
||||
onSyncComplete();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Chunked Sync Progress */}
|
||||
{(isChunkedSyncing || chunkedProgress.totalChunks > 0) && (
|
||||
<ChunkedSyncProgress
|
||||
isActive={isChunkedSyncing}
|
||||
currentChunk={chunkedProgress.currentChunk}
|
||||
completedChunks={chunkedProgress.completedChunks}
|
||||
totalChunks={chunkedProgress.totalChunks}
|
||||
totalRecords={chunkedProgress.totalRecords}
|
||||
failedChunks={chunkedProgress.failedChunks}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sync Controls</CardTitle>
|
||||
<CardDescription>
|
||||
Trigger manual sync operations to update PostgreSQL database from Autotask
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Entity Selector */}
|
||||
<EntitySelector
|
||||
selectedEntities={selectedEntities}
|
||||
onChange={onSelectedEntitiesChange}
|
||||
disabled={isSyncing}
|
||||
/>
|
||||
|
||||
{/* Date Range Selector for Time-Based Entities */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="years-back" className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Date Range for Tickets/Tasks
|
||||
</Label>
|
||||
<Select
|
||||
value={yearsBack.toString()}
|
||||
onValueChange={(value) => setYearsBack(parseFloat(value))}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
<SelectTrigger id="years-back" className="w-full sm:w-64">
|
||||
<SelectValue placeholder="Select date range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0.019">Last 7 Days</SelectItem>
|
||||
<SelectItem value="0.082">Last 30 Days</SelectItem>
|
||||
<SelectItem value="0.25">Last 90 Days</SelectItem>
|
||||
<SelectItem value="1">Last 1 Year</SelectItem>
|
||||
<SelectItem value="2">Last 2 Years (Recommended)</SelectItem>
|
||||
<SelectItem value="3">Last 3 Years</SelectItem>
|
||||
<SelectItem value="5">Last 5 Years</SelectItem>
|
||||
<SelectItem value="10">Last 10 Years</SelectItem>
|
||||
<SelectItem value="999">All Time (Slow)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Limits tickets, tasks, and projects to reduce sync time and API usage.
|
||||
Use "All Time" during off-hours for historical data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sync Buttons */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Button
|
||||
onClick={() => handleSync('full')}
|
||||
disabled={isSyncing || isChunkedSyncing}
|
||||
size="lg"
|
||||
variant="default"
|
||||
className="w-full"
|
||||
>
|
||||
{isSyncing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Full Sync
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSync('incremental')}
|
||||
disabled={isSyncing || isChunkedSyncing}
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
>
|
||||
{isSyncing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Incremental Sync
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSync('chunked')}
|
||||
disabled={isSyncing || isChunkedSyncing}
|
||||
size="lg"
|
||||
variant="default"
|
||||
className="w-full bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{isChunkedSyncing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Layers className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Chunked Tickets
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSync('entity')}
|
||||
disabled={isSyncing || isChunkedSyncing || selectedEntities.length === 0}
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
{isSyncing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Sync Selected ({selectedEntities.length})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p><strong>Full Sync:</strong> Syncs all entities and soft-deletes missing records</p>
|
||||
<p><strong>Incremental Sync:</strong> Only syncs records modified since last sync</p>
|
||||
<p><strong>Chunked Tickets:</strong> Syncs tickets in monthly chunks to prevent timeouts (recommended for large date ranges)</p>
|
||||
<p><strong>Sync Selected:</strong> Syncs only the selected entities</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Full Sync</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
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?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmFullSync}>
|
||||
Start Full Sync
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
153
components/admin/SyncDashboard.tsx
Normal file
153
components/admin/SyncDashboard.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* Sync Dashboard Component
|
||||
* Displays sync status and last sync information
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { getEntityDisplayName } from '@/lib/utils/sync-helpers';
|
||||
import { EntityType } from '@/lib/types/sync';
|
||||
|
||||
interface LastSyncInfo {
|
||||
[key: string]: {
|
||||
completed_at: string;
|
||||
status: string;
|
||||
records_added: number;
|
||||
records_updated: number;
|
||||
records_deleted: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface SyncDashboardProps {
|
||||
refreshKey: number;
|
||||
}
|
||||
|
||||
export default function SyncDashboard({ refreshKey }: SyncDashboardProps) {
|
||||
const [lastSyncInfo, setLastSyncInfo] = useState<LastSyncInfo>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLastSyncInfo();
|
||||
}, [refreshKey]);
|
||||
|
||||
const fetchLastSyncInfo = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/sync/last-sync');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setLastSyncInfo(data.lastSync || {});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch last sync info:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sync Status</CardTitle>
|
||||
<CardDescription>
|
||||
Last sync information for each entity
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 md:gap-4">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="border rounded-lg p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
|
||||
<div className="h-5 w-16 bg-muted animate-pulse rounded-full" />
|
||||
</div>
|
||||
<div className="h-3 w-32 bg-muted animate-pulse rounded" />
|
||||
<div className="space-y-1">
|
||||
<div className="h-3 w-full bg-muted animate-pulse rounded" />
|
||||
<div className="h-3 w-full bg-muted animate-pulse rounded" />
|
||||
<div className="h-3 w-full bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const entityKeys = Object.keys(lastSyncInfo);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sync Status</CardTitle>
|
||||
<CardDescription>
|
||||
Last sync information for each entity
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{entityKeys.length === 0 ? (
|
||||
<p className="text-muted-foreground">No sync history available</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 md:gap-4">
|
||||
{entityKeys.map((entityKey) => {
|
||||
const info = lastSyncInfo[entityKey];
|
||||
const completedAt = new Date(info.completed_at);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entityKey}
|
||||
className="group relative overflow-hidden rounded-lg border bg-card p-4 transition-all hover:shadow-md hover:border-primary/50 space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-semibold text-sm truncate">
|
||||
{getEntityDisplayName(entityKey as EntityType)}
|
||||
</h4>
|
||||
<Badge
|
||||
variant={info.status === 'completed' ? 'default' : 'destructive'}
|
||||
className="text-xs shrink-0"
|
||||
>
|
||||
{info.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(completedAt, { addSuffix: true })}
|
||||
</p>
|
||||
|
||||
<div className="text-xs space-y-1 pt-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Added:</span>
|
||||
<span className="font-medium text-green-600 dark:text-green-400">
|
||||
+{info.records_added.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Updated:</span>
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">
|
||||
~{info.records_updated.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Deleted:</span>
|
||||
<span className="font-medium text-red-600 dark:text-red-400">
|
||||
-{info.records_deleted.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtle hover indicator */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-primary/50 transform scale-x-0 group-hover:scale-x-100 transition-transform" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
292
components/admin/SyncHistoryTable.tsx
Normal file
292
components/admin/SyncHistoryTable.tsx
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
/**
|
||||
* Sync History Table Component
|
||||
* Displays paginated sync history from database
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { format } from 'date-fns';
|
||||
import { getEntityDisplayName } from '@/lib/utils/sync-helpers';
|
||||
import { EntityType } from '@/lib/types/sync';
|
||||
import { ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||
|
||||
interface SyncHistoryRecord {
|
||||
id: number;
|
||||
entity_type: string;
|
||||
sync_type: string;
|
||||
status: string;
|
||||
started_at: string;
|
||||
completed_at?: string;
|
||||
records_added: number;
|
||||
records_updated: number;
|
||||
records_deleted: number;
|
||||
error_message?: string;
|
||||
triggered_by?: string;
|
||||
}
|
||||
|
||||
interface SyncHistoryTableProps {
|
||||
refreshKey: number;
|
||||
}
|
||||
|
||||
export default function SyncHistoryTable({ refreshKey }: SyncHistoryTableProps) {
|
||||
const [history, setHistory] = useState<SyncHistoryRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 10;
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
}, [refreshKey, page]);
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/sync/history?limit=${limit}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setHistory(data.history || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch sync history:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive'> = {
|
||||
completed: 'default',
|
||||
started: 'secondary',
|
||||
in_progress: 'secondary',
|
||||
failed: 'destructive',
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || 'secondary'}>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const formatDuration = (started: string, completed?: string) => {
|
||||
if (!completed) return '-';
|
||||
const start = new Date(started);
|
||||
const end = new Date(completed);
|
||||
const duration = end.getTime() - start.getTime();
|
||||
const seconds = Math.floor(duration / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
const downloadJSON = () => {
|
||||
const dataStr = JSON.stringify(history, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `sync-history-${format(new Date(), 'yyyy-MM-dd-HHmmss')}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const downloadCSV = () => {
|
||||
// CSV headers
|
||||
const headers = [
|
||||
'ID',
|
||||
'Entity Type',
|
||||
'Sync Type',
|
||||
'Status',
|
||||
'Started At',
|
||||
'Completed At',
|
||||
'Duration (seconds)',
|
||||
'Records Added',
|
||||
'Records Updated',
|
||||
'Records Deleted',
|
||||
'Triggered By',
|
||||
'Error Message'
|
||||
];
|
||||
|
||||
// Convert history to CSV rows
|
||||
const rows = history.map(record => {
|
||||
const duration = record.completed_at
|
||||
? Math.floor((new Date(record.completed_at).getTime() - new Date(record.started_at).getTime()) / 1000)
|
||||
: '';
|
||||
|
||||
return [
|
||||
record.id,
|
||||
getEntityDisplayName(record.entity_type as EntityType),
|
||||
record.sync_type,
|
||||
record.status,
|
||||
format(new Date(record.started_at), 'yyyy-MM-dd HH:mm:ss'),
|
||||
record.completed_at ? format(new Date(record.completed_at), 'yyyy-MM-dd HH:mm:ss') : '',
|
||||
duration,
|
||||
record.records_added,
|
||||
record.records_updated,
|
||||
record.records_deleted,
|
||||
record.triggered_by || 'system',
|
||||
record.error_message ? `"${record.error_message.replace(/"/g, '""')}"` : ''
|
||||
];
|
||||
});
|
||||
|
||||
// Combine headers and rows
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.join(','))
|
||||
].join('\n');
|
||||
|
||||
// Create and download file
|
||||
const dataBlob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `sync-history-${format(new Date(), 'yyyy-MM-dd-HHmmss')}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
if (loading && history.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sync History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Sync History</CardTitle>
|
||||
<CardDescription>
|
||||
Recent sync operations and their results
|
||||
</CardDescription>
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={downloadJSON}
|
||||
disabled={loading}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
JSON
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={downloadCSV}
|
||||
disabled={loading}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
CSV
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{history.length === 0 ? (
|
||||
<p className="text-muted-foreground">No sync history available</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Entity</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead className="text-right">Added</TableHead>
|
||||
<TableHead className="text-right">Updated</TableHead>
|
||||
<TableHead className="text-right">Deleted</TableHead>
|
||||
<TableHead>Triggered By</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{history.map((record) => (
|
||||
<TableRow key={record.id}>
|
||||
<TableCell className="font-medium">
|
||||
{getEntityDisplayName(record.entity_type as EntityType)}
|
||||
</TableCell>
|
||||
<TableCell className="capitalize">
|
||||
{record.sync_type.replace('-', ' ')}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(record.status)}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{format(new Date(record.started_at), 'MMM d, HH:mm:ss')}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{formatDuration(record.started_at, record.completed_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-green-600">
|
||||
+{record.records_added}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-blue-600">
|
||||
~{record.records_updated}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-red-600">
|
||||
-{record.records_deleted}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{record.triggered_by || 'system'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {history.length} records
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
<span className="hidden sm:inline">Previous</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={history.length < limit}
|
||||
>
|
||||
<span className="hidden sm:inline">Next</span>
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue