Add comprehensive admin features and multi-system integration
- Add admin dashboard with sync controls and data browser - Implement RMM, Auvik, and Addigy organization mappings - Add chunked ticket sync with progress tracking - Implement entity sync service with rate limiting - Add analytics engine and performance optimizer - Create data browser for all PSA entities - Add navigation components and UI improvements - Implement background processing and sync services - Add comprehensive documentation and migration scripts - Update configuration items with multi-system support - Enhance contact management and purchase history - Add issue type assignment and LLM analyzer - Improve error handling and logging utilities
This commit is contained in:
parent
e8462ef301
commit
6eee14f8af
171 changed files with 32671 additions and 621 deletions
633
app/addigy-mappings/page.tsx
Normal file
633
app/addigy-mappings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
544
app/admin/analytics/time-entries/page.tsx
Normal file
544
app/admin/analytics/time-entries/page.tsx
Normal 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(' ');
|
||||
}
|
||||
175
app/admin/data-browser/companies/page.tsx
Normal file
175
app/admin/data-browser/companies/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
app/admin/data-browser/configuration-items/page.tsx
Normal file
185
app/admin/data-browser/configuration-items/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
app/admin/data-browser/contacts/page.tsx
Normal file
182
app/admin/data-browser/contacts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
app/admin/data-browser/contracts/page.tsx
Normal file
187
app/admin/data-browser/contracts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
app/admin/data-browser/issue-types/page.tsx
Normal file
151
app/admin/data-browser/issue-types/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
app/admin/data-browser/page.tsx
Normal file
61
app/admin/data-browser/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
app/admin/data-browser/projects/page.tsx
Normal file
184
app/admin/data-browser/projects/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
app/admin/data-browser/resources/page.tsx
Normal file
179
app/admin/data-browser/resources/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
app/admin/data-browser/sub-issue-types/page.tsx
Normal file
188
app/admin/data-browser/sub-issue-types/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
app/admin/data-browser/tasks/page.tsx
Normal file
197
app/admin/data-browser/tasks/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
app/admin/data-browser/tickets/page.tsx
Normal file
187
app/admin/data-browser/tickets/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
529
app/admin/data-browser/time-entries/page.tsx
Normal file
529
app/admin/data-browser/time-entries/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
393
app/admin/data-browser/time-entries/page.tsx.backup
Normal file
393
app/admin/data-browser/time-entries/page.tsx.backup
Normal 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
92
app/admin/sync/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
app/api/addigy/org-mappings/route.ts
Normal file
171
app/api/addigy/org-mappings/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
149
app/api/auvik/device-config/route.ts
Normal file
149
app/api/auvik/device-config/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
60
app/api/auvik/devices/route.ts
Normal file
60
app/api/auvik/devices/route.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
159
app/api/auvik/tenant-mappings/route.ts
Normal file
159
app/api/auvik/tenant-mappings/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
46
app/api/configuration-items/[id]/lightweight/route.ts
Normal file
46
app/api/configuration-items/[id]/lightweight/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
77
app/api/contacts/batch/route.ts
Normal file
77
app/api/contacts/batch/route.ts
Normal 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: {} });
|
||||
}
|
||||
}
|
||||
64
app/api/data/billing-items/route.ts
Normal file
64
app/api/data/billing-items/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
73
app/api/data/companies/route.ts
Normal file
73
app/api/data/companies/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
56
app/api/data/configuration-items/route.ts
Normal file
56
app/api/data/configuration-items/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
107
app/api/data/contacts/route.ts
Normal file
107
app/api/data/contacts/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
56
app/api/data/contracts/route.ts
Normal file
56
app/api/data/contracts/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
90
app/api/data/issue-types/route.ts
Normal file
90
app/api/data/issue-types/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
56
app/api/data/projects/route.ts
Normal file
56
app/api/data/projects/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
110
app/api/data/resources/route.ts
Normal file
110
app/api/data/resources/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
82
app/api/data/sub-issue-types-with-parent/route.ts
Normal file
82
app/api/data/sub-issue-types-with-parent/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
103
app/api/data/sub-issue-types/route.ts
Normal file
103
app/api/data/sub-issue-types/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
64
app/api/data/tasks/route.ts
Normal file
64
app/api/data/tasks/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
128
app/api/data/tickets-with-issue-types/route.ts
Normal file
128
app/api/data/tickets-with-issue-types/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
95
app/api/data/tickets/route.ts
Normal file
95
app/api/data/tickets/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
313
app/api/data/time-entries/route.ts
Normal file
313
app/api/data/time-entries/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
323
app/api/rmm/site-mappings/route.ts
Normal file
323
app/api/rmm/site-mappings/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
74
app/api/sync/entity/route.ts
Normal file
74
app/api/sync/entity/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
52
app/api/sync/full/route.ts
Normal file
52
app/api/sync/full/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
45
app/api/sync/history/route.ts
Normal file
45
app/api/sync/history/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
52
app/api/sync/incremental/route.ts
Normal file
52
app/api/sync/incremental/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
42
app/api/sync/last-sync/route.ts
Normal file
42
app/api/sync/last-sync/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
49
app/api/sync/progress/route.ts
Normal file
49
app/api/sync/progress/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
38
app/api/sync/status/route.ts
Normal file
38
app/api/sync/status/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
54
app/api/sync/tickets-chunked/route.ts
Normal file
54
app/api/sync/tickets-chunked/route.ts
Normal 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
479
app/auvik-mappings/page.tsx
Normal 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
374
app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
236
app/page.tsx
236
app/page.tsx
|
|
@ -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
531
app/rmm-mappings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue