2025-10-28 11:21:04 -04:00
|
|
|
'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';
|
2025-11-19 14:18:16 -05:00
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from '@/components/ui/dialog';
|
|
|
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
|
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
2025-10-28 11:21:04 -04:00
|
|
|
import {
|
|
|
|
|
Server,
|
|
|
|
|
Edit,
|
|
|
|
|
Save,
|
|
|
|
|
Power,
|
|
|
|
|
Info,
|
|
|
|
|
Cpu,
|
|
|
|
|
RefreshCw,
|
|
|
|
|
User,
|
|
|
|
|
Receipt,
|
2025-11-19 14:18:16 -05:00
|
|
|
Ticket as TicketIcon,
|
|
|
|
|
Download
|
2025-10-28 11:21:04 -04:00
|
|
|
} 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);
|
2025-11-19 14:18:16 -05:00
|
|
|
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]);
|
2025-10-28 11:21:04 -04:00
|
|
|
|
|
|
|
|
// 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) {
|
2025-11-19 14:18:16 -05:00
|
|
|
const errorData = await response.json();
|
|
|
|
|
throw new Error(errorData.error || 'Failed to update configuration item');
|
2025-10-28 11:21:04 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updated = await response.json();
|
2025-11-19 14:18:16 -05:00
|
|
|
if (updated.configurationItem) {
|
|
|
|
|
onUpdate(updated.configurationItem);
|
|
|
|
|
setEditedData(updated.configurationItem);
|
|
|
|
|
}
|
2025-10-28 11:21:04 -04:00
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Failed to make inactive');
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-19 14:18:16 -05:00
|
|
|
// 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);
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-28 11:21:04 -04:00
|
|
|
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 ? (
|
|
|
|
|
<>
|
2025-11-19 14:18:16 -05:00
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setExportModalOpen(true)}
|
|
|
|
|
disabled={!device}
|
|
|
|
|
>
|
|
|
|
|
<Download className="w-4 h-4 mr-2" />
|
|
|
|
|
Export
|
|
|
|
|
</Button>
|
2025-10-28 11:21:04 -04:00
|
|
|
<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}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2025-11-19 14:18:16 -05:00
|
|
|
|
|
|
|
|
{/* 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>
|
2025-10-28 11:21:04 -04:00
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|