wulf-pulse/app/addigy-mappings/page.tsx
root 6eee14f8af 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
2025-11-19 14:18:16 -05:00

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