- 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
633 lines
20 KiB
TypeScript
633 lines
20 KiB
TypeScript
'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>
|
|
);
|
|
}
|