wulf-pulse/components/configuration-items/psa-tab.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

651 lines
22 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Server,
Edit,
Save,
Power,
Info,
Cpu,
RefreshCw,
User,
Receipt,
Ticket as TicketIcon,
Download
} from 'lucide-react';
import { format } from 'date-fns';
import { ConfigurationItem } from '@/lib/types/autotask';
import { PurchaseHistoryModal } from './purchase-history-modal';
import { RelatedTicketsModal } from './related-tickets-modal';
interface PSATabProps {
device?: ConfigurationItem;
onUpdate: (device: ConfigurationItem) => void;
}
export function PSATab({ device, onUpdate }: PSATabProps) {
const [editMode, setEditMode] = useState(false);
const [saving, setSaving] = useState(false);
const [editedData, setEditedData] = useState<Partial<ConfigurationItem>>(device || {});
const [error, setError] = useState<string | null>(null);
const [contactName, setContactName] = useState<string | null>(null);
const [loadingContact, setLoadingContact] = useState(false);
const [purchaseHistoryOpen, setPurchaseHistoryOpen] = useState(false);
const [relatedTicketsOpen, setRelatedTicketsOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [selectedFields, setSelectedFields] = useState<Set<string>>(new Set());
// Sync editedData when device prop changes
useEffect(() => {
if (device) {
setEditedData(device);
}
}, [device]);
// Fetch contact information if contactID exists
useEffect(() => {
if (!device?.contactID) {
setContactName(null);
return;
}
const fetchContact = async () => {
setLoadingContact(true);
try {
const response = await fetch(`/api/contacts/${device.contactID}`);
if (response.ok) {
const data = await response.json();
setContactName(data.contact ? `${data.contact.firstName} ${data.contact.lastName}` : null);
}
} catch (err) {
console.error('Failed to fetch contact:', err);
} finally {
setLoadingContact(false);
}
};
fetchContact();
}, [device?.contactID]);
const handleSave = async () => {
if (!device) return;
setSaving(true);
setError(null);
try {
const response = await fetch(`/api/configuration-items/${device.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(editedData),
});
if (!response.ok) {
throw new Error('Failed to update configuration item');
}
const updated = await response.json();
onUpdate(updated.configurationItem);
setEditMode(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save changes');
} finally {
setSaving(false);
}
};
const handleMakeInactive = async () => {
if (!device) return;
setSaving(true);
setError(null);
try {
const response = await fetch(`/api/configuration-items/${device.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ isActive: false }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update configuration item');
}
const updated = await response.json();
if (updated.configurationItem) {
onUpdate(updated.configurationItem);
setEditedData(updated.configurationItem);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to make inactive');
} finally {
setSaving(false);
}
};
// Define available fields for export
const availableFields = [
{ key: 'id', label: 'ID' },
{ key: 'referenceTitle', label: 'Reference Title' },
{ key: 'referenceNumber', label: 'Reference Number' },
{ key: 'serialNumber', label: 'Serial 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: 'warrantyExpirationDate', label: 'Warranty Expiration' },
{ key: 'rmmDeviceUID', label: 'RMM Device UID' },
{ key: 'notes', label: 'Notes' },
{ key: 'companyID', label: 'Company ID' },
{ key: 'contactID', label: 'Contact ID' },
{ key: 'contractID', label: 'Contract ID' },
{ key: 'createDate', label: 'Create Date' },
{ key: 'lastModifiedTime', label: 'Last Modified Time' },
{ key: 'productID', label: 'Product ID' },
{ key: 'vendorName', label: 'Vendor Name' },
{ key: 'deviceNetworkingID', label: 'Device Networking ID' },
{ key: 'numberOfUsers', label: 'Number of Users' },
{ key: 'setupFee', label: 'Setup Fee' },
];
const toggleField = (fieldKey: string) => {
const newSelected = new Set(selectedFields);
if (newSelected.has(fieldKey)) {
newSelected.delete(fieldKey);
} else {
newSelected.add(fieldKey);
}
setSelectedFields(newSelected);
};
const toggleAllFields = () => {
if (selectedFields.size === availableFields.length) {
setSelectedFields(new Set());
} else {
setSelectedFields(new Set(availableFields.map(f => f.key)));
}
};
const handleExport = () => {
if (!device || selectedFields.size === 0) return;
// Build CSV header
const headers = availableFields
.filter(f => selectedFields.has(f.key))
.map(f => f.label);
// Build CSV row
const row = availableFields
.filter(f => selectedFields.has(f.key))
.map(f => {
const value = device[f.key as keyof ConfigurationItem];
// Format dates
if ((f.key === 'installDate' || f.key === 'warrantyExpirationDate' ||
f.key === 'createDate' || f.key === 'lastModifiedTime') && value) {
return format(new Date(value as string), 'yyyy-MM-dd HH:mm:ss');
}
// Handle boolean
if (typeof value === 'boolean') {
return value ? 'Active' : 'Inactive';
}
// Handle null/undefined
if (value === null || value === undefined) {
return '';
}
// Escape quotes and wrap in quotes if contains comma or newline
const stringValue = String(value);
if (stringValue.includes(',') || stringValue.includes('\n') || stringValue.includes('"')) {
return `"${stringValue.replace(/"/g, '""')}"`;
}
return stringValue;
});
// Create CSV content
const csvContent = [headers.join(','), row.join(',')].join('\n');
// Create and trigger download
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);
link.setAttribute('download', `config-item-${device.id}-${format(new Date(), 'yyyy-MM-dd')}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setExportModalOpen(false);
};
return (
<>
<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">
<CardTitle className="text-lg">PSA Configuration Item</CardTitle>
<div className="flex items-center gap-2">
{!editMode ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => setExportModalOpen(true)}
disabled={!device}
>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPurchaseHistoryOpen(true)}
>
<Receipt className="w-4 h-4 mr-2" />
Purchase History
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setRelatedTicketsOpen(true)}
>
<TicketIcon className="w-4 h-4 mr-2" />
Related Tickets
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditMode(true);
setEditedData(device || {});
}}
disabled={!device}
>
<Edit className="w-4 h-4 mr-2" />
Edit
</Button>
{device?.isActive && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
<Power className="w-4 h-4 mr-2" />
Make Inactive
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Make Configuration Item Inactive?</AlertDialogTitle>
<AlertDialogDescription>
This will mark the configuration item as inactive in Autotask PSA.
You can reactivate it later if needed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleMakeInactive}>
Make Inactive
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditMode(false);
setEditedData(device || {});
setError(null);
}}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={saving}
>
{saving ? (
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
) : (
<Save className="w-4 h-4 mr-2" />
)}
Save
</Button>
</>
)}
</div>
</div>
</CardHeader>
<CardContent className="pt-6">
{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-md">
{error}
</div>
)}
{device ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Basic Information */}
<div className="space-y-4">
<h3 className="font-semibold flex items-center gap-2">
<Info className="w-4 h-4" />
Basic Information
</h3>
<div className="space-y-3">
<div>
<Label>Reference Title</Label>
{editMode ? (
<Input
value={editedData.referenceTitle || ''}
onChange={(e) => setEditedData({...editedData, referenceTitle: e.target.value})}
className="mt-1"
/>
) : (
<p className="text-sm text-muted-foreground mt-1">{device.referenceTitle}</p>
)}
</div>
<div>
<Label>Reference Number</Label>
{editMode ? (
<Input
value={editedData.referenceNumber || ''}
onChange={(e) => setEditedData({...editedData, referenceNumber: e.target.value})}
className="mt-1"
/>
) : (
<p className="text-sm text-muted-foreground font-mono mt-1">
{device.referenceNumber || '-'}
</p>
)}
</div>
<div>
<Label>Serial Number</Label>
{editMode ? (
<Input
value={editedData.serialNumber || ''}
onChange={(e) => setEditedData({...editedData, serialNumber: e.target.value})}
className="mt-1"
/>
) : (
<p className="text-sm text-muted-foreground font-mono mt-1">
{device.serialNumber || '-'}
</p>
)}
</div>
<div>
<Label>Location</Label>
{editMode ? (
<Input
value={editedData.location || ''}
onChange={(e) => setEditedData({...editedData, location: e.target.value})}
className="mt-1"
/>
) : (
<p className="text-sm text-muted-foreground mt-1">
{device.location || '-'}
</p>
)}
</div>
<div>
<Label>Active Status</Label>
{editMode ? (
<div className="flex items-center space-x-2 mt-1">
<Switch
checked={editedData.isActive}
onCheckedChange={(checked) => setEditedData({...editedData, isActive: checked})}
/>
<Label>{editedData.isActive ? 'Active' : 'Inactive'}</Label>
</div>
) : (
<div className="mt-1">
{device.isActive ? (
<Badge variant="default" className="bg-green-600">Active</Badge>
) : (
<Badge variant="secondary">Inactive</Badge>
)}
</div>
)}
</div>
<div>
<Label>Associated Contact</Label>
{loadingContact ? (
<p className="text-sm text-muted-foreground mt-1">Loading...</p>
) : contactName ? (
<div className="flex items-center gap-2 mt-1">
<Badge variant="default" className="bg-blue-600">
<User className="w-3 h-3 mr-1" />
{contactName}
</Badge>
</div>
) : device.contactID ? (
<p className="text-sm text-muted-foreground mt-1">Contact ID: {device.contactID}</p>
) : (
<p className="text-sm text-muted-foreground mt-1">No contact assigned</p>
)}
</div>
</div>
</div>
{/* Technical Details */}
<div className="space-y-4">
<h3 className="font-semibold flex items-center gap-2">
<Cpu className="w-4 h-4" />
Technical Details
</h3>
<div className="space-y-3">
<div>
<Label>Model Number</Label>
{editMode ? (
<Input
value={editedData.modelNumber || ''}
onChange={(e) => setEditedData({...editedData, modelNumber: e.target.value})}
className="mt-1"
/>
) : (
<p className="text-sm text-muted-foreground mt-1">
{device.modelNumber || '-'}
</p>
)}
</div>
<div>
<Label>MAC Address</Label>
{editMode ? (
<Input
value={editedData.macAddress || ''}
onChange={(e) => setEditedData({...editedData, macAddress: e.target.value})}
className="mt-1"
/>
) : (
<p className="text-sm text-muted-foreground font-mono mt-1">
{device.macAddress || '-'}
</p>
)}
</div>
<div>
<Label>Install Date</Label>
<p className="text-sm text-muted-foreground mt-1">
{device.installDate ?
format(new Date(device.installDate), 'MMM d, yyyy') :
'-'}
</p>
</div>
<div>
<Label>Warranty Expiration</Label>
<p className="text-sm text-muted-foreground mt-1">
{device.warrantyExpirationDate ?
format(new Date(device.warrantyExpirationDate), 'MMM d, yyyy') :
'-'}
</p>
</div>
<div>
<Label>RMM Device UID</Label>
<p className="text-sm text-muted-foreground font-mono mt-1">
{device.rmmDeviceUID || '-'}
</p>
</div>
</div>
</div>
{/* Notes */}
<div className="md:col-span-2 space-y-4">
<h3 className="font-semibold">Notes</h3>
{editMode ? (
<Textarea
value={editedData.notes || ''}
onChange={(e) => setEditedData({...editedData, notes: e.target.value})}
rows={4}
placeholder="Add notes..."
/>
) : (
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{device.notes || 'No notes available'}
</p>
)}
</div>
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<Server className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No PSA data available for this device</p>
</div>
)}
</CardContent>
</Card>
{/* Purchase History Modal */}
{device && (
<PurchaseHistoryModal
configItemId={device.id}
serialNumber={device.serialNumber}
createDate={device.createDate}
open={purchaseHistoryOpen}
onOpenChange={setPurchaseHistoryOpen}
/>
)}
{/* Related Tickets Modal */}
{device && (
<RelatedTicketsModal
configItemId={device.id}
open={relatedTicketsOpen}
onOpenChange={setRelatedTicketsOpen}
/>
)}
{/* Export Modal */}
<Dialog open={exportModalOpen} onOpenChange={setExportModalOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Export Configuration Item</DialogTitle>
<DialogDescription>
Select the fields you want to include in the CSV export
</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"
checked={selectedFields.size === availableFields.length}
onCheckedChange={toggleAllFields}
/>
<Label htmlFor="select-all" className="font-semibold cursor-pointer">
Select All ({selectedFields.size}/{availableFields.length})
</Label>
</div>
</div>
<ScrollArea className="h-[400px] pr-4">
<div className="grid grid-cols-2 gap-3">
{availableFields.map((field) => (
<div key={field.key} className="flex items-center space-x-2">
<Checkbox
id={field.key}
checked={selectedFields.has(field.key)}
onCheckedChange={() => toggleField(field.key)}
/>
<Label
htmlFor={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={selectedFields.size === 0}
>
<Download className="w-4 h-4 mr-2" />
Export CSV
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}