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

View file

@ -0,0 +1,416 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import {
Brain,
Lightbulb,
TrendingUp,
AlertTriangle,
CheckCircle,
Info,
RefreshCw,
Download,
Filter,
Calendar,
Target,
Activity
} from 'lucide-react';
import { AnalyticsInsight, LLMAnalysisResponse } from '@/lib/types/analytics';
import { cn } from '@/lib/utils';
interface AnalysisPanelProps {
insights: AnalyticsInsight[];
llmAnalysis?: LLMAnalysisResponse;
loading?: boolean;
onRefresh?: () => void;
onExport?: () => void;
className?: string;
}
export function AnalysisPanel({
insights,
llmAnalysis,
loading = false,
onRefresh,
onExport,
className
}: AnalysisPanelProps) {
const [activeTab, setActiveTab] = useState('insights');
const [filter, setFilter] = useState<'all' | 'warnings' | 'recommendations' | 'success'>('all');
// Filter insights based on selected filter
const filteredInsights = insights.filter(insight => {
switch (filter) {
case 'warnings':
return insight.type === 'warning' || insight.type === 'error';
case 'recommendations':
return insight.actionable === true && insight.recommendation;
case 'success':
return insight.type === 'success';
default:
return true;
}
});
// Group insights by category
const insightsByCategory = filteredInsights.reduce((groups, insight) => {
if (!groups[insight.category]) {
groups[insight.category] = [];
}
groups[insight.category].push(insight);
return groups;
}, {} as Record<string, AnalyticsInsight[]>);
const getInsightIcon = (type: string) => {
switch (type) {
case 'success':
return <CheckCircle className="h-4 w-4 text-green-500" />;
case 'warning':
return <AlertTriangle className="h-4 w-4 text-yellow-500" />;
case 'error':
return <AlertTriangle className="h-4 w-4 text-red-500" />;
case 'info':
default:
return <Info className="h-4 w-4 text-blue-500" />;
}
};
const getInsightColor = (type: string) => {
switch (type) {
case 'success':
return 'border-green-200 bg-green-50';
case 'warning':
return 'border-yellow-200 bg-yellow-50';
case 'error':
return 'border-red-200 bg-red-50';
case 'info':
default:
return 'border-blue-200 bg-blue-50';
}
};
const getSeverityColor = (severity?: string) => {
switch (severity) {
case 'high':
return 'bg-red-100 text-red-800';
case 'medium':
return 'bg-yellow-100 text-yellow-800';
case 'low':
default:
return 'bg-gray-100 text-gray-800';
}
};
const getCategoryIcon = (category: string) => {
switch (category) {
case 'activity':
return <Activity className="h-4 w-4 text-blue-500" />;
case 'content':
return <Target className="h-4 w-4 text-green-500" />;
case 'timeliness':
return <Calendar className="h-4 w-4 text-orange-500" />;
case 'patterns':
return <TrendingUp className="h-4 w-4 text-purple-500" />;
case 'recommendations':
return <Lightbulb className="h-4 w-4 text-yellow-500" />;
default:
return <Info className="h-4 w-4 text-gray-500" />;
}
};
if (loading) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Brain className="h-5 w-5" />
AI Analysis
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-4">
<RefreshCw className="h-8 w-8 animate-spin text-blue-600" />
<p className="text-sm text-gray-600">Analyzing time entries...</p>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className={className}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Brain className="h-5 w-5" />
AI Analysis & Insights
</CardTitle>
<div className="flex items-center gap-2">
{onRefresh && (
<Button variant="outline" size="sm" onClick={onRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
)}
{onExport && (
<Button variant="outline" size="sm" onClick={onExport}>
<Download className="h-4 w-4 mr-2" />
Export
</Button>
)}
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{insights.length}</div>
<div className="text-sm text-gray-600">Total Insights</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{insights.filter(i => i.type === 'success').length}
</div>
<div className="text-sm text-gray-600">Positive</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-yellow-600">
{insights.filter(i => i.type === 'warning').length}
</div>
<div className="text-sm text-gray-600">Warnings</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-red-600">
{insights.filter(i => i.type === 'error').length}
</div>
<div className="text-sm text-gray-600">Issues</div>
</div>
</div>
</CardHeader>
<CardContent>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<div className="flex items-center justify-between mb-4">
<TabsList>
<TabsTrigger value="insights">Insights</TabsTrigger>
<TabsTrigger value="patterns">Patterns</TabsTrigger>
<TabsTrigger value="recommendations">Recommendations</TabsTrigger>
{llmAnalysis && <TabsTrigger value="llm">AI Analysis</TabsTrigger>}
</TabsList>
{/* Filter */}
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-gray-500" />
<select
value={filter}
onChange={(e) => setFilter(e.target.value as any)}
className="text-sm border rounded px-2 py-1"
>
<option value="all">All</option>
<option value="warnings">Warnings</option>
<option value="recommendations">Recommendations</option>
<option value="success">Success</option>
</select>
</div>
</div>
<TabsContent value="insights" className="space-y-4">
{filteredInsights.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Info className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No insights found for the selected filter</p>
</div>
) : (
<ScrollArea className="h-96">
<div className="space-y-4">
{Object.entries(insightsByCategory).map(([category, categoryInsights]) => (
<div key={category} className="space-y-3">
<div className="flex items-center gap-2">
{getCategoryIcon(category)}
<h3 className="font-medium capitalize">{category}</h3>
<Badge variant="secondary">{categoryInsights.length}</Badge>
</div>
<div className="space-y-2 pl-6">
{categoryInsights.map((insight, index) => (
<div
key={index}
className={cn(
"p-4 rounded-lg border",
getInsightColor(insight.type)
)}
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
{getInsightIcon(insight.type)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h4 className="font-medium">{insight.title}</h4>
{insight.severity && (
<Badge variant="outline" className={getSeverityColor(insight.severity)}>
{insight.severity}
</Badge>
)}
{insight.actionable && (
<Badge variant="outline" className="bg-blue-100 text-blue-800">
Actionable
</Badge>
)}
</div>
<p className="text-sm text-gray-700 mb-2">
{insight.description}
</p>
{insight.recommendation && (
<div className="bg-white bg-opacity-50 p-3 rounded border border-gray-200">
<p className="text-sm font-medium text-gray-800 mb-1">
Recommendation:
</p>
<p className="text-sm text-gray-700">
{insight.recommendation}
</p>
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</ScrollArea>
)}
</TabsContent>
<TabsContent value="patterns" className="space-y-4">
{llmAnalysis?.patterns && llmAnalysis.patterns.length > 0 ? (
<ScrollArea className="h-96">
<div className="space-y-3">
{llmAnalysis.patterns.map((pattern, index) => (
<div key={index} className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{pattern.type}</h4>
<div className="flex items-center gap-2">
<Badge variant="outline">
{pattern.frequency} occurrences
</Badge>
<Badge
variant="outline"
className={getSeverityColor(pattern.impact)}
>
{pattern.impact} impact
</Badge>
</div>
</div>
<p className="text-sm text-gray-700">{pattern.description}</p>
</div>
))}
</div>
</ScrollArea>
) : (
<div className="text-center py-8 text-gray-500">
<TrendingUp className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No patterns detected yet</p>
<p className="text-sm">AI analysis will identify recurring work patterns</p>
</div>
)}
</TabsContent>
<TabsContent value="recommendations" className="space-y-4">
{llmAnalysis?.recommendations && llmAnalysis.recommendations.length > 0 ? (
<ScrollArea className="h-96">
<div className="space-y-3">
{llmAnalysis.recommendations.map((rec, index) => (
<div key={index} className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{rec.category}</h4>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={getSeverityColor(rec.priority)}
>
{rec.priority} priority
</Badge>
</div>
</div>
<p className="text-sm text-gray-700 mb-2">{rec.action}</p>
<div className="text-xs text-gray-600 bg-gray-50 p-2 rounded">
<strong>Expected Impact:</strong> {rec.expectedImpact}
</div>
</div>
))}
</div>
</ScrollArea>
) : (
<div className="text-center py-8 text-gray-500">
<Lightbulb className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No recommendations available yet</p>
<p className="text-sm">AI will provide actionable recommendations based on analysis</p>
</div>
)}
</TabsContent>
{llmAnalysis && (
<TabsContent value="llm" className="space-y-4">
<div className="space-y-6">
{/* Summary */}
<div className="p-4 bg-gray-50 rounded-lg">
<h4 className="font-medium mb-3">AI Summary</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="text-sm text-gray-600">Overall Quality</div>
<div className="text-2xl font-bold text-blue-600">
{Math.round(llmAnalysis.summary.overallQuality * 100)}%
</div>
</div>
<div>
<div className="text-sm text-gray-600">Productivity Level</div>
<div className="text-2xl font-bold text-green-600">
{Math.round(llmAnalysis.summary.productivityLevel * 100)}%
</div>
</div>
</div>
{llmAnalysis.summary.keyFindings.length > 0 && (
<div className="mt-4">
<h5 className="font-medium text-sm mb-2">Key Findings</h5>
<ul className="text-sm text-gray-700 space-y-1">
{llmAnalysis.summary.keyFindings.map((finding, index) => (
<li key={index} className="flex items-start gap-2">
<span className="text-blue-500 mt-1"></span>
{finding}
</li>
))}
</ul>
</div>
)}
</div>
{/* Processing Info */}
<div className="text-xs text-gray-500 border-t pt-4">
<div className="flex justify-between">
<span>Processing time: {llmAnalysis.processingTime}ms</span>
<span>Tokens used: {llmAnalysis.tokensUsed}</span>
</div>
</div>
</div>
</TabsContent>
)}
</Tabs>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,533 @@
'use client';
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
Activity,
FileText,
Clock,
TrendingUp,
TrendingDown,
Minus,
Info,
CheckCircle,
AlertTriangle,
XCircle
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
ActivityScore,
ContentScore,
TimelinessScore,
TimeEntryAnalysis,
AggregateAnalysis
} from '@/lib/types/analytics';
interface ScoreCardProps {
title: string;
score: number;
description?: string;
trend?: 'up' | 'down' | 'neutral';
trendValue?: number;
icon?: React.ReactNode;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function ScoreCard({
title,
score,
description,
trend,
trendValue,
icon,
size = 'md',
className
}: ScoreCardProps) {
const percentage = Math.round(score * 100);
const scoreColor = getScoreColor(score);
const scoreLabel = getScoreLabel(score);
const sizeClasses = {
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
};
const titleSizeClasses = {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
};
const scoreSizeClasses = {
sm: 'text-2xl',
md: 'text-3xl',
lg: 'text-4xl',
};
return (
<Card className={cn(sizeClasses[size], className)}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className={cn("flex items-center gap-2", titleSizeClasses[size])}>
{icon}
{title}
</CardTitle>
{trend && (
<div className="flex items-center gap-1">
{trend === 'up' && <TrendingUp className="h-4 w-4 text-green-500" />}
{trend === 'down' && <TrendingDown className="h-4 w-4 text-red-500" />}
{trend === 'neutral' && <Minus className="h-4 w-4 text-gray-500" />}
{(trendValue !== undefined) && (
<span className={cn(
"text-sm font-medium",
trendValue > 0 ? "text-green-600" : trendValue < 0 ? "text-red-600" : "text-gray-600"
)}>
{trendValue > 0 ? '+' : ''}{trendValue}%
</span>
)}
</div>
)}
</div>
{description && (
<p className="text-sm text-gray-600">{description}</p>
)}
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Score Display */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn(scoreSizeClasses[size], "font-bold", scoreColor.text)}>
{percentage}%
</span>
<Badge variant="outline" className={scoreColor.badge}>
{scoreLabel}
</Badge>
</div>
<div className="flex items-center gap-1">
{getScoreIcon(score)}
<span className="text-sm text-gray-500">{scoreLabel}</span>
</div>
</div>
{/* Progress Bar */}
<div className="space-y-2">
<Progress
value={percentage}
className="h-2"
/>
<div className="flex justify-between text-xs text-gray-500">
<span>Poor</span>
<span>Average</span>
<span>Excellent</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
interface ActivityScoreCardProps {
title: string;
score: ActivityScore;
icon?: React.ReactNode;
className?: string;
}
export function ActivityScoreCard({ title, score, icon, className }: ActivityScoreCardProps) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{icon || <Activity className="h-5 w-5 text-blue-500" />}
{title}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Overall Score */}
<div className="text-center">
<div className="text-3xl font-bold text-blue-600">
{Math.round(score.score * 100)}%
</div>
<div className="text-sm text-gray-600">Activity Score</div>
</div>
{/* Breakdown */}
<div className="space-y-3">
<h4 className="font-medium text-sm">Score Breakdown</h4>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm">Completeness</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.completeness * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.completeness * 100)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Consistency</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.consistency * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.consistency * 100)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Duration</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.duration * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.duration * 100)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Categorization</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.categorization * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.categorization * 100)}%
</span>
</div>
</div>
</div>
</div>
{/* Positive Factors */}
{score.factors.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium text-sm flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-green-500" />
Positive Factors
</h4>
<div className="flex flex-wrap gap-1">
{score.factors.map((factor: string, index: number) => (
<Badge key={index} variant="secondary" className="text-xs">
{factor}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
interface ContentScoreCardProps {
title: string;
score: ContentScore;
icon?: React.ReactNode;
className?: string;
}
export function ContentScoreCard({ title, score, icon, className }: ContentScoreCardProps) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{icon || <FileText className="h-5 w-5 text-green-500" />}
{title}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Overall Score */}
<div className="text-center">
<div className="text-3xl font-bold text-green-600">
{Math.round(score.score * 100)}%
</div>
<div className="text-sm text-gray-600">Content Score</div>
</div>
{/* Breakdown */}
<div className="space-y-3">
<h4 className="font-medium text-sm">Score Breakdown</h4>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm">Notes Quality</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.notesQuality * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.notesQuality * 100)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Title Clarity</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.titleClarity * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.titleClarity * 100)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Internal Notes</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.internalNotes * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.internalNotes * 100)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Technical Detail</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.technicalDetail * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.technicalDetail * 100)}%
</span>
</div>
</div>
</div>
</div>
{/* Positive Factors */}
{score.factors.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium text-sm flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-green-500" />
Positive Factors
</h4>
<div className="flex flex-wrap gap-1">
{score.factors.map((factor: string, index: number) => (
<Badge key={index} variant="secondary" className="text-xs">
{factor}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
interface TimelinessScoreCardProps {
title: string;
score: TimelinessScore;
icon?: React.ReactNode;
className?: string;
}
export function TimelinessScoreCard({ title, score, icon, className }: TimelinessScoreCardProps) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{icon || <Clock className="h-5 w-5 text-orange-500" />}
{title}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Overall Score */}
<div className="text-center">
<div className="text-3xl font-bold text-orange-600">
{Math.round(score.score * 100)}%
</div>
<div className="text-sm text-gray-600">Timeliness Score</div>
</div>
{/* Breakdown */}
<div className="space-y-3">
<h4 className="font-medium text-sm">Score Breakdown</h4>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm">Entry Delay</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.entryDelay * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.entryDelay * 100)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Business Hours</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.businessHours * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.businessHours * 100)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Regularity</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.regularity * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.regularity * 100)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Approval Time</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.approvalTimeliness * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.approvalTimeliness * 100)}%
</span>
</div>
</div>
</div>
</div>
{/* Positive Factors */}
{score.factors.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium text-sm flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-green-500" />
Positive Factors
</h4>
<div className="flex flex-wrap gap-1">
{score.factors.map((factor: string, index: number) => (
<Badge key={index} variant="secondary" className="text-xs">
{factor}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
interface AggregateScoreCardProps {
analysis: AggregateAnalysis;
className?: string;
}
export function AggregateScoreCard({ analysis, className }: AggregateScoreCardProps) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-purple-500" />
Overall Performance
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Overall Score */}
<div className="text-center">
<div className="text-4xl font-bold text-purple-600">
{Math.round(analysis.scores.overall * 100)}%
</div>
<div className="text-sm text-gray-600">Overall Score</div>
</div>
{/* Individual Scores */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="text-center p-3 bg-blue-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">
{Math.round(analysis.scores.activity * 100)}%
</div>
<div className="text-sm text-gray-600">Activity</div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">
{Math.round(analysis.scores.content * 100)}%
</div>
<div className="text-sm text-gray-600">Content</div>
</div>
<div className="text-center p-3 bg-orange-50 rounded-lg">
<div className="text-2xl font-bold text-orange-600">
{Math.round(analysis.scores.timeliness * 100)}%
</div>
<div className="text-sm text-gray-600">Timeliness</div>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Total Entries:</span>
<span className="font-medium">{analysis.totalEntries}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Total Hours:</span>
<span className="font-medium">{Number(analysis.totalHours).toFixed(1)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Avg Hours/Entry:</span>
<span className="font-medium">{Number(analysis.averageHoursPerEntry).toFixed(1)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Date Range:</span>
<span className="font-medium">
{analysis.dateRange.latest.toLocaleDateString()}
</span>
</div>
</div>
</CardContent>
</Card>
);
}
// Helper functions
function getScoreColor(score: number) {
if (score >= 0.8) {
return {
text: 'text-green-600',
badge: 'bg-green-100 text-green-800 border-green-200',
progress: 'bg-green-500',
};
}
if (score >= 0.6) {
return {
text: 'text-yellow-600',
badge: 'bg-yellow-100 text-yellow-800 border-yellow-200',
progress: 'bg-yellow-500',
};
}
return {
text: 'text-red-600',
badge: 'bg-red-100 text-red-800 border-red-200',
progress: 'bg-red-500',
};
}
function getScoreLabel(score: number) {
if (score >= 0.8) return 'Excellent';
if (score >= 0.6) return 'Good';
if (score >= 0.4) return 'Average';
return 'Poor';
}
function getScoreIcon(score: number) {
if (score >= 0.8) {
return <CheckCircle className="h-4 w-4 text-green-500" />;
}
if (score >= 0.6) {
return <AlertTriangle className="h-4 w-4 text-yellow-500" />;
}
return <XCircle className="h-4 w-4 text-red-500" />;
}

View file

@ -0,0 +1,335 @@
'use client';
import React, { useState, useEffect, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Calendar, Clock, Users, ChevronDown, ChevronRight, Activity, AlertCircle, CheckCircle } from 'lucide-react';
import { TimelineEvent, TimelineView as TimelineViewType } from '@/lib/types/analytics';
import { cn } from '@/lib/utils';
interface TimelineViewProps {
events: TimelineEvent[];
timeRange: 'hour' | 'day' | 'week' | 'month';
onTimeRangeChange: (range: 'hour' | 'day' | 'week' | 'month') => void;
loading?: boolean;
className?: string;
}
export function TimelineView({
events,
timeRange,
onTimeRangeChange,
loading = false,
className
}: TimelineViewProps) {
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null);
// Group events by time period based on timeRange
const groupedEvents = useMemo(() => {
const groups = new Map<string, TimelineEvent[]>();
events.forEach(event => {
const eventDate = new Date(event.timestamp);
let groupKey: string;
switch (timeRange) {
case 'hour':
groupKey = eventDate.toISOString().substring(0, 13); // YYYY-MM-DDTHH
break;
case 'day':
groupKey = eventDate.toISOString().substring(0, 10); // YYYY-MM-DD
break;
case 'week':
const weekStart = new Date(eventDate);
weekStart.setDate(eventDate.getDate() - eventDate.getDay());
groupKey = `Week of ${weekStart.toISOString().substring(0, 10)}`;
break;
case 'month':
groupKey = eventDate.toISOString().substring(0, 7); // YYYY-MM
break;
default:
groupKey = eventDate.toISOString().substring(0, 10);
}
if (!groups.has(groupKey)) {
groups.set(groupKey, []);
}
groups.get(groupKey)!.push(event);
});
// Sort events within each group by timestamp
groups.forEach(group => {
group.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
});
return groups;
}, [events, timeRange]);
// Calculate summary statistics
const summary = useMemo(() => {
const humanActivities = events.filter(e => e.isHumanActivity).length;
const systemActivities = events.filter(e => !e.isHumanActivity).length;
const totalHours = events.reduce((sum, e) => sum + (e.duration || 0), 0);
const averageScore = events.length > 0
? events.reduce((sum, e) => sum + (e.score || 0), 0) / events.length
: 0;
return {
totalEvents: events.length,
humanActivities,
systemActivities,
totalHours,
averageScore,
};
}, [events]);
const toggleSection = (sectionKey: string) => {
const newExpanded = new Set(expandedSections);
if (newExpanded.has(sectionKey)) {
newExpanded.delete(sectionKey);
} else {
newExpanded.add(sectionKey);
}
setExpandedSections(newExpanded);
};
const getEventIcon = (event: TimelineEvent) => {
switch (event.type) {
case 'key_moment':
return <AlertCircle className="h-4 w-4 text-red-500" />;
case 'milestone':
return <CheckCircle className="h-4 w-4 text-green-500" />;
case 'time_entry':
default:
if (event.isHumanActivity) {
return <Users className="h-4 w-4 text-blue-500" />;
} else {
return <Activity className="h-4 w-4 text-gray-500" />;
}
}
};
const getImportanceColor = (importance: string) => {
switch (importance) {
case 'critical':
return 'bg-red-100 text-red-800 border-red-200';
case 'high':
return 'bg-orange-100 text-orange-800 border-orange-200';
case 'medium':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'low':
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const formatGroupTitle = (groupKey: string) => {
switch (timeRange) {
case 'hour':
return new Date(groupKey + ':00:00').toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
hour12: true,
});
case 'day':
return new Date(groupKey).toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
});
case 'week':
return groupKey;
case 'month':
return new Date(groupKey + '-01').toLocaleDateString('en-US', {
month: 'long',
year: 'numeric',
});
default:
return groupKey;
}
};
if (loading) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Timeline View
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className={className}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Timeline View
</CardTitle>
<Select value={timeRange} onValueChange={onTimeRangeChange}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hour">Hour</SelectItem>
<SelectItem value="day">Day</SelectItem>
<SelectItem value="week">Week</SelectItem>
<SelectItem value="month">Month</SelectItem>
</SelectContent>
</Select>
</div>
{/* Summary Statistics */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mt-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{summary.totalEvents}</div>
<div className="text-sm text-gray-600">Total Events</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{summary.humanActivities}</div>
<div className="text-sm text-gray-600">Human Activities</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-600">{summary.systemActivities}</div>
<div className="text-sm text-gray-600">System Activities</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">{Number(summary.totalHours).toFixed(1)}</div>
<div className="text-sm text-gray-600">Total Hours</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-orange-600">
{(summary.averageScore * 100).toFixed(0)}%
</div>
<div className="text-sm text-gray-600">Avg Score</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{groupedEvents.size === 0 ? (
<div className="text-center py-8 text-gray-500">
<Calendar className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No events found for the selected time range</p>
</div>
) : (
Array.from(groupedEvents.entries())
.sort(([a], [b]) => b.localeCompare(a)) // Sort by date descending
.map(([groupKey, groupEvents]) => (
<Collapsible
key={groupKey}
open={expandedSections.has(groupKey)}
onOpenChange={() => toggleSection(groupKey)}
>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="w-full justify-between p-4 h-auto hover:bg-gray-50"
>
<div className="flex items-center gap-3">
{expandedSections.has(groupKey) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<span className="font-medium">{formatGroupTitle(groupKey)}</span>
<Badge variant="secondary">{groupEvents.length} events</Badge>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<span>
{groupEvents.filter(e => e.isHumanActivity).length} human
</span>
<span></span>
<span>
{groupEvents.reduce((sum, e) => sum + (Number(e.duration) || 0), 0).toFixed(1)}h
</span>
</div>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 px-4 pb-4">
{groupEvents.map((event) => (
<div
key={event.id}
className={cn(
"flex items-start gap-3 p-3 rounded-lg border transition-colors cursor-pointer",
selectedEvent?.id === event.id
? "border-blue-300 bg-blue-50"
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50",
!event.isHumanActivity && "opacity-60"
)}
onClick={() => setSelectedEvent(event)}
>
<div className="mt-0.5">
{getEventIcon(event)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium truncate">{event.title}</h4>
<Badge
variant="outline"
className={cn("text-xs", getImportanceColor(event.importance))}
>
{event.importance}
</Badge>
</div>
{event.description && (
<p className="text-sm text-gray-600 mb-2 line-clamp-2">
{event.description}
</p>
)}
<div className="flex items-center gap-4 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(event.timestamp).toLocaleTimeString()}
</span>
{event.duration && (
<span className="flex items-center gap-1">
<Activity className="h-3 w-3" />
{event.duration}h
</span>
)}
{event.score && (
<span className="flex items-center gap-1">
<div className="w-8 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-green-500"
style={{ width: `${event.score * 100}%` }}
/>
</div>
{(event.score * 100).toFixed(0)}%
</span>
)}
</div>
</div>
</div>
))}
</CollapsibleContent>
</Collapsible>
))
)}
</CardContent>
</Card>
);
}

View file

@ -1,17 +1,17 @@
'use client';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { Check, ChevronsUpDown, Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Company } from '@/lib/types/autotask';
import { useApi } from '@/lib/hooks/use-api';
import { Building2 } from 'lucide-react';
interface CompanySelectorEnhancedProps {
value?: number;
@ -24,38 +24,72 @@ export function CompanySelectorEnhanced({
onValueChange,
label = 'Select Company'
}: CompanySelectorEnhancedProps) {
const [open, setOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const { data, loading, error } = useApi<{ companies: Company[] }>('/api/companies');
const companies = data?.companies || [];
const selectedCompany = companies.find(c => c.id === value);
const handleChange = (val: string) => {
const companyId = parseInt(val);
const company = companies.find(c => c.id === companyId);
onValueChange(companyId, company?.companyName);
};
const filteredCompanies = companies.filter(company =>
company.companyName.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="space-y-2">
<Label htmlFor="company-select" className="flex items-center gap-2">
<Building2 className="w-4 h-4" />
{label}
</Label>
<Select
value={value?.toString()}
onValueChange={handleChange}
disabled={loading || !!error}
>
<SelectTrigger id="company-select">
<SelectValue placeholder={loading ? 'Loading...' : 'Select a company'} />
</SelectTrigger>
<SelectContent>
{companies.map((company) => (
<SelectItem key={company.id} value={company.id.toString()}>
{company.companyName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={loading || !!error}
>
{selectedCompany ? selectedCompany.companyName : (loading ? 'Loading...' : 'Select company...')}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[600px] p-0" align="start">
<div className="flex items-center border-b px-3">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="Search companies..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
<div className="max-h-[300px] overflow-y-auto p-1">
{filteredCompanies.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No company found.
</div>
) : (
filteredCompanies.map((company) => (
<div
key={company.id}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground',
value === company.id && 'bg-accent'
)}
onClick={() => {
onValueChange(company.id, company.companyName);
setOpen(false);
setSearchTerm('');
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
value === company.id ? 'opacity-100' : 'opacity-0'
)}
/>
{company.companyName}
</div>
))
)}
</div>
</PopoverContent>
</Popover>
);
}

View file

@ -0,0 +1,360 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import {
Smartphone,
Info,
HardDrive,
Shield,
Wifi,
XCircle,
Battery,
CheckCircle,
AlertCircle
} from 'lucide-react';
import { AddigyDevice } from '@/lib/types/addigy';
interface AddigyTabProps {
device?: AddigyDevice;
}
export function AddigyTab({ device }: AddigyTabProps) {
if (!device) {
return (
<Card className="border-0 shadow-lg">
<CardContent className="pt-6">
<div className="text-center py-12 text-muted-foreground">
<Smartphone className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No Addigy data available for this device</p>
</div>
</CardContent>
</Card>
);
}
const isOnline = device.online;
const batteryPercentage = device['Battery Percentage'];
const isCharging = device['Battery Charging'];
const freeSpacePercentage = device['Free Disk Percentage'];
return (
<Card className="border-0 shadow-lg">
<CardHeader className="bg-gradient-to-r from-orange-50 to-orange-100 dark:from-orange-950 dark:to-orange-900 rounded-t-lg">
<CardTitle className="text-lg flex items-center gap-2">
<Smartphone className="w-5 h-5" />
Addigy Device Information (Apple RMM)
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Basic Information */}
<div className="space-y-4">
<h3 className="font-semibold flex items-center gap-2">
<Info className="w-4 h-4" />
Basic Information
</h3>
<div className="space-y-3">
<div>
<Label>Device Name</Label>
<p className="text-sm text-muted-foreground mt-1">{device['Device Name']}</p>
</div>
<div>
<Label>Model</Label>
<p className="text-sm text-muted-foreground mt-1">
{device['Device Model Name'] || '-'}
</p>
</div>
<div>
<Label>Serial Number</Label>
<p className="text-sm text-muted-foreground font-mono mt-1">
{device['Serial Number'] || '-'}
</p>
</div>
<div>
<Label>Current User</Label>
<p className="text-sm text-muted-foreground mt-1">
{device['Current User'] || '-'}
</p>
</div>
<div>
<Label>Status</Label>
<div className="flex items-center gap-2 mt-1">
{isOnline ? (
<Badge variant="default" className="bg-green-600">
<Wifi className="w-3 h-3 mr-1" />
Online
</Badge>
) : (
<Badge variant="secondary">
<XCircle className="w-3 h-3 mr-1" />
Offline
</Badge>
)}
</div>
</div>
{device['Last Check In'] && (
<div>
<Label>Last Check In</Label>
<p className="text-sm text-muted-foreground mt-1">
{new Date(device['Last Check In']).toLocaleString()}
</p>
</div>
)}
</div>
</div>
{/* System Information */}
<div className="space-y-4">
<h3 className="font-semibold flex items-center gap-2">
<HardDrive className="w-4 h-4" />
System Information
</h3>
<div className="space-y-3">
<div>
<Label>Operating System</Label>
<p className="text-sm text-muted-foreground mt-1">
{device['MAC OS X Version'] || device['iOS Version'] || '-'}
</p>
</div>
{device['Processor Type'] && (
<div>
<Label>Processor</Label>
<p className="text-sm text-muted-foreground mt-1">
{device['Processor Type']}
{device['Processor Speed (GHz)'] && ` @ ${device['Processor Speed (GHz)']} GHz`}
</p>
</div>
)}
{device['Total Disk Space (GB)'] && (
<div>
<Label>Disk Space</Label>
<div className="mt-1">
<p className="text-sm text-muted-foreground">
{device['Free Disk Space (GB)']} GB free of {device['Total Disk Space (GB)']} GB
</p>
{freeSpacePercentage !== undefined && (
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full ${
freeSpacePercentage < 10 ? 'bg-red-500' :
freeSpacePercentage < 20 ? 'bg-orange-500' :
'bg-green-500'
}`}
style={{ width: `${freeSpacePercentage}%` }}
/>
</div>
<span className="text-xs text-muted-foreground">{freeSpacePercentage.toFixed(1)}%</span>
</div>
)}
</div>
</div>
)}
{batteryPercentage !== undefined && (
<div>
<Label>Battery</Label>
<div className="flex items-center gap-2 mt-1">
<Battery className={`w-4 h-4 ${isCharging ? 'text-green-600' : ''}`} />
<span className="text-sm text-muted-foreground">
{batteryPercentage}%
{isCharging && ' (Charging)'}
</span>
{device['Battery Capacity Loss Percentage'] !== undefined && (
<span className="text-xs text-muted-foreground">
({device['Battery Capacity Loss Percentage']}% capacity loss)
</span>
)}
</div>
</div>
)}
<div>
<Label>Agent Version</Label>
<p className="text-sm text-muted-foreground mt-1">
{device['Agent Version'] || '-'}
</p>
</div>
{device.Timezone && (
<div>
<Label>Timezone</Label>
<p className="text-sm text-muted-foreground mt-1">
{device.Timezone}
</p>
</div>
)}
</div>
</div>
{/* Security & Features */}
<div className="space-y-4">
<h3 className="font-semibold flex items-center gap-2">
<Shield className="w-4 h-4" />
Security & Features
</h3>
<div className="space-y-3">
<div>
<Label>Firewall</Label>
<div className="flex items-center gap-2 mt-1">
{device['Firewall Enabled'] ? (
<Badge variant="default" className="bg-green-600">
<CheckCircle className="w-3 h-3 mr-1" />
Enabled
</Badge>
) : (
<Badge variant="destructive">
<XCircle className="w-3 h-3 mr-1" />
Disabled
</Badge>
)}
</div>
</div>
<div>
<Label>FileVault</Label>
<div className="flex items-center gap-2 mt-1">
{device['FileVault Enabled'] ? (
<Badge variant="default" className="bg-green-600">
<CheckCircle className="w-3 h-3 mr-1" />
Enabled
</Badge>
) : (
<Badge variant="destructive">
<XCircle className="w-3 h-3 mr-1" />
Disabled
</Badge>
)}
</div>
</div>
{device['Remote Login Enabled'] !== undefined && (
<div>
<Label>Remote Login</Label>
<div className="flex items-center gap-2 mt-1">
{device['Remote Login Enabled'] ? (
<Badge variant="outline">
<CheckCircle className="w-3 h-3 mr-1" />
Enabled
</Badge>
) : (
<Badge variant="secondary">
<XCircle className="w-3 h-3 mr-1" />
Disabled
</Badge>
)}
</div>
</div>
)}
{device['SMART Failing'] !== undefined && (
<div>
<Label>SMART Status</Label>
<div className="flex items-center gap-2 mt-1">
{device['SMART Failing'] ? (
<Badge variant="destructive">
<AlertCircle className="w-3 h-3 mr-1" />
Failing
</Badge>
) : (
<Badge variant="default" className="bg-green-600">
<CheckCircle className="w-3 h-3 mr-1" />
Healthy
</Badge>
)}
</div>
</div>
)}
{device['Has Wireless'] !== undefined && (
<div>
<Label>Wireless Capability</Label>
<p className="text-sm text-muted-foreground mt-1">
{device['Has Wireless'] ? 'Yes' : 'No'}
</p>
</div>
)}
{device['XCode Installed'] !== undefined && (
<div>
<Label>XCode</Label>
<p className="text-sm text-muted-foreground mt-1">
{device['XCode Installed'] ? 'Installed' : 'Not Installed'}
</p>
</div>
)}
</div>
</div>
{/* Additional Information */}
<div className="space-y-4">
<h3 className="font-semibold flex items-center gap-2">
<Info className="w-4 h-4" />
Additional Information
</h3>
<div className="space-y-3">
<div>
<Label>Agent ID</Label>
<p className="text-sm text-muted-foreground font-mono mt-1">
{device.agentid}
</p>
</div>
<div>
<Label>Policy ID</Label>
<p className="text-sm text-muted-foreground font-mono mt-1">
{device.policy_id}
</p>
</div>
{device['Warranty Expiration Date'] && (
<div>
<Label>Warranty</Label>
<p className="text-sm text-muted-foreground mt-1">
Expires: {new Date(device['Warranty Expiration Date']).toLocaleDateString()}
{device['Warranty Days Left'] !== undefined && (
<span className="ml-2">({device['Warranty Days Left']} days left)</span>
)}
</p>
</div>
)}
{device['TeamViewer Client Id'] && (
<div>
<Label>TeamViewer ID</Label>
<p className="text-sm text-muted-foreground font-mono mt-1">
{device['TeamViewer Client Id']}
</p>
</div>
)}
{device['Displays Serial Number'] && device['Displays Serial Number'].length > 0 && (
<div>
<Label>Display Serial Numbers</Label>
<div className="text-sm text-muted-foreground font-mono mt-1 space-y-1">
{device['Displays Serial Number'].map((serial, idx) => (
<div key={idx}>{serial}</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,245 @@
import { AuvikDevice } from '@/lib/types/auvik';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Network, Info, Wifi, XCircle, AlertCircle } from 'lucide-react';
interface AuvikTabProps {
device?: AuvikDevice;
}
export function AuvikTab({ device }: AuvikTabProps) {
if (!device) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Network className="w-16 h-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No Auvik data available</h3>
<p className="text-sm text-muted-foreground max-w-md">
This device is not monitored by Auvik or could not be matched to an Auvik device.
</p>
</div>
);
}
const formatDate = (dateString?: string) => {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString();
};
const getStatusBadge = () => {
switch (device.onlineStatus) {
case 'online':
return (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100">
<Wifi className="w-3 h-3 mr-1" />
Online
</Badge>
);
case 'offline':
return (
<Badge variant="secondary">
<XCircle className="w-3 h-3 mr-1" />
Offline
</Badge>
);
default:
return (
<Badge variant="outline">
<AlertCircle className="w-3 h-3 mr-1" />
Unknown
</Badge>
);
}
};
return (
<div className="space-y-6">
{/* Status Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Network className="w-5 h-5 text-muted-foreground" />
<h3 className="text-lg font-semibold">{device.deviceName}</h3>
</div>
{getStatusBadge()}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Info className="w-4 h-4" />
Basic Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label className="text-xs text-muted-foreground">Device Name</Label>
<p className="text-sm font-medium">{device.deviceName || 'N/A'}</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">Device Type</Label>
<p className="text-sm font-medium capitalize">{device.deviceType || 'N/A'}</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">Serial Number</Label>
<p className="text-sm font-medium font-mono">{device.serialNumber || 'N/A'}</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">Manufacturer</Label>
<p className="text-sm font-medium">{device.manufacturer || device.vendorName || 'N/A'}</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">Model</Label>
<p className="text-sm font-medium">{device.model || device.makeModel || 'N/A'}</p>
</div>
</CardContent>
</Card>
{/* Network Information */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Network className="w-4 h-4" />
Network Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label className="text-xs text-muted-foreground">IP Addresses</Label>
{device.ipAddresses && device.ipAddresses.length > 0 ? (
<div className="flex flex-wrap gap-1 mt-1">
{device.ipAddresses.map((ip, index) => (
<Badge key={index} variant="outline" className="font-mono text-xs">
{ip}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">N/A</p>
)}
</div>
<div>
<Label className="text-xs text-muted-foreground">MAC Addresses</Label>
{device.macAddresses && device.macAddresses.length > 0 ? (
<div className="flex flex-wrap gap-1 mt-1">
{device.macAddresses.map((mac, index) => (
<Badge key={index} variant="outline" className="font-mono text-xs">
{mac}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">N/A</p>
)}
</div>
<div>
<Label className="text-xs text-muted-foreground">Tenant</Label>
<p className="text-sm font-medium">{device.tenantName || 'N/A'}</p>
</div>
</CardContent>
</Card>
{/* Status Information */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Wifi className="w-4 h-4" />
Status Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label className="text-xs text-muted-foreground">Online Status</Label>
<div className="mt-1">{getStatusBadge()}</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">Last Seen</Label>
<p className="text-sm font-medium">{formatDate(device.lastSeenTime)}</p>
</div>
</CardContent>
</Card>
{/* Firmware Information */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Info className="w-4 h-4" />
Firmware Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label className="text-xs text-muted-foreground">Firmware Version</Label>
<p className="text-sm font-medium font-mono">
{device.firmwareVersion || 'N/A'}
</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">Software Version</Label>
<p className="text-sm font-medium font-mono">
{device.softwareVersion || 'N/A'}
</p>
</div>
</CardContent>
</Card>
</div>
{/* Description */}
{device.description && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Description</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{device.description}</p>
</CardContent>
</Card>
)}
{/* Network Interfaces */}
{device.networkInterfaces && device.networkInterfaces.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Network className="w-4 h-4" />
Network Interfaces
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{device.networkInterfaces.map((iface, index) => (
<div
key={index}
className="flex items-center justify-between p-2 border rounded-md"
>
<div className="flex-1">
<p className="text-sm font-medium">{iface.interfaceName}</p>
{iface.macAddress && (
<p className="text-xs text-muted-foreground font-mono">
{iface.macAddress}
</p>
)}
</div>
<div className="flex items-center gap-2">
{iface.ipAddress && (
<Badge variant="outline" className="font-mono text-xs">
{iface.ipAddress}
</Badge>
)}
<Badge
variant={iface.status === 'up' ? 'default' : 'secondary'}
className="text-xs"
>
{iface.status}
</Badge>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View file

@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
@ -12,18 +13,25 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PSATab } from './psa-tab';
import { RMMTab } from './rmm-tab';
import { AuvikTab } from './auvik-tab';
import { AddigyTab } from './addigy-tab';
import { StatusCards } from './status-cards';
import {
Server,
Monitor,
Network,
AlertCircle
} from 'lucide-react';
import { ConfigurationItem } from '@/lib/types/autotask';
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
import { AuvikDevice } from '@/lib/types/auvik';
import { AddigyDevice } from '@/lib/types/addigy';
interface ConfigItemDetail {
autotaskDevice?: ConfigurationItem;
rmmDevice?: DattoRMMDevice;
auvikDevice?: AuvikDevice;
addigyDevice?: AddigyDevice;
companyName?: string;
}
@ -32,9 +40,20 @@ interface ConfigItemModalProps {
type?: 'autotask' | 'rmm';
open: boolean;
onOpenChange: (open: boolean) => void;
rmmDevice?: DattoRMMDevice; // Pass RMM device directly from comparison
auvikDevice?: AuvikDevice; // Pass Auvik device directly from comparison
addigyDevice?: AddigyDevice; // Pass Addigy device directly from comparison
}
export function ConfigItemModal({ itemId, type = 'autotask', open, onOpenChange }: ConfigItemModalProps) {
export function ConfigItemModal({
itemId,
type = 'autotask',
open,
onOpenChange,
rmmDevice: passedRmmDevice,
auvikDevice: passedAuvikDevice,
addigyDevice: passedAddigyDevice
}: ConfigItemModalProps) {
const [data, setData] = useState<ConfigItemDetail>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -48,14 +67,70 @@ export function ConfigItemModal({ itemId, type = 'autotask', open, onOpenChange
setLoading(true);
setError(null);
console.log('Modal opening with:', {
itemId,
hasPassedRmmDevice: !!passedRmmDevice,
hasPassedAuvikDevice: !!passedAuvikDevice,
hasPassedAddigyDevice: !!passedAddigyDevice,
passedRmmDevice,
passedAuvikDevice,
passedAddigyDevice
});
try {
const response = await fetch(`/api/configuration-items/${itemId}?type=${type}`);
// For RMM-only, Auvik-only, or Addigy-only devices, skip API call and use passed data
if (itemId === 'rmm-only' || itemId === 'auvik-only' || itemId === 'addigy-only') {
console.log('Using passed devices only (no Autotask record)');
setData({
autotaskDevice: undefined,
rmmDevice: passedRmmDevice,
auvikDevice: passedAuvikDevice,
addigyDevice: passedAddigyDevice,
companyName: passedRmmDevice?.siteName || passedAuvikDevice?.deviceName || passedAddigyDevice?.['Device Name'] || 'Unknown',
});
setLoading(false);
return;
}
// If we have passed devices, use lightweight endpoint that only fetches PSA data
// Otherwise use full endpoint that does RMM/Auvik/Addigy matching
const endpoint = (passedRmmDevice || passedAuvikDevice || passedAddigyDevice)
? `/api/configuration-items/${itemId}/lightweight`
: `/api/configuration-items/${itemId}?type=${type}`;
console.log(`Using ${passedRmmDevice || passedAuvikDevice ? 'lightweight' : 'full'} endpoint`);
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error('Failed to fetch configuration item');
}
const result = await response.json();
setData(result);
console.log('Fetched result:', {
hasAutotask: !!result.autotaskDevice,
hasRmm: !!result.rmmDevice,
hasAuvik: !!result.auvikDevice,
hasAddigy: !!result.addigyDevice
});
// Always prioritize passed devices over fetched data
const finalData = {
autotaskDevice: result.autotaskDevice,
rmmDevice: passedRmmDevice || result.rmmDevice,
auvikDevice: passedAuvikDevice || result.auvikDevice,
addigyDevice: passedAddigyDevice || result.addigyDevice,
companyName: result.companyName,
};
console.log('Final data:', {
hasAutotask: !!finalData.autotaskDevice,
hasRmm: !!finalData.rmmDevice,
hasAuvik: !!finalData.auvikDevice,
hasAddigy: !!finalData.addigyDevice
});
setData(finalData);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
@ -64,7 +139,7 @@ export function ConfigItemModal({ itemId, type = 'autotask', open, onOpenChange
};
fetchData();
}, [itemId, type, open]);
}, [itemId, type, open, passedRmmDevice, passedAuvikDevice, passedAddigyDevice]);
const handleUpdate = (updatedDevice: ConfigurationItem) => {
setData({ ...data, autotaskDevice: updatedDevice });
@ -72,6 +147,8 @@ export function ConfigItemModal({ itemId, type = 'autotask', open, onOpenChange
const device = data.autotaskDevice;
const rmmDevice = data.rmmDevice;
const auvikDevice = data.auvikDevice;
const addigyDevice = data.addigyDevice;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -95,6 +172,9 @@ export function ConfigItemModal({ itemId, type = 'autotask', open, onOpenChange
)}
</div>
</DialogTitle>
<DialogDescription>
View and manage configuration item details from PSA, RMM, Auvik, and Addigy systems
</DialogDescription>
</DialogHeader>
{loading ? (
@ -116,7 +196,7 @@ export function ConfigItemModal({ itemId, type = 'autotask', open, onOpenChange
{/* Tabs */}
<Tabs defaultValue="psa" className="space-y-4">
<TabsList className="grid w-full grid-cols-2">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="psa" className="flex items-center gap-2">
<Server className="w-4 h-4" />
PSA Data
@ -135,6 +215,24 @@ export function ConfigItemModal({ itemId, type = 'autotask', open, onOpenChange
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="auvik" className="flex items-center gap-2">
<Network className="w-4 h-4" />
Auvik Data
{auvikDevice && (
<Badge variant="outline" className="ml-1">
{auvikDevice.onlineStatus === 'online' ? 'Online' : 'Offline'}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="addigy" className="flex items-center gap-2">
<Monitor className="w-4 h-4" />
Addigy Data
{addigyDevice && (
<Badge variant="outline" className="ml-1">
{addigyDevice.online ? 'Online' : 'Offline'}
</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="psa">
@ -144,6 +242,14 @@ export function ConfigItemModal({ itemId, type = 'autotask', open, onOpenChange
<TabsContent value="rmm">
<RMMTab device={rmmDevice} />
</TabsContent>
<TabsContent value="auvik">
<AuvikTab device={auvikDevice} />
</TabsContent>
<TabsContent value="addigy">
<AddigyTab device={addigyDevice} />
</TabsContent>
</Tabs>
</div>
)}

View file

@ -1,58 +1,31 @@
'use client';
import { useState, useEffect } from 'react';
import { Badge } from '@/components/ui/badge';
import { User } from 'lucide-react';
interface ContactCellProps {
contactId?: number;
contacts?: Record<number, any>;
}
export function ContactCell({ contactId }: ContactCellProps) {
const [contactName, setContactName] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!contactId) {
setContactName(null);
return;
}
const fetchContact = async () => {
setLoading(true);
try {
const response = await fetch(`/api/contacts/${contactId}`);
if (response.ok) {
const data = await response.json();
if (data.contact) {
setContactName(`${data.contact.firstName} ${data.contact.lastName}`);
}
}
} catch (err) {
console.error('Failed to fetch contact:', err);
} finally {
setLoading(false);
}
};
fetchContact();
}, [contactId]);
if (loading) {
return <span className="text-xs text-muted-foreground">Loading...</span>;
}
export function ContactCell({ contactId, contacts }: ContactCellProps) {
if (!contactId) {
return <span className="text-xs text-muted-foreground">-</span>;
}
if (contactName) {
return (
<Badge variant="outline" className="text-xs">
<User className="w-3 h-3 mr-1" />
{contactName}
</Badge>
);
// Get contact from the contacts map
const contact = contacts?.[contactId];
if (contact) {
const contactName = `${contact.firstName || ''} ${contact.lastName || ''}`.trim();
if (contactName) {
return (
<Badge variant="outline" className="text-xs">
<User className="w-3 h-3 mr-1" />
{contactName}
</Badge>
);
}
}
return <span className="text-xs text-muted-foreground">ID: {contactId}</span>;

View file

@ -19,6 +19,16 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Server,
Edit,
@ -29,7 +39,8 @@ import {
RefreshCw,
User,
Receipt,
Ticket as TicketIcon
Ticket as TicketIcon,
Download
} from 'lucide-react';
import { format } from 'date-fns';
import { ConfigurationItem } from '@/lib/types/autotask';
@ -50,6 +61,15 @@ export function PSATab({ device, onUpdate }: PSATabProps) {
const [loadingContact, setLoadingContact] = useState(false);
const [purchaseHistoryOpen, setPurchaseHistoryOpen] = useState(false);
const [relatedTicketsOpen, setRelatedTicketsOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [selectedFields, setSelectedFields] = useState<Set<string>>(new Set());
// Sync editedData when device prop changes
useEffect(() => {
if (device) {
setEditedData(device);
}
}, [device]);
// Fetch contact information if contactID exists
useEffect(() => {
@ -119,12 +139,15 @@ export function PSATab({ device, onUpdate }: PSATabProps) {
});
if (!response.ok) {
throw new Error('Failed to update configuration item');
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update configuration item');
}
const updated = await response.json();
onUpdate(updated.configurationItem);
setEditedData({ ...editedData, isActive: false });
if (updated.configurationItem) {
onUpdate(updated.configurationItem);
setEditedData(updated.configurationItem);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to make inactive');
} finally {
@ -132,6 +155,106 @@ export function PSATab({ device, onUpdate }: PSATabProps) {
}
};
// Define available fields for export
const availableFields = [
{ key: 'id', label: 'ID' },
{ key: 'referenceTitle', label: 'Reference Title' },
{ key: 'referenceNumber', label: 'Reference Number' },
{ key: 'serialNumber', label: 'Serial Number' },
{ key: 'location', label: 'Location' },
{ key: 'isActive', label: 'Active Status' },
{ key: 'modelNumber', label: 'Model Number' },
{ key: 'macAddress', label: 'MAC Address' },
{ key: 'installDate', label: 'Install Date' },
{ key: 'warrantyExpirationDate', label: 'Warranty Expiration' },
{ key: 'rmmDeviceUID', label: 'RMM Device UID' },
{ key: 'notes', label: 'Notes' },
{ key: 'companyID', label: 'Company ID' },
{ key: 'contactID', label: 'Contact ID' },
{ key: 'contractID', label: 'Contract ID' },
{ key: 'createDate', label: 'Create Date' },
{ key: 'lastModifiedTime', label: 'Last Modified Time' },
{ key: 'productID', label: 'Product ID' },
{ key: 'vendorName', label: 'Vendor Name' },
{ key: 'deviceNetworkingID', label: 'Device Networking ID' },
{ key: 'numberOfUsers', label: 'Number of Users' },
{ key: 'setupFee', label: 'Setup Fee' },
];
const toggleField = (fieldKey: string) => {
const newSelected = new Set(selectedFields);
if (newSelected.has(fieldKey)) {
newSelected.delete(fieldKey);
} else {
newSelected.add(fieldKey);
}
setSelectedFields(newSelected);
};
const toggleAllFields = () => {
if (selectedFields.size === availableFields.length) {
setSelectedFields(new Set());
} else {
setSelectedFields(new Set(availableFields.map(f => f.key)));
}
};
const handleExport = () => {
if (!device || selectedFields.size === 0) return;
// Build CSV header
const headers = availableFields
.filter(f => selectedFields.has(f.key))
.map(f => f.label);
// Build CSV row
const row = availableFields
.filter(f => selectedFields.has(f.key))
.map(f => {
const value = device[f.key as keyof ConfigurationItem];
// Format dates
if ((f.key === 'installDate' || f.key === 'warrantyExpirationDate' ||
f.key === 'createDate' || f.key === 'lastModifiedTime') && value) {
return format(new Date(value as string), 'yyyy-MM-dd HH:mm:ss');
}
// Handle boolean
if (typeof value === 'boolean') {
return value ? 'Active' : 'Inactive';
}
// Handle null/undefined
if (value === null || value === undefined) {
return '';
}
// Escape quotes and wrap in quotes if contains comma or newline
const stringValue = String(value);
if (stringValue.includes(',') || stringValue.includes('\n') || stringValue.includes('"')) {
return `"${stringValue.replace(/"/g, '""')}"`;
}
return stringValue;
});
// Create CSV content
const csvContent = [headers.join(','), row.join(',')].join('\n');
// Create and trigger download
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `config-item-${device.id}-${format(new Date(), 'yyyy-MM-dd')}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setExportModalOpen(false);
};
return (
<>
<Card className="border-0 shadow-lg">
@ -141,6 +264,15 @@ export function PSATab({ device, onUpdate }: PSATabProps) {
<div className="flex items-center gap-2">
{!editMode ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => setExportModalOpen(true)}
disabled={!device}
>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button
variant="outline"
size="sm"
@ -451,6 +583,69 @@ export function PSATab({ device, onUpdate }: PSATabProps) {
onOpenChange={setRelatedTicketsOpen}
/>
)}
{/* Export Modal */}
<Dialog open={exportModalOpen} onOpenChange={setExportModalOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Export Configuration Item</DialogTitle>
<DialogDescription>
Select the fields you want to include in the CSV export
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between pb-2 border-b">
<div className="flex items-center space-x-2">
<Checkbox
id="select-all"
checked={selectedFields.size === availableFields.length}
onCheckedChange={toggleAllFields}
/>
<Label htmlFor="select-all" className="font-semibold cursor-pointer">
Select All ({selectedFields.size}/{availableFields.length})
</Label>
</div>
</div>
<ScrollArea className="h-[400px] pr-4">
<div className="grid grid-cols-2 gap-3">
{availableFields.map((field) => (
<div key={field.key} className="flex items-center space-x-2">
<Checkbox
id={field.key}
checked={selectedFields.has(field.key)}
onCheckedChange={() => toggleField(field.key)}
/>
<Label
htmlFor={field.key}
className="text-sm cursor-pointer font-normal"
>
{field.label}
</Label>
</div>
))}
</div>
</ScrollArea>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setExportModalOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleExport}
disabled={selectedFields.size === 0}
>
<Download className="w-4 h-4 mr-2" />
Export CSV
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -226,19 +226,19 @@ export function PurchaseHistoryModal({
<div className="px-3 py-2 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950 dark:to-green-900 rounded-lg">
<p className="text-xs text-muted-foreground">Sale Price</p>
<p className="text-sm font-bold">
${data.billingItems.reduce((sum: number, item: any) => sum + (item.totalAmount || 0), 0).toFixed(2)}
${data.billingItems.reduce((sum: number, item: any) => sum + (Number(item.totalAmount) || 0), 0).toFixed(2)}
</p>
</div>
<div className="px-3 py-2 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-950 dark:to-blue-900 rounded-lg">
<p className="text-xs text-muted-foreground">Cost</p>
<p className="text-sm font-bold">
${data.billingItems.reduce((sum: number, item: any) => sum + (item.ourCost || 0), 0).toFixed(2)}
${data.billingItems.reduce((sum: number, item: any) => sum + (Number(item.ourCost) || 0), 0).toFixed(2)}
</p>
</div>
<div className="px-3 py-2 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-950 dark:to-purple-900 rounded-lg">
<p className="text-xs text-muted-foreground">Profit</p>
<p className="text-sm font-bold">
${data.billingItems.reduce((sum: number, item: any) => sum + (item.profit || 0), 0).toFixed(2)}
${data.billingItems.reduce((sum: number, item: any) => sum + (Number(item.profit) || 0), 0).toFixed(2)}
</p>
</div>
</div>

View file

@ -0,0 +1,230 @@
'use client';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { cn } from '@/lib/utils';
import {
LayoutDashboard,
Server,
Network,
Globe,
Smartphone,
Database,
RefreshCw,
ChevronDown,
Activity
} from 'lucide-react';
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from '@/components/ui/navigation-menu';
import { Button } from '@/components/ui/button';
import { ThemeToggle } from '@/components/theme-toggle';
interface NavItem {
title: string;
href?: string;
icon?: React.ElementType;
description?: string;
children?: NavItem[];
}
const navigationItems: NavItem[] = [
{
title: 'Dashboard',
href: '/',
icon: LayoutDashboard,
description: 'Overview and quick access'
},
{
title: 'Configuration Items',
href: '/configuration-items',
icon: Server,
description: 'Manage IT assets and devices'
},
{
title: 'Admin',
icon: Activity,
children: [
{
title: 'Sync Management',
href: '/admin/sync',
icon: RefreshCw,
description: 'Sync data from external systems'
},
{
title: 'NMS Mapping (Auvik)',
href: '/auvik-mappings',
icon: Network,
description: 'Map Auvik tenants to companies'
},
{
title: 'RMM Mapping (Datto)',
href: '/rmm-mappings',
icon: Globe,
description: 'Map RMM sites to companies'
},
{
title: 'Apple RMM Mapping (Addigy)',
href: '/addigy-mappings',
icon: Smartphone,
description: 'Map Addigy devices to companies'
},
{
title: 'Data Browser',
href: '/admin/data-browser',
icon: Database,
description: 'Browse and query system data'
},
]
},
];
export function AppNavigation() {
const pathname = usePathname();
const isActive = (href?: string) => {
if (!href) return false;
return pathname === href || pathname.startsWith(href + '/');
};
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center">
<div className="flex flex-1 items-center justify-between">
{/* Logo and App Name */}
<Link href="/" className="flex items-center space-x-3 mr-6">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-blue-600 to-blue-700 text-white shadow-lg">
<Activity className="h-5 w-5" />
</div>
<div className="hidden sm:block">
<h1 className="text-xl font-semibold tracking-tight">
Pulse
</h1>
<p className="text-xs text-muted-foreground">PSA Management System</p>
</div>
</Link>
{/* Main Navigation */}
<NavigationMenu className="mx-6">
<NavigationMenuList>
{navigationItems.map((item) => (
<NavigationMenuItem key={item.title}>
{item.children ? (
<>
<NavigationMenuTrigger className={cn(
"h-9 px-4 py-2",
item.children.some(child => isActive(child.href)) && "bg-accent"
)}>
{item.icon && <item.icon className="w-4 h-4 mr-2" />}
{item.title}
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px]">
{item.children.map((child) => (
<li key={child.title}>
<NavigationMenuLink asChild>
<Link
href={child.href || '#'}
className={cn(
"block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
isActive(child.href) && "bg-accent"
)}
>
<div className="flex items-center text-sm font-medium leading-none">
{child.icon && <child.icon className="w-4 h-4 mr-2" />}
{child.title}
</div>
{child.description && (
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
{child.description}
</p>
)}
</Link>
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</>
) : (
<Link href={item.href || '#'} legacyBehavior passHref>
<NavigationMenuLink className={cn(
navigationMenuTriggerStyle(),
"h-9",
isActive(item.href) && "bg-accent"
)}>
{item.icon && <item.icon className="w-4 h-4 mr-2" />}
{item.title}
</NavigationMenuLink>
</Link>
)}
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
{/* Right Side Actions */}
<div className="flex items-center gap-2">
<ThemeToggle />
</div>
</div>
</div>
</header>
);
}
// Breadcrumb component for secondary navigation
export interface BreadcrumbItem {
label: string;
href?: string;
}
interface PageHeaderProps {
title: string;
description?: string;
breadcrumbs?: BreadcrumbItem[];
actions?: React.ReactNode;
}
export function PageHeader({ title, description, breadcrumbs, actions }: PageHeaderProps) {
return (
<div className="border-b">
<div className="container py-4">
{/* Breadcrumbs */}
{breadcrumbs && breadcrumbs.length > 0 && (
<nav className="flex items-center space-x-2 text-sm text-muted-foreground mb-2">
{breadcrumbs.map((crumb, index) => (
<div key={index} className="flex items-center">
{index > 0 && <span className="mx-2">/</span>}
{crumb.href ? (
<Link href={crumb.href} className="hover:text-foreground transition-colors">
{crumb.label}
</Link>
) : (
<span className="text-foreground">{crumb.label}</span>
)}
</div>
))}
</nav>
)}
{/* Title and Actions */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
{description && (
<p className="text-muted-foreground mt-1">{description}</p>
)}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View file

@ -0,0 +1,128 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

View file

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View file

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View file

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }