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,633 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Checkbox } from '@/components/ui/checkbox';
import {
Smartphone,
Building2,
CheckCircle,
XCircle,
AlertCircle,
Save,
Trash2,
Search,
RefreshCw
} from 'lucide-react';
import { AddigyOrgMapping } from '@/lib/types/addigy';
import { Company } from '@/lib/types/autotask';
// Simple toast implementation
const useToast = () => {
return {
toast: ({ title, description, variant }: { title: string; description: string; variant?: string }) => {
// For now, use console and alert - can be enhanced with a proper toast library later
if (variant === 'destructive') {
console.error(`${title}: ${description}`);
alert(`Error: ${description}`);
} else {
console.log(`${title}: ${description}`);
}
}
};
};
interface OrgRow extends Partial<AddigyOrgMapping> {
addigyOrgId: string;
addigyOrgName: string;
isMapped: boolean;
deviceCount?: number;
}
export default function AddigyMappingsPage() {
const [orgs, setOrgs] = useState<OrgRow[]>([]);
const [companies, setCompanies] = useState<Company[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState<'all' | 'mapped' | 'unmapped'>('all');
const [selectedPolicies, setSelectedPolicies] = useState<Set<string>>(new Set());
const [bulkCompanyId, setBulkCompanyId] = useState<number>(0);
const [bulkSaving, setBulkSaving] = useState(false);
const { toast } = useToast();
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
setLoading(true);
try {
// Fetch org mappings (including unmapped)
const mappingsRes = await fetch('/api/addigy/org-mappings?includeUnmapped=true');
const mappingsData = await mappingsRes.json();
// Fetch all companies
const companiesRes = await fetch('/api/companies');
const companiesData = await companiesRes.json();
const orgRows: OrgRow[] = (mappingsData.mappings || []).map((m: any) => ({
...m,
isMapped: m.autotaskCompanyId > 0,
}));
setOrgs(orgRows);
setCompanies(companiesData.companies || []);
// Show warning if Addigy API is not configured
if (mappingsData.warning) {
console.warn(mappingsData.warning);
toast({
title: 'Warning',
description: mappingsData.warning,
});
}
} catch (error) {
console.error('Error fetching data:', error);
toast({
title: 'Error',
description: 'Failed to load Addigy org mappings',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
const handleSaveMapping = async (orgId: string, orgName: string, companyId: number) => {
setSaving(orgId);
try {
const company = companies.find((c) => c.id === companyId);
if (!company) {
throw new Error('Company not found');
}
const response = await fetch('/api/addigy/org-mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
addigyOrgId: orgId,
addigyOrgName: orgName,
autotaskCompanyId: companyId,
autotaskCompanyName: company.companyName,
}),
});
if (!response.ok) {
throw new Error('Failed to save mapping');
}
toast({
title: 'Success',
description: `Mapped ${orgName} to ${company.companyName}`,
});
await fetchData();
} catch (error) {
console.error('Error saving mapping:', error);
toast({
title: 'Error',
description: 'Failed to save mapping',
variant: 'destructive',
});
} finally {
setSaving(null);
}
};
const handleDeleteMapping = async (mappingId: number) => {
try {
const response = await fetch(`/api/addigy/org-mappings?id=${mappingId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete mapping');
}
toast({
title: 'Success',
description: 'Mapping deleted successfully',
});
await fetchData();
} catch (error) {
console.error('Error deleting mapping:', error);
toast({
title: 'Error',
description: 'Failed to delete mapping',
variant: 'destructive',
});
}
};
const handleBulkSave = async () => {
if (selectedPolicies.size === 0 || bulkCompanyId === 0) {
toast({
title: 'Error',
description: 'Please select policies and a company',
variant: 'destructive',
});
return;
}
setBulkSaving(true);
const company = companies.find((c) => c.id === bulkCompanyId);
if (!company) {
toast({
title: 'Error',
description: 'Company not found',
variant: 'destructive',
});
setBulkSaving(false);
return;
}
let successCount = 0;
let errorCount = 0;
for (const policyId of selectedPolicies) {
const policy = orgs.find((o) => o.addigyOrgId === policyId);
if (!policy) continue;
try {
const response = await fetch('/api/addigy/org-mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
addigyOrgId: policy.addigyOrgId,
addigyOrgName: policy.addigyOrgName,
autotaskCompanyId: bulkCompanyId,
autotaskCompanyName: company.companyName,
}),
});
if (response.ok) {
successCount++;
} else {
errorCount++;
}
} catch (error) {
console.error('Error saving mapping:', error);
errorCount++;
}
}
setBulkSaving(false);
setSelectedPolicies(new Set());
setBulkCompanyId(0);
if (errorCount === 0) {
toast({
title: 'Success',
description: `Mapped ${successCount} ${successCount === 1 ? 'policy' : 'policies'} to ${company.companyName}`,
});
} else {
toast({
title: 'Partial Success',
description: `Mapped ${successCount} policies, ${errorCount} failed`,
variant: 'destructive',
});
}
await fetchData();
};
const togglePolicySelection = (policyId: string) => {
const newSelection = new Set(selectedPolicies);
if (newSelection.has(policyId)) {
newSelection.delete(policyId);
} else {
newSelection.add(policyId);
}
setSelectedPolicies(newSelection);
};
const toggleSelectAll = () => {
if (selectedPolicies.size === filteredOrgs.length) {
setSelectedPolicies(new Set());
} else {
setSelectedPolicies(new Set(filteredOrgs.map((o) => o.addigyOrgId)));
}
};
const filteredOrgs = orgs.filter((org) => {
const matchesSearch =
org.addigyOrgName.toLowerCase().includes(searchTerm.toLowerCase()) ||
org.autotaskCompanyName?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesFilter =
filterStatus === 'all' ||
(filterStatus === 'mapped' && org.isMapped) ||
(filterStatus === 'unmapped' && !org.isMapped);
return matchesSearch && matchesFilter;
});
const stats = {
total: orgs.length,
mapped: orgs.filter((o) => o.isMapped).length,
unmapped: orgs.filter((o) => !o.isMapped).length,
};
return (
<div className="container mx-auto py-8 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold flex items-center gap-3">
<Smartphone className="w-8 h-8 text-orange-600" />
Apple RMM Policy Mappings
</h1>
<p className="text-muted-foreground mt-2">
Map Addigy policies to Autotask companies for Apple device synchronization
</p>
</div>
<Button onClick={fetchData} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Policies
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
Mapped
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{stats.mapped}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-orange-600" />
Unmapped
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-orange-600">{stats.unmapped}</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card>
<CardHeader>
<CardTitle>Policy Mappings</CardTitle>
<CardDescription>
Select an Autotask company for each Addigy policy to enable device matching
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search policies or companies..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<Select
value={filterStatus}
onValueChange={(value: any) => setFilterStatus(value)}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Policies</SelectItem>
<SelectItem value="mapped">Mapped Only</SelectItem>
<SelectItem value="unmapped">Unmapped Only</SelectItem>
</SelectContent>
</Select>
</div>
{/* Bulk Actions */}
{selectedPolicies.size > 0 && (
<div className="flex items-center gap-4 p-4 bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-blue-600" />
<span className="font-medium">
{selectedPolicies.size} {selectedPolicies.size === 1 ? 'policy' : 'policies'} selected
</span>
</div>
<div className="flex-1">
<Select
value={bulkCompanyId.toString()}
onValueChange={(value) => setBulkCompanyId(parseInt(value))}
disabled={bulkSaving}
>
<SelectTrigger className="w-full bg-white dark:bg-gray-950">
<SelectValue placeholder="Select company to map to...">
{bulkCompanyId === 0
? "Select company to map to..."
: companies.find(c => c.id === bulkCompanyId)?.companyName || "Select company..."}
</SelectValue>
</SelectTrigger>
<SelectContent>
{companies
.sort((a, b) => a.companyName.localeCompare(b.companyName))
.map((company) => (
<SelectItem key={company.id} value={company.id.toString()}>
{company.companyName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
onClick={handleBulkSave}
disabled={bulkSaving || bulkCompanyId === 0}
className="bg-blue-600 hover:bg-blue-700"
>
{bulkSaving ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Map Selected
</>
)}
</Button>
<Button
variant="outline"
onClick={() => setSelectedPolicies(new Set())}
disabled={bulkSaving}
>
Clear Selection
</Button>
</div>
)}
{/* Table */}
{loading ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">
<Checkbox
checked={selectedPolicies.size === filteredOrgs.length && filteredOrgs.length > 0}
onCheckedChange={toggleSelectAll}
/>
</TableHead>
<TableHead className="w-[200px]">
<div className="flex items-center gap-2">
<Smartphone className="w-4 h-4" />
Addigy Policy
</div>
</TableHead>
<TableHead>
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4" />
Autotask Company
</div>
</TableHead>
<TableHead className="w-[100px]">Status</TableHead>
<TableHead className="w-[100px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredOrgs.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
No policies found
</TableCell>
</TableRow>
) : (
filteredOrgs.map((org) => (
<OrgMappingRow
key={org.addigyOrgId}
org={org}
companies={companies}
saving={saving === org.addigyOrgId}
onSave={handleSaveMapping}
onDelete={handleDeleteMapping}
isSelected={selectedPolicies.has(org.addigyOrgId)}
onToggleSelect={() => togglePolicySelection(org.addigyOrgId)}
/>
))
)}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
interface OrgMappingRowProps {
org: OrgRow;
companies: Company[];
saving: boolean;
onSave: (orgId: string, orgName: string, companyId: number) => void;
onDelete: (mappingId: number) => void;
isSelected: boolean;
onToggleSelect: () => void;
}
function OrgMappingRow({
org,
companies,
saving,
onSave,
onDelete,
isSelected,
onToggleSelect,
}: OrgMappingRowProps) {
const [selectedCompanyId, setSelectedCompanyId] = useState<number>(
org.autotaskCompanyId || 0
);
const [hasChanges, setHasChanges] = useState(false);
const handleCompanyChange = (value: string) => {
const companyId = parseInt(value);
setSelectedCompanyId(companyId);
setHasChanges(companyId !== org.autotaskCompanyId);
};
const handleSave = () => {
if (selectedCompanyId > 0) {
onSave(org.addigyOrgId, org.addigyOrgName, selectedCompanyId);
setHasChanges(false);
}
};
return (
<TableRow>
<TableCell>
<Checkbox
checked={isSelected}
onCheckedChange={onToggleSelect}
/>
</TableCell>
<TableCell>
<div className="flex items-center justify-between">
<div>
<div className="font-medium">{org.addigyOrgName}</div>
<div className="text-xs text-muted-foreground font-mono">
{org.addigyOrgId}
</div>
</div>
</div>
</TableCell>
<TableCell>
<Select
value={selectedCompanyId.toString()}
onValueChange={handleCompanyChange}
disabled={saving}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a company...">
{selectedCompanyId === 0
? "No mapping"
: companies.find(c => c.id === selectedCompanyId)?.companyName || "Select a company..."}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="0">No mapping</SelectItem>
{companies
.sort((a, b) => a.companyName.localeCompare(b.companyName))
.map((company) => (
<SelectItem key={company.id} value={company.id.toString()}>
{company.companyName}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
{org.isMapped ? (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100">
<CheckCircle className="w-3 h-3 mr-1" />
Mapped
</Badge>
) : (
<Badge variant="secondary">
<XCircle className="w-3 h-3 mr-1" />
Unmapped
</Badge>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
{hasChanges && (
<Button
size="sm"
onClick={handleSave}
disabled={saving || selectedCompanyId === 0}
>
{saving ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<>
<Save className="w-3 h-3 mr-1" />
Save
</>
)}
</Button>
)}
{org.isMapped && org.id && (
<Button
size="sm"
variant="ghost"
onClick={() => onDelete(org.id!)}
disabled={saving}
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
}

View file

@ -0,0 +1,544 @@
'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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
Calendar,
Filter,
Download,
RefreshCw,
TrendingUp,
Clock,
Users,
Target,
Activity,
BarChart3,
Settings,
AlertCircle,
CheckCircle
} from 'lucide-react';
import { TimelineView } from '@/components/analytics/TimelineView';
import {
ScoreCard,
ActivityScoreCard,
ContentScoreCard,
TimelinessScoreCard,
AggregateScoreCard
} from '@/components/analytics/ScoreCard';
import { AnalysisPanel } from '@/components/analytics/AnalysisPanel';
import { TimeEntry } from '@/lib/types/database';
import {
TimelineEvent,
AnalyticsInsight,
LLMAnalysisResponse,
AggregateAnalysis
} from '@/lib/types/analytics';
export default function TimeEntriesAnalyticsPage() {
const [loading, setLoading] = useState(true);
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
const [timelineEvents, setTimelineEvents] = useState<TimelineEvent[]>([]);
const [analysis, setAnalysis] = useState<AggregateAnalysis | null>(null);
const [insights, setInsights] = useState<AnalyticsInsight[]>([]);
const [llmAnalysis, setLlmAnalysis] = useState<LLMAnalysisResponse | undefined>(undefined);
// Filter states
const [timeRange, setTimeRange] = useState<'hour' | 'day' | 'week' | 'month'>('day');
const [selectedResources, setSelectedResources] = useState<number[]>([]);
const [selectedProjects, setSelectedProjects] = useState<number[]>([]);
const [selectedTickets, setSelectedTickets] = useState<number[]>([]);
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [minHours, setMinHours] = useState<string>('');
const [maxHours, setMaxHours] = useState<string>('');
const [billable, setBillable] = useState<boolean | undefined>(undefined);
const [approved, setApproved] = useState<boolean | undefined>(undefined);
// Mock data for demonstration
useEffect(() => {
loadMockData();
}, []);
const loadMockData = async () => {
setLoading(true);
try {
// Build query parameters
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
if (minHours) params.append('min_hours', minHours);
if (maxHours) params.append('max_hours', maxHours);
if (billable !== undefined) params.append('billable', String(billable));
if (approved !== undefined) params.append('approved', String(approved));
if (selectedTickets.length > 0) params.append('ticket_id', String(selectedTickets[0]));
params.append('limit', '1000'); // Get more data for analytics
// Fetch real time entries from API
const response = await fetch(`/api/data/time-entries?${params}`);
if (!response.ok) {
throw new Error('Failed to fetch time entries');
}
const data = await response.json();
const fetchedEntries: TimeEntry[] = data.timeEntries || [];
// Use fetched entries instead of mock data
const timeEntriesData = fetchedEntries.length > 0 ? fetchedEntries : [];
// Fallback mock time entries if no data
const mockTimeEntries: TimeEntry[] = timeEntriesData.length > 0 ? timeEntriesData : [
{
id: 1,
resource_id: 1,
ticket_id: 101,
task_id: 201,
project_id: 301,
company_id: 401,
entry_date: new Date('2024-01-15T09:00:00Z'),
hours_worked: 2.5,
notes: 'Fixed critical bug in authentication system. Updated JWT token validation logic.',
title: 'Bug Fix - Authentication',
type: 1,
start_date_time: new Date('2024-01-15T09:00:00Z'),
end_date_time: new Date('2024-01-15T11:30:00Z'),
billable: true,
approved: true,
created_at: new Date('2024-01-15T12:00:00Z'),
updated_at: new Date('2024-01-15T12:00:00Z'),
synced_at: new Date('2024-01-15T12:00:00Z'),
is_deleted: false,
},
{
id: 2,
resource_id: 2,
ticket_id: 102,
task_id: 202,
project_id: 302,
company_id: 402,
entry_date: new Date('2024-01-15T14:00:00Z'),
hours_worked: 4.0,
notes: 'Implemented new dashboard feature with React components. Added data visualization charts.',
title: 'Feature Development - Dashboard',
type: 2,
start_date_time: new Date('2024-01-15T14:00:00Z'),
end_date_time: new Date('2024-01-15T18:00:00Z'),
billable: true,
approved: false,
created_at: new Date('2024-01-15T18:30:00Z'),
updated_at: new Date('2024-01-15T18:30:00Z'),
synced_at: new Date('2024-01-15T18:30:00Z'),
is_deleted: false,
},
// Add more mock entries as needed
];
// Mock timeline events
const mockTimelineEvents: TimelineEvent[] = mockTimeEntries.map(entry => ({
id: `te-${entry.id}`,
type: 'time_entry',
timestamp: new Date(entry.entry_date),
title: entry.title || 'Time Entry',
description: entry.notes || undefined,
duration: entry.hours_worked,
isHumanActivity: true,
importance: entry.billable ? 'high' : 'medium',
score: 0.8, // Mock score
}));
// Mock analysis
const totalHours = mockTimeEntries.reduce((sum, entry) => {
const hours = typeof entry.hours_worked === 'string' ? parseFloat(entry.hours_worked) : entry.hours_worked;
return sum + hours;
}, 0);
const mockAnalysis: AggregateAnalysis = {
totalEntries: mockTimeEntries.length,
totalHours: totalHours,
averageHoursPerEntry: mockTimeEntries.length > 0 ? totalHours / mockTimeEntries.length : 0,
dateRange: {
earliest: new Date('2024-01-15'),
latest: new Date('2024-01-15'),
},
scores: {
activity: 0.85,
content: 0.78,
timeliness: 0.92,
overall: 0.85,
},
insights: [
{
type: 'success',
category: 'overall',
title: 'High Quality Time Tracking',
description: 'Overall time entry quality is excellent.',
recommendation: 'Maintain current documentation standards.',
severity: 'low',
actionable: false,
},
{
type: 'warning',
category: 'billing',
title: 'Pending Approvals',
description: 'Some time entries are awaiting approval.',
recommendation: 'Review and approve pending time entries.',
severity: 'medium',
actionable: true,
},
],
patterns: {
dayOfWeek: [0, 5, 8, 12, 6, 3, 1],
hourly: [0, 1, 2, 3, 4, 2, 8, 15, 12, 8, 6, 4, 3, 5, 7, 6, 4, 2, 1, 0, 0, 0, 0, 0],
},
trends: {
weekly: [
{ week: new Date('2024-01-08'), hours: 25, entries: 8 },
{ week: new Date('2024-01-15'), hours: 32, entries: 10 },
],
},
analyzedAt: new Date(),
};
// Mock LLM analysis
const mockLlmAnalysis: LLMAnalysisResponse = {
insights: [
'Team shows excellent documentation practices with detailed notes',
'Consistent time entry patterns indicate good workflow discipline',
],
patterns: [
{
type: 'Morning Productivity',
description: 'Most productive work occurs in morning hours (9 AM - 12 PM)',
frequency: 8,
impact: 'medium',
},
],
recommendations: [
{
category: 'Process Improvement',
priority: 'medium',
action: 'Implement automated reminders for time entry approval',
expectedImpact: 'Reduce approval delays by 50%',
},
],
summary: {
overallQuality: 0.85,
productivityLevel: 0.78,
keyFindings: [
'Strong documentation quality',
'Consistent time tracking patterns',
'Need for faster approval process',
],
},
processingTime: 1250,
tokensUsed: 245,
};
setTimeEntries(mockTimeEntries);
setTimelineEvents(mockTimelineEvents);
setAnalysis(mockAnalysis);
setInsights(mockAnalysis.insights);
setLlmAnalysis(mockLlmAnalysis);
setLoading(false);
} catch (error) {
console.error('Error loading time entries:', error);
setTimeEntries([]);
setLoading(false);
}
};
const handleRefresh = () => {
loadMockData();
};
const handleExport = () => {
// Implement export functionality
console.log('Exporting analytics data...');
};
const applyFilters = () => {
// Implement filter application
console.log('Applying filters...');
loadMockData();
};
const clearFilters = () => {
setSelectedResources([]);
setSelectedProjects([]);
setSelectedTickets([]);
setStartDate('');
setEndDate('');
setMinHours('');
setMaxHours('');
setBillable(undefined);
setApproved(undefined);
loadMockData();
};
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
<BarChart3 className="h-8 w-8 text-blue-600" />
Time Entries Analytics
</h1>
<p className="text-gray-600 mt-2">
Advanced analytics and insights for time tracking data
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleRefresh} disabled={loading}>
<RefreshCw className={cn("h-4 w-4 mr-2", loading ? "animate-spin" : "")} />
Refresh
</Button>
<Button variant="outline" onClick={handleExport}>
<Download className="h-4 w-4 mr-2" />
Export
</Button>
</div>
</div>
{/* Summary Cards */}
{analysis && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<ScoreCard
title="Total Entries"
score={analysis.totalEntries / 50} // Normalize to 0-1
description={`${analysis.totalEntries} time entries`}
icon={<Activity className="h-5 w-5 text-blue-500" />}
/>
<ScoreCard
title="Total Hours"
score={Math.min(Number(analysis.totalHours) / 40, 1)} // Normalize to 0-1
description={`${Number(analysis.totalHours).toFixed(1)} hours tracked`}
icon={<Clock className="h-5 w-5 text-green-500" />}
/>
<ScoreCard
title="Overall Score"
score={analysis.scores.overall}
description="Average quality score"
icon={<Target className="h-5 w-5 text-purple-500" />}
/>
<ScoreCard
title="Productivity"
score={analysis.scores.activity}
description="Activity and consistency score"
icon={<TrendingUp className="h-5 w-5 text-orange-500" />}
/>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Filters Panel */}
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Filter className="h-5 w-5" />
Filters
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Date Range */}
<div className="space-y-2">
<Label>Start Date</Label>
<Input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
{/* Ticket ID Filter */}
<div className="space-y-2">
<Label>Ticket ID</Label>
<Input
type="number"
placeholder="Enter ticket ID"
value={selectedTickets[0] || ''}
onChange={(e) => {
const ticketId = e.target.value ? parseInt(e.target.value) : null;
setSelectedTickets(ticketId ? [ticketId] : []);
}}
/>
<p className="text-xs text-muted-foreground">
Filter timeline to show only entries for this ticket
</p>
</div>
{/* Hours Range */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label>Min Hours</Label>
<Input
type="number"
step="0.5"
placeholder="0"
value={minHours}
onChange={(e) => setMinHours(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Max Hours</Label>
<Input
type="number"
step="0.5"
placeholder="24"
value={maxHours}
onChange={(e) => setMaxHours(e.target.value)}
/>
</div>
</div>
{/* Checkboxes */}
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="billable"
checked={billable === true}
onCheckedChange={(checked) => setBillable(checked === true)}
/>
<Label htmlFor="billable">Billable only</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="approved"
checked={approved === true}
onCheckedChange={(checked) => setApproved(checked === true)}
/>
<Label htmlFor="approved">Approved only</Label>
</div>
</div>
{/* Action Buttons */}
<div className="space-y-2 pt-4">
<Button onClick={applyFilters} className="w-full">
Apply Filters
</Button>
<Button variant="outline" onClick={clearFilters} className="w-full">
Clear Filters
</Button>
</div>
</CardContent>
</Card>
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Tabs */}
<Tabs defaultValue="overview" className="space-y-4">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="timeline">Timeline</TabsTrigger>
<TabsTrigger value="scores">Scores</TabsTrigger>
<TabsTrigger value="analysis">AI Analysis</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
{analysis && <AggregateScoreCard analysis={analysis} />}
{/* Additional overview content */}
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600">
Detailed activity overview and trends will be displayed here.
</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="timeline">
<TimelineView
events={timelineEvents}
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
loading={loading}
/>
</TabsContent>
<TabsContent value="scores" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<ActivityScoreCard
title="Activity Score"
score={{
score: analysis?.scores.activity || 0,
factors: ['Complete entry details', 'Consistent time tracking'],
breakdown: {
completeness: 0.9,
consistency: 0.8,
duration: 0.85,
categorization: 0.9,
},
}}
/>
<ContentScoreCard
title="Content Score"
score={{
score: analysis?.scores.content || 0,
factors: ['Detailed work description', 'Technical details included'],
breakdown: {
notesQuality: 0.85,
titleClarity: 0.8,
internalNotes: 0.7,
technicalDetail: 0.75,
},
}}
/>
<TimelinessScoreCard
title="Timeliness Score"
score={{
score: analysis?.scores.timeliness || 0,
factors: ['Prompt time entry', 'Business hours compliance'],
breakdown: {
entryDelay: 0.95,
businessHours: 0.9,
regularity: 0.85,
approvalTimeliness: 0.9,
},
}}
/>
</div>
</TabsContent>
<TabsContent value="analysis">
<AnalysisPanel
insights={insights}
llmAnalysis={llmAnalysis}
loading={loading}
onRefresh={handleRefresh}
onExport={handleExport}
/>
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
}
function cn(...classes: string[]) {
return classes.filter(Boolean).join(' ');
}

View file

@ -0,0 +1,175 @@
'use client';
import { useState, useEffect } from 'react';
import DataTable from '@/components/admin/DataTable';
import DetailModal from '@/components/admin/DetailModal';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, Users } from 'lucide-react';
import Link from 'next/link';
export default function CompaniesBrowserPage() {
const [companies, setCompanies] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(50);
const [isLoading, setIsLoading] = useState(false);
const [selectedCompany, setSelectedCompany] = useState<any>(null);
const [modalOpen, setModalOpen] = useState(false);
const fetchCompanies = async (currentPage: number, search?: string, sortBy?: string, sortOrder?: string) => {
setIsLoading(true);
try {
const params = new URLSearchParams({
page: currentPage.toString(),
limit: pageSize.toString(),
});
if (search) params.append('search', search);
if (sortBy) params.append('sort', sortBy);
if (sortOrder) params.append('order', sortOrder);
const response = await fetch(`/api/data/companies?${params}`);
const result = await response.json();
setCompanies(result.data || []);
setTotalCount(result.pagination?.total || 0);
} catch (error) {
console.error('Failed to fetch companies:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchCompanies(page);
}, [page]);
const handleRowClick = (company: any) => {
setSelectedCompany(company);
setModalOpen(true);
};
const columns = [
{
key: 'id',
label: 'ID',
sortable: true,
},
{
key: 'company_name',
label: 'Company Name',
sortable: true,
render: (value: string) => (
<div className="font-medium">{value}</div>
),
},
{
key: 'company_number',
label: 'Company #',
sortable: true,
},
{
key: 'phone',
label: 'Phone',
},
{
key: 'city',
label: 'City',
sortable: true,
},
{
key: 'state',
label: 'State',
},
{
key: 'is_active',
label: 'Active',
render: (value: boolean) => (
<Badge variant={value ? 'default' : 'secondary'}>
{value ? 'Active' : 'Inactive'}
</Badge>
),
},
{
key: 'is_deleted',
label: 'Deleted',
render: (value: boolean) => (
<Badge variant={value ? 'destructive' : 'secondary'}>
{value ? 'Yes' : 'No'}
</Badge>
),
},
];
const detailFields = [
{ key: 'id', label: 'ID' },
{ key: 'company_name', label: 'Company Name' },
{ key: 'company_number', label: 'Company Number' },
{ key: 'is_active', label: 'Active' },
{ key: 'phone', label: 'Phone' },
{ key: 'alternate_phone1', label: 'Alternate Phone 1' },
{ key: 'alternate_phone2', label: 'Alternate Phone 2' },
{ key: 'fax', label: 'Fax' },
{ key: 'web_site_url', label: 'Website' },
{ key: 'address1', label: 'Address 1' },
{ key: 'address2', label: 'Address 2' },
{ key: 'city', label: 'City' },
{ key: 'state', label: 'State' },
{ key: 'postal_code', label: 'Postal Code' },
{ key: 'country', label: 'Country' },
{ key: 'company_type', label: 'Company Type' },
{ key: 'synced_at', label: 'Synced At' },
{ key: 'is_deleted', label: 'Is Deleted' },
];
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-3">
<Link href="/admin/data-browser">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<Users className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Companies Browser</h1>
<p className="text-sm text-muted-foreground">Browse and inspect company data</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Companies</CardTitle>
<CardDescription>
{totalCount} total companies in database
</CardDescription>
</CardHeader>
<CardContent>
<DataTable
columns={columns}
data={companies}
totalCount={totalCount}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onSort={(column, direction) => fetchCompanies(page, undefined, column, direction)}
onSearch={(query) => fetchCompanies(1, query)}
onRowClick={handleRowClick}
isLoading={isLoading}
/>
</CardContent>
</Card>
<DetailModal
open={modalOpen}
onOpenChange={setModalOpen}
title={`Company: ${selectedCompany?.company_name || selectedCompany?.id}`}
data={selectedCompany}
fields={detailFields}
/>
</div>
);
}

View file

@ -0,0 +1,185 @@
'use client';
import { useState, useEffect } from 'react';
import DataTable from '@/components/admin/DataTable';
import DetailModal from '@/components/admin/DetailModal';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, Wrench } from 'lucide-react';
import Link from 'next/link';
export default function ConfigurationItemsBrowserPage() {
const [configItems, setConfigItems] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(50);
const [isLoading, setIsLoading] = useState(false);
const [selectedItem, setSelectedItem] = useState<any>(null);
const [modalOpen, setModalOpen] = useState(false);
const fetchConfigItems = async (currentPage: number, search?: string, sortBy?: string, sortOrder?: string) => {
setIsLoading(true);
try {
const params = new URLSearchParams({
limit: pageSize.toString(),
offset: ((currentPage - 1) * pageSize).toString(),
});
if (search) params.append('search', search);
if (sortBy) params.append('sort', sortBy);
if (sortOrder) params.append('order', sortOrder);
const response = await fetch(`/api/data/configuration-items?${params}`);
const result = await response.json();
setConfigItems(result.configurationItems || []);
setTotalCount(result.pagination?.total || 0);
} catch (error) {
console.error('Failed to fetch configuration items:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchConfigItems(page);
}, [page]);
const handleRowClick = (item: any) => {
setSelectedItem(item);
setModalOpen(true);
};
const columns = [
{
key: 'id',
label: 'ID',
sortable: true,
},
{
key: 'reference_title',
label: 'Title',
sortable: true,
render: (value: string) => (
<div className="font-medium">{value}</div>
),
},
{
key: 'reference_number',
label: 'Reference #',
sortable: true,
},
{
key: 'serial_number',
label: 'Serial #',
sortable: true,
},
{
key: 'company_id',
label: 'Company ID',
sortable: true,
},
{
key: 'configuration_item_type',
label: 'Type',
},
{
key: 'is_active',
label: 'Active',
render: (value: boolean) => (
<Badge variant={value ? 'default' : 'secondary'}>
{value ? 'Active' : 'Inactive'}
</Badge>
),
},
];
const detailFields = [
{ key: 'id', label: 'ID' },
{ key: 'company_id', label: 'Company ID' },
{ key: 'reference_title', label: 'Title' },
{ key: 'reference_number', label: 'Reference Number' },
{ key: 'serial_number', label: 'Serial Number' },
{ key: 'product_id', label: 'Product ID' },
{ key: 'configuration_item_type', label: 'Type' },
{ key: 'configuration_item_category_id', label: 'Category ID' },
{ key: 'is_active', label: 'Active' },
{ key: 'install_date', label: 'Install Date' },
{ key: 'warranty_expiration_date', label: 'Warranty Expiration' },
{ key: 'contact_id', label: 'Contact ID' },
{ key: 'location_id', label: 'Location ID' },
{ key: 'vendor_id', label: 'Vendor ID' },
{ key: 'device_type', label: 'Device Type' },
{ key: 'rmm_device_uid', label: 'RMM Device UID' },
{ key: 'notes', label: 'Notes' },
{ key: 'synced_at', label: 'Synced At' },
{ key: 'is_deleted', label: 'Is Deleted' },
];
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/data-browser">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
Back
</Button>
</Link>
<div className="h-8 w-px bg-border" />
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-orange-100 dark:bg-orange-950">
<Wrench className="w-5 h-5 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">Configuration Items</h1>
<p className="text-sm text-muted-foreground">Manage and inspect device data</p>
</div>
</div>
</div>
<Badge variant="secondary" className="w-fit">
{totalCount} total records
</Badge>
</div>
{/* Data Table Card */}
<Card className="border-none shadow-md">
<CardHeader className="border-b bg-muted/30">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">All Configuration Items</CardTitle>
<CardDescription className="mt-1">
View and search through all configuration items in the system
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-6">
<DataTable
columns={columns}
data={configItems}
totalCount={totalCount}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onSort={(column, direction) => fetchConfigItems(page, undefined, column, direction)}
onSearch={(query) => fetchConfigItems(1, query)}
onRowClick={handleRowClick}
isLoading={isLoading}
/>
</CardContent>
</Card>
{/* Detail Modal */}
<DetailModal
open={modalOpen}
onOpenChange={setModalOpen}
title={selectedItem?.reference_title || 'Configuration Item Details'}
data={selectedItem}
fields={detailFields}
/>
</div>
);
}

View file

@ -0,0 +1,182 @@
'use client';
import { useState, useEffect } from 'react';
import DataTable from '@/components/admin/DataTable';
import DetailModal from '@/components/admin/DetailModal';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, Users } from 'lucide-react';
import Link from 'next/link';
export default function ContactsBrowserPage() {
const [contacts, setContacts] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(50);
const [isLoading, setIsLoading] = useState(false);
const [selectedContact, setSelectedContact] = useState<any>(null);
const [modalOpen, setModalOpen] = useState(false);
const fetchContacts = async (currentPage: number, search?: string, sortBy?: string, sortOrder?: string) => {
setIsLoading(true);
try {
const params = new URLSearchParams({
page: currentPage.toString(),
limit: pageSize.toString(),
});
if (search) params.append('search', search);
if (sortBy) params.append('sort', sortBy);
if (sortOrder) params.append('order', sortOrder);
const response = await fetch(`/api/data/contacts?${params}`);
const result = await response.json();
setContacts(result.contacts || result.data || []);
setTotalCount(result.pagination?.total || 0);
} catch (error) {
console.error('Failed to fetch contacts:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchContacts(page);
}, [page]);
const handleRowClick = (contact: any) => {
setSelectedContact(contact);
setModalOpen(true);
};
const columns = [
{
key: 'id',
label: 'ID',
sortable: true,
},
{
key: 'first_name',
label: 'First Name',
sortable: true,
},
{
key: 'last_name',
label: 'Last Name',
sortable: true,
},
{
key: 'email_address',
label: 'Email',
sortable: true,
render: (value: string) => (
<div className="max-w-xs truncate" title={value}>
{value || '-'}
</div>
),
},
{
key: 'phone',
label: 'Phone',
},
{
key: 'company_id',
label: 'Company ID',
sortable: true,
},
{
key: 'is_active',
label: 'Active',
render: (value: boolean) => (
<Badge variant={value ? 'default' : 'secondary'}>
{value ? 'Active' : 'Inactive'}
</Badge>
),
},
{
key: 'is_deleted',
label: 'Deleted',
render: (value: boolean) => (
<Badge variant={value ? 'destructive' : 'secondary'}>
{value ? 'Yes' : 'No'}
</Badge>
),
},
];
const detailFields = [
{ key: 'id', label: 'ID' },
{ key: 'company_id', label: 'Company ID' },
{ key: 'first_name', label: 'First Name' },
{ key: 'last_name', label: 'Last Name' },
{ key: 'title', label: 'Title' },
{ key: 'email_address', label: 'Email' },
{ key: 'email_address2', label: 'Email 2' },
{ key: 'email_address3', label: 'Email 3' },
{ key: 'phone', label: 'Phone' },
{ key: 'extension', label: 'Extension' },
{ key: 'alternate_phone', label: 'Alternate Phone' },
{ key: 'mobile_phone', label: 'Mobile Phone' },
{ key: 'fax', label: 'Fax' },
{ key: 'address_line', label: 'Address' },
{ key: 'city', label: 'City' },
{ key: 'state', label: 'State' },
{ key: 'zip_code', label: 'Zip Code' },
{ key: 'country', label: 'Country' },
{ key: 'is_active', label: 'Active' },
{ key: 'primary_contact', label: 'Primary Contact' },
{ key: 'synced_at', label: 'Synced At' },
{ key: 'is_deleted', label: 'Is Deleted' },
];
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-3">
<Link href="/admin/data-browser">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<Users className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Contacts Browser</h1>
<p className="text-sm text-muted-foreground">Browse and inspect contact data</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Contacts</CardTitle>
<CardDescription>
{totalCount} total contacts in database
</CardDescription>
</CardHeader>
<CardContent>
<DataTable
columns={columns}
data={contacts}
totalCount={totalCount}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onSort={(column, direction) => fetchContacts(page, undefined, column, direction)}
onSearch={(query) => fetchContacts(1, query)}
onRowClick={handleRowClick}
isLoading={isLoading}
/>
</CardContent>
</Card>
<DetailModal
open={modalOpen}
onOpenChange={setModalOpen}
title={`Contact: ${selectedContact?.first_name} ${selectedContact?.last_name}`}
data={selectedContact}
fields={detailFields}
/>
</div>
);
}

View file

@ -0,0 +1,187 @@
'use client';
import { useState, useEffect } from 'react';
import DataTable from '@/components/admin/DataTable';
import DetailModal from '@/components/admin/DetailModal';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, Table2 } from 'lucide-react';
import Link from 'next/link';
export default function ContractsBrowserPage() {
const [contracts, setContracts] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(50);
const [isLoading, setIsLoading] = useState(false);
const [selectedContract, setSelectedContract] = useState<any>(null);
const [modalOpen, setModalOpen] = useState(false);
const fetchContracts = async (currentPage: number, search?: string, sortBy?: string, sortOrder?: string) => {
setIsLoading(true);
try {
const params = new URLSearchParams({
limit: pageSize.toString(),
offset: ((currentPage - 1) * pageSize).toString(),
});
if (search) params.append('search', search);
if (sortBy) params.append('sort', sortBy);
if (sortOrder) params.append('order', sortOrder);
const response = await fetch(`/api/data/contracts?${params}`);
const result = await response.json();
setContracts(result.contracts || []);
setTotalCount(result.pagination?.total || 0);
} catch (error) {
console.error('Failed to fetch contracts:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchContracts(page);
}, [page]);
const handleRowClick = (contract: any) => {
setSelectedContract(contract);
setModalOpen(true);
};
const columns = [
{
key: 'id',
label: 'ID',
sortable: true,
},
{
key: 'contract_name',
label: 'Contract Name',
sortable: true,
render: (value: string) => (
<div className="font-medium">{value}</div>
),
},
{
key: 'contract_number',
label: 'Contract #',
sortable: true,
},
{
key: 'company_id',
label: 'Company ID',
sortable: true,
},
{
key: 'status',
label: 'Status',
sortable: true,
},
{
key: 'start_date',
label: 'Start Date',
sortable: true,
render: (value: string) => (
value ? new Date(value).toLocaleDateString() : '-'
),
},
{
key: 'end_date',
label: 'End Date',
sortable: true,
render: (value: string) => (
value ? new Date(value).toLocaleDateString() : '-'
),
},
];
const detailFields = [
{ key: 'id', label: 'ID' },
{ key: 'company_id', label: 'Company ID' },
{ key: 'contract_name', label: 'Contract Name' },
{ key: 'contract_number', label: 'Contract Number' },
{ key: 'description', label: 'Description' },
{ key: 'status', label: 'Status' },
{ key: 'contract_type', label: 'Type' },
{ key: 'contract_category', label: 'Category' },
{ key: 'start_date', label: 'Start Date' },
{ key: 'end_date', label: 'End Date' },
{ key: 'estimated_cost', label: 'Estimated Cost' },
{ key: 'estimated_hours', label: 'Estimated Hours' },
{ key: 'estimated_revenue', label: 'Estimated Revenue' },
{ key: 'contact_id', label: 'Contact ID' },
{ key: 'contact_name', label: 'Contact Name' },
{ key: 'is_default_contract', label: 'Default Contract' },
{ key: 'synced_at', label: 'Synced At' },
{ key: 'is_deleted', label: 'Is Deleted' },
];
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/data-browser">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
Back
</Button>
</Link>
<div className="h-8 w-px bg-border" />
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-950">
<Table2 className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">Contracts</h1>
<p className="text-sm text-muted-foreground">Manage and inspect contract data</p>
</div>
</div>
</div>
<Badge variant="secondary" className="w-fit">
{totalCount} total records
</Badge>
</div>
{/* Data Table Card */}
<Card className="border-none shadow-md">
<CardHeader className="border-b bg-muted/30">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">All Contracts</CardTitle>
<CardDescription className="mt-1">
View and search through all contracts in the system
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-6">
<DataTable
columns={columns}
data={contracts}
totalCount={totalCount}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onSort={(column, direction) => fetchContracts(page, undefined, column, direction)}
onSearch={(query) => fetchContracts(1, query)}
onRowClick={handleRowClick}
isLoading={isLoading}
/>
</CardContent>
</Card>
{/* Detail Modal */}
<DetailModal
open={modalOpen}
onOpenChange={setModalOpen}
title={selectedContract?.contract_name || 'Contract Details'}
data={selectedContract}
fields={detailFields}
/>
</div>
);
}

View file

@ -0,0 +1,151 @@
'use client';
import { useState, useEffect } from 'react';
import DataTable from '@/components/admin/DataTable';
import DetailModal from '@/components/admin/DetailModal';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, Tag } from 'lucide-react';
import Link from 'next/link';
export default function IssueTypesBrowserPage() {
const [issueTypes, setIssueTypes] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(100);
const [isLoading, setIsLoading] = useState(false);
const [selectedIssueType, setSelectedIssueType] = useState<any>(null);
const [modalOpen, setModalOpen] = useState(false);
const fetchIssueTypes = async (currentPage: number, search?: string, sortBy?: string, sortOrder?: string) => {
setIsLoading(true);
try {
const params = new URLSearchParams({
limit: pageSize.toString(),
offset: ((currentPage - 1) * pageSize).toString(),
});
if (search) params.append('search', search);
if (sortBy) params.append('sort', sortBy);
if (sortOrder) params.append('order', sortOrder);
const response = await fetch(`/api/data/issue-types?${params}`);
const result = await response.json();
setIssueTypes(result.issueTypes || []);
setTotalCount(result.pagination?.total || 0);
} catch (error) {
console.error('Failed to fetch issue types:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchIssueTypes(page);
}, [page]);
const handleRowClick = (issueType: any) => {
setSelectedIssueType(issueType);
setModalOpen(true);
};
const columns = [
{
key: 'value',
label: 'Value',
sortable: true,
},
{
key: 'label',
label: 'Label',
sortable: true,
render: (value: string) => (
<div className="font-medium">{value}</div>
),
},
{
key: 'is_active',
label: 'Active',
render: (value: boolean) => (
<Badge variant={value ? 'default' : 'secondary'}>
{value ? 'Active' : 'Inactive'}
</Badge>
),
},
{
key: 'is_system',
label: 'System',
render: (value: boolean) => (
<Badge variant={value ? 'outline' : 'secondary'}>
{value ? 'System' : 'Custom'}
</Badge>
),
},
{
key: 'sort_order',
label: 'Sort Order',
sortable: true,
},
];
const detailFields = [
{ key: 'value', label: 'Value' },
{ key: 'label', label: 'Label' },
{ key: 'is_active', label: 'Active' },
{ key: 'is_system', label: 'System' },
{ key: 'sort_order', label: 'Sort Order' },
{ key: 'parent_value', label: 'Parent Value' },
{ key: 'synced_at', label: 'Synced At' },
];
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-3">
<Link href="/admin/data-browser">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<Tag className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Issue Types Browser</h1>
<p className="text-sm text-muted-foreground">Browse issue type picklist values</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Issue Types</CardTitle>
<CardDescription>
{totalCount} total issue types in database
</CardDescription>
</CardHeader>
<CardContent>
<DataTable
columns={columns}
data={issueTypes}
totalCount={totalCount}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onSort={(column, direction) => fetchIssueTypes(page, undefined, column, direction)}
onSearch={(query) => fetchIssueTypes(1, query)}
onRowClick={handleRowClick}
isLoading={isLoading}
/>
</CardContent>
</Card>
<DetailModal
open={modalOpen}
onOpenChange={setModalOpen}
title={`Issue Type: ${selectedIssueType?.label}`}
data={selectedIssueType}
fields={detailFields}
/>
</div>
);
}

View file

@ -0,0 +1,61 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Database, Table2, Users, Ticket, CheckSquare, FolderKanban, Wrench, Tag, ArrowLeft, Home, Clock } from 'lucide-react';
import Link from 'next/link';
const entities = [
{ name: 'Companies', icon: Users, path: '/admin/data-browser/companies', description: 'View all companies' },
{ name: 'Tickets', icon: Ticket, path: '/admin/data-browser/tickets', description: 'Browse support tickets' },
{ name: 'Tasks', icon: CheckSquare, path: '/admin/data-browser/tasks', description: 'View all tasks' },
{ name: 'Projects', icon: FolderKanban, path: '/admin/data-browser/projects', description: 'Browse projects' },
{ name: 'Time Entries', icon: Clock, path: '/admin/data-browser/time-entries', description: 'View time tracking data' },
{ name: 'Resources', icon: Users, path: '/admin/data-browser/resources', description: 'View resources/users' },
{ name: 'Configuration Items', icon: Wrench, path: '/admin/data-browser/configuration-items', description: 'Browse config items' },
{ name: 'Contacts', icon: Users, path: '/admin/data-browser/contacts', description: 'View contacts' },
{ name: 'Contracts', icon: Table2, path: '/admin/data-browser/contracts', description: 'Browse contracts' },
{ name: 'Issue Types', icon: Tag, path: '/admin/data-browser/issue-types', description: 'Browse issue types' },
{ name: 'Sub-Issue Types', icon: Tag, path: '/admin/data-browser/sub-issue-types', description: 'Browse sub-issue types' },
];
export default function DataBrowserPage() {
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-4">
<Link href="/">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
<Home className="w-4 h-4" />
<span className="hidden sm:inline">Back to Dashboard</span>
</Button>
</Link>
<Database className="w-8 h-8" />
<div>
<h1 className="text-3xl font-bold">Database Browser</h1>
<p className="text-muted-foreground">Inspect synced data from PostgreSQL</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{entities.map((entity) => {
const Icon = entity.icon;
return (
<Link key={entity.name} href={entity.path}>
<Card className="hover:bg-accent transition-colors cursor-pointer h-full">
<CardHeader>
<div className="flex items-center gap-2">
<Icon className="w-5 h-5" />
<CardTitle className="text-lg">{entity.name}</CardTitle>
</div>
<CardDescription>{entity.description}</CardDescription>
</CardHeader>
</Card>
</Link>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,184 @@
'use client';
import { useState, useEffect } from 'react';
import DataTable from '@/components/admin/DataTable';
import DetailModal from '@/components/admin/DetailModal';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, FolderKanban } from 'lucide-react';
import Link from 'next/link';
export default function ProjectsBrowserPage() {
const [projects, setProjects] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(50);
const [isLoading, setIsLoading] = useState(false);
const [selectedProject, setSelectedProject] = useState<any>(null);
const [modalOpen, setModalOpen] = useState(false);
const fetchProjects = async (currentPage: number, search?: string, sortBy?: string, sortOrder?: string) => {
setIsLoading(true);
try {
const params = new URLSearchParams({
limit: pageSize.toString(),
offset: ((currentPage - 1) * pageSize).toString(),
});
if (search) params.append('search', search);
if (sortBy) params.append('sort', sortBy);
if (sortOrder) params.append('order', sortOrder);
const response = await fetch(`/api/data/projects?${params}`);
const result = await response.json();
setProjects(result.projects || []);
setTotalCount(result.pagination?.total || 0);
} catch (error) {
console.error('Failed to fetch projects:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchProjects(page);
}, [page]);
const handleRowClick = (project: any) => {
setSelectedProject(project);
setModalOpen(true);
};
const columns = [
{
key: 'id',
label: 'ID',
sortable: true,
},
{
key: 'project_name',
label: 'Project Name',
sortable: true,
render: (value: string) => (
<div className="font-medium">{value}</div>
),
},
{
key: 'project_number',
label: 'Project #',
sortable: true,
},
{
key: 'company_id',
label: 'Company ID',
sortable: true,
},
{
key: 'status',
label: 'Status',
sortable: true,
},
{
key: 'start_date_time',
label: 'Start Date',
sortable: true,
render: (value: string) => (
value ? new Date(value).toLocaleDateString() : '-'
),
},
{
key: 'completed_percentage',
label: 'Progress',
render: (value: number) => (
<div>{value ? `${value}%` : '-'}</div>
),
},
];
const detailFields = [
{ key: 'id', label: 'ID' },
{ key: 'company_id', label: 'Company ID' },
{ key: 'project_name', label: 'Project Name' },
{ key: 'project_number', label: 'Project Number' },
{ key: 'description', label: 'Description' },
{ key: 'status', label: 'Status' },
{ key: 'type', label: 'Type' },
{ key: 'start_date_time', label: 'Start Date' },
{ key: 'end_date_time', label: 'End Date' },
{ key: 'estimated_time', label: 'Estimated Time' },
{ key: 'actual_hours', label: 'Actual Hours' },
{ key: 'completed_percentage', label: 'Completed %' },
{ key: 'project_lead_resource_id', label: 'Project Lead ID' },
{ key: 'owner_resource_id', label: 'Owner ID' },
{ key: 'synced_at', label: 'Synced At' },
{ key: 'is_deleted', label: 'Is Deleted' },
];
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/data-browser">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
Back
</Button>
</Link>
<div className="h-8 w-px bg-border" />
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-950">
<FolderKanban className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">Projects</h1>
<p className="text-sm text-muted-foreground">Manage and inspect project data</p>
</div>
</div>
</div>
<Badge variant="secondary" className="w-fit">
{totalCount} total records
</Badge>
</div>
{/* Data Table Card */}
<Card className="border-none shadow-md">
<CardHeader className="border-b bg-muted/30">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">All Projects</CardTitle>
<CardDescription className="mt-1">
View and search through all projects in the system
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-6">
<DataTable
columns={columns}
data={projects}
totalCount={totalCount}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onSort={(column, direction) => fetchProjects(page, undefined, column, direction)}
onSearch={(query) => fetchProjects(1, query)}
onRowClick={handleRowClick}
isLoading={isLoading}
/>
</CardContent>
</Card>
{/* Detail Modal */}
<DetailModal
open={modalOpen}
onOpenChange={setModalOpen}
title={selectedProject?.project_name || 'Project Details'}
data={selectedProject}
fields={detailFields}
/>
</div>
);
}

View file

@ -0,0 +1,179 @@
'use client';
import { useState, useEffect } from 'react';
import DataTable from '@/components/admin/DataTable';
import DetailModal from '@/components/admin/DetailModal';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, Users } from 'lucide-react';
import Link from 'next/link';
export default function ResourcesBrowserPage() {
const [resources, setResources] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(50);
const [isLoading, setIsLoading] = useState(false);
const [selectedResource, setSelectedResource] = useState<any>(null);
const [modalOpen, setModalOpen] = useState(false);
const fetchResources = async (currentPage: number, search?: string, sortBy?: string, sortOrder?: string) => {
setIsLoading(true);
try {
const params = new URLSearchParams({
limit: pageSize.toString(),
offset: ((currentPage - 1) * pageSize).toString(),
});
if (search) params.append('search', search);
if (sortBy) params.append('sort', sortBy);
if (sortOrder) params.append('order', sortOrder);
const response = await fetch(`/api/data/resources?${params}`);
const result = await response.json();
setResources(result.resources || []);
setTotalCount(result.pagination?.total || 0);
} catch (error) {
console.error('Failed to fetch resources:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchResources(page);
}, [page]);
const handleRowClick = (resource: any) => {
setSelectedResource(resource);
setModalOpen(true);
};
const columns = [
{
key: 'id',
label: 'ID',
sortable: true,
},
{
key: 'first_name',
label: 'First Name',
sortable: true,
},
{
key: 'last_name',
label: 'Last Name',
sortable: true,
render: (value: string) => (
<div className="font-medium">{value}</div>
),
},
{
key: 'email',
label: 'Email',
sortable: true,
},
{
key: 'title',
label: 'Title',
},
{
key: 'office_phone',
label: 'Phone',
},
{
key: 'is_active',
label: 'Active',
render: (value: boolean) => (
<Badge variant={value ? 'default' : 'secondary'}>
{value ? 'Active' : 'Inactive'}
</Badge>
),
},
];
const detailFields = [
{ key: 'id', label: 'ID' },
{ key: 'first_name', label: 'First Name' },
{ key: 'last_name', label: 'Last Name' },
{ key: 'email', label: 'Email' },
{ key: 'user_name', label: 'Username' },
{ key: 'title', label: 'Title' },
{ key: 'office_phone', label: 'Office Phone' },
{ key: 'mobile_phone', label: 'Mobile Phone' },
{ key: 'office_extension', label: 'Extension' },
{ key: 'is_active', label: 'Active' },
{ key: 'resource_type', label: 'Resource Type' },
{ key: 'hire_date', label: 'Hire Date' },
{ key: 'synced_at', label: 'Synced At' },
{ key: 'is_deleted', label: 'Is Deleted' },
];
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/data-browser">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
Back
</Button>
</Link>
<div className="h-8 w-px bg-border" />
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-950">
<Users className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">Resources</h1>
<p className="text-sm text-muted-foreground">Manage and inspect user data</p>
</div>
</div>
</div>
<Badge variant="secondary" className="w-fit">
{totalCount} total records
</Badge>
</div>
{/* Data Table Card */}
<Card className="border-none shadow-md">
<CardHeader className="border-b bg-muted/30">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">All Resources</CardTitle>
<CardDescription className="mt-1">
View and search through all resources in the system
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-6">
<DataTable
columns={columns}
data={resources}
totalCount={totalCount}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onSort={(column, direction) => fetchResources(page, undefined, column, direction)}
onSearch={(query) => fetchResources(1, query)}
onRowClick={handleRowClick}
isLoading={isLoading}
/>
</CardContent>
</Card>
{/* Detail Modal */}
<DetailModal
open={modalOpen}
onOpenChange={setModalOpen}
title={`${selectedResource?.first_name} ${selectedResource?.last_name}`}
data={selectedResource}
fields={detailFields}
/>
</div>
);
}

View file

@ -0,0 +1,188 @@
'use client';
import { useState, useEffect } from 'react';
import DataTable from '@/components/admin/DataTable';
import DetailModal from '@/components/admin/DetailModal';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, Tag, Eye, EyeOff } from 'lucide-react';
import Link from 'next/link';
export default function SubIssueTypesBrowserPage() {
const [subIssueTypes, setSubIssueTypes] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(100);
const [isLoading, setIsLoading] = useState(false);
const [selectedSubIssueType, setSelectedSubIssueType] = useState<any>(null);
const [modalOpen, setModalOpen] = useState(false);
const [hideInactive, setHideInactive] = useState(false);
const fetchSubIssueTypes = async (currentPage: number, search?: string, sortBy?: string, sortOrder?: string, activeOnly?: boolean) => {
setIsLoading(true);
try {
const params = new URLSearchParams({
limit: pageSize.toString(),
offset: ((currentPage - 1) * pageSize).toString(),
});
if (search) params.append('search', search);
if (sortBy) params.append('sort', sortBy);
if (sortOrder) params.append('order', sortOrder);
if (activeOnly !== undefined) params.append('isActive', activeOnly.toString());
const response = await fetch(`/api/data/sub-issue-types?${params}`);
const result = await response.json();
setSubIssueTypes(result.subIssueTypes || []);
setTotalCount(result.pagination?.total || 0);
} catch (error) {
console.error('Failed to fetch sub-issue types:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchSubIssueTypes(page, undefined, undefined, undefined, hideInactive);
}, [page, hideInactive]);
const handleToggleInactive = () => {
setHideInactive(!hideInactive);
setPage(1); // Reset to first page when toggling
};
const handleRowClick = (subIssueType: any) => {
setSelectedSubIssueType(subIssueType);
setModalOpen(true);
};
const columns = [
{
key: 'value',
label: 'Value',
sortable: true,
},
{
key: 'label',
label: 'Label',
sortable: true,
render: (value: string) => (
<div className="font-medium">{value}</div>
),
},
{
key: 'parent_issue_type_label',
label: 'Parent Issue Type',
sortable: true,
render: (value: string, row: any) => (
<div>
{value ? (
<Badge variant="outline">{value}</Badge>
) : row.parent_value ? (
<Badge variant="secondary">ID: {row.parent_value}</Badge>
) : (
<Badge variant="secondary">None</Badge>
)}
</div>
),
},
{
key: 'is_active',
label: 'Active',
render: (value: boolean) => (
<Badge variant={value ? 'default' : 'secondary'}>
{value ? 'Active' : 'Inactive'}
</Badge>
),
},
{
key: 'is_system',
label: 'System',
render: (value: boolean) => (
<Badge variant={value ? 'outline' : 'secondary'}>
{value ? 'System' : 'Custom'}
</Badge>
),
},
{
key: 'sort_order',
label: 'Sort Order',
sortable: true,
},
];
const detailFields = [
{ key: 'value', label: 'Value' },
{ key: 'label', label: 'Label' },
{ key: 'parent_issue_type_label', label: 'Parent Issue Type' },
{ key: 'parent_value', label: 'Parent Issue Type Value' },
{ key: 'is_active', label: 'Active' },
{ key: 'is_system', label: 'System' },
{ key: 'sort_order', label: 'Sort Order' },
{ key: 'synced_at', label: 'Synced At' },
];
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-3">
<Link href="/admin/data-browser">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<Tag className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Sub-Issue Types Browser</h1>
<p className="text-sm text-muted-foreground">Browse sub-issue type picklist values</p>
</div>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Sub-Issue Types</CardTitle>
<CardDescription>
{totalCount} total sub-issue types {hideInactive ? '(active only)' : 'in database'}
</CardDescription>
</div>
<Button
variant={hideInactive ? "default" : "outline"}
size="sm"
onClick={handleToggleInactive}
className="flex items-center gap-2"
>
{hideInactive ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
{hideInactive ? 'Show All' : 'Hide Inactive'}
</Button>
</div>
</CardHeader>
<CardContent>
<DataTable
columns={columns}
data={subIssueTypes}
totalCount={totalCount}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onSort={(column, direction) => fetchSubIssueTypes(page, undefined, column, direction, hideInactive)}
onSearch={(query) => fetchSubIssueTypes(1, query, undefined, undefined, hideInactive)}
onRowClick={handleRowClick}
isLoading={isLoading}
/>
</CardContent>
</Card>
<DetailModal
open={modalOpen}
onOpenChange={setModalOpen}
title={`Sub-Issue Type: ${selectedSubIssueType?.label}`}
data={selectedSubIssueType}
fields={detailFields}
/>
</div>
);
}

View file

@ -0,0 +1,197 @@
'use client';
import { useState, useEffect } from 'react';
import DataTable from '@/components/admin/DataTable';
import DetailModal from '@/components/admin/DetailModal';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, CheckSquare } from 'lucide-react';
import Link from 'next/link';
export default function TasksBrowserPage() {
const [tasks, setTasks] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(50);
const [isLoading, setIsLoading] = useState(false);
const [selectedTask, setSelectedTask] = useState<any>(null);
const [modalOpen, setModalOpen] = useState(false);
const fetchTasks = async (currentPage: number, search?: string, sortBy?: string, sortOrder?: string) => {
setIsLoading(true);
try {
const params = new URLSearchParams({
limit: pageSize.toString(),
offset: ((currentPage - 1) * pageSize).toString(),
});
if (search) params.append('search', search);
if (sortBy) params.append('sort', sortBy);
if (sortOrder) params.append('order', sortOrder);
const response = await fetch(`/api/data/tasks?${params}`);
const result = await response.json();
setTasks(result.tasks || []);
setTotalCount(result.pagination?.total || 0);
} catch (error) {
console.error('Failed to fetch tasks:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchTasks(page);
}, [page]);
const handleRowClick = (task: any) => {
setSelectedTask(task);
setModalOpen(true);
};
const columns = [
{
key: 'id',
label: 'ID',
sortable: true,
},
{
key: 'title',
label: 'Title',
sortable: true,
render: (value: string) => (
<div className="font-medium max-w-md truncate" title={value}>{value}</div>
),
},
{
key: 'status',
label: 'Status',
sortable: true,
},
{
key: 'priority',
label: 'Priority',
sortable: true,
},
{
key: 'assigned_resource_id',
label: 'Assigned To',
sortable: true,
},
{
key: 'project_id',
label: 'Project ID',
sortable: true,
},
{
key: 'estimated_hours',
label: 'Est. Hours',
render: (value: number) => (
value ? value.toFixed(2) : '-'
),
},
{
key: 'create_date_time',
label: 'Created',
sortable: true,
render: (value: string) => (
value ? new Date(value).toLocaleDateString() : '-'
),
},
];
const detailFields = [
{ key: 'id', label: 'ID' },
{ key: 'title', label: 'Title' },
{ key: 'description', label: 'Description' },
{ key: 'status', label: 'Status' },
{ key: 'priority', label: 'Priority' },
{ key: 'assigned_resource_id', label: 'Assigned Resource ID' },
{ key: 'assigned_resource_role_id', label: 'Assigned Role ID' },
{ key: 'department_id', label: 'Department ID' },
{ key: 'estimated_hours', label: 'Estimated Hours' },
{ key: 'remaining_hours', label: 'Remaining Hours' },
{ key: 'hours_to_be_scheduled', label: 'Hours to Schedule' },
{ key: 'start_date_time', label: 'Start Date' },
{ key: 'end_date_time', label: 'End Date' },
{ key: 'completed_date_time', label: 'Completed Date' },
{ key: 'create_date_time', label: 'Created Date' },
{ key: 'creator_resource_id', label: 'Creator ID' },
{ key: 'completed_by_resource_id', label: 'Completed By ID' },
{ key: 'project_id', label: 'Project ID' },
{ key: 'ticket_id', label: 'Ticket ID' },
{ key: 'task_type', label: 'Task Type' },
{ key: 'task_is_billable', label: 'Billable' },
{ key: 'task_number', label: 'Task Number' },
{ key: 'synced_at', label: 'Synced At' },
{ key: 'is_deleted', label: 'Is Deleted' },
];
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/data-browser">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
Back
</Button>
</Link>
<div className="h-8 w-px bg-border" />
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-950">
<CheckSquare className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">Tasks</h1>
<p className="text-sm text-muted-foreground">Manage and inspect task data</p>
</div>
</div>
</div>
<Badge variant="secondary" className="w-fit">
{totalCount} total records
</Badge>
</div>
{/* Data Table Card */}
<Card className="border-none shadow-md">
<CardHeader className="border-b bg-muted/30">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">All Tasks</CardTitle>
<CardDescription className="mt-1">
View and search through all tasks in the system
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-6">
<DataTable
columns={columns}
data={tasks}
totalCount={totalCount}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onSort={(column, direction) => fetchTasks(page, undefined, column, direction)}
onSearch={(query) => fetchTasks(1, query)}
onRowClick={handleRowClick}
isLoading={isLoading}
/>
</CardContent>
</Card>
{/* Detail Modal */}
<DetailModal
open={modalOpen}
onOpenChange={setModalOpen}
title={selectedTask?.title || 'Task Details'}
data={selectedTask}
fields={detailFields}
/>
</div>
);
}

View file

@ -0,0 +1,187 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import DataTable from '@/components/admin/DataTable';
import DetailModal from '@/components/admin/DetailModal';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, Ticket } from 'lucide-react';
import Link from 'next/link';
export default function TicketsBrowserPage() {
const router = useRouter();
const [tickets, setTickets] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(50);
const [isLoading, setIsLoading] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null);
const [modalOpen, setModalOpen] = useState(false);
const fetchTickets = async (currentPage: number, search?: string, sortBy?: string, sortOrder?: string) => {
setIsLoading(true);
try {
const params = new URLSearchParams({
page: currentPage.toString(),
limit: pageSize.toString(),
});
if (search) params.append('search', search);
if (sortBy) params.append('sort', sortBy);
if (sortOrder) params.append('order', sortOrder);
const response = await fetch(`/api/data/tickets?${params}`);
const result = await response.json();
setTickets(result.data || []);
setTotalCount(result.pagination?.total || 0);
} catch (error) {
console.error('Failed to fetch tickets:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchTickets(page);
}, [page]);
const handleRowClick = (ticket: any) => {
setSelectedTicket(ticket);
setModalOpen(true);
};
const columns = [
{
key: 'id',
label: 'ID',
sortable: true,
},
{
key: 'ticket_number',
label: 'Ticket #',
sortable: true,
},
{
key: 'title',
label: 'Title',
sortable: true,
render: (value: string) => (
<div className="max-w-md truncate" title={value}>
{value}
</div>
),
},
{
key: 'status',
label: 'Status',
sortable: true,
render: (value: number) => (
<Badge variant="outline">{value}</Badge>
),
},
{
key: 'priority',
label: 'Priority',
sortable: true,
render: (value: number) => {
const variant = value === 1 ? 'destructive' : value === 2 ? 'default' : 'secondary';
return <Badge variant={variant}>{value}</Badge>;
},
},
{
key: 'company_id',
label: 'Company ID',
sortable: true,
},
{
key: 'create_date',
label: 'Created',
sortable: true,
render: (value: string) => value ? new Date(value).toLocaleDateString() : '-',
},
{
key: 'is_deleted',
label: 'Deleted',
render: (value: boolean) => (
<Badge variant={value ? 'destructive' : 'secondary'}>
{value ? 'Yes' : 'No'}
</Badge>
),
},
];
const detailFields = [
{ key: 'id', label: 'ID' },
{ key: 'ticket_number', label: 'Ticket Number' },
{ key: 'title', label: 'Title' },
{ key: 'description', label: 'Description' },
{ key: 'status', label: 'Status' },
{ key: 'priority', label: 'Priority' },
{ key: 'company_id', label: 'Company ID' },
{ key: 'contact_id', label: 'Contact ID' },
{ key: 'assigned_resource_id', label: 'Assigned Resource' },
{ key: 'queue_id', label: 'Queue ID' },
{ key: 'issue_type', label: 'Issue Type' },
{ key: 'sub_issue_type', label: 'Sub Issue Type' },
{ key: 'source', label: 'Source' },
{ key: 'due_date_time', label: 'Due Date' },
{ key: 'estimated_hours', label: 'Estimated Hours' },
{ key: 'completed_date', label: 'Completed Date' },
{ key: 'create_date', label: 'Created Date' },
{ key: 'last_activity_date', label: 'Last Activity' },
{ key: 'synced_at', label: 'Synced At' },
{ key: 'is_deleted', label: 'Is Deleted' },
];
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-3">
<Link href="/admin/data-browser">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<Ticket className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Tickets Browser</h1>
<p className="text-sm text-muted-foreground">Browse and inspect ticket data</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Tickets</CardTitle>
<CardDescription>
{totalCount} total tickets in database
</CardDescription>
</CardHeader>
<CardContent>
<DataTable
columns={columns}
data={tickets}
totalCount={totalCount}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onSort={(column, direction) => fetchTickets(page, undefined, column, direction)}
onSearch={(query) => fetchTickets(1, query)}
onRowClick={handleRowClick}
isLoading={isLoading}
/>
</CardContent>
</Card>
<DetailModal
open={modalOpen}
onOpenChange={setModalOpen}
title={`Ticket #${selectedTicket?.ticket_number || selectedTicket?.id}`}
data={selectedTicket}
fields={detailFields}
/>
</div>
);
}

View file

@ -0,0 +1,529 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import DataTable from '@/components/admin/DataTable';
import DetailModal from '@/components/admin/DetailModal';
import {
Clock,
Users,
Ticket,
Calendar,
ArrowLeft,
Home,
RefreshCw,
Download,
Filter,
Sparkles,
TicketX
} from 'lucide-react';
import Link from 'next/link';
import { TimeEntry } from '@/lib/types/database';
function cn(...classes: string[]) {
return classes.filter(Boolean).join(' ');
}
export default function TimeEntriesPage() {
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedEntry, setSelectedEntry] = useState<TimeEntry | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
// Pagination states
const [totalCount, setTotalCount] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(100);
// Filter states
const [search, setSearch] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [billable, setBillable] = useState<string>('all');
const [approved, setApproved] = useState<string>('all');
// Sort states
const [sortBy, setSortBy] = useState('entry_date');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Enrichment and filter states
const [enriched, setEnriched] = useState(false);
const [hideNonTicket, setHideNonTicket] = useState(true); // Default ON
const [enrichedData, setEnrichedData] = useState<Record<string, any>>({});
const fetchTimeEntries = async (page: number = 1) => {
setLoading(true);
setError(null);
try {
const offset = (page - 1) * pageSize;
const params = new URLSearchParams();
if (search) params.append('search', search);
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
if (billable !== 'all') params.append('billable', billable);
if (approved !== 'all') params.append('approved', approved);
if (hideNonTicket) params.append('has_ticket', 'true');
params.append('limit', pageSize.toString());
params.append('offset', offset.toString());
params.append('sort_by', sortBy);
params.append('sort_order', sortOrder);
const response = await fetch(`/api/data/time-entries?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch time entries: ${response.statusText}`);
}
const data = await response.json();
setTimeEntries(data.timeEntries || []);
setTotalCount(data.pagination?.total || 0);
setCurrentPage(page);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setError(errorMessage);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTimeEntries(1);
}, [pageSize]);
const handleRefresh = () => {
fetchTimeEntries(currentPage);
};
const handlePageChange = (newPage: number) => {
fetchTimeEntries(newPage);
};
const handleSort = async (column: string, direction: 'asc' | 'desc') => {
setSortBy(column);
setSortOrder(direction);
// Fetch with new sort parameters
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (search) params.append('search', search);
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
if (billable !== 'all') params.append('billable', billable);
if (approved !== 'all') params.append('approved', approved);
if (hideNonTicket) params.append('has_ticket', 'true');
params.append('limit', pageSize.toString());
params.append('offset', '0'); // Reset to first page
params.append('sort_by', column);
params.append('sort_order', direction);
const response = await fetch(`/api/data/time-entries?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch time entries: ${response.statusText}`);
}
const data = await response.json();
setTimeEntries(data.timeEntries || []);
setTotalCount(data.pagination?.total || 0);
setCurrentPage(1); // Reset to page 1
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setError(errorMessage);
} finally {
setLoading(false);
}
};
const handleApplyFilters = () => {
fetchTimeEntries(1);
};
const handleEnrichData = async () => {
if (enriched) {
// Toggle off - clear enriched data
setEnriched(false);
setEnrichedData({});
return;
}
try {
// Extract unique resource and ticket IDs
const resourceIds = [...new Set(timeEntries.map(e => e.resource_id).filter(Boolean))];
const ticketIds = [...new Set(timeEntries.map(e => e.ticket_id).filter(Boolean))];
// Fetch resource and ticket data
const [resourcesRes, ticketsRes] = await Promise.all([
fetch(`/api/data/resources?ids=${resourceIds.join(',')}`),
fetch(`/api/data/tickets?ids=${ticketIds.join(',')}`)
]);
const resources = await resourcesRes.json();
const tickets = await ticketsRes.json();
// Build lookup maps
const enrichmentMap: Record<string, any> = {};
resources.resources?.forEach((r: any) => {
enrichmentMap[`resource_${r.id}`] = `${r.first_name} ${r.last_name}`;
});
tickets.tickets?.forEach((t: any) => {
enrichmentMap[`ticket_${t.id}`] = t.ticket_number;
});
setEnrichedData(enrichmentMap);
setEnriched(true);
} catch (error) {
console.error('Failed to enrich data:', error);
setError('Failed to enrich data');
}
};
const toggleHideNonTicket = () => {
setHideNonTicket(!hideNonTicket);
fetchTimeEntries(1);
};
const handleClearFilters = () => {
setSearch('');
setStartDate('');
setEndDate('');
setBillable('all');
setApproved('all');
setPageSize(100);
setCurrentPage(1);
// Fetch will be triggered by useEffect when pageSize changes
};
const handleExport = async () => {
try {
const params = new URLSearchParams({ format: 'csv' });
if (search) params.append('search', search);
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
if (billable !== 'all') params.append('billable', billable);
if (approved !== 'all') params.append('approved', approved);
const response = await fetch(`/api/data/time-entries/export?${params}`);
if (!response.ok) {
throw new Error(`Failed to export data: ${response.statusText}`);
}
// Download file
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `time-entries-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setError(errorMessage);
}
};
const handleRowClick = (entry: TimeEntry) => {
setSelectedEntry(entry);
setShowDetailModal(true);
};
const columns = [
{
key: 'id',
label: 'ID',
sortable: true,
},
{
key: 'resource_id',
label: 'Resource',
sortable: true,
render: (value: number) => {
const displayValue = enriched && enrichedData[`resource_${value}`]
? enrichedData[`resource_${value}`]
: value;
return (
<Badge variant="outline">
<Users className="w-3 h-3 mr-1" />
{displayValue}
</Badge>
);
},
},
{
key: 'ticket_id',
label: 'Ticket',
sortable: true,
render: (value: number) => {
if (!value) return <span className="text-muted-foreground"></span>;
const displayValue = enriched && enrichedData[`ticket_${value}`]
? enrichedData[`ticket_${value}`]
: value;
return (
<Badge variant="outline">
<Ticket className="w-3 h-3 mr-1" />
{displayValue}
</Badge>
);
},
},
{
key: 'entry_date',
label: 'Date',
sortable: true,
render: (value: string) => (
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(value).toLocaleDateString()}
</div>
),
},
{
key: 'hours_worked',
label: 'Hours',
sortable: true,
render: (value: number | string) => {
const hours = typeof value === 'string' ? parseFloat(value) : value;
return (
<Badge variant={hours > 4 ? 'destructive' : 'secondary'}>
<Clock className="w-3 h-3 mr-1" />
{hours.toFixed(1)}h
</Badge>
);
},
},
{
key: 'title',
label: 'Title',
sortable: true,
render: (value: string) => (
<div className="max-w-48 truncate" title={value}>
{value || 'No title'}
</div>
),
},
{
key: 'billable',
label: 'Billable',
sortable: true,
render: (value: boolean) => (
<Badge variant={value ? 'default' : 'secondary'}>
{value ? 'Yes' : 'No'}
</Badge>
),
},
{
key: 'approved',
label: 'Approved',
sortable: true,
render: (value: boolean) => (
<Badge variant={value ? 'default' : 'destructive'}>
{value ? 'Yes' : 'No'}
</Badge>
),
},
];
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin/data-browser">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
<span className="hidden sm:inline">Back</span>
</Button>
</Link>
<Clock className="w-8 h-8 text-blue-600" />
<div>
<h1 className="text-3xl font-bold">Time Entries</h1>
<p className="text-muted-foreground">Browse and analyze time tracking data</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant={hideNonTicket ? "default" : "outline"}
onClick={toggleHideNonTicket}
className={hideNonTicket ? "bg-blue-600 hover:bg-blue-700" : ""}
>
<TicketX className="w-4 h-4 mr-2" />
{hideNonTicket ? 'Tickets Only' : 'Show All'}
</Button>
<Button
variant={enriched ? "default" : "outline"}
onClick={handleEnrichData}
disabled={loading || timeEntries.length === 0}
className={enriched ? "bg-purple-600 hover:bg-purple-700" : ""}
>
<Sparkles className="w-4 h-4 mr-2" />
{enriched ? 'Enriched' : 'Enrich'}
</Button>
<Link href="/admin/analytics/time-entries">
<Button variant="outline">
<Filter className="w-4 h-4 mr-2" />
Analytics
</Button>
</Link>
<Button variant="outline" onClick={handleExport}>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button variant="outline" onClick={handleRefresh} disabled={loading}>
<RefreshCw className={cn("w-4 h-4 mr-2", loading ? "animate-spin" : "")} />
Refresh
</Button>
</div>
</div>
{/* Filters */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Filter className="w-5 h-5" />
Filters
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label>Search</Label>
<Input
placeholder="Search notes or title..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Start Date</Label>
<Input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Page Size</Label>
<Select value={pageSize.toString()} onValueChange={(val) => setPageSize(parseInt(val))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="250">250</SelectItem>
<SelectItem value="500">500</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Billable</Label>
<Select value={billable} onValueChange={setBillable}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="true">Billable</SelectItem>
<SelectItem value="false">Non-billable</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Approved</Label>
<Select value={approved} onValueChange={setApproved}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="true">Approved</SelectItem>
<SelectItem value="false">Not Approved</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2 col-span-1 md:col-span-2">
<Button onClick={handleApplyFilters} disabled={loading}>
Apply Filters
</Button>
<Button variant="outline" onClick={handleClearFilters}>
Clear
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Data Table */}
<Card>
<CardHeader>
<CardTitle>Time Entries ({totalCount.toLocaleString()} total)</CardTitle>
<CardDescription>
Showing {timeEntries.length} of {totalCount.toLocaleString()} entries Click on any row to view details
</CardDescription>
</CardHeader>
<CardContent>
{error && (
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-md p-4 mb-4">
<p className="text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<DataTable
data={timeEntries}
columns={columns}
isLoading={loading}
onRowClick={handleRowClick}
totalCount={totalCount}
page={currentPage}
pageSize={pageSize}
onPageChange={handlePageChange}
onSort={handleSort}
/>
</CardContent>
</Card>
{/* Detail Modal */}
<DetailModal
open={showDetailModal}
onOpenChange={(open) => setShowDetailModal(open)}
title={`Time Entry #${selectedEntry?.id}`}
data={selectedEntry}
/>
</div>
);
}

View file

@ -0,0 +1,393 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import DataTable from '@/components/admin/DataTable';
import DetailModal from '@/components/admin/DetailModal';
import {
Clock,
Users,
Ticket,
Calendar,
ArrowLeft,
Home,
RefreshCw,
Download,
Filter
} from 'lucide-react';
import Link from 'next/link';
import { TimeEntry } from '@/lib/types/database';
export default function TimeEntriesPage() {
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedEntry, setSelectedEntry] = useState<TimeEntry | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
// Pagination states
const [totalCount, setTotalCount] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(100);
// Filter states
const [search, setSearch] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [billable, setBillable] = useState<string>('all');
const [approved, setApproved] = useState<string>('all');
const [limit, setLimit] = useState('100');
const fetchTimeEntries = async (page: number = 1) => {
setLoading(true);
setError(null);
try {
const offset = (page - 1) * pageSize;
const params = new URLSearchParams();
if (search) params.append('search', search);
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
if (billable !== 'all') params.append('billable', billable);
if (approved !== 'all') params.append('approved', approved);
params.append('limit', pageSize.toString());
params.append('offset', offset.toString());
const response = await fetch(`/api/data/time-entries?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch time entries: ${response.statusText}`);
}
const data = await response.json();
setTimeEntries(data.timeEntries || []);
setTotalCount(data.pagination?.total || 0);
setCurrentPage(page);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setError(errorMessage);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTimeEntries(1);
}, [pageSize]);
const handleRefresh = () => {
fetchTimeEntries(currentPage);
};
const handlePageChange = (newPage: number) => {
fetchTimeEntries(newPage);
};
const handleExport = async () => {
try {
const params = new URLSearchParams({ format: 'csv' });
if (search) params.append('search', search);
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
if (billable !== 'all') params.append('billable', billable);
if (approved !== 'all') params.append('approved', approved);
const response = await fetch(`/api/data/time-entries/export?${params}`);
if (!response.ok) {
throw new Error(`Failed to export data: ${response.statusText}`);
}
// Download file
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `time-entries-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
setError(errorMessage);
}
};
const handleRowClick = (entry: TimeEntry) => {
setSelectedEntry(entry);
setShowDetailModal(true);
};
const columns = [
{
key: 'id',
label: 'ID',
sortable: true,
},
{
key: 'resource_id',
label: 'Resource',
sortable: true,
render: (value: number) => (
<Badge variant="outline">
<Users className="w-3 h-3 mr-1" />
{value}
</Badge>
),
},
{
key: 'ticket_id',
label: 'Ticket',
sortable: true,
render: (value: number) => (
<Badge variant="outline">
<Ticket className="w-3 h-3 mr-1" />
{value}
</Badge>
),
},
{
key: 'entry_date',
label: 'Date',
sortable: true,
render: (value: string) => (
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(value).toLocaleDateString()}
</div>
),
},
{
key: 'hours_worked',
label: 'Hours',
sortable: true,
render: (value: number | string) => {
const hours = typeof value === 'string' ? parseFloat(value) : value;
return (
<Badge variant={hours > 4 ? 'destructive' : 'secondary'}>
<Clock className="w-3 h-3 mr-1" />
{hours.toFixed(1)}h
</Badge>
);
},
},
{
key: 'title',
label: 'Title',
sortable: true,
render: (value: string) => (
<div className="max-w-48 truncate" title={value}>
{value || 'No title'}
</div>
),
},
{
key: 'billable',
label: 'Billable',
sortable: true,
render: (value: boolean) => (
<Badge variant={value ? 'default' : 'secondary'}>
{value ? 'Yes' : 'No'}
</Badge>
),
},
{
key: 'approved',
label: 'Approved',
sortable: true,
render: (value: boolean) => (
<Badge variant={value ? 'default' : 'destructive'}>
{value ? 'Yes' : 'No'}
</Badge>
),
},
];
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin/data-browser">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
<span className="hidden sm:inline">Back</span>
</Button>
</Link>
<Clock className="w-8 h-8 text-blue-600" />
<div>
<h1 className="text-3xl font-bold">Time Entries</h1>
<p className="text-muted-foreground">Browse and analyze time tracking data</p>
</div>
</div>
<div className="flex items-center gap-2">
<Link href="/admin/analytics/time-entries">
<Button variant="outline">
<Filter className="w-4 h-4 mr-2" />
Analytics
</Button>
</Link>
<Button variant="outline" onClick={handleExport}>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button variant="outline" onClick={handleRefresh} disabled={loading}>
<RefreshCw className={cn("w-4 h-4 mr-2", loading ? "animate-spin" : "")} />
Refresh
</Button>
</div>
</div>
{/* Filters */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Filter className="w-5 h-5" />
Filters
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label>Search</Label>
<Input
placeholder="Search notes or title..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Start Date</Label>
<Input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Page Size</Label>
<Select value={pageSize.toString()} onValueChange={(val) => setPageSize(parseInt(val))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="250">250</SelectItem>
<SelectItem value="500">500</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Billable</Label>
<Select value={billable} onValueChange={setBillable}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="true">Billable</SelectItem>
<SelectItem value="false">Non-billable</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Approved</Label>
<Select value={approved} onValueChange={setApproved}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="true">Approved</SelectItem>
<SelectItem value="false">Not approved</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Button variant="outline" onClick={() => {
setBillable('all');
setApproved('all');
setPageSize(100);
setCurrentPage(1);
}}>
Clear
</Button>
</div>
</div>
<div className="flex justify-end mt-4">
<Button variant="outline" onClick={handleRefresh} disabled={loading}>
<RefreshCw className={cn("w-4 h-4 mr-2", loading ? "animate-spin" : "")} />
Apply Filters
</Button>
</div>
</CardContent>
</Card>
{/* Data Table */}
<Card>
<CardHeader>
<CardTitle>Time Entries ({timeEntries.length})</CardTitle>
<CardDescription>
Click on any row to view detailed information
</CardDescription>
</CardHeader>
<CardContent>
{error && (
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-md p-4 mb-4">
<p className="text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<DataTable
data={timeEntries}
columns={columns}
isLoading={loading}
onRowClick={handleRowClick}
totalCount={totalCount}
page={currentPage}
pageSize={pageSize}
onPageChange={handlePageChange}
/>
</CardContent>
</Card>
{/* Detail Modal */}
<DetailModal
open={showDetailModal}
onOpenChange={(open) => setShowDetailModal(open)}
title={`Time Entry #${selectedEntry?.id}`}
data={selectedEntry}
/>
</div>
);
}
function cn(...classes: string[]) {
return classes.filter(Boolean).join(' ');
}

