The dropdown wasn't showing mapped company names due to type mismatch between string and number IDs. Changed === to == for comparison.
479 lines
15 KiB
TypeScript
479 lines
15 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 {
|
|
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>
|
|
);
|
|
}
|