The page was rendering its own sticky <header> on top of the global AppNavigation, which obscured the top nav and duplicated the ThemeToggle and a redundant "Back to Dashboard" link. Replace with PageHeader (title, description, breadcrumbs, actions slot holding Refresh / Bulk-admin / Export) and switch the main container to the conventional px-6 py-6. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1312 lines
59 KiB
TypeScript
1312 lines
59 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, Suspense } from 'react';
|
|
import { useSearchParams } from 'next/navigation';
|
|
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 { Skeleton } from '@/components/ui/skeleton';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { CompanySelectorEnhanced } from '@/components/companies/company-selector-enhanced';
|
|
import { PageHeader } from '@/components/navigation/page-header';
|
|
import { ConfigItemModal } from '@/components/configuration-items/config-item-modal';
|
|
import { ContactCell } from '@/components/configuration-items/contact-cell';
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Calendar as CalendarComponent } from '@/components/ui/calendar';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
import {
|
|
Server,
|
|
Monitor,
|
|
HardDrive,
|
|
Network,
|
|
AlertCircle,
|
|
CheckCircle,
|
|
XCircle,
|
|
RefreshCw,
|
|
Search,
|
|
Settings,
|
|
Activity,
|
|
Cpu,
|
|
MemoryStick,
|
|
Wifi,
|
|
Shield,
|
|
Calendar as CalendarIcon,
|
|
Hash,
|
|
Building2,
|
|
Filter,
|
|
ArrowUpDown,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
Users,
|
|
Download,
|
|
ChevronRight,
|
|
Info,
|
|
Power
|
|
} from 'lucide-react';
|
|
import { format } from 'date-fns';
|
|
import { ConfigurationItem } from '@/lib/types/autotask';
|
|
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
|
|
import { AuvikDevice } from '@/lib/types/auvik';
|
|
import { AddigyDevice } from '@/lib/types/addigy';
|
|
import { useApi } from '@/lib/hooks/use-api';
|
|
import { RmmDispatchDialog } from '@/components/rmm/rmm-dispatch-dialog';
|
|
|
|
interface DeviceComparison {
|
|
autotaskDevice?: ConfigurationItem;
|
|
rmmDevice?: DattoRMMDevice;
|
|
auvikDevice?: AuvikDevice;
|
|
addigyDevice?: AddigyDevice;
|
|
status: 'matched' | 'autotask-only' | 'rmm-only';
|
|
matchedBy?: string;
|
|
}
|
|
|
|
function ConfigurationItemsContent() {
|
|
const searchParams = useSearchParams();
|
|
const [selectedCompany, setSelectedCompany] = useState<number | undefined>();
|
|
const [selectedCompanyName, setSelectedCompanyName] = useState<string>('');
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [filterType, setFilterType] = useState<string>('all');
|
|
const [configItems, setConfigItems] = useState<ConfigurationItem[]>([]);
|
|
const [comparison, setComparison] = useState<DeviceComparison[]>([]);
|
|
const [stats, setStats] = useState<any>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [viewMode, setViewMode] = useState<'autotask' | 'comparison'>('comparison');
|
|
const [selectedItemId, setSelectedItemId] = useState<string | number | null>(null);
|
|
const [selectedItem, setSelectedItem] = useState<DeviceComparison | null>(null);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [adminExpanded, setAdminExpanded] = useState(false);
|
|
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
|
|
const [bulkProcessing, setBulkProcessing] = useState(false);
|
|
const [lastSeenAfterDate, setLastSeenAfterDate] = useState<Date | undefined>();
|
|
const [activeFilter, setActiveFilter] = useState<'active' | 'inactive' | 'all'>('active');
|
|
const [displayLimit, setDisplayLimit] = useState(50); // Start with 50 items
|
|
const [contacts, setContacts] = useState<Record<number, any>>({});
|
|
const [filtersExpanded, setFiltersExpanded] = useState(false);
|
|
const [sortField, setSortField] = useState<'name' | 'ip' | 'contact' | null>(null);
|
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
|
const [groupByContact, setGroupByContact] = useState(false);
|
|
const [expandedContacts, setExpandedContacts] = useState<Set<number>>(new Set());
|
|
const [exportModalOpen, setExportModalOpen] = useState(false);
|
|
const [selectedExportFields, setSelectedExportFields] = useState<Set<string>>(new Set());
|
|
const [configItemTypePicklist, setConfigItemTypePicklist] = useState<Record<number, string>>({});
|
|
const [rmmDeviceTypePicklist, setRmmDeviceTypePicklist] = useState<Record<number, string>>({});
|
|
const [forceRefresh, setForceRefresh] = useState(0);
|
|
|
|
// Initialize company from URL params on mount
|
|
useEffect(() => {
|
|
const companyIdParam = searchParams.get('companyId');
|
|
const companyNameParam = searchParams.get('companyName');
|
|
|
|
if (companyIdParam) {
|
|
setSelectedCompany(parseInt(companyIdParam));
|
|
setSelectedCompanyName(companyNameParam || '');
|
|
}
|
|
}, [searchParams]);
|
|
|
|
// Fetch configuration item type picklists on mount
|
|
useEffect(() => {
|
|
const fetchPicklists = async () => {
|
|
try {
|
|
// Fetch main type picklist
|
|
const typeResponse = await fetch('/api/picklists?entity=ConfigurationItems&field=type');
|
|
if (typeResponse.ok) {
|
|
const typeData = await typeResponse.json();
|
|
console.log('Config Item Type Picklist Response:', typeData);
|
|
console.log('Config Item Type Picklist Values:', typeData.picklistValues);
|
|
setConfigItemTypePicklist(typeData.picklistValues || {});
|
|
} else {
|
|
console.error('Failed to fetch config item type picklist:', typeResponse.status);
|
|
}
|
|
|
|
// Fetch RMM device type picklist
|
|
const rmmTypeResponse = await fetch('/api/picklists?entity=ConfigurationItems&field=rmmDeviceAuditDeviceTypeID');
|
|
if (rmmTypeResponse.ok) {
|
|
const rmmTypeData = await rmmTypeResponse.json();
|
|
console.log('RMM Device Type Picklist Response:', rmmTypeData);
|
|
console.log('Setting rmmDeviceTypePicklist to:', rmmTypeData.picklistValues);
|
|
setRmmDeviceTypePicklist(rmmTypeData.picklistValues || {});
|
|
console.log('rmmDeviceTypePicklist state should now be set');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch picklists:', error);
|
|
}
|
|
};
|
|
fetchPicklists();
|
|
}, []);
|
|
|
|
// Fetch configuration items when company changes
|
|
useEffect(() => {
|
|
if (!selectedCompany) {
|
|
setConfigItems([]);
|
|
return;
|
|
}
|
|
|
|
const fetchConfigItems = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
// Fetch comparison data (includes both Autotask and RMM)
|
|
// Add skipCache parameter when forceRefresh is triggered
|
|
const skipCache = forceRefresh > 0 ? '&skipCache=true' : '';
|
|
const response = await fetch(
|
|
`/api/rmm-devices?companyId=${selectedCompany}&companyName=${encodeURIComponent(selectedCompanyName)}&activeFilter=${activeFilter}${skipCache}`
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch devices');
|
|
}
|
|
|
|
const data = await response.json();
|
|
console.log('Sample autotask device:', data.comparison[0]?.autotaskDevice);
|
|
console.log('configurationItemType (camelCase):', data.comparison[0]?.autotaskDevice?.configurationItemType);
|
|
console.log('type field:', data.comparison[0]?.autotaskDevice?.type);
|
|
console.log('configuration_item_type (snake_case):', data.comparison[0]?.autotaskDevice?.configuration_item_type);
|
|
console.log('rmmDeviceAuditDeviceTypeID:', data.comparison[0]?.autotaskDevice?.rmmDeviceAuditDeviceTypeID);
|
|
setComparison(data.comparison || []);
|
|
setStats(data.stats);
|
|
setContacts(data.contacts || {});
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
setComparison([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchConfigItems();
|
|
}, [selectedCompany, selectedCompanyName, activeFilter, forceRefresh]);
|
|
|
|
// Filter comparison items based on search and type
|
|
const filteredComparison = comparison.filter((item: DeviceComparison) => {
|
|
const deviceName = item.autotaskDevice?.referenceTitle || item.rmmDevice?.hostname || '';
|
|
const serialNumber = item.autotaskDevice?.serialNumber || item.rmmDevice?.serialNumber || '';
|
|
const searchLower = searchTerm.toLowerCase();
|
|
|
|
const matchesSearch = deviceName.toLowerCase().includes(searchLower) ||
|
|
serialNumber.toLowerCase().includes(searchLower);
|
|
|
|
const matchesType = filterType === 'all' ||
|
|
(filterType === 'matched' && item.status === 'matched') ||
|
|
(filterType === 'autotask-only' && item.status === 'autotask-only') ||
|
|
(filterType === 'rmm-only' && item.status === 'rmm-only');
|
|
|
|
// Filter by last seen date in RMM (after specified date)
|
|
let matchesLastSeen = true;
|
|
if (lastSeenAfterDate && item.rmmDevice?.lastSeen) {
|
|
const lastSeenDate = new Date(item.rmmDevice.lastSeen);
|
|
matchesLastSeen = lastSeenDate >= lastSeenAfterDate;
|
|
}
|
|
|
|
return matchesSearch && matchesType && matchesLastSeen;
|
|
});
|
|
|
|
// Sort filtered items
|
|
const sortedComparison = [...filteredComparison].sort((a, b) => {
|
|
if (!sortField) return 0;
|
|
|
|
let aValue = '';
|
|
let bValue = '';
|
|
|
|
if (sortField === 'name') {
|
|
aValue = (a.autotaskDevice?.referenceTitle || a.rmmDevice?.hostname || '').toLowerCase();
|
|
bValue = (b.autotaskDevice?.referenceTitle || b.rmmDevice?.hostname || '').toLowerCase();
|
|
} else if (sortField === 'ip') {
|
|
aValue = (a.autotaskDevice?.rmmDeviceAuditIPAddress || a.rmmDevice?.intIpAddress || '').toLowerCase();
|
|
bValue = (b.autotaskDevice?.rmmDeviceAuditIPAddress || b.rmmDevice?.intIpAddress || '').toLowerCase();
|
|
} else if (sortField === 'contact') {
|
|
const aContactId = a.autotaskDevice?.contactID;
|
|
const bContactId = b.autotaskDevice?.contactID;
|
|
const aContact = aContactId ? contacts[aContactId] : null;
|
|
const bContact = bContactId ? contacts[bContactId] : null;
|
|
aValue = aContact ? `${aContact.firstName || ''} ${aContact.lastName || ''}`.toLowerCase() : '';
|
|
bValue = bContact ? `${bContact.firstName || ''} ${bContact.lastName || ''}`.toLowerCase() : '';
|
|
}
|
|
|
|
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
|
|
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
|
|
return 0;
|
|
});
|
|
|
|
// Group by contact if enabled
|
|
const groupedByContact = groupByContact
|
|
? sortedComparison.reduce((acc, item) => {
|
|
const contactId = item.autotaskDevice?.contactID || 0;
|
|
if (!acc[contactId]) {
|
|
acc[contactId] = [];
|
|
}
|
|
acc[contactId].push(item);
|
|
return acc;
|
|
}, {} as Record<number, DeviceComparison[]>)
|
|
: null;
|
|
|
|
const displayComparison = sortedComparison;
|
|
|
|
const handleSort = (field: 'name' | 'ip' | 'contact') => {
|
|
if (sortField === field) {
|
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
|
} else {
|
|
setSortField(field);
|
|
setSortDirection('asc');
|
|
}
|
|
};
|
|
|
|
// Handle company selection
|
|
const handleCompanyChange = (companyId: number | undefined, companyName?: string) => {
|
|
setSelectedCompany(companyId);
|
|
setSelectedCompanyName(companyName || '');
|
|
setSelectedItems(new Set()); // Clear selections when company changes
|
|
};
|
|
|
|
const handleSelectItem = (itemId: number, checked: boolean) => {
|
|
const newSelected = new Set(selectedItems);
|
|
if (checked) {
|
|
newSelected.add(itemId);
|
|
} else {
|
|
newSelected.delete(itemId);
|
|
}
|
|
setSelectedItems(newSelected);
|
|
};
|
|
|
|
const handleSelectAll = (checked: boolean) => {
|
|
if (checked) {
|
|
const allIds = new Set(
|
|
filteredComparison
|
|
.filter(item => item.autotaskDevice?.id)
|
|
.map(item => item.autotaskDevice!.id)
|
|
);
|
|
setSelectedItems(allIds);
|
|
} else {
|
|
setSelectedItems(new Set());
|
|
}
|
|
};
|
|
|
|
const handleBulkMakeInactive = async () => {
|
|
if (selectedItems.size === 0) return;
|
|
|
|
setBulkProcessing(true);
|
|
try {
|
|
const promises = Array.from(selectedItems).map(itemId =>
|
|
fetch(`/api/configuration-items/${itemId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ isActive: false }),
|
|
})
|
|
);
|
|
|
|
await Promise.all(promises);
|
|
|
|
// Refresh the data
|
|
if (selectedCompany) {
|
|
const response = await fetch(
|
|
`/api/rmm-devices?companyId=${selectedCompany}&companyName=${encodeURIComponent(selectedCompanyName)}`
|
|
);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setComparison(data.comparison || []);
|
|
setStats(data.stats);
|
|
}
|
|
}
|
|
|
|
setSelectedItems(new Set());
|
|
setAdminExpanded(false);
|
|
} catch (err) {
|
|
console.error('Bulk operation failed:', err);
|
|
setError('Failed to make items inactive');
|
|
} finally {
|
|
setBulkProcessing(false);
|
|
}
|
|
};
|
|
|
|
const getDeviceIcon = (item: ConfigurationItem) => {
|
|
if (item.rmmDeviceAuditDeviceTypeID) {
|
|
// You can map device type IDs to specific icons
|
|
return <Monitor className="w-4 h-4" />;
|
|
}
|
|
if (item.dattoSerialNumber) {
|
|
return <HardDrive className="w-4 h-4" />;
|
|
}
|
|
return <Server className="w-4 h-4" />;
|
|
};
|
|
|
|
const getRMMStatus = (item: ConfigurationItem) => {
|
|
if (item.rmmDeviceUID) {
|
|
return (
|
|
<Badge variant="default" className="bg-green-600">
|
|
<CheckCircle className="w-3 h-3 mr-1" />
|
|
RMM Connected
|
|
</Badge>
|
|
);
|
|
}
|
|
return (
|
|
<Badge variant="secondary">
|
|
<XCircle className="w-3 h-3 mr-1" />
|
|
No RMM
|
|
</Badge>
|
|
);
|
|
};
|
|
|
|
const getDattoStatus = (item: ConfigurationItem) => {
|
|
if (item.dattoSerialNumber) {
|
|
return (
|
|
<Badge variant="default" className="bg-blue-600">
|
|
<Shield className="w-3 h-3 mr-1" />
|
|
Datto Protected
|
|
</Badge>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Define available fields for export
|
|
const exportFields = [
|
|
{ key: 'status', label: 'Match Status' },
|
|
{ key: 'deviceName', label: 'Device Name' },
|
|
{ key: 'serialNumber', label: 'Serial Number' },
|
|
{ key: 'ipAddress', label: 'IP Address' },
|
|
{ key: 'contact', label: 'Contact Name' },
|
|
{ key: 'configurationItemType', label: 'Configuration Item Type' },
|
|
{ key: 'psaId', label: 'PSA ID' },
|
|
{ key: 'rmmId', label: 'RMM ID' },
|
|
{ key: 'referenceNumber', label: 'Reference Number' },
|
|
{ key: 'location', label: 'Location' },
|
|
{ key: 'isActive', label: 'Active Status' },
|
|
{ key: 'modelNumber', label: 'Model Number' },
|
|
{ key: 'macAddress', label: 'MAC Address' },
|
|
{ key: 'installDate', label: 'Install Date' },
|
|
{ key: 'warrantyExpiration', label: 'Warranty Expiration' },
|
|
{ key: 'rmmDeviceUID', label: 'RMM Device UID' },
|
|
{ key: 'lastSeen', label: 'RMM Last Seen' },
|
|
{ key: 'operatingSystem', label: 'Operating System' },
|
|
{ key: 'matchType', label: 'Match Type' },
|
|
];
|
|
|
|
const toggleExportField = (fieldKey: string) => {
|
|
const newSelected = new Set(selectedExportFields);
|
|
if (newSelected.has(fieldKey)) {
|
|
newSelected.delete(fieldKey);
|
|
} else {
|
|
newSelected.add(fieldKey);
|
|
}
|
|
setSelectedExportFields(newSelected);
|
|
};
|
|
|
|
const toggleAllExportFields = () => {
|
|
if (selectedExportFields.size === exportFields.length) {
|
|
setSelectedExportFields(new Set());
|
|
} else {
|
|
setSelectedExportFields(new Set(exportFields.map(f => f.key)));
|
|
}
|
|
};
|
|
|
|
const handleExport = () => {
|
|
if (selectedExportFields.size === 0 || displayComparison.length === 0) return;
|
|
|
|
const headers = exportFields.filter(f => selectedExportFields.has(f.key)).map(f => f.label);
|
|
const rows = displayComparison.map(item => {
|
|
return exportFields.filter(f => selectedExportFields.has(f.key)).map(f => {
|
|
let value = '';
|
|
switch (f.key) {
|
|
case 'status':
|
|
value = item.status === 'matched' ? 'Matched' : item.status === 'autotask-only' ? 'Autotask Only' : 'RMM Only';
|
|
break;
|
|
case 'deviceName':
|
|
value = item.autotaskDevice?.referenceTitle || item.rmmDevice?.hostname || '';
|
|
break;
|
|
case 'serialNumber':
|
|
value = item.autotaskDevice?.serialNumber || item.rmmDevice?.serialNumber || '';
|
|
break;
|
|
case 'ipAddress':
|
|
value = item.autotaskDevice?.rmmDeviceAuditIPAddress || item.rmmDevice?.intIpAddress || '';
|
|
break;
|
|
case 'contact':
|
|
const contactId = item.autotaskDevice?.contactID;
|
|
const contact = contactId ? contacts[contactId] : null;
|
|
value = contact ? `${contact.firstName || ''} ${contact.lastName || ''}`.trim() : '';
|
|
break;
|
|
case 'configurationItemType':
|
|
// Prioritize rmmDeviceAuditDeviceTypeID since it has picklist values
|
|
const rmmTypeValue = item.autotaskDevice?.rmmDeviceAuditDeviceTypeID;
|
|
if (rmmTypeValue) {
|
|
value = rmmDeviceTypePicklist[rmmTypeValue] || rmmTypeValue.toString();
|
|
}
|
|
break;
|
|
case 'psaId':
|
|
value = item.autotaskDevice?.id?.toString() || '';
|
|
break;
|
|
case 'rmmId':
|
|
value = item.rmmDevice?.uid || '';
|
|
break;
|
|
case 'referenceNumber':
|
|
value = item.autotaskDevice?.referenceNumber || '';
|
|
break;
|
|
case 'location':
|
|
value = item.autotaskDevice?.location || '';
|
|
break;
|
|
case 'isActive':
|
|
value = item.autotaskDevice?.isActive ? 'Active' : 'Inactive';
|
|
break;
|
|
case 'modelNumber':
|
|
value = item.autotaskDevice?.modelNumber || '';
|
|
break;
|
|
case 'macAddress':
|
|
value = item.autotaskDevice?.macAddress || '';
|
|
break;
|
|
case 'installDate':
|
|
value = item.autotaskDevice?.installDate ? format(new Date(item.autotaskDevice.installDate), 'yyyy-MM-dd') : '';
|
|
break;
|
|
case 'warrantyExpiration':
|
|
value = item.autotaskDevice?.warrantyExpirationDate ? format(new Date(item.autotaskDevice.warrantyExpirationDate), 'yyyy-MM-dd') : '';
|
|
break;
|
|
case 'rmmDeviceUID':
|
|
value = item.autotaskDevice?.rmmDeviceUID || '';
|
|
break;
|
|
case 'lastSeen':
|
|
value = item.rmmDevice?.lastSeen ? format(new Date(item.rmmDevice.lastSeen), 'yyyy-MM-dd HH:mm:ss') : '';
|
|
break;
|
|
case 'operatingSystem':
|
|
value = item.rmmDevice?.operatingSystem || '';
|
|
break;
|
|
case 'matchType':
|
|
value = item.matchedBy || '';
|
|
break;
|
|
}
|
|
if (value && (value.includes(',') || value.includes('\n') || value.includes('"'))) {
|
|
return `"${value.replace(/"/g, '""')}"`;
|
|
}
|
|
return value;
|
|
});
|
|
});
|
|
|
|
const csvContent = [headers.join(','), ...rows.map(row => row.join(','))].join('\n');
|
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const link = document.createElement('a');
|
|
const url = URL.createObjectURL(blob);
|
|
link.setAttribute('href', url);
|
|
const filename = `config-items-${selectedCompanyName || selectedCompany}-${format(new Date(), 'yyyy-MM-dd')}.csv`;
|
|
link.setAttribute('download', filename);
|
|
link.style.visibility = 'hidden';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
setExportModalOpen(false);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="Configuration items"
|
|
description="Autotask & RMM device management"
|
|
breadcrumbs={[{ label: 'Configuration items' }]}
|
|
actions={
|
|
<>
|
|
{selectedCompany && filteredComparison.length > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => setAdminExpanded(!adminExpanded)}
|
|
className={`border-orange-500 ${adminExpanded ? 'bg-orange-50 dark:bg-orange-950/20' : ''}`}
|
|
aria-label="Bulk admin actions"
|
|
>
|
|
<Shield className="h-4 w-4 text-orange-600" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => selectedCompany && setForceRefresh(prev => prev + 1)}
|
|
disabled={!selectedCompany || loading}
|
|
>
|
|
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
|
Refresh
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setExportModalOpen(true)}
|
|
disabled={!selectedCompany || displayComparison.length === 0}
|
|
>
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Export
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<main className="container mx-auto px-6 py-6">
|
|
{/* Compact Company Selector & Filters */}
|
|
<Card className="mb-6 border-0 shadow-lg">
|
|
<CardContent className="pt-6">
|
|
<div className="space-y-4">
|
|
{/* Company Selector Row */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Building2 className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
|
<div className="w-full sm:flex-1 sm:min-w-[280px] sm:max-w-[500px]">
|
|
<CompanySelectorEnhanced
|
|
value={selectedCompany}
|
|
onValueChange={handleCompanyChange}
|
|
label=""
|
|
/>
|
|
</div>
|
|
{selectedCompany && (
|
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-primary/10 rounded-md flex-shrink-0">
|
|
<Server className="h-4 w-4 text-primary" />
|
|
<span className="text-xs font-medium num">
|
|
PSA {stats?.totalAutotask || 0} · RMM {stats?.totalRmm || 0} · NMS {stats?.totalAuvik || 0} · ARMM {stats?.totalAddigy || 0}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Collapsible Filters */}
|
|
{selectedCompany && (
|
|
<Collapsible open={filtersExpanded} onOpenChange={setFiltersExpanded}>
|
|
<div className="flex items-center justify-between">
|
|
<CollapsibleTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="gap-2">
|
|
<Filter className="h-4 w-4" />
|
|
{filtersExpanded ? 'Hide Filters' : 'Show Filters'}
|
|
<ChevronRight className={`h-4 w-4 transition-transform ${filtersExpanded ? 'rotate-90' : ''}`} />
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
</div>
|
|
<CollapsibleContent>
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4 pt-4 border-t">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="search">Search Devices</Label>
|
|
<div className="relative">
|
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
id="search"
|
|
placeholder="Search by name, serial, IP..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>PSA Status</Label>
|
|
<Select value={activeFilter} onValueChange={(value: 'active' | 'inactive' | 'all') => setActiveFilter(value)}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="active">Active Only</SelectItem>
|
|
<SelectItem value="inactive">Inactive Only</SelectItem>
|
|
<SelectItem value="all">All (Active & Inactive)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Device Type</Label>
|
|
<Select value={filterType} onValueChange={setFilterType}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select device type" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Devices</SelectItem>
|
|
<SelectItem value="matched">Matched (In Both)</SelectItem>
|
|
<SelectItem value="autotask-only">Autotask Only</SelectItem>
|
|
<SelectItem value="rmm-only">RMM Only</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Last Seen After</Label>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
className={`w-full justify-start text-left font-normal ${!lastSeenAfterDate && "text-muted-foreground"}`}
|
|
>
|
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
{lastSeenAfterDate ? format(lastSeenAfterDate, "PPP") : "Pick a date"}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0" align="start">
|
|
<CalendarComponent
|
|
mode="single"
|
|
selected={lastSeenAfterDate}
|
|
onSelect={setLastSeenAfterDate}
|
|
initialFocus
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
{lastSeenAfterDate && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setLastSeenAfterDate(undefined)}
|
|
className="w-full"
|
|
>
|
|
Clear Filter
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Admin Section - Appears when shield icon is clicked */}
|
|
{selectedCompany && filteredComparison.length > 0 && adminExpanded && (
|
|
<Card className="mb-6 border-0 shadow-lg border-l-4 border-l-orange-500">
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center justify-between p-4 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-orange-200 dark:border-orange-800">
|
|
<div>
|
|
<p className="font-medium flex items-center gap-2">
|
|
<Shield className="w-4 h-4 text-orange-600" />
|
|
Bulk Actions
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{selectedItems.size} device{selectedItems.size !== 1 ? 's' : ''} selected
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleBulkMakeInactive}
|
|
disabled={selectedItems.size === 0 || bulkProcessing}
|
|
>
|
|
{bulkProcessing ? (
|
|
<>
|
|
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
|
Processing...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Power className="w-4 h-4 mr-2" />
|
|
Make Inactive ({selectedItems.size})
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Configuration Items Table */}
|
|
{selectedCompany && (
|
|
<Card className="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 className="flex items-center gap-4">
|
|
<CardTitle className="text-lg flex items-center gap-2">
|
|
<Server className="w-5 h-5 text-purple-600" />
|
|
Device Comparison
|
|
<Badge variant="secondary" className="ml-2">{displayComparison.length}</Badge>
|
|
</CardTitle>
|
|
{selectedCompany && stats && (
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-muted-foreground">PSA:</span>
|
|
<span className="font-semibold">{stats.totalAutotask || 0}</span>
|
|
</div>
|
|
<span className="text-muted-foreground">|</span>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-muted-foreground">RMM:</span>
|
|
<span className="font-semibold">{stats.totalRmm || 0}</span>
|
|
</div>
|
|
<span className="text-muted-foreground">|</span>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-muted-foreground">NMS:</span>
|
|
<span className="font-semibold">{stats.totalAuvik || 0}</span>
|
|
</div>
|
|
<span className="text-muted-foreground">|</span>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-muted-foreground">ARMM:</span>
|
|
<span className="font-semibold">{stats.totalAddigy || 0}</span>
|
|
</div>
|
|
<span className="text-muted-foreground">|</span>
|
|
<div className="flex items-center gap-1.5">
|
|
<CheckCircle className="h-3.5 w-3.5 text-green-600" />
|
|
<span className="text-muted-foreground">Matched:</span>
|
|
<span className="font-semibold">{stats.matched || 0}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{loading && (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
|
Loading...
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-6">
|
|
{loading ? (
|
|
<div className="space-y-2">
|
|
{[1, 2, 3].map(i => (
|
|
<Skeleton key={i} className="h-12 w-full" />
|
|
))}
|
|
</div>
|
|
) : error ? (
|
|
<div className="text-red-500 flex items-center gap-2">
|
|
<AlertCircle className="w-4 h-4" />
|
|
Error: {error}
|
|
</div>
|
|
) : displayComparison.length === 0 ? (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
{!selectedCompany ? (
|
|
<div>
|
|
<Server className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
|
<p>Select a company to view configuration items</p>
|
|
</div>
|
|
) : searchTerm || filterType !== 'all' ? (
|
|
<div>
|
|
<Search className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
|
<p>No devices found matching your filters</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<Server className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
|
<p>No configuration items found for this company</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-12">
|
|
<Checkbox
|
|
checked={selectedItems.size === filteredComparison.filter(i => i.autotaskDevice).length && selectedItems.size > 0}
|
|
onCheckedChange={handleSelectAll}
|
|
/>
|
|
</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead className="max-w-[250px]">
|
|
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={() => handleSort('name')}>
|
|
Device Name
|
|
{sortField === 'name' && (sortDirection === 'asc' ? <ArrowUp className="ml-2 h-3 w-3" /> : <ArrowDown className="ml-2 h-3 w-3" />)}
|
|
{sortField !== 'name' && <ArrowUpDown className="ml-2 h-3 w-3 opacity-50" />}
|
|
</Button>
|
|
</TableHead>
|
|
<TableHead className="w-32">Serial Number</TableHead>
|
|
<TableHead>
|
|
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={() => handleSort('ip')}>
|
|
IP Address
|
|
{sortField === 'ip' && (sortDirection === 'asc' ? <ArrowUp className="ml-2 h-3 w-3" /> : <ArrowDown className="ml-2 h-3 w-3" />)}
|
|
{sortField !== 'ip' && <ArrowUpDown className="ml-2 h-3 w-3 opacity-50" />}
|
|
</Button>
|
|
</TableHead>
|
|
<TableHead>
|
|
<div className="flex items-center gap-1">
|
|
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={() => handleSort('contact')}>
|
|
Contact
|
|
{sortField === 'contact' && (sortDirection === 'asc' ? <ArrowUp className="ml-2 h-3 w-3" /> : <ArrowDown className="ml-2 h-3 w-3" />)}
|
|
{sortField !== 'contact' && <ArrowUpDown className="ml-2 h-3 w-3 opacity-50" />}
|
|
</Button>
|
|
<Button
|
|
variant={groupByContact ? "default" : "ghost"}
|
|
size="sm"
|
|
className="h-8 px-2"
|
|
onClick={() => setGroupByContact(!groupByContact)}
|
|
>
|
|
<Users className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</TableHead>
|
|
<TableHead className="w-32">Type</TableHead>
|
|
<TableHead>PSA</TableHead>
|
|
<TableHead>RMM</TableHead>
|
|
<TableHead>NMS</TableHead>
|
|
<TableHead>ARMM</TableHead>
|
|
<TableHead className="w-32">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{groupByContact && groupedByContact ? (
|
|
Object.entries(groupedByContact).map(([contactIdStr, items]) => {
|
|
const contactId = parseInt(contactIdStr);
|
|
const contact = contactId ? contacts[contactId] : null;
|
|
const contactName = contact ? `${contact.firstName || ''} ${contact.lastName || ''}`.trim() : 'No Contact';
|
|
const isExpanded = expandedContacts.has(contactId);
|
|
|
|
return (
|
|
<>
|
|
<TableRow
|
|
key={`contact-${contactId}`}
|
|
className="bg-muted/30 font-semibold cursor-pointer hover:bg-muted/50"
|
|
onClick={() => {
|
|
const newExpanded = new Set(expandedContacts);
|
|
if (isExpanded) {
|
|
newExpanded.delete(contactId);
|
|
} else {
|
|
newExpanded.add(contactId);
|
|
}
|
|
setExpandedContacts(newExpanded);
|
|
}}
|
|
>
|
|
<TableCell colSpan={12}>
|
|
<div className="flex items-center gap-2">
|
|
<ChevronRight className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
|
|
<Users className="h-4 w-4" />
|
|
<span>{contactName}</span>
|
|
<Badge variant="secondary" className="ml-2">{items.length}</Badge>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
{isExpanded && items.map((item: DeviceComparison, index: number) => (
|
|
<TableRow
|
|
key={`comparison-${index}`}
|
|
className="cursor-pointer hover:bg-muted/50"
|
|
onClick={(e) => {
|
|
// Don't open modal if clicking checkbox or button
|
|
const target = e.target as HTMLElement;
|
|
if (target.closest('input[type="checkbox"]') || target.closest('button')) return;
|
|
// For RMM-only devices, use a special ID since there's no Autotask record
|
|
const itemId = item.autotaskDevice?.id || 'rmm-only';
|
|
setSelectedItemId(itemId);
|
|
setSelectedItem(item);
|
|
setModalOpen(true);
|
|
}}
|
|
>
|
|
<TableCell>
|
|
{item.autotaskDevice?.id && (
|
|
<Checkbox
|
|
checked={selectedItems.has(item.autotaskDevice.id)}
|
|
onCheckedChange={(checked) => handleSelectItem(item.autotaskDevice!.id, checked as boolean)}
|
|
/>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{item.status === 'matched' && (
|
|
<Badge variant="default" className="bg-green-600">
|
|
<CheckCircle className="w-3 h-3 mr-1" />
|
|
Matched
|
|
</Badge>
|
|
)}
|
|
{item.status === 'autotask-only' && (
|
|
<Badge variant="secondary">
|
|
<Server className="w-3 h-3 mr-1" />
|
|
AT Only
|
|
</Badge>
|
|
)}
|
|
{item.status === 'rmm-only' && (
|
|
<Badge variant="outline">
|
|
<Monitor className="w-3 h-3 mr-1" />
|
|
{item.addigyDevice && !item.rmmDevice ? 'ARMM Only' : 'RMM Only'}
|
|
</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="max-w-[250px]">
|
|
<div className="flex items-center gap-2">
|
|
<Server className="w-4 h-4 flex-shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="font-medium truncate">
|
|
{item.autotaskDevice?.referenceTitle ||
|
|
item.rmmDevice?.hostname ||
|
|
item.auvikDevice?.deviceName ||
|
|
item.addigyDevice?.['Device Name'] ||
|
|
'Unknown Device'}
|
|
</div>
|
|
{(item.autotaskDevice?.rmmDeviceAuditHostname || item.rmmDevice?.description || item.auvikDevice?.description) && (
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{item.autotaskDevice?.rmmDeviceAuditHostname || item.rmmDevice?.description || item.auvikDevice?.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm w-32">
|
|
{item.autotaskDevice?.serialNumber ||
|
|
item.rmmDevice?.serialNumber ||
|
|
item.auvikDevice?.serialNumber ||
|
|
item.addigyDevice?.['Serial Number'] ||
|
|
'-'}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm">
|
|
{item.autotaskDevice?.rmmDeviceAuditIPAddress ||
|
|
item.rmmDevice?.intIpAddress ||
|
|
item.auvikDevice?.ipAddresses?.[0] ||
|
|
item.addigyDevice?.['IP Address'] ||
|
|
'-'}
|
|
</TableCell>
|
|
<TableCell>
|
|
<ContactCell contactId={item.autotaskDevice?.contactID} contacts={contacts} />
|
|
</TableCell>
|
|
<TableCell className="w-32">
|
|
{(() => {
|
|
// Prioritize rmmDeviceAuditDeviceTypeID since it has picklist values
|
|
const rmmTypeValue = item.autotaskDevice?.rmmDeviceAuditDeviceTypeID;
|
|
|
|
if (rmmTypeValue) {
|
|
const label = rmmDeviceTypePicklist[rmmTypeValue];
|
|
if (!label && typeof window !== 'undefined') {
|
|
console.log('Missing label for rmmTypeValue:', rmmTypeValue, 'picklist:', rmmDeviceTypePicklist);
|
|
}
|
|
return (
|
|
<Badge variant="outline" className="text-xs truncate max-w-[120px]" title={label || rmmTypeValue.toString()}>
|
|
{label || rmmTypeValue}
|
|
</Badge>
|
|
);
|
|
}
|
|
return <span className="text-muted-foreground">-</span>;
|
|
})()}
|
|
</TableCell>
|
|
<TableCell>
|
|
{item.autotaskDevice ? (
|
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
) : (
|
|
<XCircle className="w-4 h-4 text-gray-400" />
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{item.rmmDevice ? (
|
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
) : (
|
|
<XCircle className="w-4 h-4 text-gray-400" />
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{item.auvikDevice ? (
|
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
) : (
|
|
<XCircle className="w-4 h-4 text-gray-400" />
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{item.addigyDevice ? (
|
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
) : (
|
|
<XCircle className="w-4 h-4 text-gray-400" />
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="w-32" onClick={(e) => e.stopPropagation()}>
|
|
{item.rmmDevice?.uid ? (
|
|
<RmmDispatchDialog
|
|
deviceUid={item.rmmDevice.uid}
|
|
hostname={item.rmmDevice.hostname ?? null}
|
|
companyId={selectedCompany ?? null}
|
|
/>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">—</span>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</>
|
|
);
|
|
})
|
|
) : (
|
|
displayComparison.slice(0, displayLimit).map((item: DeviceComparison, index: number) => (
|
|
<TableRow
|
|
key={`comparison-${index}`}
|
|
className="cursor-pointer hover:bg-muted/50"
|
|
onClick={(e) => {
|
|
// Don't open modal if clicking checkbox or button
|
|
const target = e.target as HTMLElement;
|
|
if (target.closest('input[type="checkbox"]') || target.closest('button')) return;
|
|
// For RMM-only devices, use a special ID since there's no Autotask record
|
|
const itemId = item.autotaskDevice?.id || 'rmm-only';
|
|
setSelectedItemId(itemId);
|
|
setSelectedItem(item);
|
|
setModalOpen(true);
|
|
}}
|
|
>
|
|
<TableCell>
|
|
{item.autotaskDevice?.id && (
|
|
<Checkbox
|
|
checked={selectedItems.has(item.autotaskDevice.id)}
|
|
onCheckedChange={(checked) => handleSelectItem(item.autotaskDevice!.id, checked as boolean)}
|
|
/>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{item.status === 'matched' && (
|
|
<Badge variant="default" className="bg-green-600">
|
|
<CheckCircle className="w-3 h-3 mr-1" />
|
|
Matched
|
|
</Badge>
|
|
)}
|
|
{item.status === 'autotask-only' && (
|
|
<Badge variant="secondary">
|
|
<Server className="w-3 h-3 mr-1" />
|
|
AT Only
|
|
</Badge>
|
|
)}
|
|
{item.status === 'rmm-only' && (
|
|
<Badge variant="outline">
|
|
<Monitor className="w-3 h-3 mr-1" />
|
|
{item.addigyDevice && !item.rmmDevice ? 'ARMM Only' : 'RMM Only'}
|
|
</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="max-w-[250px]">
|
|
<div className="flex items-center gap-2">
|
|
<Server className="w-4 h-4 flex-shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="font-medium truncate">
|
|
{item.autotaskDevice?.referenceTitle ||
|
|
item.rmmDevice?.hostname ||
|
|
item.auvikDevice?.deviceName ||
|
|
item.addigyDevice?.['Device Name'] ||
|
|
'Unknown Device'}
|
|
</div>
|
|
{(item.autotaskDevice?.rmmDeviceAuditHostname || item.rmmDevice?.description || item.auvikDevice?.description) && (
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{item.autotaskDevice?.rmmDeviceAuditHostname || item.rmmDevice?.description || item.auvikDevice?.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm w-32">
|
|
{item.autotaskDevice?.serialNumber ||
|
|
item.rmmDevice?.serialNumber ||
|
|
item.auvikDevice?.serialNumber ||
|
|
item.addigyDevice?.['Serial Number'] ||
|
|
'-'}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm">
|
|
{item.autotaskDevice?.rmmDeviceAuditIPAddress ||
|
|
item.rmmDevice?.intIpAddress ||
|
|
item.auvikDevice?.ipAddresses?.[0] ||
|
|
item.addigyDevice?.['IP Address'] ||
|
|
'-'}
|
|
</TableCell>
|
|
<TableCell>
|
|
<ContactCell contactId={item.autotaskDevice?.contactID} contacts={contacts} />
|
|
</TableCell>
|
|
<TableCell className="w-32">
|
|
{(() => {
|
|
const typeValue = item.autotaskDevice?.type || item.autotaskDevice?.configuration_item_type;
|
|
const typeLabel = typeValue ? (configItemTypePicklist[typeValue] || typeValue) : null;
|
|
return typeLabel ? (
|
|
<Badge variant="outline" className="text-xs truncate max-w-[120px]" title={typeLabel.toString()}>
|
|
{typeLabel}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-muted-foreground">-</span>
|
|
);
|
|
})()}
|
|
</TableCell>
|
|
<TableCell>
|
|
{item.autotaskDevice ? (
|
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
) : (
|
|
<XCircle className="w-4 h-4 text-gray-400" />
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{item.rmmDevice ? (
|
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
) : (
|
|
<XCircle className="w-4 h-4 text-gray-400" />
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{item.auvikDevice ? (
|
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
) : (
|
|
<XCircle className="w-4 h-4 text-gray-400" />
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{item.addigyDevice ? (
|
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
) : (
|
|
<XCircle className="w-4 h-4 text-gray-400" />
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="w-32" onClick={(e) => e.stopPropagation()}>
|
|
{item.rmmDevice?.uid ? (
|
|
<RmmDispatchDialog
|
|
deviceUid={item.rmmDevice.uid}
|
|
hostname={item.rmmDevice.hostname ?? null}
|
|
companyId={selectedCompany ?? null}
|
|
/>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">—</span>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
{/* Load More Button */}
|
|
{filteredComparison.length > displayLimit && (
|
|
<div className="flex justify-center py-4">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setDisplayLimit(prev => prev + 50)}
|
|
>
|
|
Load More ({filteredComparison.length - displayLimit} remaining)
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Info Card when no company selected */}
|
|
{!selectedCompany && (
|
|
<Card className="border-0 shadow-lg">
|
|
<CardContent className="py-12">
|
|
<div className="text-center">
|
|
<div className="flex justify-center mb-4">
|
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-purple-100 dark:bg-purple-900">
|
|
<Info className="h-8 w-8 text-purple-600" />
|
|
</div>
|
|
</div>
|
|
<h3 className="text-lg font-semibold mb-2">Get Started</h3>
|
|
<p className="text-muted-foreground mb-4 max-w-md mx-auto">
|
|
Select a company from the dropdown above to view and manage their configuration items.
|
|
You can compare devices between Autotask and RMM systems.
|
|
</p>
|
|
<div className="flex justify-center gap-4 mt-6">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
View Autotask devices
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
Check RMM status
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
Monitor Datto devices
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</main>
|
|
|
|
{/* Configuration Item Detail Modal */}
|
|
<ConfigItemModal
|
|
itemId={selectedItemId}
|
|
type="autotask"
|
|
open={modalOpen}
|
|
rmmDevice={selectedItem?.rmmDevice}
|
|
auvikDevice={selectedItem?.auvikDevice}
|
|
addigyDevice={selectedItem?.addigyDevice}
|
|
onOpenChange={(open) => {
|
|
setModalOpen(open);
|
|
// Refresh data when modal closes
|
|
if (!open && selectedCompany) {
|
|
fetch(
|
|
`/api/rmm-devices?companyId=${selectedCompany}&companyName=${encodeURIComponent(selectedCompanyName)}&activeFilter=${activeFilter}`
|
|
)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setComparison(data.comparison || []);
|
|
setStats(data.stats);
|
|
})
|
|
.catch(err => console.error('Failed to refresh data:', err));
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{/* Export Modal */}
|
|
<Dialog open={exportModalOpen} onOpenChange={setExportModalOpen}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Export Configuration Items</DialogTitle>
|
|
<DialogDescription>
|
|
Select the fields you want to include in the CSV export. Exporting {displayComparison.length} device{displayComparison.length !== 1 ? 's' : ''}.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between pb-2 border-b">
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="select-all-export"
|
|
checked={selectedExportFields.size === exportFields.length}
|
|
onCheckedChange={toggleAllExportFields}
|
|
/>
|
|
<Label htmlFor="select-all-export" className="font-semibold cursor-pointer">
|
|
Select All ({selectedExportFields.size}/{exportFields.length})
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
|
|
<ScrollArea className="h-[400px] pr-4">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{exportFields.map((field) => (
|
|
<div key={field.key} className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id={`export-${field.key}`}
|
|
checked={selectedExportFields.has(field.key)}
|
|
onCheckedChange={() => toggleExportField(field.key)}
|
|
/>
|
|
<Label
|
|
htmlFor={`export-${field.key}`}
|
|
className="text-sm cursor-pointer font-normal"
|
|
>
|
|
{field.label}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setExportModalOpen(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleExport}
|
|
disabled={selectedExportFields.size === 0}
|
|
className="bg-gradient-to-r from-purple-600 to-purple-700 text-white hover:from-purple-700 hover:to-purple-800"
|
|
>
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Export CSV ({selectedExportFields.size} fields)
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function ConfigurationItemsPage() {
|
|
return (
|
|
<Suspense fallback={<div className="p-8">Loading...</div>}>
|
|
<ConfigurationItemsContent />
|
|
</Suspense>
|
|
);
|
|
}
|