92
app/admin/sync/page.tsx Normal file
View file

@ -0,0 +1,92 @@
/**
* Admin Sync Page
* Main page for controlling and monitoring Autotask PostgreSQL sync operations
*/
'use client';
import { useState, useEffect } from 'react';
import SyncControlPanel from '@/components/admin/SyncControlPanel';
import SyncDashboard from '@/components/admin/SyncDashboard';
import SyncHistoryTable from '@/components/admin/SyncHistoryTable';
import { EntityType } from '@/lib/types/sync';
import { Button } from '@/components/ui/button';
import { ArrowLeft, Home } from 'lucide-react';
import Link from 'next/link';
export default function AdminSyncPage() {
const [selectedEntities, setSelectedEntities] = useState<EntityType[]>([]);
const [isSyncing, setIsSyncing] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
// Auto-refresh during sync and check if sync completed
useEffect(() => {
if (isSyncing) {
const interval = setInterval(async () => {
setRefreshKey(prev => prev + 1);
// Check if sync is still in progress
try {
const response = await fetch('/api/sync/status');
if (response.ok) {
const data = await response.json();
// If no sync in progress, mark as complete
if (!data.inProgress) {
setIsSyncing(false);
}
}
} catch (error) {
console.error('Failed to check sync status:', error);
}
}, 5000); // Refresh every 5 seconds
return () => clearInterval(interval);
}
}, [isSyncing]);
const handleSyncStart = () => {
setIsSyncing(true);
};
const handleSyncComplete = () => {
setIsSyncing(false);
setRefreshKey(prev => prev + 1);
};
return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6 md:space-y-8">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
<Home className="w-4 h-4" />
<span className="hidden sm:inline">Back to Dashboard</span>
</Button>
</Link>
<div>
<h1 className="text-2xl md:text-3xl font-bold">Autotask Sync</h1>
<p className="text-sm md:text-base text-muted-foreground mt-1">
Sync Autotask data to PostgreSQL database
</p>
</div>
</div>
</div>
{/* Sync Control Panel */}
<SyncControlPanel
selectedEntities={selectedEntities}
onSelectedEntitiesChange={setSelectedEntities}
onSyncStart={handleSyncStart}
onSyncComplete={handleSyncComplete}
isSyncing={isSyncing}
/>
{/* Sync Dashboard */}
<SyncDashboard refreshKey={refreshKey} />
{/* Sync History */}
<SyncHistoryTable refreshKey={refreshKey} />
</div>
);
}

View file

@ -0,0 +1,171 @@
import { NextRequest, NextResponse } from 'next/server';
import { Pool } from 'pg';
import { getAddigyClient } from '@/lib/services/addigy-factory';
import { AddigyOrgMapping } from '@/lib/types/addigy';
const pool = new Pool({
host: process.env.POSTGRES_HOST,
port: parseInt(process.env.POSTGRES_PORT || '5432'),
database: process.env.POSTGRES_DB,
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
});
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const includeUnmapped = searchParams.get('includeUnmapped') === 'true';
// Get all mappings from database
const result = await pool.query<{
id: number;
addigy_org_id: string;
addigy_org_name: string;
autotask_company_id: number;
autotask_company_name: string;
created_at: string;
updated_at: string;
}>('SELECT * FROM addigy_org_mappings ORDER BY addigy_org_name');
const mappings: AddigyOrgMapping[] = result.rows.map((row) => ({
id: row.id,
addigyOrgId: row.addigy_org_id,
addigyOrgName: row.addigy_org_name,
autotaskCompanyId: row.autotask_company_id,
autotaskCompanyName: row.autotask_company_name,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
// If includeUnmapped is true, fetch all Addigy policies (which act as organizations/sites) and merge
if (includeUnmapped) {
try {
const addigyClient = getAddigyClient();
// In Addigy, policies are the grouping mechanism (like sites/organizations)
const allPolicies = await addigyClient.getAllPolicies();
const mappedOrgIds = new Set(mappings.map((m) => m.addigyOrgId));
const unmappedOrgs = allPolicies
.filter((policy) => !mappedOrgIds.has(policy.policyId))
.map((policy) => ({
id: 0, // Temporary ID for unmapped
addigyOrgId: policy.policyId,
addigyOrgName: policy.name,
autotaskCompanyId: 0,
autotaskCompanyName: '',
createdAt: '',
updatedAt: '',
}));
return NextResponse.json({
mappings: [...mappings, ...unmappedOrgs],
totalMapped: mappings.length,
totalUnmapped: unmappedOrgs.length,
});
} catch (addigyError) {
// If Addigy API fails, just return the mapped organizations
console.warn('Failed to fetch unmapped Addigy policies:', addigyError);
return NextResponse.json({
mappings: mappings,
totalMapped: mappings.length,
totalUnmapped: 0,
warning: 'Could not fetch unmapped policies from Addigy API. Check API configuration.',
});
}
}
return NextResponse.json({ mappings });
} catch (error) {
console.error('Error fetching Addigy org mappings:', error);
return NextResponse.json(
{ error: 'Failed to fetch Addigy org mappings' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const {
addigyOrgId,
addigyOrgName,
autotaskCompanyId,
autotaskCompanyName,
} = body;
if (!addigyOrgId || !autotaskCompanyId) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
// Insert or update mapping
const result = await pool.query<{
id: number;
addigy_org_id: string;
addigy_org_name: string;
autotask_company_id: number;
autotask_company_name: string;
created_at: string;
updated_at: string;
}>(
`INSERT INTO addigy_org_mappings
(addigy_org_id, addigy_org_name, autotask_company_id, autotask_company_name)
VALUES ($1, $2, $3, $4)
ON CONFLICT (addigy_org_id)
DO UPDATE SET
autotask_company_id = EXCLUDED.autotask_company_id,
autotask_company_name = EXCLUDED.autotask_company_name,
updated_at = CURRENT_TIMESTAMP
RETURNING *`,
[addigyOrgId, addigyOrgName, autotaskCompanyId, autotaskCompanyName]
);
const mapping: AddigyOrgMapping = {
id: result.rows[0].id,
addigyOrgId: result.rows[0].addigy_org_id,
addigyOrgName: result.rows[0].addigy_org_name,
autotaskCompanyId: result.rows[0].autotask_company_id,
autotaskCompanyName: result.rows[0].autotask_company_name,
createdAt: result.rows[0].created_at,
updatedAt: result.rows[0].updated_at,
};
return NextResponse.json({ mapping });
} catch (error) {
console.error('Error creating Addigy org mapping:', error);
return NextResponse.json(
{ error: 'Failed to create Addigy org mapping' },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const id = searchParams.get('id');
if (!id) {
return NextResponse.json(
{ error: 'Missing mapping ID' },
{ status: 400 }
);
}
await pool.query('DELETE FROM addigy_org_mappings WHERE id = $1', [
parseInt(id),
]);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting Addigy org mapping:', error);
return NextResponse.json(
{ error: 'Failed to delete Addigy org mapping' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,149 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuvikClient } from '@/lib/services/auvik-factory';
import { AuvikDevice } from '@/lib/types/auvik';
interface AuvikConfigurationResponse {
data: Array<{
type: string;
id: string;
attributes: {
deviceId: string;
backupDate: string;
configType: string;
configText?: string;
configSize?: number;
};
}>;
links?: {
next?: string;
};
}
/**
* GET /api/auvik/device-config?hostname=YNGHYNSWP19
* Fetch device configuration from Auvik API
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const hostname = searchParams.get('hostname');
const deviceId = searchParams.get('deviceId');
if (!hostname && !deviceId) {
return NextResponse.json(
{ error: 'Either hostname or deviceId parameter is required' },
{ status: 400 }
);
}
// Get Auvik client
const client = getAuvikClient();
const config = {
apiUrl: process.env.AUVIK_API_URL || 'https://auvikapi.us1.my.auvik.com',
apiUser: process.env.AUVIK_API_USER || '',
apiKey: process.env.AUVIK_API_KEY || '',
};
let targetDeviceId = deviceId;
// If hostname provided, find the device first
if (hostname && !deviceId) {
console.log(`Searching for device with hostname: ${hostname}`);
const devices = await client.getAllDevices();
const matchingDevice = devices.find(
(d: AuvikDevice) => d.deviceName.toLowerCase() === hostname.toLowerCase()
);
if (!matchingDevice) {
return NextResponse.json(
{
error: `Device not found with hostname: ${hostname}`,
availableDevices: devices.map((d: AuvikDevice) => ({
name: d.deviceName,
id: d.id,
type: d.deviceType,
})).slice(0, 20), // Return first 20 for reference
},
{ status: 404 }
);
}
targetDeviceId = matchingDevice.id;
console.log(`Found device: ${matchingDevice.deviceName} (ID: ${targetDeviceId})`);
}
// Fetch device configuration
console.log(`Fetching configuration for device ID: ${targetDeviceId}`);
const configUrl = `${config.apiUrl}/v1/inventory/device/configuration?filter[deviceId]=${targetDeviceId}`;
const credentials = Buffer.from(`${config.apiUser}:${config.apiKey}`).toString('base64');
const response = await fetch(configUrl, {
headers: {
Authorization: `Basic ${credentials}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error(`Auvik API error: ${response.status} ${response.statusText}`, errorText);
// Try device detail endpoint as fallback
const detailUrl = `${config.apiUrl}/v1/inventory/device/detail/${targetDeviceId}`;
const detailResponse = await fetch(detailUrl, {
headers: {
Authorization: `Basic ${credentials}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
if (detailResponse.ok) {
const detailData = await detailResponse.json();
return NextResponse.json({
message: 'Configuration endpoint not available, returning device details',
deviceId: targetDeviceId,
deviceDetail: detailData,
});
}
return NextResponse.json(
{
error: `Failed to fetch configuration: ${response.status} ${response.statusText}`,
details: errorText,
},
{ status: response.status }
);
}
const configData: AuvikConfigurationResponse = await response.json();
console.log(`Found ${configData.data.length} configuration(s) for device ${targetDeviceId}`);
// Return the configuration data
return NextResponse.json({
deviceId: targetDeviceId,
hostname: hostname,
configurations: configData.data.map(config => ({
type: config.attributes.configType,
backupDate: config.attributes.backupDate,
size: config.attributes.configSize,
configText: config.attributes.configText,
})),
rawResponse: configData,
});
} catch (error) {
console.error('Error fetching device configuration:', error);
return NextResponse.json(
{
error: 'Internal server error',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuvikClient } from '@/lib/services/auvik-factory';
import { AuvikDevice } from '@/lib/types/auvik';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const companyId = searchParams.get('companyId');
const companyName = searchParams.get('companyName');
console.log('Auvik devices API called with:', { companyId, companyName });
const client = getAuvikClient();
let devices: AuvikDevice[] = [];
let tenantId: string | undefined;
let tenantName: string | undefined;
// If company name provided, try to find matching tenant
if (companyName) {
const tenant = await client.findTenantByName(companyName);
if (tenant) {
console.log(`Matched company "${companyName}" to Auvik tenant: ${tenant.domainPrefix} (${tenant.id})`);
tenantId = tenant.id;
tenantName = tenant.domainPrefix;
devices = await client.getDevicesByTenant(tenant.id);
} else {
console.log(`No Auvik tenant match found for company: ${companyName}`);
// Return empty array if no tenant match
devices = [];
}
} else {
// No company filter - fetch all devices
console.log('Fetching all Auvik devices (no company filter)');
devices = await client.getAllDevices();
}
console.log(`Returning ${devices.length} Auvik devices`);
return NextResponse.json({
devices,
metadata: {
tenantId,
tenantName,
count: devices.length,
},
});
} catch (error) {
console.error('Error fetching Auvik devices:', error);
// Return empty array instead of error to allow graceful degradation
return NextResponse.json({
devices: [],
metadata: {
error: error instanceof Error ? error.message : 'Unknown error',
count: 0,
},
});
}
}

View file

@ -0,0 +1,159 @@
import { NextRequest, NextResponse } from 'next/server';
import { Pool } from 'pg';
import { getAuvikClient } from '@/lib/services/auvik-factory';
import { AuvikTenantMapping } from '@/lib/types/auvik';
const pool = new Pool({
host: process.env.POSTGRES_HOST,
port: parseInt(process.env.POSTGRES_PORT || '5432'),
database: process.env.POSTGRES_DB,
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
});
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const includeUnmapped = searchParams.get('includeUnmapped') === 'true';
// Get all mappings from database
const result = await pool.query<{
id: number;
auvik_tenant_id: string;
auvik_tenant_name: string;
autotask_company_id: number;
autotask_company_name: string;
created_at: string;
updated_at: string;
}>('SELECT * FROM auvik_tenant_mappings ORDER BY auvik_tenant_name');
const mappings: AuvikTenantMapping[] = result.rows.map((row) => ({
id: row.id,
auvikTenantId: row.auvik_tenant_id,
auvikTenantName: row.auvik_tenant_name,
autotaskCompanyId: row.autotask_company_id,
autotaskCompanyName: row.autotask_company_name,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
// If includeUnmapped is true, fetch all Auvik tenants and merge
if (includeUnmapped) {
const auvikClient = getAuvikClient();
const allTenants = await auvikClient.getTenants();
const mappedTenantIds = new Set(mappings.map((m) => m.auvikTenantId));
const unmappedTenants = allTenants
.filter((t) => !mappedTenantIds.has(t.id))
.map((t) => ({
id: 0, // Temporary ID for unmapped
auvikTenantId: t.id,
auvikTenantName: t.domainPrefix,
autotaskCompanyId: 0,
autotaskCompanyName: '',
createdAt: '',
updatedAt: '',
}));
return NextResponse.json({
mappings: [...mappings, ...unmappedTenants],
totalMapped: mappings.length,
totalUnmapped: unmappedTenants.length,
});
}
return NextResponse.json({ mappings });
} catch (error) {
console.error('Error fetching tenant mappings:', error);
return NextResponse.json(
{ error: 'Failed to fetch tenant mappings' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const {
auvikTenantId,
auvikTenantName,
autotaskCompanyId,
autotaskCompanyName,
} = body;
if (!auvikTenantId || !autotaskCompanyId) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
// Insert or update mapping
const result = await pool.query<{
id: number;
auvik_tenant_id: string;
auvik_tenant_name: string;
autotask_company_id: number;
autotask_company_name: string;
created_at: string;
updated_at: string;
}>(
`INSERT INTO auvik_tenant_mappings
(auvik_tenant_id, auvik_tenant_name, autotask_company_id, autotask_company_name)
VALUES ($1, $2, $3, $4)
ON CONFLICT (auvik_tenant_id)
DO UPDATE SET
autotask_company_id = EXCLUDED.autotask_company_id,
autotask_company_name = EXCLUDED.autotask_company_name,
updated_at = CURRENT_TIMESTAMP
RETURNING *`,
[auvikTenantId, auvikTenantName, autotaskCompanyId, autotaskCompanyName]
);
const mapping: AuvikTenantMapping = {
id: result.rows[0].id,
auvikTenantId: result.rows[0].auvik_tenant_id,
auvikTenantName: result.rows[0].auvik_tenant_name,
autotaskCompanyId: result.rows[0].autotask_company_id,
autotaskCompanyName: result.rows[0].autotask_company_name,
createdAt: result.rows[0].created_at,
updatedAt: result.rows[0].updated_at,
};
return NextResponse.json({ mapping });
} catch (error) {
console.error('Error creating tenant mapping:', error);
return NextResponse.json(
{ error: 'Failed to create tenant mapping' },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const id = searchParams.get('id');
if (!id) {
return NextResponse.json(
{ error: 'Missing mapping ID' },
{ status: 400 }
);
}
await pool.query('DELETE FROM auvik_tenant_mappings WHERE id = $1', [
parseInt(id),
]);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting tenant mapping:', error);
return NextResponse.json(
{ error: 'Failed to delete tenant mapping' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAutotaskClient } from '@/lib/services/autotask-factory';
/**
* Lightweight endpoint that only fetches the PSA configuration item
* without any RMM or Auvik matching. Used when devices are already known.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const autotaskClient = getAutotaskClient();
// Fetch only the configuration item
const autotaskDevice = await autotaskClient.getConfigurationItemById(parseInt(id));
if (!autotaskDevice) {
return NextResponse.json(
{ error: 'Configuration item not found' },
{ status: 404 }
);
}
// Get company name
let companyName: string | null = null;
try {
const company = await autotaskClient.getCompanyById(autotaskDevice.companyID);
companyName = company?.companyName || null;
} catch (err) {
console.error('Failed to fetch company name:', err);
}
return NextResponse.json({
autotaskDevice,
companyName
});
} catch (error) {
console.error('Error fetching configuration item:', error);
return NextResponse.json(
{ error: 'Failed to fetch configuration item' },
{ status: 500 }
);
}
}

View file

@ -1,8 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAutotaskClient } from '@/lib/services/autotask-factory';
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
import { getAuvikClient } from '@/lib/services/auvik-factory';
import { ConfigurationItem } from '@/lib/types/autotask';
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
import { AuvikDevice } from '@/lib/types/auvik';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET(
request: NextRequest,
@ -15,6 +18,7 @@ export async function GET(
let autotaskDevice: ConfigurationItem | null = null;
let rmmDevice: DattoRMMDevice | null = null;
let auvikDevice: AuvikDevice | null = null;
let companyName: string | null = null;
if (type === 'autotask') {
@ -40,31 +44,24 @@ export async function GET(
try {
const rmmClient = getDattoRMMClient();
const devices = await rmmClient.getAllDevices();
console.log(`Fetched ${devices.length} RMM devices for matching`);
// First priority: Match by RMM Device UID if available
if (autotaskDevice?.rmmDeviceUID) {
const devices = await rmmClient.getAllDevices();
rmmDevice = devices.find(d => d.uid === autotaskDevice?.rmmDeviceUID) || null;
if (rmmDevice) {
console.log('Matched by RMM UID:', rmmDevice.uid);
} else {
console.log(`No match found for UID: ${autotaskDevice.rmmDeviceUID}`);
// Check if device exists with similar UID
const similarDevices = devices.filter(d => d.uid && d.uid.includes('3dfd7b06'));
console.log(`Devices with similar UID:`, similarDevices.map(d => ({ uid: d.uid, hostname: d.hostname })));
}
}
// Second priority: Match by RMM Device ID if available
if (!rmmDevice && autotaskDevice?.rmmDeviceID) {
try {
rmmDevice = await rmmClient.getDeviceById(autotaskDevice.rmmDeviceID);
if (rmmDevice) {
console.log('Matched by RMM ID:', rmmDevice.id);
}
} catch (err) {
console.log('Could not find device by RMM ID:', autotaskDevice.rmmDeviceID);
}
}
// Third priority: Match by serial number
// Second priority: Match by serial number
if (!rmmDevice && autotaskDevice?.serialNumber) {
const devices = await rmmClient.getAllDevices();
rmmDevice = devices.find(d =>
d.serialNumber?.toLowerCase() === autotaskDevice?.serialNumber?.toLowerCase()
) || null;
@ -73,9 +70,8 @@ export async function GET(
}
}
// Fourth priority: Match by hostname
// Third priority: Match by hostname
if (!rmmDevice && autotaskDevice?.rmmDeviceAuditHostname) {
const devices = await rmmClient.getAllDevices();
rmmDevice = devices.find(d =>
d.hostname?.toLowerCase() === autotaskDevice?.rmmDeviceAuditHostname?.toLowerCase()
) || null;
@ -106,6 +102,80 @@ export async function GET(
} catch (err) {
console.error('Failed to fetch RMM device:', err);
}
// Try to find matching Auvik device using tenant mappings
if (autotaskDevice && autotaskDevice.companyID) {
try {
const auvikClient = getAuvikClient();
// First, check if there's a tenant mapping for this company
const mappingQuery = `
SELECT auvik_tenant_id, auvik_tenant_name
FROM auvik_tenant_mappings
WHERE autotask_company_id = $1
`;
const mappingResult = await postgresClient.query<{
auvik_tenant_id: string;
auvik_tenant_name: string;
}>(mappingQuery, [autotaskDevice.companyID]);
let auvikDevices: AuvikDevice[] = [];
if (mappingResult.rows.length > 0) {
// Use the mapped tenant
const mapping = mappingResult.rows[0];
console.log(`Found Auvik tenant mapping: ${mapping.auvik_tenant_name} for company ID: ${autotaskDevice.companyID}`);
auvikDevices = await auvikClient.getDevicesByTenant(mapping.auvik_tenant_id);
} else if (companyName) {
// Fallback to name-based matching
console.log(`No mapping found, trying name match for: ${companyName}`);
const tenant = await auvikClient.findTenantByName(companyName);
if (tenant) {
console.log(`Found Auvik tenant by name: ${tenant.domainPrefix} for company: ${companyName}`);
auvikDevices = await auvikClient.getDevicesByTenant(tenant.id);
}
}
// Match Auvik device to Autotask configuration item
if (auvikDevices.length > 0) {
// Priority 1: Match by serial number
if (autotaskDevice.serialNumber) {
auvikDevice = auvikDevices.find(d =>
d.serialNumber?.toLowerCase() === autotaskDevice?.serialNumber?.toLowerCase()
) || null;
if (auvikDevice) {
console.log('Matched Auvik device by serial number:', auvikDevice.serialNumber);
}
}
// Priority 2: Match by hostname
if (!auvikDevice && autotaskDevice.rmmDeviceAuditHostname) {
auvikDevice = auvikDevices.find(d =>
d.deviceName?.toLowerCase().includes(autotaskDevice?.rmmDeviceAuditHostname?.toLowerCase() || '')
) || null;
if (auvikDevice) {
console.log('Matched Auvik device by hostname:', auvikDevice.deviceName);
}
}
// Priority 3: Match by IP address
if (!auvikDevice && autotaskDevice.rmmDeviceAuditIPAddress) {
auvikDevice = auvikDevices.find(d =>
d.ipAddresses?.includes(autotaskDevice?.rmmDeviceAuditIPAddress || '')
) || null;
if (auvikDevice) {
console.log('Matched Auvik device by IP address:', auvikDevice.ipAddresses);
}
}
if (!auvikDevice) {
console.log('No Auvik device match found for configuration item');
}
}
} catch (err) {
console.error('Failed to fetch Auvik device:', err);
}
}
}
} else if (type === 'rmm') {
// Fetch RMM device
@ -135,6 +205,7 @@ export async function GET(
return NextResponse.json({
autotaskDevice,
rmmDevice,
auvikDevice,
companyName
});
} catch (error) {

View file

@ -8,7 +8,16 @@ export async function GET(
) {
try {
const { id } = await params;
const cacheKey = `contact:${id}`;
const contactId = parseInt(id);
if (isNaN(contactId)) {
return NextResponse.json(
{ error: 'Invalid contact ID' },
{ status: 400 }
);
}
const cacheKey = `contact:${contactId}`;
// Check cache first
const cached = apiCache.get(cacheKey);
@ -20,20 +29,26 @@ export async function GET(
// Query for the contact by ID
const contacts = await autotaskClient.queryEntity('Contacts', {
filter: [{ op: 'eq', field: 'id', value: parseInt(id) }],
filter: [{ op: 'eq', field: 'id', value: contactId }],
});
const contact = contacts.length > 0 ? contacts[0] : null;
// Cache for 10 minutes
apiCache.set(cacheKey, { contact }, 10 * 60); // corrected the cache expiration time
// Cache for 10 minutes (even if null to avoid repeated failed lookups)
apiCache.set(cacheKey, { contact }, 10 * 60);
return NextResponse.json({ contact });
} catch (error) {
console.error('Error fetching contact:', error);
const { id } = await params;
console.error(`Error fetching contact ${id}:`, error);
const errorMessage = error instanceof Error && error.message
? error.message
: 'Failed to fetch contact from Autotask';
// Return 200 with null contact instead of 500 to prevent UI errors
// The contact might not exist or might not be accessible
return NextResponse.json(
{ error: 'Failed to fetch contact' },
{ status: 500 }
{ contact: null, error: errorMessage }
);
}
}

View file

@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAutotaskClient } from '@/lib/services/autotask-factory';
import { apiCache } from '@/lib/services/cache';
export async function POST(request: NextRequest) {
try {
const { contactIds } = await request.json();
if (!Array.isArray(contactIds) || contactIds.length === 0) {
return NextResponse.json({ contacts: {} });
}
// Remove duplicates
const uniqueIds = [...new Set(contactIds)];
// Check cache first
const contacts: Record<number, any> = {};
const uncachedIds: number[] = [];
for (const id of uniqueIds) {
const cacheKey = `contact:${id}`;
const cached = apiCache.get(cacheKey) as { contact: any } | undefined;
if (cached && cached.contact) {
contacts[id] = cached.contact;
} else {
uncachedIds.push(id);
}
}
// Fetch uncached contacts with rate limiting
if (uncachedIds.length > 0) {
const autotaskClient = getAutotaskClient();
try {
// Fetch contacts one by one but with rate limiting built into the client
// This is better than trying to use OR filters which Autotask doesn't support well
const fetchPromises = uncachedIds.map(async (id) => {
try {
const fetchedContacts = await autotaskClient.queryEntity('Contacts', {
filter: [{ op: 'eq', field: 'id', value: id }]
});
if (fetchedContacts.length > 0) {
const contact = fetchedContacts[0];
contacts[id] = contact;
// Cache the contact
const cacheKey = `contact:${id}`;
apiCache.set(cacheKey, { contact }, 10 * 60);
} else {
// Mark as not found
contacts[id] = null;
const cacheKey = `contact:${id}`;
apiCache.set(cacheKey, { contact: null }, 10 * 60);
}
} catch (error) {
console.error(`Error fetching contact ${id}:`, error);
contacts[id] = null;
const cacheKey = `contact:${id}`;
apiCache.set(cacheKey, { contact: null }, 10 * 60);
}
});
// Wait for all fetches to complete
await Promise.all(fetchPromises);
} catch (error) {
console.error('Error fetching batch contacts:', error);
// Return what we have from cache
}
}
return NextResponse.json({ contacts });
} catch (error) {
console.error('Error in batch contact fetch:', error);
return NextResponse.json({ contacts: {} });
}
}

View file

@ -0,0 +1,64 @@
/**
* Billing Items Data API Endpoint
* GET /api/data/billing-items - Query billing items from PostgreSQL
*/
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '100');
const offset = parseInt(searchParams.get('offset') || '0');
const companyId = searchParams.get('companyId');
const projectId = searchParams.get('projectId');
const ticketId = searchParams.get('ticketId');
const taskId = searchParams.get('taskId');
// Build where clause
const where: Record<string, any> = {};
if (companyId) {
where.company_id = parseInt(companyId);
}
if (projectId) {
where.project_id = parseInt(projectId);
}
if (ticketId) {
where.ticket_id = parseInt(ticketId);
}
if (taskId) {
where.task_id = parseInt(taskId);
}
// Query billing items
const billingItems = await postgresClient.find(
'billing_items',
where,
{
limit,
offset,
orderBy: 'created_at DESC',
}
);
// Get total count
const totalCount = await postgresClient.count('billing_items', where);
return NextResponse.json({
billingItems,
pagination: {
limit,
offset,
total: totalCount,
hasMore: offset + billingItems.length < totalCount,
},
});
} catch (error) {
console.error('Failed to fetch billing items:', error);
return NextResponse.json(
{ error: 'Failed to fetch billing items' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,73 @@
/**
* Companies Data API Endpoint
* GET /api/data/companies - Query companies from PostgreSQL
*
* Query Parameters:
* - page: Page number (default: 1)
* - limit: Records per page (default: 100, max: 1000)
* - includeDeleted: Include soft-deleted records (default: false)
* - sort: Sort field (default: company_name)
* - order: Sort order ASC/DESC (default: ASC)
* - isActive: Filter by active status (true/false)
* - Any other parameter will be treated as a filter
*/
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
import {
parseQueryParams,
buildWhereClause,
buildOrderByClause,
createPaginationInfo,
formatApiResponse,
handleApiError,
validateQueryParams,
} from '@/lib/utils/api-helpers';
export async function GET(request: NextRequest) {
try {
// Parse and validate query parameters
const options = parseQueryParams(request, {
limit: 100,
sort: 'company_name',
order: 'ASC',
});
validateQueryParams(options);
// Build WHERE clause
const where = buildWhereClause(options.filters || {}, options.includeDeleted);
// Build ORDER BY clause
const orderBy = buildOrderByClause(options.sort!, options.order!);
// Query companies
const companies = await postgresClient.find(
'companies',
where,
{
limit: options.limit,
offset: options.offset,
orderBy,
includeDeleted: options.includeDeleted,
}
);
// Get total count
const totalCount = await postgresClient.count('companies', where, options.includeDeleted);
// Create pagination info
const pagination = createPaginationInfo(options.page!, options.limit!, totalCount);
// Format and return response
return NextResponse.json(
formatApiResponse(companies, pagination, {
entity: 'companies',
filters: options.filters,
})
);
} catch (error) {
const errorResponse = handleApiError(error, 'fetch companies');
return NextResponse.json(errorResponse, { status: errorResponse.statusCode });
}
}

View file

@ -0,0 +1,56 @@
/**
* Configuration Items Data API Endpoint
* GET /api/data/configuration-items - Query configuration items from PostgreSQL
*/
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '100');
const offset = parseInt(searchParams.get('offset') || '0');
const companyId = searchParams.get('companyId');
const isActive = searchParams.get('isActive');
// Build where clause
const where: Record<string, any> = {};
if (companyId) {
where.company_id = parseInt(companyId);
}
if (isActive !== null) {
where.is_active = isActive === 'true';
}
// Query configuration items
const configurationItems = await postgresClient.find(
'configuration_items',
where,
{
limit,
offset,
orderBy: 'reference_title ASC',
}
);
// Get total count
const totalCount = await postgresClient.count('configuration_items', where);
return NextResponse.json({
configurationItems,
pagination: {
limit,
offset,
total: totalCount,
hasMore: offset + configurationItems.length < totalCount,
},
});
} catch (error) {
console.error('Failed to fetch configuration items:', error);
return NextResponse.json(
{ error: 'Failed to fetch configuration items' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,107 @@
/**
* Contacts Data API Endpoint
* GET /api/data/contacts - Query contacts from PostgreSQL
*/
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '100');
const offset = parseInt(searchParams.get('offset') || '0');
const companyId = searchParams.get('companyId');
const isActive = searchParams.get('isActive');
const sortBy = searchParams.get('sort');
const sortOrder = searchParams.get('order') || 'asc';
// Build conditions and parameters
const conditions: string[] = [];
const params: any[] = [];
if (companyId) {
conditions.push('company_id = $' + (params.length + 1));
params.push(parseInt(companyId));
}
if (isActive !== null) {
conditions.push('is_active = $' + (params.length + 1));
params.push(isActive === 'true');
}
const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
// Build dynamic ORDER BY clause
let orderByClause = 'ORDER BY last_name ASC, first_name ASC';
if (sortBy) {
const validColumns = ['id', 'first_name', 'last_name', 'email_address', 'title', 'is_active', 'company_id'];
if (validColumns.includes(sortBy)) {
const direction = sortOrder.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
orderByClause = `ORDER BY ${sortBy} ${direction}, last_name ASC, first_name ASC`;
}
}
// Query contacts
const query = `
SELECT
id,
first_name,
last_name,
email_address,
title,
phone,
extension,
alternate_phone,
mobile_phone,
fax,
address_line,
address_line1,
city,
state,
zip_code,
country,
is_active,
company_id,
created_at,
updated_at,
synced_at,
is_deleted,
deleted_at
FROM contacts
${whereClause}
${orderByClause}
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
`;
params.push(limit, offset);
const result = await postgresClient.query(query, params);
const contacts = result.rows;
// Get total count
const countQuery = `
SELECT COUNT(*) as total
FROM contacts
${whereClause}
`;
const countResult = await postgresClient.query(countQuery, params.slice(0, -2));
const totalCount = parseInt(countResult.rows[0].total);
return NextResponse.json({
contacts,
pagination: {
limit,
offset,
total: totalCount,
hasMore: offset + contacts.length < totalCount,
},
});
} catch (error) {
console.error('Failed to fetch contacts:', error);
return NextResponse.json(
{ error: 'Failed to fetch contacts' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,56 @@
/**
* Contracts Data API Endpoint
* GET /api/data/contracts - Query contracts from PostgreSQL
*/
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '100');
const offset = parseInt(searchParams.get('offset') || '0');
const companyId = searchParams.get('companyId');
const status = searchParams.get('status');
// Build where clause
const where: Record<string, any> = {};
if (companyId) {
where.company_id = parseInt(companyId);
}
if (status) {
where.status = parseInt(status);
}
// Query contracts
const contracts = await postgresClient.find(
'contracts',
where,
{
limit,
offset,
orderBy: 'start_date DESC',
}
);
// Get total count
const totalCount = await postgresClient.count('contracts', where);
return NextResponse.json({
contracts,
pagination: {
limit,
offset,
total: totalCount,
hasMore: offset + contracts.length < totalCount,
},
});
} catch (error) {
console.error('Failed to fetch contracts:', error);
return NextResponse.json(
{ error: 'Failed to fetch contracts' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,90 @@
/**
* Issue Types Data API Endpoint
* GET /api/data/issue-types - Query issue types from PostgreSQL
*/
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '100');
const offset = parseInt(searchParams.get('offset') || '0');
const isActive = searchParams.get('isActive');
const sortBy = searchParams.get('sort');
const sortOrder = searchParams.get('order') || 'asc';
// Build conditions and parameters
const conditions: string[] = [];
const params: any[] = [];
if (isActive !== null) {
conditions.push('is_active = $' + (params.length + 1));
params.push(isActive === 'true');
}
const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
// Build dynamic ORDER BY clause
let orderByClause = 'ORDER BY sort_order ASC, label ASC';
if (sortBy) {
const validColumns = ['value', 'label', 'is_active', 'is_system', 'sort_order', 'parent_value'];
if (validColumns.includes(sortBy)) {
const direction = sortOrder.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
orderByClause = `ORDER BY ${sortBy} ${direction}, sort_order ASC, label ASC`;
}
}
// Query issue types
const query = `
SELECT
value,
label,
is_active,
is_system,
sort_order,
parent_value,
created_at,
updated_at,
synced_at,
is_deleted,
deleted_at
FROM issue_types
${whereClause}
${orderByClause}
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
`;
params.push(limit, offset);
const result = await postgresClient.query(query, params);
const issueTypes = result.rows;
// Get total count
const countQuery = `
SELECT COUNT(*) as total
FROM issue_types
${whereClause}
`;
const countResult = await postgresClient.query(countQuery, params.slice(0, -2));
const totalCount = parseInt(countResult.rows[0].total);
return NextResponse.json({
issueTypes,
pagination: {
limit,
offset,
total: totalCount,
hasMore: offset + issueTypes.length < totalCount,
},
});
} catch (error) {
console.error('Failed to fetch issue types:', error);
return NextResponse.json(
{ error: 'Failed to fetch issue types' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,56 @@
/**
* Projects Data API Endpoint
* GET /api/data/projects - Query projects from PostgreSQL
*/
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '100');
const offset = parseInt(searchParams.get('offset') || '0');
const companyId = searchParams.get('companyId');
const status = searchParams.get('status');
// Build where clause
const where: Record<string, any> = {};
if (companyId) {
where.company_id = parseInt(companyId);
}
if (status) {
where.status = parseInt(status);
}
// Query projects
const projects = await postgresClient.find(
'projects',
where,
{
limit,
offset,
orderBy: 'start_date_time DESC',
}
);
// Get total count
const totalCount = await postgresClient.count('projects', where);
return NextResponse.json({
projects,
pagination: {
limit,
offset,
total: totalCount,
hasMore: offset + projects.length < totalCount,
},
});
} catch (error) {
console.error('Failed to fetch projects:', error);
return NextResponse.json(
{ error: 'Failed to fetch projects' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,110 @@
/**
* Resources Data API Endpoint
* GET /api/data/resources - Query resources (users) from PostgreSQL
*/
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '100');
const offset = parseInt(searchParams.get('offset') || '0');
const isActive = searchParams.get('isActive');
const sortBy = searchParams.get('sort');
const sortOrder = searchParams.get('order') || 'asc';
const ids = searchParams.get('ids'); // Comma-separated IDs for enrichment
// Build conditions and parameters
const conditions: string[] = [];
const params: any[] = [];
if (ids) {
// Fetch specific resources by IDs
const idArray = ids.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
if (idArray.length > 0) {
conditions.push(`id = ANY($${params.length + 1})`);
params.push(idArray);
}
}
if (isActive !== null && !ids) {
conditions.push('is_active = $' + (params.length + 1));
params.push(isActive === 'true');
}
const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
// Build dynamic ORDER BY clause
let orderByClause = 'ORDER BY last_name ASC, first_name ASC';
if (sortBy) {
const validColumns = ['id', 'first_name', 'last_name', 'email', 'user_name', 'title', 'is_active', 'resource_type'];
if (validColumns.includes(sortBy)) {
const direction = sortOrder.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
orderByClause = `ORDER BY ${sortBy} ${direction}, last_name ASC, first_name ASC`;
}
}
// Query resources
const query = `
SELECT
id,
first_name,
last_name,
email,
user_name,
title,
office_phone,
mobile_phone,
office_extension,
is_active,
location_id,
resource_type,
pay_roll_identifier,
hire_date,
travel_availability_pct,
survey_resource_rating,
created_at,
updated_at,
synced_at,
is_deleted,
deleted_at
FROM resources
${whereClause}
${orderByClause}
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
`;
params.push(limit, offset);
const result = await postgresClient.query(query, params);
const resources = result.rows;
// Get total count
const countQuery = `
SELECT COUNT(*) as total
FROM resources
${whereClause}
`;
const countResult = await postgresClient.query(countQuery, params.slice(0, -2));
const totalCount = parseInt(countResult.rows[0].total);
return NextResponse.json({
resources,
pagination: {
limit,
offset,
total: totalCount,
hasMore: offset + resources.length < totalCount,
},
});
} catch (error) {
console.error('Failed to fetch resources:', error);
return NextResponse.json(
{ error: 'Failed to fetch resources' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,82 @@
/**
* Enhanced Sub-Issue Types Data API Endpoint with Parent Issue Type Assignment
* GET /api/data/sub-issue-types-with-parent - Query sub-issue types with assigned parent issue type labels
*/
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '100');
const offset = parseInt(searchParams.get('offset') || '0');
const isActive = searchParams.get('isActive');
const parentValue = searchParams.get('parentValue');
// Build conditions and parameters
const conditions: string[] = [];
const params: any[] = [];
if (isActive !== null) {
conditions.push('sit.is_active = $' + (params.length + 1));
params.push(isActive === 'true');
}
if (parentValue) {
conditions.push('sit.parent_value = $' + (params.length + 1));
params.push(parseInt(parentValue));
}
const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
// Query sub-issue types with parent issue type information
const query = `
SELECT
sit.value,
sit.label,
sit.is_active,
sit.is_system,
sit.sort_order,
sit.parent_value,
it.label as parent_issue_type_label,
it.is_active as parent_is_active
FROM sub_issue_types sit
LEFT JOIN issue_types it ON sit.parent_value = it.value
${whereClause}
ORDER BY sit.parent_value ASC, sit.sort_order ASC, sit.label ASC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
`;
params.push(limit, offset);
// Execute query
const result = await postgresClient.query(query, params);
const subIssueTypes = result.rows;
// Get total count
const countQuery = `
SELECT COUNT(*) as total
FROM sub_issue_types sit
${whereClause}
`;
const countResult = await postgresClient.query(countQuery, params.slice(0, -2));
const totalCount = parseInt(countResult.rows[0].total);
return NextResponse.json({
subIssueTypes,
pagination: {
limit,
offset,
total: totalCount,
hasMore: offset + subIssueTypes.length < totalCount,
},
});
} catch (error) {
console.error('Failed to fetch sub-issue types with parent:', error);
return NextResponse.json(
{ error: 'Failed to fetch sub-issue types with parent' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,103 @@
/**
* Sub-Issue Types Data API Endpoint
* GET /api/data/sub-issue-types - Query sub-issue types from PostgreSQL
*/
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '100');
const offset = parseInt(searchParams.get('offset') || '0');
const isActive = searchParams.get('isActive');
const parentValue = searchParams.get('parentValue');
const sortBy = searchParams.get('sort');
const sortOrder = searchParams.get('order') || 'asc';
// Build conditions and parameters
const conditions: string[] = [];
const params: any[] = [];
if (isActive !== null) {
conditions.push('sit.is_active = $' + (params.length + 1));
params.push(isActive === 'true');
}
if (parentValue) {
conditions.push('sit.parent_value = $' + (params.length + 1));
params.push(parseInt(parentValue));
}
const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
// Build dynamic ORDER BY clause
let orderByClause = 'ORDER BY sit.parent_value ASC, sit.sort_order ASC, sit.label ASC';
if (sortBy) {
const validColumns = ['value', 'label', 'is_active', 'is_system', 'sort_order', 'parent_value', 'parent_issue_type_label'];
if (validColumns.includes(sortBy)) {
const direction = sortOrder.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
if (sortBy === 'parent_issue_type_label') {
orderByClause = `ORDER BY it.label ${direction}, sit.sort_order ASC, sit.label ASC`;
} else {
orderByClause = `ORDER BY sit.${sortBy} ${direction}, sit.sort_order ASC, sit.label ASC`;
}
}
}
// Query sub-issue types with parent issue type information
const query = `
SELECT
sit.value,
sit.label,
sit.is_active,
sit.is_system,
sit.sort_order,
sit.parent_value,
sit.created_at,
sit.updated_at,
sit.synced_at,
sit.is_deleted,
sit.deleted_at,
it.label as parent_issue_type_label,
it.is_active as parent_is_active
FROM sub_issue_types sit
LEFT JOIN issue_types it ON sit.parent_value = it.value
${whereClause}
${orderByClause}
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
`;
params.push(limit, offset);
// Execute query
const result = await postgresClient.query(query, params);
const subIssueTypes = result.rows;
// Get total count
const countQuery = `
SELECT COUNT(*) as total
FROM sub_issue_types sit
${whereClause}
`;
const countResult = await postgresClient.query(countQuery, params.slice(0, -2));
const totalCount = parseInt(countResult.rows[0].total);
return NextResponse.json({
subIssueTypes,
pagination: {
limit,
offset,
total: totalCount,
hasMore: offset + subIssueTypes.length < totalCount,
},
});
} catch (error) {
console.error('Failed to fetch sub-issue types:', error);
return NextResponse.json(
{ error: 'Failed to fetch sub-issue types' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,64 @@
/**
* Tasks Data API Endpoint
* GET /api/data/tasks - Query tasks from PostgreSQL
*/
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '100');
const offset = parseInt(searchParams.get('offset') || '0');
const projectId = searchParams.get('projectId');
const ticketId = searchParams.get('ticketId');
const assignedResourceId = searchParams.get('assignedResourceId');
const status = searchParams.get('status');
// Build where clause
const where: Record<string, any> = {};
if (projectId) {
where.project_id = parseInt(projectId);
}
if (ticketId) {
where.ticket_id = parseInt(ticketId);
}
if (assignedResourceId) {
where.assigned_resource_id = parseInt(assignedResourceId);
}
if (status) {
where.status = parseInt(status);
}
// Query tasks
const tasks = await postgresClient.find(
'tasks',
where,
{
limit,
offset,
orderBy: 'create_date_time DESC',
}
);
// Get total count
const totalCount = await postgresClient.count('tasks', where);
return NextResponse.json({
tasks,
pagination: {
limit,
offset,
total: totalCount,
hasMore: offset + tasks.length < totalCount,
},
});
} catch (error) {
console.error('Failed to fetch tasks:', error);
return NextResponse.json(
{ error: 'Failed to fetch tasks' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,128 @@
/**
* Enhanced Tickets Data API Endpoint with Issue Type Assignment
* GET /api/data/tickets-with-issue-types - Query tickets with assigned issue type and sub-issue type labels
*/
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '100');
const offset = parseInt(searchParams.get('offset') || '0');
const status = searchParams.get('status');
const priority = searchParams.get('priority');
const companyId = searchParams.get('companyId');
const issueType = searchParams.get('issueType');
const subIssueType = searchParams.get('subIssueType');
// Build where clause
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (status !== null) {
conditions.push(`t.status = $${paramIndex++}`);
params.push(parseInt(status));
}
if (priority !== null) {
conditions.push(`t.priority = $${paramIndex++}`);
params.push(parseInt(priority));
}
if (companyId !== null) {
conditions.push(`t.company_id = $${paramIndex++}`);
params.push(parseInt(companyId));
}
if (issueType !== null) {
conditions.push(`t.issue_type = $${paramIndex++}`);
params.push(parseInt(issueType));
}
if (subIssueType !== null) {
conditions.push(`t.sub_issue_type = $${paramIndex++}`);
params.push(parseInt(subIssueType));
}
// Always exclude deleted tickets
conditions.push(`t.is_deleted = false`);
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Query tickets with issue type and sub-issue type information
const query = `
SELECT
t.id,
t.ticket_number,
t.title,
t.description,
t.status,
t.priority,
t.issue_type,
t.sub_issue_type,
t.company_id,
t.assigned_resource_id,
t.contact_id,
t.create_date,
t.due_date_time,
t.completed_date,
t.last_activity_date,
it.label as issue_type_label,
it.is_active as issue_type_active,
sit.label as sub_issue_type_label,
sit.is_active as sub_issue_type_active,
sit.parent_value as sub_issue_parent_value,
pit.label as parent_issue_type_label,
c.company_name,
r.first_name as resource_first_name,
r.last_name as resource_last_name,
r.email as resource_email,
co.first_name as contact_first_name,
co.last_name as contact_last_name,
co.email_address as contact_email
FROM tickets t
LEFT JOIN issue_types it ON t.issue_type = it.value
LEFT JOIN sub_issue_types sit ON t.sub_issue_type = sit.value
LEFT JOIN issue_types pit ON sit.parent_value = pit.value
LEFT JOIN companies c ON t.company_id = c.id
LEFT JOIN resources r ON t.assigned_resource_id = r.id
LEFT JOIN contacts co ON t.contact_id = co.id
${whereClause}
ORDER BY t.create_date DESC
LIMIT $${paramIndex++}
OFFSET $${paramIndex++}
`;
params.push(limit, offset);
// Execute query
const result = await postgresClient.query(query, params);
const tickets = result.rows;
// Get total count
const countQuery = `
SELECT COUNT(*) as total
FROM tickets t
${whereClause}
`;
const countParams = params.slice(0, -2); // Remove limit and offset
const countResult = await postgresClient.query(countQuery, countParams);
const totalCount = parseInt(countResult.rows[0].total);
return NextResponse.json({
tickets,
pagination: {
limit,
offset,
total: totalCount,
hasMore: offset + tickets.length < totalCount,
},
});
} catch (error) {
console.error('Failed to fetch tickets with issue types:', error);
return NextResponse.json(
{ error: 'Failed to fetch tickets with issue types' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,95 @@
/**
* Tickets Data API Endpoint
* GET /api/data/tickets - Query tickets from PostgreSQL
*
* Query Parameters:
* - page: Page number (default: 1)
* - limit: Records per page (default: 100, max: 1000)
* - includeDeleted: Include soft-deleted records (default: false)
* - sort: Sort field (default: create_date)
* - order: Sort order ASC/DESC (default: DESC)
* - companyId: Filter by company ID
* - status: Filter by status
* - assignedResourceId: Filter by assigned resource
* - Any other parameter will be treated as a filter
*/
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
import {
parseQueryParams,
buildWhereClause,
buildOrderByClause,
createPaginationInfo,
formatApiResponse,
handleApiError,
validateQueryParams,
} from '@/lib/utils/api-helpers';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const ids = searchParams.get('ids'); // Comma-separated IDs for enrichment
// Handle ID-based enrichment requests
if (ids) {
const idArray = ids.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
if (idArray.length === 0) {
return NextResponse.json({ tickets: [] });
}
const query = `
SELECT id, ticket_number, title, status, priority, company_id
FROM tickets
WHERE id = ANY($1) AND is_deleted = false
`;
const result = await postgresClient.query(query, [idArray]);
return NextResponse.json({ tickets: result.rows });
}
// Parse and validate query parameters
const options = parseQueryParams(request, {
limit: 100,
sort: 'create_date',
order: 'DESC',
});
validateQueryParams(options);
// Build WHERE clause
const where = buildWhereClause(options.filters || {}, options.includeDeleted);
// Build ORDER BY clause
const orderBy = buildOrderByClause(options.sort!, options.order!);
// Query tickets
const tickets = await postgresClient.find(
'tickets',
where,
{
limit: options.limit,
offset: options.offset,
orderBy,
includeDeleted: options.includeDeleted,
}
);
// Get total count
const totalCount = await postgresClient.count('tickets', where, options.includeDeleted);
// Create pagination info
const pagination = createPaginationInfo(options.page!, options.limit!, totalCount);
// Format and return response
return NextResponse.json(
formatApiResponse(tickets, pagination, {
entity: 'tickets',
filters: options.filters,
})
);
} catch (error) {
const errorResponse = handleApiError(error, 'fetch tickets');
return NextResponse.json(errorResponse, { status: errorResponse.statusCode });
}
}

View file

@ -0,0 +1,313 @@
import { NextRequest, NextResponse } from 'next/server';
import { Pool } from 'pg';
import { TimeEntry } from '@/lib/types/database';
// Initialize PostgreSQL connection
const pool = new Pool({
host: process.env.POSTGRES_HOST,
port: parseInt(process.env.POSTGRES_PORT || '5432'),
database: process.env.POSTGRES_DB || 'pulse_autotask',
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
ssl: process.env.POSTGRES_SSL === 'true' ? { rejectUnauthorized: false } : false,
});
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
// Parse query parameters
const limit = parseInt(searchParams.get('limit') || '100');
const offset = parseInt(searchParams.get('offset') || '0');
const search = searchParams.get('search') || '';
const resourceId = searchParams.get('resource_id');
const ticketId = searchParams.get('ticket_id');
const taskId = searchParams.get('task_id');
const projectId = searchParams.get('project_id');
const companyId = searchParams.get('company_id');
const startDate = searchParams.get('start_date');
const endDate = searchParams.get('end_date');
const sortBy = searchParams.get('sort_by') || 'entry_date';
const sortOrder = searchParams.get('sort_order') || 'desc';
const minHours = searchParams.get('min_hours');
const maxHours = searchParams.get('max_hours');
const billable = searchParams.get('billable');
const approved = searchParams.get('approved');
const hasTicket = searchParams.get('has_ticket');
// Build WHERE conditions
const conditions: string[] = ['te.is_deleted = false'];
const params: any[] = [];
let paramIndex = 1;
// Add search condition (search in notes, title, internal_notes)
if (search) {
conditions.push(`(
te.notes ILIKE $${paramIndex} OR
te.title ILIKE $${paramIndex} OR
te.internal_notes ILIKE $${paramIndex}
)`);
params.push(`%${search}%`);
paramIndex++;
}
// Add filter conditions
if (resourceId) {
conditions.push(`te.resource_id = $${paramIndex}`);
params.push(resourceId);
paramIndex++;
}
if (ticketId) {
conditions.push(`te.ticket_id = $${paramIndex}`);
params.push(ticketId);
paramIndex++;
}
if (taskId) {
conditions.push(`te.task_id = $${paramIndex}`);
params.push(taskId);
paramIndex++;
}
if (projectId) {
conditions.push(`te.project_id = $${paramIndex}`);
params.push(projectId);
paramIndex++;
}
if (companyId) {
conditions.push(`te.company_id = $${paramIndex}`);
params.push(companyId);
paramIndex++;
}
if (startDate) {
conditions.push(`te.entry_date >= $${paramIndex}`);
params.push(startDate);
paramIndex++;
}
if (endDate) {
conditions.push(`te.entry_date <= $${paramIndex}`);
params.push(endDate);
paramIndex++;
}
if (minHours) {
conditions.push(`te.hours_worked >= $${paramIndex}`);
params.push(minHours);
paramIndex++;
}
if (maxHours) {
conditions.push(`te.hours_worked <= $${paramIndex}`);
params.push(maxHours);
paramIndex++;
}
if (billable !== null && billable !== undefined) {
conditions.push(`te.billable = $${paramIndex}`);
params.push(billable === 'true');
paramIndex++;
}
if (approved !== null && approved !== undefined) {
conditions.push(`te.approved = $${paramIndex}`);
params.push(approved === 'true');
paramIndex++;
}
if (hasTicket === 'true') {
conditions.push(`te.ticket_id IS NOT NULL`);
}
// Validate sort column
const validSortColumns = [
'entry_date', 'hours_worked', 'created_at', 'updated_at',
'resource_id', 'ticket_id', 'task_id', 'project_id', 'company_id',
'title', 'billable', 'approved'
];
const validSortBy = validSortColumns.includes(sortBy) ? sortBy : 'entry_date';
const validSortOrder = sortOrder.toLowerCase() === 'asc' ? 'ASC' : 'DESC';
// Build the main query
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const query = `
SELECT
te.id,
te.resource_id,
r.first_name || ' ' || r.last_name as resource_name,
te.ticket_id,
t.ticket_number,
t.title as ticket_title,
te.task_id,
task.title as task_title,
te.project_id,
p.project_name,
te.company_id,
c.company_name,
te.entry_date,
te.hours_worked,
te.notes,
te.internal_notes,
te.title,
te.type,
te.start_date_time,
te.end_date_time,
te.billable,
te.billing_rate,
te.approved,
te.approved_date_time,
te.non_billable,
te.created_at,
te.updated_at,
te.synced_at
FROM time_entries te
LEFT JOIN resources r ON te.resource_id = r.id
LEFT JOIN tickets t ON te.ticket_id = t.id
LEFT JOIN tasks task ON te.task_id = task.id
LEFT JOIN projects p ON te.project_id = p.id
LEFT JOIN companies c ON te.company_id = c.id
${whereClause}
ORDER BY te.${validSortBy} ${validSortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
params.push(limit, offset);
paramIndex += 2;
// Get total count
const countQuery = `
SELECT COUNT(*) as total
FROM time_entries te
${whereClause}
`;
const client = await pool.connect();
try {
// Execute both queries in parallel
const [result, countResult] = await Promise.all([
client.query(query, params),
client.query(countQuery, params.slice(0, -2)) // Remove limit and offset for count
]);
const timeEntries: TimeEntry[] = result.rows;
const total = parseInt(countResult.rows[0].total);
// Get summary statistics
const summaryQuery = `
SELECT
COUNT(*) as total_entries,
SUM(hours_worked) as total_hours,
AVG(hours_worked) as avg_hours,
MIN(entry_date) as earliest_date,
MAX(entry_date) as latest_date,
COUNT(CASE WHEN billable = true THEN 1 END) as billable_entries,
COUNT(CASE WHEN approved = true THEN 1 END) as approved_entries
FROM time_entries te
${whereClause}
`;
const summaryResult = await client.query(summaryQuery, params.slice(0, -2));
const summary = summaryResult.rows[0];
return NextResponse.json({
timeEntries,
pagination: {
total,
limit,
offset,
hasMore: offset + limit < total,
},
summary: {
totalEntries: parseInt(summary.total_entries),
totalHours: parseFloat(summary.total_hours) || 0,
averageHours: parseFloat(summary.avg_hours) || 0,
earliestDate: summary.earliest_date,
latestDate: summary.latest_date,
billableEntries: parseInt(summary.billable_entries),
approvedEntries: parseInt(summary.approved_entries),
},
});
} finally {
client.release();
}
} catch (error) {
console.error('Error fetching time entries:', error);
return NextResponse.json(
{ error: 'Failed to fetch time entries' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate required fields
const requiredFields = ['resource_id', 'entry_date', 'hours_worked'];
for (const field of requiredFields) {
if (!body[field]) {
return NextResponse.json(
{ error: `Missing required field: ${field}` },
{ status: 400 }
);
}
}
const client = await pool.connect();
try {
const query = `
INSERT INTO time_entries (
resource_id, ticket_id, task_id, project_id, company_id,
entry_date, hours_worked, notes, internal_notes, title,
type, start_date_time, end_date_time, billable,
billing_rate, approved, approved_date_time, non_billable,
created_at, updated_at, synced_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, NOW(), NOW(), NOW()
)
RETURNING *
`;
const values = [
body.resource_id,
body.ticket_id || null,
body.task_id || null,
body.project_id || null,
body.company_id || null,
body.entry_date,
body.hours_worked,
body.notes || null,
body.internal_notes || null,
body.title || null,
body.type || null,
body.start_date_time || null,
body.end_date_time || null,
body.billable !== undefined ? body.billable : true,
body.billing_rate || null,
body.approved !== undefined ? body.approved : false,
body.approved_date_time || null,
body.non_billable !== undefined ? body.non_billable : false,
];
const result = await client.query(query, values);
const timeEntry: TimeEntry = result.rows[0];
return NextResponse.json({ timeEntry }, { status: 201 });
} finally {
client.release();
}
} catch (error) {
console.error('Error creating time entry:', error);
return NextResponse.json(
{ error: 'Failed to create time entry' },
{ status: 500 }
);
}
}

View file

@ -1,30 +1,183 @@
import { NextRequest, NextResponse } from 'next/server';
import { Pool } from 'pg';
import { getAutotaskClient } from '@/lib/services/autotask-factory';
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
import { getAuvikClient } from '@/lib/services/auvik-factory';
import { getAddigyClient } from '@/lib/services/addigy-factory';
import { apiCache } from '@/lib/services/cache';
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
import { AuvikDevice } from '@/lib/types/auvik';
import { AddigyDevice } from '@/lib/types/addigy';
import { ConfigurationItem } from '@/lib/types/autotask';
const pool = new Pool({
host: process.env.POSTGRES_HOST,
port: parseInt(process.env.POSTGRES_PORT || '5432'),
database: process.env.POSTGRES_DB,
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
});
interface DeviceComparison {
autotaskDevice?: ConfigurationItem;
rmmDevice?: DattoRMMDevice;
auvikDevice?: AuvikDevice;
addigyDevice?: AddigyDevice;
status: 'matched' | 'autotask-only' | 'rmm-only';
matchedBy?: string; // What field was used to match
}
// Helper function to normalize MAC address for comparison
function normalizeMacAddress(mac: string): string {
return mac.replace(/[:-]/g, '').toLowerCase();
}
// Helper function to match Addigy device to Autotask device
function matchAddigyDevice(
autotaskDevice: ConfigurationItem,
addigyDevices: AddigyDevice[]
): AddigyDevice | null {
// Priority 1: Serial number (primary matching method for Apple devices)
if (autotaskDevice.serialNumber) {
const autotaskSerial = autotaskDevice.serialNumber?.toLowerCase().trim();
console.log(`Trying to match Autotask device "${autotaskDevice.referenceTitle}" with serial: ${autotaskSerial}`);
console.log(`Checking against ${addigyDevices.length} Addigy devices`);
const match = addigyDevices.find((d) => {
const addigySerial = d['Serial Number']?.toLowerCase().trim();
if (addigySerial) {
console.log(` Comparing with Addigy device "${d['Device Name']}" serial: ${addigySerial}`);
}
return addigySerial === autotaskSerial;
});
if (match) {
console.log(
`✓ Matched Addigy device by serial: ${match['Device Name']} (${match['Serial Number']}) -> ${autotaskDevice.referenceTitle} (${autotaskDevice.serialNumber})`
);
return match;
} else {
console.log(`✗ No Addigy serial match found for ${autotaskDevice.serialNumber}`);
}
}
// Priority 2: Device name/hostname
const hostname =
autotaskDevice.rmmDeviceAuditHostname || autotaskDevice.referenceTitle;
if (hostname) {
const match = addigyDevices.find(
(d) =>
d['Device Name']?.toLowerCase().trim() === hostname.toLowerCase().trim()
);
if (match) {
console.log(
`Matched Addigy device by name: ${match['Device Name']} -> ${autotaskDevice.referenceTitle}`
);
return match;
}
}
return null;
}
// Helper function to match Auvik device to Autotask device
function matchAuvikDevice(
autotaskDevice: ConfigurationItem,
auvikDevices: AuvikDevice[]
): AuvikDevice | null {
// Priority 1: Serial number
if (autotaskDevice.serialNumber) {
const match = auvikDevices.find(
(d) =>
d.serialNumber?.toLowerCase().trim() ===
autotaskDevice.serialNumber?.toLowerCase().trim()
);
if (match) {
console.log(
`Matched Auvik device by serial: ${match.deviceName} -> ${autotaskDevice.referenceTitle}`
);
return match;
}
}
// Priority 2: Hostname
const hostname =
autotaskDevice.rmmDeviceAuditHostname || autotaskDevice.referenceTitle;
if (hostname) {
const match = auvikDevices.find(
(d) =>
d.deviceName?.toLowerCase().trim() === hostname.toLowerCase().trim()
);
if (match) {
console.log(
`Matched Auvik device by hostname: ${match.deviceName} -> ${autotaskDevice.referenceTitle}`
);
return match;
}
}
// Priority 3: MAC address
const macAddress = autotaskDevice.rmmDeviceAuditMacAddress;
if (macAddress && macAddress.length > 0) {
const normalizedMac = normalizeMacAddress(macAddress);
const match = auvikDevices.find((d) =>
d.macAddresses?.some(
(mac) => normalizeMacAddress(mac) === normalizedMac
)
);
if (match) {
console.log(
`Matched Auvik device by MAC: ${match.deviceName} -> ${autotaskDevice.referenceTitle}`
);
return match;
}
}
return null;
}
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const companyId = searchParams.get('companyId');
const companyName = searchParams.get('companyName');
const activeFilter = searchParams.get('activeFilter') || 'active';
const skipCache = searchParams.get('skipCache') === 'true';
// Check cache first
const cacheKey = `rmm-devices:${companyId}:${activeFilter}`;
const cached = apiCache.get(cacheKey);
if (cached) {
console.log(`Cache hit for ${cacheKey}`);
return NextResponse.json(cached);
// Get mapping counts to include in cache key (so cache invalidates when mappings change)
let rmmMappingCount = 0;
let auvikMappingCount = 0;
let addigyMappingCount = 0;
if (companyId) {
const rmmMappingsResult = await pool.query(
'SELECT COUNT(*) as count FROM rmm_site_mappings WHERE company_id = $1',
[parseInt(companyId)]
);
rmmMappingCount = parseInt(rmmMappingsResult.rows[0]?.count || '0');
const auvikMappingsResult = await pool.query(
'SELECT COUNT(*) as count FROM auvik_tenant_mappings WHERE autotask_company_id = $1',
[parseInt(companyId)]
);
auvikMappingCount = parseInt(auvikMappingsResult.rows[0]?.count || '0');
const addigyMappingsResult = await pool.query(
'SELECT COUNT(*) as count FROM addigy_org_mappings WHERE autotask_company_id = $1',
[parseInt(companyId)]
);
addigyMappingCount = parseInt(addigyMappingsResult.rows[0]?.count || '0');
}
// Check cache first (unless skipCache is true)
const cacheKey = `rmm-devices:${companyId}:${activeFilter}:rmm-${rmmMappingCount}:auvik-${auvikMappingCount}:addigy-${addigyMappingCount}`;
if (!skipCache) {
const cached = apiCache.get(cacheKey);
if (cached) {
console.log(`Cache hit for ${cacheKey}`);
return NextResponse.json(cached);
}
} else {
console.log(`Skipping cache for ${cacheKey}`);
}
if (!companyId) {
@ -65,18 +218,147 @@ export async function GET(request: NextRequest) {
try {
const rmmClient = getDattoRMMClient();
if (companyName) {
// First, check if we have site mappings for this company
if (companyId) {
const mappingsResult = await pool.query(
'SELECT rmm_site_uid FROM rmm_site_mappings WHERE company_id = $1',
[parseInt(companyId)]
);
if (mappingsResult.rows.length > 0) {
// Use the new multi-site method if mappings exist
const siteUids = mappingsResult.rows.map(row => row.rmm_site_uid);
console.log(`Found ${siteUids.length} mapped RMM sites for company ${companyId}`);
rmmDevices = await rmmClient.getDevicesForSites(siteUids);
} else if (companyName) {
// Fall back to old method if no mappings exist
console.log(`No RMM site mappings found for company ${companyId}, using name-based matching`);
rmmDevices = await rmmClient.getDevicesByCompanyName(companyName);
}
} else if (companyName) {
// Try to get devices by company name (matching site name)
rmmDevices = await rmmClient.getDevicesByCompanyName(companyName);
} else {
// If no company name, get all devices and try to match
// If no company info, get all devices and try to match
rmmDevices = await rmmClient.getAllDevices();
}
// Filter RMM devices based on activeFilter
if (activeFilter === 'active') {
// Only show non-deleted, non-suspended RMM devices when filtering for active
rmmDevices = rmmDevices.filter(device => !device.deleted && !device.suspended);
} else if (activeFilter === 'inactive') {
// Only show deleted or suspended RMM devices when filtering for inactive
rmmDevices = rmmDevices.filter(device => device.deleted || device.suspended);
}
// If 'all', show all RMM devices (no filtering)
// Deduplicate RMM devices by ID (in case the same device appears in multiple sites)
const uniqueRmmDevices = new Map<string, DattoRMMDevice>();
rmmDevices.forEach(device => {
const deviceId = String(device.id);
if (!uniqueRmmDevices.has(deviceId)) {
uniqueRmmDevices.set(deviceId, device);
}
});
rmmDevices = Array.from(uniqueRmmDevices.values());
console.log(`After deduplication: ${rmmDevices.length} unique RMM devices`)
} catch (rmmError) {
console.error('Error fetching RMM devices:', rmmError);
// Continue with empty RMM devices array
}
// Get Auvik devices
let auvikDevices: AuvikDevice[] = [];
try {
const auvikClient = getAuvikClient();
if (companyId) {
// Try to find tenant using company ID mapping first (most accurate)
const tenant = await auvikClient.findTenantByCompanyId(parseInt(companyId));
if (tenant) {
console.log(`Found Auvik tenant via mapping: ${tenant.domainPrefix} for company ID: ${companyId}`);
auvikDevices = await auvikClient.getDevicesByTenant(tenant.id);
} else if (companyName) {
// Fallback to name-based matching
console.log(`No mapping found, trying name match for: ${companyName}`);
const tenantByName = await auvikClient.findTenantByName(companyName);
if (tenantByName) {
console.log(`Found Auvik tenant by name: ${tenantByName.domainPrefix} for company: ${companyName}`);
auvikDevices = await auvikClient.getDevicesByTenant(tenantByName.id);
} else {
console.log(`No Auvik tenant found for company: ${companyName}`);
}
}
} else if (companyName) {
// If no company ID, try name matching
const tenant = await auvikClient.findTenantByName(companyName);
if (tenant) {
console.log(`Found Auvik tenant: ${tenant.domainPrefix} for company: ${companyName}`);
auvikDevices = await auvikClient.getDevicesByTenant(tenant.id);
}
} else {
// If no company info, get all devices
auvikDevices = await auvikClient.getAllDevices();
}
console.log(`Fetched ${auvikDevices.length} Auvik devices before filtering`);
// Filter Auvik devices to only show those with valid hostnames
// Exclude devices without deviceName or with names starting with "Device@"
auvikDevices = auvikDevices.filter(device => {
if (!device.deviceName) {
return false;
}
if (device.deviceName.startsWith('Device@')) {
return false;
}
return true;
});
console.log(`Filtered to ${auvikDevices.length} Auvik devices with valid hostnames`);
} catch (auvikError) {
console.error('Error fetching Auvik devices:', auvikError);
// Continue with empty Auvik devices array
}
// Get Addigy devices (Apple RMM)
let addigyDevices: AddigyDevice[] = [];
try {
const addigyClient = getAddigyClient();
if (companyId) {
// Try to find policy using company ID mapping first
const mappingsResult = await pool.query(
'SELECT addigy_org_id FROM addigy_org_mappings WHERE autotask_company_id = $1',
[parseInt(companyId)]
);
if (mappingsResult.rows.length > 0) {
// Get all devices and filter by policy IDs in code
const policyIds = new Set(mappingsResult.rows.map(row => row.addigy_org_id));
console.log(`Found ${policyIds.size} mapped Addigy policies for company ${companyId}:`, Array.from(policyIds));
// Fetch all devices (without filter)
const allDevices = await addigyClient.getAllDevices();
console.log(`Fetched ${allDevices.length} total Addigy devices`);
// Filter devices by policy_id in code
addigyDevices = allDevices.filter(device => policyIds.has(device.policy_id));
console.log(`Filtered to ${addigyDevices.length} devices matching mapped policies`);
} else {
console.log(`No Addigy policy mappings found for company ${companyId}`);
}
}
console.log(`Final Addigy devices count: ${addigyDevices.length}`);
} catch (addigyError) {
console.error('Error fetching Addigy devices:', addigyError);
// Continue with empty Addigy devices array
}
// Compare and match devices
const comparison: DeviceComparison[] = [];
const matchedAutotaskIds = new Set<number>();
@ -84,6 +366,11 @@ export async function GET(request: NextRequest) {
// Try to match devices
for (const rmmDevice of rmmDevices) {
// Skip if this RMM device has already been matched
if (matchedRmmIds.has(String(rmmDevice.id))) {
continue;
}
let matched = false;
// Try to match by RMM Device UID
@ -180,13 +467,71 @@ export async function GET(request: NextRequest) {
}
}
// Add Autotask-only devices
// Add Autotask-only devices and match with Auvik and Addigy
for (const autotaskDevice of autotaskDevices) {
if (!matchedAutotaskIds.has(autotaskDevice.id)) {
// Try to match with Auvik device
const auvikMatch = matchAuvikDevice(autotaskDevice, auvikDevices);
// Try to match with Addigy device
const addigyMatch = matchAddigyDevice(autotaskDevice, addigyDevices);
comparison.push({
autotaskDevice: autotaskDevice,
auvikDevice: auvikMatch || undefined,
addigyDevice: addigyMatch || undefined,
status: 'autotask-only'
});
} else {
// For already matched devices, also try to match with Auvik and Addigy
const existingComparison = comparison.find(
(c) => c.autotaskDevice?.id === autotaskDevice.id
);
if (existingComparison) {
if (!existingComparison.auvikDevice) {
const auvikMatch = matchAuvikDevice(autotaskDevice, auvikDevices);
if (auvikMatch) {
existingComparison.auvikDevice = auvikMatch;
}
}
if (!existingComparison.addigyDevice) {
const addigyMatch = matchAddigyDevice(autotaskDevice, addigyDevices);
if (addigyMatch) {
existingComparison.addigyDevice = addigyMatch;
}
}
}
}
}
// Track which Auvik and Addigy devices have been matched
const matchedAuvikIds = new Set<string>();
const matchedAddigyIds = new Set<string>();
comparison.forEach(item => {
if (item.auvikDevice?.id) {
matchedAuvikIds.add(item.auvikDevice.id);
}
if (item.addigyDevice?.agentid) {
matchedAddigyIds.add(item.addigyDevice.agentid);
}
});
// Skip unmatched Auvik devices (NMS-only) - don't add them to comparison
// They will still be counted in stats but won't appear in the device list
for (const auvikDevice of auvikDevices) {
if (!matchedAuvikIds.has(auvikDevice.id)) {
// Mark as matched so it's counted but don't add to comparison
matchedAuvikIds.add(auvikDevice.id);
}
}
// Add unmatched Addigy devices (ARMM-only)
for (const addigyDevice of addigyDevices) {
if (!matchedAddigyIds.has(addigyDevice.agentid)) {
comparison.push({
addigyDevice: addigyDevice,
status: 'rmm-only' // Using rmm-only status for non-PSA devices
});
}
}
@ -197,19 +542,62 @@ export async function GET(request: NextRequest) {
const statusDiff = statusOrder[a.status] - statusOrder[b.status];
if (statusDiff !== 0) return statusDiff;
// Then sort by device name
const aName = a.autotaskDevice?.referenceTitle || a.rmmDevice?.hostname || '';
const bName = b.autotaskDevice?.referenceTitle || b.rmmDevice?.hostname || '';
// Then sort by device name (check all possible sources)
const aName = a.autotaskDevice?.referenceTitle ||
a.rmmDevice?.hostname ||
a.auvikDevice?.deviceName ||
a.addigyDevice?.['Device Name'] ||
'';
const bName = b.autotaskDevice?.referenceTitle ||
b.rmmDevice?.hostname ||
b.auvikDevice?.deviceName ||
b.addigyDevice?.['Device Name'] ||
'';
return aName.localeCompare(bName);
});
// Fetch contacts for the company to avoid individual API calls
const contacts: Record<number, any> = {};
try {
const contactIds = new Set<number>();
autotaskDevices.forEach(device => {
if (device.contactID) {
contactIds.add(device.contactID);
}
});
if (contactIds.size > 0) {
console.log(`Fetching ${contactIds.size} contacts for company ${companyId}`);
// Fetch all contacts for the company in one query
const companyContacts = await autotaskClient.queryEntity('Contacts', {
filter: [{ op: 'eq', field: 'companyID', value: parseInt(companyId) }],
});
// Map contacts by ID
companyContacts.forEach((contact: any) => {
contacts[contact.id] = contact;
});
console.log(`Fetched ${Object.keys(contacts).length} contacts`);
}
} catch (contactError) {
console.error('Error fetching contacts:', contactError);
// Continue without contacts
}
const response = {
rmmDevices,
autotaskDevices,
auvikDevices,
addigyDevices,
comparison,
contacts, // Include contacts in response
stats: {
totalRmm: rmmDevices.length,
totalAutotask: autotaskDevices.length,
totalAuvik: auvikDevices.length,
totalAddigy: addigyDevices.length,
matched: comparison.filter(c => c.status === 'matched').length,
autotaskOnly: comparison.filter(c => c.status === 'autotask-only').length,
rmmOnly: comparison.filter(c => c.status === 'rmm-only').length,

View file

@ -0,0 +1,323 @@
import { NextRequest, NextResponse } from 'next/server';
import { Pool } from 'pg';
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
import { DattoRMMSite } from '@/lib/types/datto-rmm';
const pool = new Pool({
host: process.env.POSTGRES_HOST,
port: parseInt(process.env.POSTGRES_PORT || '5432'),
database: process.env.POSTGRES_DB,
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
});
interface RMMSiteMapping {
id: number;
company_id: number;
company_name?: string;
rmm_site_uid: string;
rmm_site_name: string;
is_primary: boolean;
device_count: number;
notes: string | null;
last_sync_at: string | null;
created_at: string;
updated_at: string;
created_by: string | null;
}
// GET /api/rmm/site-mappings
// Get all RMM site mappings, optionally including unmapped sites
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const includeUnmapped = searchParams.get('includeUnmapped') === 'true';
const companyId = searchParams.get('companyId');
// Get all existing mappings
let query = `
SELECT
rsm.id,
rsm.company_id,
rsm.rmm_site_uid,
rsm.rmm_site_name,
rsm.is_primary,
rsm.device_count,
rsm.notes,
rsm.last_sync_at,
rsm.created_at,
rsm.updated_at,
rsm.created_by,
c.company_name
FROM rmm_site_mappings rsm
JOIN companies c ON c.id = rsm.company_id
`;
const queryParams: any[] = [];
if (companyId) {
query += ' WHERE rsm.company_id = $1';
queryParams.push(companyId);
}
query += ' ORDER BY c.company_name, rsm.rmm_site_name';
const mappingsResult = await pool.query(query, queryParams);
const mappings = mappingsResult.rows;
if (!includeUnmapped) {
return NextResponse.json({ mappings });
}
// Get all RMM sites from the RMM API
const rmmClient = getDattoRMMClient();
const allSites = await rmmClient.getSites();
// Get list of already mapped site UIDs
const mappedSiteUids = new Set(mappings.map((m: any) => m.rmm_site_uid));
// Create mapping entries for unmapped sites
const unmappedSites = allSites
.filter((site: DattoRMMSite) => !mappedSiteUids.has(site.uid))
.map((site: DattoRMMSite) => ({
id: null,
company_id: null,
company_name: null,
rmm_site_uid: site.uid,
rmm_site_name: site.name,
is_primary: false,
device_count: 0,
notes: null,
last_sync_at: null,
created_at: null,
updated_at: null,
created_by: null,
}));
// Combine mapped and unmapped sites
const allMappings = [...mappings, ...unmappedSites];
// Sort by mapping status (mapped first), then by site name
allMappings.sort((a, b) => {
if (a.company_id && !b.company_id) return -1;
if (!a.company_id && b.company_id) return 1;
return (a.rmm_site_name || '').localeCompare(b.rmm_site_name || '');
});
return NextResponse.json({
mappings: allMappings,
stats: {
total: allMappings.length,
mapped: mappings.length,
unmapped: unmappedSites.length
}
});
} catch (error) {
console.error('Error fetching RMM site mappings:', error);
return NextResponse.json(
{ error: 'Failed to fetch RMM site mappings' },
{ status: 500 }
);
}
}
// POST /api/rmm/site-mappings
// Create or update an RMM site mapping
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const {
rmmSiteUid,
rmmSiteName,
companyId,
companyName,
isPrimary = false,
notes = null,
createdBy = 'system'
} = body;
if (!rmmSiteUid || !rmmSiteName || !companyId) {
return NextResponse.json(
{ error: 'Missing required fields: rmmSiteUid, rmmSiteName, and companyId are required' },
{ status: 400 }
);
}
// If setting as primary, unset other primary sites for this company
if (isPrimary) {
await pool.query(
'UPDATE rmm_site_mappings SET is_primary = false WHERE company_id = $1',
[companyId]
);
}
// Insert or update the mapping
const query = `
INSERT INTO rmm_site_mappings (
company_id,
rmm_site_uid,
rmm_site_name,
is_primary,
notes,
created_by
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (company_id, rmm_site_uid)
DO UPDATE SET
rmm_site_name = EXCLUDED.rmm_site_name,
is_primary = EXCLUDED.is_primary,
notes = EXCLUDED.notes,
updated_at = CURRENT_TIMESTAMP
RETURNING *
`;
const result = await pool.query(query, [
companyId,
rmmSiteUid,
rmmSiteName,
isPrimary,
notes,
createdBy
]);
return NextResponse.json({
success: true,
mapping: result.rows[0]
});
} catch (error) {
console.error('Error saving RMM site mapping:', error);
return NextResponse.json(
{ error: 'Failed to save RMM site mapping' },
{ status: 500 }
);
}
}
// DELETE /api/rmm/site-mappings
// Delete an RMM site mapping
export async function DELETE(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const id = searchParams.get('id');
if (!id) {
return NextResponse.json(
{ error: 'Missing required parameter: id' },
{ status: 400 }
);
}
const result = await pool.query(
'DELETE FROM rmm_site_mappings WHERE id = $1 RETURNING *',
[id]
);
if (result.rowCount === 0) {
return NextResponse.json(
{ error: 'Mapping not found' },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
deleted: result.rows[0]
});
} catch (error) {
console.error('Error deleting RMM site mapping:', error);
return NextResponse.json(
{ error: 'Failed to delete RMM site mapping' },
{ status: 500 }
);
}
}
// PUT /api/rmm/site-mappings/bulk
// Create multiple mappings at once
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { mappings, createdBy = 'system' } = body;
if (!mappings || !Array.isArray(mappings)) {
return NextResponse.json(
{ error: 'Missing required field: mappings (array)' },
{ status: 400 }
);
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const results = [];
for (const mapping of mappings) {
const {
rmmSiteUid,
rmmSiteName,
companyId,
isPrimary = false,
notes = null
} = mapping;
if (!rmmSiteUid || !rmmSiteName || !companyId) {
await client.query('ROLLBACK');
return NextResponse.json(
{ error: 'Each mapping must have rmmSiteUid, rmmSiteName, and companyId' },
{ status: 400 }
);
}
// If setting as primary, unset other primary sites for this company
if (isPrimary) {
await client.query(
'UPDATE rmm_site_mappings SET is_primary = false WHERE company_id = $1',
[companyId]
);
}
const result = await client.query(
`
INSERT INTO rmm_site_mappings (
company_id,
rmm_site_uid,
rmm_site_name,
is_primary,
notes,
created_by
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (company_id, rmm_site_uid)
DO UPDATE SET
rmm_site_name = EXCLUDED.rmm_site_name,
is_primary = EXCLUDED.is_primary,
notes = EXCLUDED.notes,
updated_at = CURRENT_TIMESTAMP
RETURNING *
`,
[companyId, rmmSiteUid, rmmSiteName, isPrimary, notes, createdBy]
);
results.push(result.rows[0]);
}
await client.query('COMMIT');
return NextResponse.json({
success: true,
mappings: results
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
console.error('Error saving bulk RMM site mappings:', error);
return NextResponse.json(
{ error: 'Failed to save bulk RMM site mappings' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,74 @@
/**
* Entity-Specific Sync API Endpoint
* POST /api/sync/entity - Trigger sync for specific entities
*/
import { NextRequest, NextResponse } from 'next/server';
import { AutotaskClient } from '@/lib/services/autotask-client';
import { createSyncService } from '@/lib/services/sync-service';
import { EntityType, SyncType } from '@/lib/types/sync';
import { isValidEntityType } from '@/lib/utils/sync-helpers';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { entities, syncType = 'entity-specific', triggeredBy = 'api', yearsBack } = body;
// Validate entities
if (!entities || !Array.isArray(entities) || entities.length === 0) {
return NextResponse.json(
{ error: 'entities array is required' },
{ status: 400 }
);
}
// Validate each entity type
const validEntities: EntityType[] = [];
for (const entity of entities) {
if (isValidEntityType(entity)) {
validEntities.push(entity as EntityType);
} else {
return NextResponse.json(
{ error: `Invalid entity type: ${entity}` },
{ status: 400 }
);
}
}
// Initialize Autotask client
const autotaskClient = new AutotaskClient({
apiUrl: process.env.AUTOTASK_API_URL || '',
username: process.env.AUTOTASK_USERNAME || '',
password: process.env.AUTOTASK_SECRET || '',
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
});
// Create sync service
const syncService = createSyncService(autotaskClient);
// Check if sync is already in progress
if (syncService.isSyncInProgress()) {
return NextResponse.json(
{ error: 'A sync operation is already in progress' },
{ status: 409 }
);
}
// Start entity sync (non-blocking)
syncService.syncEntities(validEntities, syncType as SyncType, triggeredBy, yearsBack).catch((error) => {
console.error('Entity sync failed:', error);
});
return NextResponse.json({
message: `Sync started for ${validEntities.length} entities`,
syncId: syncService.getCurrentSyncId(),
entities: validEntities,
});
} catch (error) {
console.error('Failed to start entity sync:', error);
return NextResponse.json(
{ error: 'Failed to start entity sync' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,52 @@
/**
* Full Sync API Endpoint
* POST /api/sync/full - Trigger a full sync of all entities
*/
import { NextRequest, NextResponse } from 'next/server';
import { AutotaskClient } from '@/lib/services/autotask-client';
import { createSyncService } from '@/lib/services/sync-service';
export async function POST(request: NextRequest) {
try {
// Get triggered by from request body
const body = await request.json().catch(() => ({}));
const triggeredBy = body.triggeredBy || 'api';
const yearsBack = body.yearsBack;
// Initialize Autotask client
const autotaskClient = new AutotaskClient({
apiUrl: process.env.AUTOTASK_API_URL || '',
username: process.env.AUTOTASK_USERNAME || '',
password: process.env.AUTOTASK_SECRET || '',
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
});
// Create sync service
const syncService = createSyncService(autotaskClient);
// Check if sync is already in progress
if (syncService.isSyncInProgress()) {
return NextResponse.json(
{ error: 'A sync operation is already in progress' },
{ status: 409 }
);
}
// Start full sync (non-blocking)
syncService.fullSync(triggeredBy, yearsBack).catch((error) => {
console.error('Full sync failed:', error);
});
return NextResponse.json({
message: 'Full sync started',
syncId: syncService.getCurrentSyncId(),
});
} catch (error) {
console.error('Failed to start full sync:', error);
return NextResponse.json(
{ error: 'Failed to start full sync' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,45 @@
/**
* Sync History API Endpoint
* GET /api/sync/history - Get sync history records
*/
import { NextRequest, NextResponse } from 'next/server';
import { AutotaskClient } from '@/lib/services/autotask-client';
import { createSyncService } from '@/lib/services/sync-service';
import { EntityType } from '@/lib/types/sync';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '50');
const entityType = searchParams.get('entityType') as EntityType | null;
// Initialize Autotask client (needed for service instantiation)
const autotaskClient = new AutotaskClient({
apiUrl: process.env.AUTOTASK_API_URL || '',
username: process.env.AUTOTASK_USERNAME || '',
password: process.env.AUTOTASK_SECRET || '',
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
});
// Create sync service
const syncService = createSyncService(autotaskClient);
// Get sync history
const history = await syncService.getSyncHistory(
limit,
entityType || undefined
);
return NextResponse.json({
history,
count: history.length,
});
} catch (error) {
console.error('Failed to fetch sync history:', error);
return NextResponse.json(
{ error: 'Failed to fetch sync history' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,52 @@
/**
* Incremental Sync API Endpoint
* POST /api/sync/incremental - Trigger an incremental sync of all entities
*/
import { NextRequest, NextResponse } from 'next/server';
import { AutotaskClient } from '@/lib/services/autotask-client';
import { createSyncService } from '@/lib/services/sync-service';
export async function POST(request: NextRequest) {
try {
// Get triggered by from request body
const body = await request.json().catch(() => ({}));
const triggeredBy = body.triggeredBy || 'api';
const yearsBack = body.yearsBack;
// Initialize Autotask client
const autotaskClient = new AutotaskClient({
apiUrl: process.env.AUTOTASK_API_URL || '',
username: process.env.AUTOTASK_USERNAME || '',
password: process.env.AUTOTASK_SECRET || '',
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
});
// Create sync service
const syncService = createSyncService(autotaskClient);
// Check if sync is already in progress
if (syncService.isSyncInProgress()) {
return NextResponse.json(
{ error: 'A sync operation is already in progress' },
{ status: 409 }
);
}
// Start incremental sync (non-blocking)
syncService.incrementalSync(triggeredBy, yearsBack).catch((error) => {
console.error('Incremental sync failed:', error);
});
return NextResponse.json({
message: 'Incremental sync started',
syncId: syncService.getCurrentSyncId(),
});
} catch (error) {
console.error('Failed to start incremental sync:', error);
return NextResponse.json(
{ error: 'Failed to start incremental sync' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,42 @@
/**
* Last Sync Info API Endpoint
* GET /api/sync/last-sync - Get last sync information for all entities
*/
import { NextResponse } from 'next/server';
import { AutotaskClient } from '@/lib/services/autotask-client';
import { createSyncService } from '@/lib/services/sync-service';
export async function GET() {
try {
// Initialize Autotask client
const autotaskClient = new AutotaskClient({
apiUrl: process.env.AUTOTASK_API_URL || '',
username: process.env.AUTOTASK_USERNAME || '',
password: process.env.AUTOTASK_SECRET || '',
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
});
// Create sync service
const syncService = createSyncService(autotaskClient);
// Get last sync info
const lastSyncMap = await syncService.getLastSyncInfo();
// Convert Map to object for JSON serialization
const lastSyncInfo: Record<string, any> = {};
lastSyncMap.forEach((value, key) => {
lastSyncInfo[key] = value;
});
return NextResponse.json({
lastSync: lastSyncInfo,
});
} catch (error) {
console.error('Failed to fetch last sync info:', error);
return NextResponse.json(
{ error: 'Failed to fetch last sync info' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { syncProgressTracker } from '@/lib/services/sync-progress-tracker';
/**
* GET /api/sync/progress
* Get sync progress for a specific sync or entity type
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const syncId = searchParams.get('syncId');
const entityType = searchParams.get('entityType');
if (syncId) {
// Get specific sync progress
const progress = syncProgressTracker.getProgress(syncId);
if (!progress) {
return NextResponse.json(
{ error: 'Sync not found' },
{ status: 404 }
);
}
return NextResponse.json({ progress });
}
if (entityType) {
// Get latest sync for entity type
const progress = syncProgressTracker.getLatestSync(entityType);
if (!progress) {
return NextResponse.json(
{ error: 'No sync found for entity type' },
{ status: 404 }
);
}
return NextResponse.json({ progress });
}
// Get all active syncs
const activeSyncs = syncProgressTracker.getActiveSyncs();
return NextResponse.json({ activeSyncs });
} catch (error) {
console.error('Error fetching sync progress:', error);
return NextResponse.json(
{ error: 'Failed to fetch sync progress' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,38 @@
/**
* Sync Status API Endpoint
* GET /api/sync/status - Check if a sync is currently in progress
*/
import { NextResponse } from 'next/server';
import { AutotaskClient } from '@/lib/services/autotask-client';
import { createSyncService } from '@/lib/services/sync-service';
export async function GET() {
try {
// Initialize Autotask client (needed to create sync service)
const autotaskClient = new AutotaskClient({
apiUrl: process.env.AUTOTASK_API_URL || '',
username: process.env.AUTOTASK_USERNAME || '',
password: process.env.AUTOTASK_SECRET || '',
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
});
// Create sync service
const syncService = createSyncService(autotaskClient);
// Check if sync is in progress
const inProgress = syncService.isSyncInProgress();
const currentSyncId = syncService.getCurrentSyncId();
return NextResponse.json({
inProgress,
syncId: currentSyncId,
});
} catch (error) {
console.error('Failed to check sync status:', error);
return NextResponse.json(
{ error: 'Failed to check sync status' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,54 @@
/**
* Chunked Ticket Sync API Endpoint
* POST /api/sync/tickets-chunked - Trigger chunked ticket sync with progress updates
*/
import { NextRequest, NextResponse } from 'next/server';
import { AutotaskClient } from '@/lib/services/autotask-client';
import { createEntitySyncService } from '@/lib/services/entity-sync';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { yearsBack = 2, triggeredBy = 'api' } = body;
// Validate yearsBack
if (typeof yearsBack !== 'number' || yearsBack <= 0) {
return NextResponse.json(
{ error: 'yearsBack must be a positive number' },
{ status: 400 }
);
}
// Initialize Autotask client
const autotaskClient = new AutotaskClient({
apiUrl: process.env.AUTOTASK_API_URL || '',
username: process.env.AUTOTASK_USERNAME || '',
password: process.env.AUTOTASK_SECRET || '',
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
});
// Create entity sync service
const entitySyncService = createEntitySyncService(autotaskClient);
// Start chunked ticket sync (non-blocking)
// Progress updates will be logged to console
entitySyncService.syncTicketsChunked(yearsBack, (chunk) => {
console.log(`[Chunked Sync Progress] ${chunk.description}: ${chunk.index}/${chunk.total} (${chunk.recordsProcessed} records)`);
}).catch((error) => {
console.error('Chunked ticket sync failed:', error);
});
return NextResponse.json({
message: `Chunked ticket sync started for last ${yearsBack} years`,
triggeredBy,
yearsBack,
});
} catch (error) {
console.error('Failed to start chunked ticket sync:', error);
return NextResponse.json(
{ error: 'Failed to start chunked ticket sync' },
{ status: 500 }
);
}
}

479
app/auvik-mappings/page.tsx Normal file
View file

@ -0,0 +1,479 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import {
Network,
Building2,
CheckCircle,
XCircle,
AlertCircle,
Save,
Trash2,
Search,
RefreshCw
} from 'lucide-react';
import { AuvikTenantMapping } from '@/lib/types/auvik';
import { Company } from '@/lib/types/autotask';
// Simple toast implementation
const useToast = () => {
return {
toast: ({ title, description, variant }: { title: string; description: string; variant?: string }) => {
// For now, use console and alert - can be enhanced with a proper toast library later
if (variant === 'destructive') {
console.error(`${title}: ${description}`);
alert(`Error: ${description}`);
} else {
console.log(`${title}: ${description}`);
}
}
};
};
interface TenantRow extends Partial<AuvikTenantMapping> {
auvikTenantId: string;
auvikTenantName: string;
isMapped: boolean;
deviceCount?: number;
}
interface CompanyWithCounts extends Company {
nmsDeviceCount?: number;
}
export default function AuvikMappingsPage() {
const [tenants, setTenants] = useState<TenantRow[]>([]);
const [companies, setCompanies] = useState<Company[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState<'all' | 'mapped' | 'unmapped'>('all');
const { toast } = useToast();
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
setLoading(true);
try {
// Fetch tenant mappings (including unmapped)
const mappingsRes = await fetch('/api/auvik/tenant-mappings?includeUnmapped=true');
const mappingsData = await mappingsRes.json();
// Fetch all companies
const companiesRes = await fetch('/api/companies');
const companiesData = await companiesRes.json();
// Fetch device counts for each tenant
const auvikDevicesRes = await fetch('/api/auvik/devices');
let auvikDevices: any[] = [];
if (auvikDevicesRes.ok) {
const auvikData = await auvikDevicesRes.json();
auvikDevices = auvikData.devices || [];
}
// Count devices per tenant
const deviceCountsByTenant: Record<string, number> = {};
auvikDevices.forEach((device: any) => {
const tenantId = device.tenantId;
if (tenantId) {
deviceCountsByTenant[tenantId] = (deviceCountsByTenant[tenantId] || 0) + 1;
}
});
const tenantRows: TenantRow[] = mappingsData.mappings.map((m: any) => ({
...m,
isMapped: m.autotaskCompanyId > 0,
deviceCount: deviceCountsByTenant[m.auvikTenantId] || 0,
}));
setTenants(tenantRows);
setCompanies(companiesData.companies || []);
} catch (error) {
console.error('Error fetching data:', error);
toast({
title: 'Error',
description: 'Failed to load tenant mappings',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
const handleSaveMapping = async (tenantId: string, tenantName: string, companyId: number) => {
setSaving(tenantId);
try {
const company = companies.find((c) => c.id === companyId);
if (!company) {
throw new Error('Company not found');
}
const response = await fetch('/api/auvik/tenant-mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
auvikTenantId: tenantId,
auvikTenantName: tenantName,
autotaskCompanyId: companyId,
autotaskCompanyName: company.companyName,
}),
});
if (!response.ok) {
throw new Error('Failed to save mapping');
}
toast({
title: 'Success',
description: `Mapped ${tenantName} to ${company.companyName}`,
});
await fetchData();
} catch (error) {
console.error('Error saving mapping:', error);
toast({
title: 'Error',
description: 'Failed to save mapping',
variant: 'destructive',
});
} finally {
setSaving(null);
}
};
const handleDeleteMapping = async (mappingId: number) => {
try {
const response = await fetch(`/api/auvik/tenant-mappings?id=${mappingId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete mapping');
}
toast({
title: 'Success',
description: 'Mapping deleted successfully',
});
await fetchData();
} catch (error) {
console.error('Error deleting mapping:', error);
toast({
title: 'Error',
description: 'Failed to delete mapping',
variant: 'destructive',
});
}
};
const filteredTenants = tenants.filter((tenant) => {
const matchesSearch =
tenant.auvikTenantName.toLowerCase().includes(searchTerm.toLowerCase()) ||
tenant.autotaskCompanyName?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesFilter =
filterStatus === 'all' ||
(filterStatus === 'mapped' && tenant.isMapped) ||
(filterStatus === 'unmapped' && !tenant.isMapped);
return matchesSearch && matchesFilter;
});
const stats = {
total: tenants.length,
mapped: tenants.filter((t) => t.isMapped).length,
unmapped: tenants.filter((t) => !t.isMapped).length,
};
return (
<div className="container mx-auto py-8 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold flex items-center gap-3">
<Network className="w-8 h-8 text-blue-600" />
NMS Tenant Mappings
</h1>
<p className="text-muted-foreground mt-2">
Map NMS (Auvik) tenants to Autotask companies for device synchronization
</p>
</div>
<Button onClick={fetchData} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Tenants
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
Mapped
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{stats.mapped}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-orange-600" />
Unmapped
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-orange-600">{stats.unmapped}</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card>
<CardHeader>
<CardTitle>Tenant Mappings</CardTitle>
<CardDescription>
Select an Autotask company for each NMS tenant to enable device matching
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search tenants or companies..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<Select
value={filterStatus}
onValueChange={(value: any) => setFilterStatus(value)}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Tenants</SelectItem>
<SelectItem value="mapped">Mapped Only</SelectItem>
<SelectItem value="unmapped">Unmapped Only</SelectItem>
</SelectContent>
</Select>
</div>
{/* Table */}
{loading ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">
<div className="flex items-center gap-2">
<Network className="w-4 h-4" />
NMS Tenant
</div>
</TableHead>
<TableHead>
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4" />
Autotask Company
</div>
</TableHead>
<TableHead className="w-[100px]">Status</TableHead>
<TableHead className="w-[100px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredTenants.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
No tenants found
</TableCell>
</TableRow>
) : (
filteredTenants.map((tenant) => (
<TenantMappingRow
key={tenant.auvikTenantId}
tenant={tenant}
companies={companies}
saving={saving === tenant.auvikTenantId}
onSave={handleSaveMapping}
onDelete={handleDeleteMapping}
/>
))
)}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
interface TenantMappingRowProps {
tenant: TenantRow;
companies: Company[];
saving: boolean;
onSave: (tenantId: string, tenantName: string, companyId: number) => void;
onDelete: (mappingId: number) => void;
}
function TenantMappingRow({
tenant,
companies,
saving,
onSave,
onDelete,
}: TenantMappingRowProps) {
const [selectedCompanyId, setSelectedCompanyId] = useState<number>(
tenant.autotaskCompanyId || 0
);
const [hasChanges, setHasChanges] = useState(false);
const handleCompanyChange = (value: string) => {
const companyId = parseInt(value);
setSelectedCompanyId(companyId);
setHasChanges(companyId !== tenant.autotaskCompanyId);
};
const handleSave = () => {
if (selectedCompanyId > 0) {
onSave(tenant.auvikTenantId, tenant.auvikTenantName, selectedCompanyId);
setHasChanges(false);
}
};
return (
<TableRow>
<TableCell>
<div className="flex items-center justify-between">
<div>
<div className="font-medium">{tenant.auvikTenantName}</div>
<div className="text-xs text-muted-foreground font-mono">
{tenant.auvikTenantId}
</div>
</div>
{tenant.deviceCount !== undefined && tenant.deviceCount > 0 && (
<Badge variant="secondary" className="ml-2">
<Network className="w-3 h-3 mr-1" />
{tenant.deviceCount} {tenant.deviceCount === 1 ? 'device' : 'devices'}
</Badge>
)}
</div>
</TableCell>
<TableCell>
<Select
value={selectedCompanyId.toString()}
onValueChange={handleCompanyChange}
disabled={saving}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a company...">
{selectedCompanyId === 0
? "No mapping"
: companies.find(c => c.id === selectedCompanyId)?.companyName || "Select a company..."}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="0">No mapping</SelectItem>
{companies
.sort((a, b) => a.companyName.localeCompare(b.companyName))
.map((company) => (
<SelectItem key={company.id} value={company.id.toString()}>
{company.companyName}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
{tenant.isMapped ? (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100">
<CheckCircle className="w-3 h-3 mr-1" />
Mapped
</Badge>
) : (
<Badge variant="secondary">
<XCircle className="w-3 h-3 mr-1" />
Unmapped
</Badge>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
{hasChanges && (
<Button
size="sm"
onClick={handleSave}
disabled={saving || selectedCompanyId === 0}
>
{saving ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<>
<Save className="w-3 h-3 mr-1" />
Save
</>
)}
</Button>
)}
{tenant.isMapped && tenant.id && (
<Button
size="sm"
variant="ghost"
onClick={() => onDelete(tenant.id!)}
disabled={saving}
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
}

File diff suppressed because it is too large Load diff

374
app/dashboard/page.tsx Normal file
View file

@ -0,0 +1,374 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
Server,
Building2,
Network,
Globe,
Smartphone,
Database,
RefreshCw,
ArrowRight,
Activity,
TrendingUp,
AlertCircle,
CheckCircle,
XCircle,
Users,
HardDrive,
Wifi
} from 'lucide-react';
interface DashboardStats {
companies: {
total: number;
active: number;
};
configurationItems: {
total: number;
active: number;
};
mappings: {
auvik: {
mapped: number;
unmapped: number;
};
rmm: {
mapped: number;
unmapped: number;
};
};
}
export default function DashboardPage() {
const [stats, setStats] = useState<DashboardStats>({
companies: { total: 0, active: 0 },
configurationItems: { total: 0, active: 0 },
mappings: {
auvik: { mapped: 0, unmapped: 0 },
rmm: { mapped: 0, unmapped: 0 }
}
});
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchStats();
}, []);
const fetchStats = async () => {
try {
// Fetch companies
const companiesRes = await fetch('/api/companies');
const companiesData = await companiesRes.json();
// Fetch Auvik mappings
const auvikRes = await fetch('/api/auvik/tenant-mappings?includeUnmapped=true');
const auvikData = await auvikRes.json();
// Fetch RMM mappings
const rmmRes = await fetch('/api/rmm/site-mappings?includeUnmapped=true');
const rmmData = await rmmRes.json();
setStats({
companies: {
total: companiesData.companies?.length || 0,
active: companiesData.companies?.filter((c: any) => c.isActive).length || 0
},
configurationItems: {
total: 0, // Would need to fetch this
active: 0
},
mappings: {
auvik: {
mapped: auvikData.stats?.mapped || 0,
unmapped: auvikData.stats?.unmapped || 0
},
rmm: {
mapped: rmmData.stats?.mapped || 0,
unmapped: rmmData.stats?.unmapped || 0
}
}
});
} catch (error) {
console.error('Error fetching dashboard stats:', error);
} finally {
setLoading(false);
}
};
const quickLinks = [
{
title: 'Configuration Items',
description: 'View and manage IT assets and devices',
href: '/configuration-items',
icon: Server,
color: 'blue',
stats: `${stats.configurationItems.active} active items`
},
{
title: 'Sync Management',
description: 'Synchronize data from external systems',
href: '/admin/sync',
icon: RefreshCw,
color: 'green',
stats: 'Run data synchronization'
},
{
title: 'Data Browser',
description: 'Browse and query system data',
href: '/admin/data-browser',
icon: Database,
color: 'purple',
stats: 'Explore database tables'
}
];
const mappingCards = [
{
title: 'NMS Mapping (Auvik)',
description: 'Network Management System integration',
href: '/auvik-mappings',
icon: Network,
color: 'blue',
mapped: stats.mappings.auvik.mapped,
unmapped: stats.mappings.auvik.unmapped,
total: stats.mappings.auvik.mapped + stats.mappings.auvik.unmapped
},
{
title: 'RMM Mapping (Datto)',
description: 'Remote Monitoring & Management',
href: '/rmm-mappings',
icon: Globe,
color: 'purple',
mapped: stats.mappings.rmm.mapped,
unmapped: stats.mappings.rmm.unmapped,
total: stats.mappings.rmm.mapped + stats.mappings.rmm.unmapped
},
{
title: 'Apple RMM (Addigy)',
description: 'Apple device management',
href: '/addigy-mappings',
icon: Smartphone,
color: 'orange',
mapped: 0,
unmapped: 0,
total: 0,
comingSoon: true
}
];
const getMappingProgress = (mapped: number, total: number) => {
if (total === 0) return 0;
return (mapped / total) * 100;
};
return (
<div className="container mx-auto py-8 space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-bold">Dashboard</h1>
<p className="text-muted-foreground mt-2">
Welcome to Pulse - Your PSA Management System
</p>
</div>
<Button onClick={fetchStats} variant="outline" size="sm" disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{/* Stats Overview */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Companies</CardTitle>
<Building2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.companies.total}</div>
<p className="text-xs text-muted-foreground">
{stats.companies.active} active
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">NMS Coverage</CardTitle>
<Wifi className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.mappings.auvik.mapped + stats.mappings.auvik.unmapped > 0
? Math.round(getMappingProgress(stats.mappings.auvik.mapped, stats.mappings.auvik.mapped + stats.mappings.auvik.unmapped))
: 0}%
</div>
<p className="text-xs text-muted-foreground">
{stats.mappings.auvik.mapped} of {stats.mappings.auvik.mapped + stats.mappings.auvik.unmapped} tenants
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">RMM Coverage</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.mappings.rmm.mapped + stats.mappings.rmm.unmapped > 0
? Math.round(getMappingProgress(stats.mappings.rmm.mapped, stats.mappings.rmm.mapped + stats.mappings.rmm.unmapped))
: 0}%
</div>
<p className="text-xs text-muted-foreground">
{stats.mappings.rmm.mapped} of {stats.mappings.rmm.mapped + stats.mappings.rmm.unmapped} sites
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">System Status</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" />
Online
</div>
<p className="text-xs text-muted-foreground">
All systems operational
</p>
</CardContent>
</Card>
</div>
{/* Quick Links */}
<div>
<h2 className="text-2xl font-bold mb-4">Quick Access</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{quickLinks.map((link) => (
<Link key={link.href} href={link.href}>
<Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
<CardHeader>
<div className="flex items-center justify-between">
<link.icon className={`h-8 w-8 text-${link.color}-600`} />
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</div>
<CardTitle className="mt-4">{link.title}</CardTitle>
<CardDescription>{link.description}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{link.stats}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
{/* Mapping Status */}
<div>
<h2 className="text-2xl font-bold mb-4">Integration Mappings</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{mappingCards.map((mapping) => (
<Card key={mapping.href} className="relative">
{mapping.comingSoon && (
<Badge className="absolute top-4 right-4" variant="secondary">
Coming Soon
</Badge>
)}
<CardHeader>
<div className="flex items-center justify-between">
<mapping.icon className={`h-8 w-8 text-${mapping.color}-600`} />
{!mapping.comingSoon && mapping.unmapped > 0 && (
<Badge variant="outline" className="bg-orange-50 border-orange-200 text-orange-700">
<AlertCircle className="h-3 w-3 mr-1" />
{mapping.unmapped} unmapped
</Badge>
)}
</div>
<CardTitle className="mt-4">{mapping.title}</CardTitle>
<CardDescription>{mapping.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!mapping.comingSoon ? (
<>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Coverage</span>
<span className="font-medium">
{Math.round(getMappingProgress(mapping.mapped, mapping.total))}%
</span>
</div>
<Progress value={getMappingProgress(mapping.mapped, mapping.total)} />
</div>
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-1">
<CheckCircle className="h-3 w-3 text-green-600" />
{mapping.mapped} mapped
</span>
<span className="flex items-center gap-1">
<XCircle className="h-3 w-3 text-gray-400" />
{mapping.unmapped} unmapped
</span>
</div>
<Link href={mapping.href}>
<Button className="w-full" variant="outline" size="sm">
Manage Mappings
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
</Link>
</>
) : (
<p className="text-sm text-muted-foreground">
Integration under development
</p>
)}
</CardContent>
</Card>
))}
</div>
</div>
{/* Recent Activity - Placeholder */}
<div>
<h2 className="text-2xl font-bold mb-4">Recent Activity</h2>
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="h-2 w-2 bg-green-600 rounded-full" />
<div className="flex-1">
<p className="text-sm font-medium">Data sync completed</p>
<p className="text-xs text-muted-foreground">Companies synchronized successfully - 5 minutes ago</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="h-2 w-2 bg-blue-600 rounded-full" />
<div className="flex-1">
<p className="text-sm font-medium">New RMM site mapped</p>
<p className="text-xs text-muted-foreground">Site "Acme Corp - Dallas" mapped to Acme Corp - 2 hours ago</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="h-2 w-2 bg-purple-600 rounded-full" />
<div className="flex-1">
<p className="text-sm font-medium">Configuration items updated</p>
<p className="text-xs text-muted-foreground">247 devices synchronized from RMM - 1 day ago</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -51,18 +51,18 @@
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary: oklch(0.488 0.243 264.376); /* Blue */
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--accent: oklch(0.696 0.17 162.48); /* Teal */
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--ring: oklch(0.488 0.243 264.376);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
@ -70,7 +70,7 @@
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
@ -85,18 +85,18 @@
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--primary: oklch(0.65 0.22 264.376); /* Bright Blue for dark mode */
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--accent: oklch(0.75 0.15 162.48); /* Bright Teal for dark mode */
--accent-foreground: oklch(0.145 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--ring: oklch(0.65 0.22 264.376);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
@ -104,12 +104,12 @@
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary: oklch(0.65 0.22 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--sidebar-ring: oklch(0.65 0.22 264.376);
}
@layer base {

View file

@ -2,12 +2,14 @@ import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { AppNavigation } from "@/components/navigation/app-navigation";
import { Toaster } from "sonner";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Autotask Dashboard",
description: "Modern dashboard for Autotask PSA integration",
title: "Pulse - PSA Management System",
description: "Modern dashboard for Autotask PSA integration with RMM and NMS mapping",
};
export default function RootLayout({
@ -24,7 +26,11 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
{children}
<div className="min-h-screen bg-background">
<AppNavigation />
<main>{children}</main>
</div>
<Toaster position="top-right" richColors />
</ThemeProvider>
</body>
</html>

View file

@ -1,236 +1,6 @@
'use client';
import { useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { TicketList } from '@/components/tickets/ticket-list';
import { TaskList } from '@/components/tasks/task-list';
import { CompanySelector } from '@/components/companies/company-selector';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Ticket,
User,
ListTodo,
Building2,
RefreshCw,
Search,
Settings,
Plus,
Activity,
TrendingUp,
Server
} from 'lucide-react';
import { ThemeToggle } from '@/components/theme-toggle';
import { redirect } from 'next/navigation';
export default function Home() {
const [selectedCompany, setSelectedCompany] = useState<number | undefined>();
const [selectedResource, setSelectedResource] = useState<number | undefined>();
const [activeTab, setActiveTab] = useState('tickets');
return (
<div className="min-h-screen bg-background">
{/* Header */}
<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">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-3">
<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>
<h1 className="text-xl font-semibold tracking-tight">
Autotask Dashboard
</h1>
<p className="text-xs text-muted-foreground">PSA Management System</p>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<Button variant="ghost" size="sm" asChild>
<a href="/configuration-items">
<Server className="w-4 h-4 mr-2" />
Config Items
</a>
</Button>
<Button variant="ghost" size="sm" asChild>
<a href="/setup">
<Settings className="w-4 h-4 mr-2" />
Setup
</a>
</Button>
<Button variant="ghost" size="icon" className="relative">
<RefreshCw className="h-4 w-4" />
</Button>
<ThemeToggle />
<Button size="sm" className="bg-gradient-to-r from-blue-600 to-blue-700 text-white hover:from-blue-700 hover:to-blue-800">
<Plus className="w-4 h-4 mr-2" />
New Ticket
</Button>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="container mx-auto px-4 py-8">
{/* Filters */}
<Card className="mb-6 border-0 shadow-lg">
<CardHeader className="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 rounded-t-lg">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">Quick Filters</CardTitle>
<CardDescription>
Narrow down your view by company or resource
</CardDescription>
</div>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-white dark:bg-gray-900 shadow-sm">
<Search className="h-4 w-4 text-muted-foreground" />
</div>
</div>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<CompanySelector
value={selectedCompany}
onValueChange={setSelectedCompany}
label="Filter by Company"
/>
<div className="space-y-2">
<Label htmlFor="resource-search" className="flex items-center gap-2">
<User className="w-4 h-4" />
Filter by Resource
</Label>
<div className="flex space-x-2">
<Input
id="resource-search"
placeholder="Enter resource email..."
type="email"
className="bg-background"
/>
<Button variant="secondary" size="icon">
<Search className="w-4 h-4" />
</Button>
</div>
</div>
<div className="flex items-end">
<Button
variant="outline"
className="w-full"
onClick={() => {
setSelectedCompany(undefined);
setSelectedResource(undefined);
}}
>
<RefreshCw className="w-4 h-4 mr-2" />
Clear Filters
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Tabs for Tickets and Tasks */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList className="grid w-full grid-cols-2 max-w-md bg-muted/50">
<TabsTrigger value="tickets" className="flex items-center gap-2 data-[state=active]:bg-background data-[state=active]:shadow-sm">
<Ticket className="w-4 h-4" />
Tickets
</TabsTrigger>
<TabsTrigger value="tasks" className="flex items-center gap-2 data-[state=active]:bg-background data-[state=active]:shadow-sm">
<ListTodo className="w-4 h-4" />
Tasks
</TabsTrigger>
</TabsList>
<TabsContent value="tickets" className="space-y-4">
<TicketList
companyId={selectedCompany}
resourceId={selectedResource}
/>
</TabsContent>
<TabsContent value="tasks" className="space-y-4">
<TaskList
resourceId={selectedResource}
/>
</TabsContent>
</Tabs>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-8">
<Card className="border-0 shadow-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-950 dark:to-blue-900">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Open Tickets
</CardTitle>
<div className="h-8 w-8 rounded-full bg-blue-600/10 flex items-center justify-center">
<Ticket className="h-4 w-4 text-blue-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">-</div>
<div className="flex items-center text-xs text-muted-foreground mt-1">
<TrendingUp className="h-3 w-3 mr-1 text-green-600" />
<span>12% from last month</span>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-lg bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-950 dark:to-purple-900">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Active Tasks
</CardTitle>
<div className="h-8 w-8 rounded-full bg-purple-600/10 flex items-center justify-center">
<ListTodo className="h-4 w-4 text-purple-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">-</div>
<div className="flex items-center text-xs text-muted-foreground mt-1">
<Activity className="h-3 w-3 mr-1 text-orange-600" />
<span>In progress</span>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-lg bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950 dark:to-green-900">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Companies
</CardTitle>
<div className="h-8 w-8 rounded-full bg-green-600/10 flex items-center justify-center">
<Building2 className="h-4 w-4 text-green-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">-</div>
<div className="flex items-center text-xs text-muted-foreground mt-1">
<User className="h-3 w-3 mr-1" />
<span>Active clients</span>
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-lg bg-gradient-to-br from-orange-50 to-orange-100 dark:from-orange-950 dark:to-orange-900">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Response Time
</CardTitle>
<div className="h-8 w-8 rounded-full bg-orange-600/10 flex items-center justify-center">
<Activity className="h-4 w-4 text-orange-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">2.4h</div>
<div className="flex items-center text-xs text-muted-foreground mt-1">
<TrendingUp className="h-3 w-3 mr-1 text-green-600" />
<span>15% faster</span>
</div>
</CardContent>
</Card>
</div>
</main>
</div>
);
redirect('/dashboard');
return null; // This won't be reached but TypeScript needs it
}

531
app/rmm-mappings/page.tsx Normal file
View file

@ -0,0 +1,531 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Checkbox } from '@/components/ui/checkbox';
import {
Server,
Building2,
CheckCircle,
XCircle,
AlertCircle,
Save,
Trash2,
Search,
RefreshCw,
MapPin,
Globe
} from 'lucide-react';
import { Company } from '@/lib/types/autotask';
// Simple toast implementation
const useToast = () => {
return {
toast: ({ title, description, variant }: { title: string; description: string; variant?: string }) => {
if (variant === 'destructive') {
console.error(`${title}: ${description}`);
alert(`Error: ${description}`);
} else {
console.log(`${title}: ${description}`);
}
}
};
};
interface SiteRow {
id?: number | null;
company_id?: number | null;
company_name?: string | null;
rmm_site_uid: string;
rmm_site_name: string;
is_primary: boolean;
device_count: number;
notes?: string | null;
last_sync_at?: string | null;
created_at?: string | null;
updated_at?: string | null;
created_by?: string | null;
isMapped: boolean;
}
export default function RMMSiteMappingsPage() {
const [sites, setSites] = useState<SiteRow[]>([]);
const [companies, setCompanies] = useState<Company[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState<'all' | 'mapped' | 'unmapped'>('all');
const [filterCompany, setFilterCompany] = useState<string>('all');
const { toast } = useToast();
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
setLoading(true);
try {
// Fetch site mappings (including unmapped)
const mappingsRes = await fetch('/api/rmm/site-mappings?includeUnmapped=true');
const mappingsData = await mappingsRes.json();
// Fetch all companies
const companiesRes = await fetch('/api/companies');
const companiesData = await companiesRes.json();
const siteRows: SiteRow[] = mappingsData.mappings.map((m: any) => ({
...m,
isMapped: m.company_id !== null && m.company_id > 0,
}));
setSites(siteRows);
setCompanies(companiesData.companies || []);
} catch (error) {
console.error('Error fetching data:', error);
toast({
title: 'Error',
description: 'Failed to load RMM site mappings',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
const handleSaveMapping = async (
siteUid: string,
siteName: string,
companyId: number,
isPrimary: boolean = false
) => {
setSaving(siteUid);
try {
const company = companies.find((c) => c.id === companyId);
if (!company) {
throw new Error('Company not found');
}
const response = await fetch('/api/rmm/site-mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rmmSiteUid: siteUid,
rmmSiteName: siteName,
companyId: companyId,
companyName: company.companyName,
isPrimary: isPrimary,
}),
});
if (!response.ok) {
throw new Error('Failed to save mapping');
}
toast({
title: 'Success',
description: `Mapped ${siteName} to ${company.companyName}`,
});
await fetchData();
} catch (error) {
console.error('Error saving mapping:', error);
toast({
title: 'Error',
description: 'Failed to save mapping',
variant: 'destructive',
});
} finally {
setSaving(null);
}
};
const handleDeleteMapping = async (mappingId: number) => {
try {
const response = await fetch(`/api/rmm/site-mappings?id=${mappingId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete mapping');
}
toast({
title: 'Success',
description: 'Mapping deleted successfully',
});
await fetchData();
} catch (error) {
console.error('Error deleting mapping:', error);
toast({
title: 'Error',
description: 'Failed to delete mapping',
variant: 'destructive',
});
}
};
const filteredSites = sites.filter((site) => {
const matchesSearch =
site.rmm_site_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
site.company_name?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesFilter =
filterStatus === 'all' ||
(filterStatus === 'mapped' && site.isMapped) ||
(filterStatus === 'unmapped' && !site.isMapped);
const matchesCompany =
filterCompany === 'all' ||
(filterCompany === 'unmapped' && !site.isMapped) ||
site.company_id?.toString() === filterCompany;
return matchesSearch && matchesFilter && matchesCompany;
});
const stats = {
total: sites.length,
mapped: sites.filter((s) => s.isMapped).length,
unmapped: sites.filter((s) => !s.isMapped).length,
companies: new Set(sites.filter(s => s.company_id).map(s => s.company_id)).size,
};
// Get unique companies with mappings for filter dropdown
const mappedCompanies = companies.filter(c =>
sites.some(s => s.company_id === c.id)
);
return (
<div className="container mx-auto py-8 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold flex items-center gap-3">
<Server className="w-8 h-8 text-purple-600" />
RMM Site Mappings
</h1>
<p className="text-muted-foreground mt-2">
Map RMM (Datto) sites to Autotask companies for complete device coverage
</p>
</div>
<Button onClick={fetchData} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Sites
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
Mapped
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{stats.mapped}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-orange-600" />
Unmapped
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-orange-600">{stats.unmapped}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Building2 className="w-4 h-4 text-blue-600" />
Companies
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">{stats.companies}</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card>
<CardHeader>
<CardTitle>Site Mappings</CardTitle>
<CardDescription>
Map RMM sites to Autotask companies. Companies can have multiple sites for different locations.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search sites or companies..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<Select
value={filterStatus}
onValueChange={(value: any) => setFilterStatus(value)}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Sites</SelectItem>
<SelectItem value="mapped">Mapped Only</SelectItem>
<SelectItem value="unmapped">Unmapped Only</SelectItem>
</SelectContent>
</Select>
<Select
value={filterCompany}
onValueChange={setFilterCompany}
>
<SelectTrigger className="w-[250px]">
<SelectValue placeholder="Filter by company..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Companies</SelectItem>
<SelectItem value="unmapped">Unmapped Sites</SelectItem>
{mappedCompanies.map((company) => (
<SelectItem key={company.id} value={company.id.toString()}>
{company.companyName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Table */}
{loading ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[300px]">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4" />
RMM Site
</div>
</TableHead>
<TableHead>
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4" />
Autotask Company
</div>
</TableHead>
<TableHead className="w-[100px]">Primary</TableHead>
<TableHead className="w-[100px]">Status</TableHead>
<TableHead className="w-[150px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredSites.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
No sites found
</TableCell>
</TableRow>
) : (
filteredSites.map((site) => (
<SiteMappingRow
key={site.rmm_site_uid}
site={site}
companies={companies}
saving={saving === site.rmm_site_uid}
onSave={handleSaveMapping}
onDelete={handleDeleteMapping}
/>
))
)}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
interface SiteMappingRowProps {
site: SiteRow;
companies: Company[];
saving: boolean;
onSave: (siteUid: string, siteName: string, companyId: number, isPrimary: boolean) => void;
onDelete: (mappingId: number) => void;
}
function SiteMappingRow({
site,
companies,
saving,
onSave,
onDelete,
}: SiteMappingRowProps) {
const [selectedCompanyId, setSelectedCompanyId] = useState<number>(
site.company_id || 0
);
const [isPrimary, setIsPrimary] = useState(site.is_primary);
const [hasChanges, setHasChanges] = useState(false);
const handleCompanyChange = (value: string) => {
const companyId = parseInt(value);
setSelectedCompanyId(companyId);
setHasChanges(companyId !== site.company_id || isPrimary !== site.is_primary);
};
const handlePrimaryChange = (checked: boolean) => {
setIsPrimary(checked);
setHasChanges(selectedCompanyId !== site.company_id || checked !== site.is_primary);
};
const handleSave = () => {
if (selectedCompanyId > 0) {
onSave(site.rmm_site_uid, site.rmm_site_name, selectedCompanyId, isPrimary);
setHasChanges(false);
}
};
return (
<TableRow>
<TableCell>
<div className="flex items-center justify-between">
<div>
<div className="font-medium flex items-center gap-2">
<MapPin className="w-4 h-4 text-muted-foreground" />
{site.rmm_site_name}
</div>
<div className="text-xs text-muted-foreground font-mono">
{site.rmm_site_uid}
</div>
</div>
{site.device_count !== undefined && site.device_count > 0 && (
<Badge variant="secondary" className="ml-2">
<Server className="w-3 h-3 mr-1" />
{site.device_count} {site.device_count === 1 ? 'device' : 'devices'}
</Badge>
)}
</div>
</TableCell>
<TableCell>
<Select
value={selectedCompanyId.toString()}
onValueChange={handleCompanyChange}
disabled={saving}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a company...">
{selectedCompanyId === 0
? "No mapping"
: companies.find(c => c.id === selectedCompanyId)?.companyName || "Select a company..."}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="0">No mapping</SelectItem>
{companies
.sort((a, b) => a.companyName.localeCompare(b.companyName))
.map((company) => (
<SelectItem key={company.id} value={company.id.toString()}>
{company.companyName}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Checkbox
checked={isPrimary}
onCheckedChange={handlePrimaryChange}
disabled={saving || selectedCompanyId === 0}
/>
</TableCell>
<TableCell>
{site.isMapped ? (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100">
<CheckCircle className="w-3 h-3 mr-1" />
Mapped
</Badge>
) : (
<Badge variant="secondary">
<XCircle className="w-3 h-3 mr-1" />
Unmapped
</Badge>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
{hasChanges && (
<Button
size="sm"
onClick={handleSave}
disabled={saving || selectedCompanyId === 0}
>
{saving ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<>
<Save className="w-3 h-3 mr-1" />
Save
</>
)}
</Button>
)}
{site.isMapped && site.id && (
<Button
size="sm"
variant="ghost"
onClick={() => onDelete(site.id!)}
disabled={saving}
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
}