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:
root 2025-11-19 14:18:16 -05:00
parent e8462ef301
commit 6eee14f8af
171 changed files with 32671 additions and 621 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}