Restructure: rename to Pulse and move app to root

- Renamed project from PSA-Utils to Pulse
- Moved all app files from autotask-app/ to root
- Updated package.json name to 'pulse'
- Updated Docker container names to pulse-app and pulse-redis
- Updated Docker network name to pulse-network
This commit is contained in:
Lorentz Hinrichsen 2025-10-28 23:08:54 -04:00
parent f429f3af54
commit 3c3124d8c9
117 changed files with 8433 additions and 239 deletions

View file

@ -0,0 +1,153 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { PSATab } from './psa-tab';
import { RMMTab } from './rmm-tab';
import { StatusCards } from './status-cards';
import {
Server,
Monitor,
AlertCircle
} from 'lucide-react';
import { ConfigurationItem } from '@/lib/types/autotask';
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
interface ConfigItemDetail {
autotaskDevice?: ConfigurationItem;
rmmDevice?: DattoRMMDevice;
companyName?: string;
}
interface ConfigItemModalProps {
itemId: string | number | null;
type?: 'autotask' | 'rmm';
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ConfigItemModal({ itemId, type = 'autotask', open, onOpenChange }: ConfigItemModalProps) {
const [data, setData] = useState<ConfigItemDetail>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !itemId) {
return;
}
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/configuration-items/${itemId}?type=${type}`);
if (!response.ok) {
throw new Error('Failed to fetch configuration item');
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
fetchData();
}, [itemId, type, open]);
const handleUpdate = (updatedDevice: ConfigurationItem) => {
setData({ ...data, autotaskDevice: updatedDevice });
};
const device = data.autotaskDevice;
const rmmDevice = data.rmmDevice;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="w-[95vw] sm:w-[90vw] md:w-[85vw] lg:w-[80vw] xl:w-[75vw] h-[90vh] overflow-y-auto"
style={{ maxWidth: '1400px' }}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-purple-600 to-purple-700 text-white">
<Server className="h-4 w-4" />
</div>
<div>
<div className="text-lg font-semibold">
{device?.referenceTitle || rmmDevice?.hostname || 'Configuration Item'}
</div>
{data.companyName && (
<div className="text-sm font-normal text-muted-foreground">
{data.companyName}
</div>
)}
</div>
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="space-y-4 py-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-96 w-full" />
</div>
) : error ? (
<div className="py-8">
<div className="flex items-center gap-2 text-red-500 justify-center">
<AlertCircle className="w-5 h-5" />
<p>Error: {error}</p>
</div>
</div>
) : (
<div className="space-y-6">
{/* Status Cards */}
<StatusCards device={device} rmmDevice={rmmDevice} />
{/* Tabs */}
<Tabs defaultValue="psa" className="space-y-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="psa" className="flex items-center gap-2">
<Server className="w-4 h-4" />
PSA Data
{device && (
<Badge variant="outline" className="ml-1">
{device.isActive ? 'Active' : 'Inactive'}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="rmm" className="flex items-center gap-2">
<Monitor className="w-4 h-4" />
RMM Data
{rmmDevice && (
<Badge variant="outline" className="ml-1">
{rmmDevice.online ? 'Online' : 'Offline'}
</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="psa">
<PSATab device={device} onUpdate={handleUpdate} />
</TabsContent>
<TabsContent value="rmm">
<RMMTab device={rmmDevice} />
</TabsContent>
</Tabs>
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,59 @@
'use client';
import { useState, useEffect } from 'react';
import { Badge } from '@/components/ui/badge';
import { User } from 'lucide-react';
interface ContactCellProps {
contactId?: number;
}
export function ContactCell({ contactId }: ContactCellProps) {
const [contactName, setContactName] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!contactId) {
setContactName(null);
return;
}
const fetchContact = async () => {
setLoading(true);
try {
const response = await fetch(`/api/contacts/${contactId}`);
if (response.ok) {
const data = await response.json();
if (data.contact) {
setContactName(`${data.contact.firstName} ${data.contact.lastName}`);
}
}
} catch (err) {
console.error('Failed to fetch contact:', err);
} finally {
setLoading(false);
}
};
fetchContact();
}, [contactId]);
if (loading) {
return <span className="text-xs text-muted-foreground">Loading...</span>;
}
if (!contactId) {
return <span className="text-xs text-muted-foreground">-</span>;
}
if (contactName) {
return (
<Badge variant="outline" className="text-xs">
<User className="w-3 h-3 mr-1" />
{contactName}
</Badge>
);
}
return <span className="text-xs text-muted-foreground">ID: {contactId}</span>;
}

View file

@ -0,0 +1,456 @@
'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 {
Server,
Edit,
Save,
Power,
Info,
Cpu,
RefreshCw,
User,
Receipt,
Ticket as TicketIcon
} 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);
// 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) {
throw new Error('Failed to update configuration item');
}
const updated = await response.json();
onUpdate(updated.configurationItem);
setEditedData({ ...editedData, isActive: false });
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to make inactive');
} finally {
setSaving(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={() => 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}
/>
)}
</>
);
}

View file

@ -0,0 +1,571 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { DollarSign, FileText, AlertCircle, Receipt } from 'lucide-react';
import { format } from 'date-fns';
interface PurchaseHistoryModalProps {
configItemId: number;
serialNumber?: string;
createDate?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface PurchaseHistoryData {
billingItems: any[];
invoices?: any[];
}
export function PurchaseHistoryModal({
configItemId,
serialNumber,
createDate,
open,
onOpenChange,
}: PurchaseHistoryModalProps) {
const [data, setData] = useState<PurchaseHistoryData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchAttempt, setSearchAttempt] = useState(0); // 0 = initial, 1 = before, 2 = after
const [selectedInvoice, setSelectedInvoice] = useState<any>(null);
const [selectedTicket, setSelectedTicket] = useState<any>(null);
const [loadingTicket, setLoadingTicket] = useState(false);
const [timeEntries, setTimeEntries] = useState<any[]>([]);
const [loadingTimeEntries, setLoadingTimeEntries] = useState(false);
const [showTimeEntries, setShowTimeEntries] = useState(false);
const [invoiceLineItems, setInvoiceLineItems] = useState<any[]>([]);
const [loadingInvoiceItems, setLoadingInvoiceItems] = useState(false);
const [showInvoiceItems, setShowInvoiceItems] = useState(false);
useEffect(() => {
if (open && configItemId) {
setSearchAttempt(0); // Reset search attempt
fetchPurchaseHistory(0);
}
}, [open, configItemId]);
const fetchPurchaseHistory = async (attempt: number) => {
setLoading(true);
setError(null);
try {
// Calculate date range based on creation date and attempt
let startDate: Date;
let endDate: Date;
if (!createDate) {
// Fallback if no creation date: search last 120 days
endDate = new Date();
startDate = new Date();
startDate.setDate(startDate.getDate() - 120);
} else {
const created = new Date(createDate);
if (attempt === 0) {
// Initial: 60 days before to 60 days after creation
startDate = new Date(created);
startDate.setDate(startDate.getDate() - 60);
endDate = new Date(created);
endDate.setDate(endDate.getDate() + 60);
console.log('Initial search: 60 days before/after creation date');
} else if (attempt === 1) {
// Second attempt: 120 days before the initial window
startDate = new Date(created);
startDate.setDate(startDate.getDate() - 180); // 60 + 120
endDate = new Date(created);
endDate.setDate(endDate.getDate() - 60);
console.log('Extended search: 120 days BEFORE initial window');
} else {
// Third attempt: 120 days after the initial window
startDate = new Date(created);
startDate.setDate(startDate.getDate() + 60);
endDate = new Date(created);
endDate.setDate(endDate.getDate() + 180); // 60 + 120
console.log('Extended search: 120 days AFTER initial window');
}
}
const url = `/api/config-enrichment?configItemId=${configItemId}&startDate=${startDate.toISOString().split('T')[0]}&endDate=${endDate.toISOString().split('T')[0]}`;
console.log('Fetching from:', url);
const response = await fetch(url);
console.log('Response status:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('Error response:', errorText);
throw new Error('Failed to fetch purchase history');
}
const result = await response.json();
console.log('Purchase history result:', result);
// If no results and we haven't tried all attempts yet, try next window
if (result.billingItems.length === 0 && attempt < 2) {
console.log('No results found, trying extended search...');
setSearchAttempt(attempt + 1);
await fetchPurchaseHistory(attempt + 1);
} else {
setData(result);
setSearchAttempt(attempt);
}
} catch (err) {
console.error('Purchase history error:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
const fetchTicketDetails = async (ticketId: number) => {
console.log('Fetching ticket details for ID:', ticketId);
setLoadingTicket(true);
setShowTimeEntries(false);
setTimeEntries([]);
try {
const url = `/api/tickets/${ticketId}`;
console.log('Fetching from:', url);
const response = await fetch(url);
console.log('Response status:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('Error response:', errorText);
throw new Error('Failed to fetch ticket details');
}
const result = await response.json();
console.log('Ticket result:', result);
setSelectedTicket(result.ticket);
} catch (err) {
console.error('Error fetching ticket:', err);
setSelectedTicket({ ticketNumber: ticketId, title: 'Error loading ticket details' });
} finally {
setLoadingTicket(false);
}
};
const fetchTimeEntries = async (ticketId: number) => {
setLoadingTimeEntries(true);
try {
const response = await fetch(`/api/tickets/${ticketId}/time-entries`);
if (!response.ok) {
throw new Error('Failed to fetch time entries');
}
const result = await response.json();
setTimeEntries(result.timeEntries || []);
setShowTimeEntries(true);
} catch (err) {
console.error('Error fetching time entries:', err);
setTimeEntries([]);
} finally {
setLoadingTimeEntries(false);
}
};
const fetchInvoiceLineItems = async (invoiceId: number) => {
setLoadingInvoiceItems(true);
try {
const response = await fetch(`/api/invoices/${invoiceId}/line-items`);
if (!response.ok) {
throw new Error('Failed to fetch invoice line items');
}
const result = await response.json();
setInvoiceLineItems(result.lineItems || []);
setShowInvoiceItems(true);
} catch (err) {
console.error('Error fetching invoice line items:', err);
setInvoiceLineItems([]);
} finally {
setLoadingInvoiceItems(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-[95vw] !w-[95vw] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<DialogTitle className="flex items-center gap-2">
<Receipt className="w-5 h-5" />
Purchase History
</DialogTitle>
<DialogDescription>
Hardware purchase history for this device
{serialNumber && ` - Serial: ${serialNumber}`}
{createDate && (
<span className="block text-xs mt-1">
Searching around creation date: {format(new Date(createDate), 'MMM d, yyyy')}
{searchAttempt > 0 && ` (Extended search ${searchAttempt}/2)`}
</span>
)}
</DialogDescription>
</div>
{/* Summary Cards - Compact */}
{!loading && !error && data && data.billingItems.length > 0 && (
<div className="flex gap-3 mr-8">
<div className="px-3 py-2 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950 dark:to-green-900 rounded-lg">
<p className="text-xs text-muted-foreground">Sale Price</p>
<p className="text-sm font-bold">
${data.billingItems.reduce((sum: number, item: any) => sum + (item.totalAmount || 0), 0).toFixed(2)}
</p>
</div>
<div className="px-3 py-2 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-950 dark:to-blue-900 rounded-lg">
<p className="text-xs text-muted-foreground">Cost</p>
<p className="text-sm font-bold">
${data.billingItems.reduce((sum: number, item: any) => sum + (item.ourCost || 0), 0).toFixed(2)}
</p>
</div>
<div className="px-3 py-2 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-950 dark:to-purple-900 rounded-lg">
<p className="text-xs text-muted-foreground">Profit</p>
<p className="text-sm font-bold">
${data.billingItems.reduce((sum: number, item: any) => sum + (item.profit || 0), 0).toFixed(2)}
</p>
</div>
</div>
)}
</div>
</DialogHeader>
{loading && (
<div className="space-y-4 py-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)}
{error && (
<div className="flex items-center gap-2 text-red-500 p-4 bg-red-50 dark:bg-red-950/20 rounded-lg">
<AlertCircle className="w-5 h-5" />
<p>Error: {error}</p>
</div>
)}
{!loading && !error && data && (
<div className="space-y-6 py-4">
{/* Billing Items */}
{data.billingItems.length > 0 ? (
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<FileText className="w-5 h-5" />
Purchase Details
</h3>
<div className="border rounded-lg overflow-hidden overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-28">Date</TableHead>
<TableHead className="min-w-[300px] max-w-[400px]">Description</TableHead>
<TableHead className="w-32">Serial</TableHead>
<TableHead className="w-24">Cost</TableHead>
<TableHead className="w-24">Sale Price</TableHead>
<TableHead className="w-24">Profit</TableHead>
<TableHead className="w-24">Invoice</TableHead>
<TableHead className="w-24">Ticket</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.billingItems.map((item: any, index: number) => (
<TableRow key={index}>
<TableCell className="whitespace-nowrap">
{item.itemDate ? format(new Date(item.itemDate), 'MMM d, yyyy') : '-'}
</TableCell>
<TableCell className="min-w-[300px] max-w-[400px]">
<div className="text-sm font-medium whitespace-normal break-words">{item.description}</div>
{item.purchaseOrderNumber && (
<div className="text-xs text-muted-foreground">PO: {item.purchaseOrderNumber}</div>
)}
</TableCell>
<TableCell>
{item.extractedSerialNumber && (
<span className="font-mono text-xs bg-green-100 dark:bg-green-900 px-2 py-1 rounded">
{item.extractedSerialNumber}
</span>
)}
</TableCell>
<TableCell className="whitespace-nowrap">
${(item.ourCost || 0).toFixed(2)}
</TableCell>
<TableCell className="whitespace-nowrap font-medium">
${(item.totalAmount || 0).toFixed(2)}
</TableCell>
<TableCell className="whitespace-nowrap">
<span className={item.profit > 0 ? 'text-green-600' : 'text-red-600'}>
${(item.profit || 0).toFixed(2)}
</span>
<Badge variant="secondary" className="ml-2">
{item.profitMargin}%
</Badge>
</TableCell>
<TableCell>
{item.invoiceID ? (
<Button
variant="link"
size="sm"
className="h-auto p-0 font-mono text-blue-600 hover:text-blue-800"
onClick={() => {
const invoice = data.invoices?.find((inv: any) => inv.id === item.invoiceID);
setSelectedInvoice(invoice || { id: item.invoiceID });
}}
>
{item.invoiceID}
</Button>
) : '-'}
</TableCell>
<TableCell>
{item.ticketID ? (
<Button
variant="link"
size="sm"
className="h-auto p-0 font-mono text-blue-600 hover:text-blue-800"
onClick={() => fetchTicketDetails(item.ticketID)}
>
{item.ticketID}
</Button>
) : '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<AlertCircle className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p className="font-medium">No purchase records found for this serial number</p>
<p className="text-sm mt-2">
Serial number: {serialNumber || 'Not set'}
</p>
<p className="text-sm mt-4">This device may have been:</p>
<ul className="text-sm mt-2 space-y-1">
<li> Purchased more than 90 days ago</li>
<li> Added manually without an invoice</li>
<li> Serial number not found in invoice line items</li>
</ul>
</div>
)}
</div>
)}
{/* Invoice Details Section */}
{selectedInvoice && (
<div className="mt-6 border-t pt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<FileText className="w-5 h-5" />
Invoice #{selectedInvoice.id} Details
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedInvoice(null)}
>
Close
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<div>
<p className="text-sm text-muted-foreground">Invoice Date</p>
<p className="font-medium">
{selectedInvoice.invoiceDateTime ? format(new Date(selectedInvoice.invoiceDateTime), 'MMM d, yyyy') : '-'}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Total</p>
<p className="font-medium">${(selectedInvoice.invoiceTotal || 0).toFixed(2)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Status</p>
<Badge variant={selectedInvoice.isPaid ? 'default' : 'secondary'}>
{selectedInvoice.isPaid ? 'Paid' : 'Unpaid'}
</Badge>
</div>
<div>
<p className="text-sm text-muted-foreground">Due Date</p>
<p className="font-medium">
{selectedInvoice.dueDateTime ? format(new Date(selectedInvoice.dueDateTime), 'MMM d, yyyy') : '-'}
</p>
</div>
</div>
{/* Invoice Line Items */}
<div className="mt-4">
<Button
variant="outline"
size="sm"
onClick={() => {
if (!showInvoiceItems && invoiceLineItems.length === 0) {
fetchInvoiceLineItems(selectedInvoice.id);
} else {
setShowInvoiceItems(!showInvoiceItems);
}
}}
className="w-full"
>
{showInvoiceItems ? 'Hide' : 'Show'} Line Items
{invoiceLineItems.length > 0 && ` (${invoiceLineItems.length})`}
</Button>
{loadingInvoiceItems && (
<div className="mt-4">
<Skeleton className="h-32 w-full" />
</div>
)}
{showInvoiceItems && invoiceLineItems.length > 0 && (
<div className="mt-4 overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[300px] max-w-[500px]">Description</TableHead>
<TableHead className="text-right w-20">Qty</TableHead>
<TableHead className="text-right w-28">Unit Price</TableHead>
<TableHead className="text-right w-28">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoiceLineItems.map((item: any, index: number) => (
<TableRow key={index}>
<TableCell className="min-w-[300px] max-w-[500px]">
<div className="text-sm font-medium whitespace-normal break-words">{item.description || '-'}</div>
</TableCell>
<TableCell className="text-right whitespace-nowrap">{item.quantity || 0}</TableCell>
<TableCell className="text-right whitespace-nowrap">${(item.unitPrice || 0).toFixed(2)}</TableCell>
<TableCell className="text-right font-medium whitespace-nowrap">${(item.totalAmount || 0).toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{showInvoiceItems && invoiceLineItems.length === 0 && !loadingInvoiceItems && (
<p className="text-sm text-muted-foreground mt-4 text-center">No line items found</p>
)}
</div>
</div>
)}
{/* Ticket Details Section */}
{selectedTicket && (
<div className="mt-6 border-t pt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<FileText className="w-5 h-5" />
Ticket #{selectedTicket.ticketNumber} Details
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedTicket(null)}
>
Close
</Button>
</div>
{loadingTicket ? (
<Skeleton className="h-24 w-full" />
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<div>
<p className="text-sm text-muted-foreground">Ticket Number</p>
<p className="font-medium">{selectedTicket.ticketNumber}</p>
</div>
<div className="md:col-span-2">
<p className="text-sm text-muted-foreground">Description</p>
<p className="font-medium">{selectedTicket.title || '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Primary Resource</p>
<p className="font-medium">{selectedTicket.assignedResourceName || '-'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Status</p>
<Badge>{selectedTicket.status || '-'}</Badge>
</div>
<div>
<p className="text-sm text-muted-foreground">Created</p>
<p className="font-medium">
{selectedTicket.createDate ? format(new Date(selectedTicket.createDate), 'MMM d, yyyy') : '-'}
</p>
</div>
</div>
)}
{/* Time Entries Timeline */}
<div className="mt-4">
<Button
variant="outline"
size="sm"
onClick={() => {
if (!showTimeEntries && timeEntries.length === 0) {
fetchTimeEntries(selectedTicket.id);
} else {
setShowTimeEntries(!showTimeEntries);
}
}}
className="w-full"
>
{showTimeEntries ? 'Hide' : 'Show'} Time Entries Timeline
{timeEntries.length > 0 && ` (${timeEntries.length})`}
</Button>
{loadingTimeEntries && (
<div className="mt-4">
<Skeleton className="h-32 w-full" />
</div>
)}
{showTimeEntries && timeEntries.length > 0 && (
<div className="mt-4 space-y-3">
{timeEntries.map((entry: any, index: number) => (
<div key={index} className="flex gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border">
<div className="flex-shrink-0 w-1 bg-blue-500 rounded"></div>
<div className="flex-1">
<div className="flex items-start justify-between">
<div>
<p className="font-medium text-sm">{entry.resourceName || 'Unknown Resource'}</p>
<p className="text-xs text-muted-foreground">
{entry.dateWorked ? format(new Date(entry.dateWorked), 'MMM d, yyyy') : '-'}
</p>
</div>
<Badge variant="secondary">{entry.hoursWorked || 0}h</Badge>
</div>
{entry.summaryNotes && (
<p className="text-sm mt-2 text-muted-foreground">{entry.summaryNotes}</p>
)}
</div>
</div>
))}
</div>
)}
{showTimeEntries && timeEntries.length === 0 && !loadingTimeEntries && (
<p className="text-sm text-muted-foreground mt-4 text-center">No time entries found</p>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,198 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Ticket, AlertCircle, Filter } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { format } from 'date-fns';
interface RelatedTicketsModalProps {
configItemId: number;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function RelatedTicketsModal({
configItemId,
open,
onOpenChange,
}: RelatedTicketsModalProps) {
const [tickets, setTickets] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hideRmmAlerts, setHideRmmAlerts] = useState(true);
useEffect(() => {
if (open && configItemId) {
fetchRelatedTickets();
}
}, [open, configItemId]);
const fetchRelatedTickets = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/config-items/${configItemId}/tickets`);
if (!response.ok) {
throw new Error('Failed to fetch related tickets');
}
const result = await response.json();
setTickets(result.tickets || []);
} catch (err) {
console.error('Error fetching related tickets:', err);
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-[95vw] !w-[95vw] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-start justify-between">
<div>
<DialogTitle className="flex items-center gap-2">
<Ticket className="w-5 h-5" />
Related Tickets
</DialogTitle>
<DialogDescription>
Tickets associated with this configuration item
</DialogDescription>
</div>
{!loading && !error && tickets.length > 0 && (
<div className="flex gap-3 mr-8">
<div className="px-3 py-2 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-950 dark:to-blue-900 rounded-lg">
<p className="text-xs text-muted-foreground">Total Tickets</p>
<p className="text-sm font-bold">{tickets.length}</p>
</div>
<div className="px-3 py-2 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950 dark:to-green-900 rounded-lg">
<p className="text-xs text-muted-foreground">Open</p>
<p className="text-sm font-bold">
{tickets.filter((t: any) => t.status !== 'Complete' && t.status !== 'Closed').length}
</p>
</div>
</div>
)}
</div>
</DialogHeader>
{loading && (
<div className="space-y-4 py-4">
<Skeleton className="h-64 w-full" />
</div>
)}
{error && (
<div className="flex items-center gap-2 text-red-500 p-4 bg-red-50 dark:bg-red-950/20 rounded-lg">
<AlertCircle className="w-5 h-5" />
<p>Error: {error}</p>
</div>
)}
{!loading && !error && (
<div className="py-4">
{/* Filter Toggle */}
<div className="flex items-center gap-2 mb-4 p-3 bg-muted/50 rounded-lg">
<Filter className="w-4 h-4 text-muted-foreground" />
<Label htmlFor="hide-rmm-alerts" className="text-sm cursor-pointer flex-1">
Hide RMM Alert Tickets
</Label>
<Switch
id="hide-rmm-alerts"
checked={hideRmmAlerts}
onCheckedChange={setHideRmmAlerts}
/>
</div>
{tickets.filter((ticket: any) => !hideRmmAlerts || ticket.source !== 'RMM Alert').length > 0 ? (
<div className="border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-28">Ticket #</TableHead>
<TableHead className="min-w-[300px]">Title</TableHead>
<TableHead className="w-32">Status</TableHead>
<TableHead className="w-32">Priority</TableHead>
<TableHead className="w-40">Assigned To</TableHead>
<TableHead className="w-32">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tickets
.filter((ticket: any) => !hideRmmAlerts || ticket.source !== 'RMM Alert')
.map((ticket: any) => (
<TableRow key={ticket.id} className="hover:bg-muted/50 transition-colors">
<TableCell className="font-mono font-semibold text-blue-600 dark:text-blue-400">
{ticket.ticketNumber}
</TableCell>
<TableCell className="min-w-[300px]">
<div className="text-sm font-medium whitespace-normal break-words">
{ticket.title || '-'}
</div>
</TableCell>
<TableCell>
<Badge
variant={ticket.status === 'Complete' || ticket.status === 'Closed' ? 'secondary' : 'default'}
>
{ticket.status || '-'}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={ticket.priority === 'High' || ticket.priority === 'Critical' ? 'destructive' : 'secondary'}
>
{ticket.priority || '-'}
</Badge>
</TableCell>
<TableCell className="text-sm">{ticket.assignedResourceName || '-'}</TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{ticket.createDate ? format(new Date(ticket.createDate), 'MMM d, yyyy') : '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<AlertCircle className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p className="font-medium">
{hideRmmAlerts && tickets.length > 0
? 'All tickets are RMM Alerts (filtered out)'
: 'No related tickets found'
}
</p>
<p className="text-sm mt-2">
{hideRmmAlerts && tickets.length > 0
? 'Toggle the filter above to show RMM Alert tickets'
: 'This configuration item has no associated tickets'
}
</p>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,337 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import {
Monitor,
Info,
Network,
HardDrive,
Shield,
Wifi,
XCircle
} from 'lucide-react';
import { format } from 'date-fns';
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
import { lookupDeviceBySerial, formatDeviceInfo } from '@/lib/utils/device-lookup';
interface RMMTabProps {
device?: DattoRMMDevice;
}
export function RMMTab({ device }: RMMTabProps) {
if (!device) {
return (
<Card className="border-0 shadow-lg">
<CardContent className="pt-6">
<div className="text-center py-12 text-muted-foreground">
<Monitor className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No RMM data available for this device</p>
</div>
</CardContent>
</Card>
);
}
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">
<CardTitle className="text-lg">RMM Device Information</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<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>Hostname</Label>
<p className="text-sm text-muted-foreground mt-1">{device.hostname}</p>
</div>
<div>
<Label>Description</Label>
<p className="text-sm text-muted-foreground mt-1">
{device.description || '-'}
</p>
</div>
<div>
<Label>Serial Number</Label>
<p className="text-sm text-muted-foreground font-mono mt-1">
{device.serialNumber || '-'}
</p>
</div>
<div>
<Label>Device Type</Label>
<p className="text-sm text-muted-foreground mt-1">
{device.deviceType?.type || '-'}
</p>
</div>
<div>
<Label>Status</Label>
<div className="flex items-center gap-2 mt-1">
{device.online ? (
<Badge variant="default" className="bg-green-600">
<Wifi className="w-3 h-3 mr-1" />
Online
</Badge>
) : (
<Badge variant="secondary">
<XCircle className="w-3 h-3 mr-1" />
Offline
</Badge>
)}
{device.rebootRequired && (
<Badge variant="outline" className="text-orange-600">
Reboot Required
</Badge>
)}
</div>
</div>
</div>
</div>
{/* Network Information */}
<div className="space-y-4">
<h3 className="font-semibold flex items-center gap-2">
<Network className="w-4 h-4" />
Network Information
</h3>
<div className="space-y-3">
<div>
<Label>Internal IP</Label>
<p className="text-sm text-muted-foreground font-mono mt-1">
{device.intIpAddress || '-'}
</p>
</div>
<div>
<Label>External IP</Label>
<p className="text-sm text-muted-foreground font-mono mt-1">
{device.extIpAddress || '-'}
</p>
</div>
<div>
<Label>MAC Addresses</Label>
<div className="text-sm text-muted-foreground font-mono mt-1">
{device.macAddresses && device.macAddresses.length > 0 ? (
device.macAddresses.map((mac, i) => (
<div key={i}>{mac}</div>
))
) : (
'-'
)}
</div>
</div>
<div>
<Label>Domain</Label>
<p className="text-sm text-muted-foreground mt-1">
{device.domain || '-'}
</p>
</div>
<div>
<Label>Last Seen</Label>
<p className="text-sm text-muted-foreground mt-1">
{device.lastSeen ?
format(new Date(device.lastSeen), 'MMM d, yyyy h:mm a') :
'-'}
</p>
</div>
<div>
<Label>Last User</Label>
<p className="text-sm text-muted-foreground mt-1">
{device.lastLoggedInUser || '-'}
</p>
</div>
</div>
</div>
{/* System Information */}
<div className="space-y-4">
<h3 className="font-semibold flex items-center gap-2">
<HardDrive className="w-4 h-4" />
System Information
</h3>
<div className="space-y-3">
<div>
<Label>Operating System</Label>
<p className="text-sm text-muted-foreground mt-1">
{device.operatingSystem || '-'}
</p>
{device.a64Bit !== undefined && (
<Badge variant="outline" className="text-xs mt-1">
{device.a64Bit ? '64-bit' : '32-bit'}
</Badge>
)}
</div>
<div>
<Label>Device Category</Label>
<p className="text-sm text-muted-foreground mt-1">
{device.deviceType?.category || '-'}
</p>
</div>
<div>
<Label>Manufacturer</Label>
<p className="text-sm text-muted-foreground mt-1">
{(() => {
if (device.manufacturer) return device.manufacturer;
const deviceInfo = lookupDeviceBySerial(device.serialNumber);
return deviceInfo?.manufacturer || '-';
})()}
</p>
</div>
<div>
<Label>Model</Label>
<p className="text-sm text-muted-foreground mt-1">
{(() => {
if (device.model) return device.model;
const deviceInfo = lookupDeviceBySerial(device.serialNumber);
return deviceInfo?.estimatedModel || deviceInfo?.modelFamily || '-';
})()}
</p>
{!device.model && device.serialNumber && (
<p className="text-xs text-muted-foreground mt-1 italic">
Estimated from serial: {device.serialNumber}
</p>
)}
</div>
<div>
<Label>CPU</Label>
<p className="text-sm text-muted-foreground mt-1">
{device.cpuName || '-'} {device.cpuCores ? `(${device.cpuCores} cores)` : ''}
</p>
</div>
<div>
<Label>Memory</Label>
<p className="text-sm text-muted-foreground mt-1">
{device.memory ? `${(device.memory / 1024).toFixed(2)} GB` : '-'}
</p>
</div>
<div>
<Label>Total Disk Size</Label>
<p className="text-sm text-muted-foreground mt-1">
{device.diskSize ? `${(device.diskSize / (1024 * 1024 * 1024)).toFixed(2)} GB` : '-'}
</p>
</div>
<div>
<Label>Agent Version</Label>
<p className="text-sm text-muted-foreground mt-1">
{device.displayVersion || device.cagVersion || '-'}
</p>
</div>
<div>
<Label>Last Reboot</Label>
<p className="text-sm text-muted-foreground mt-1">
{device.lastReboot ?
format(new Date(device.lastReboot), 'MMM d, yyyy h:mm a') :
'-'}
</p>
</div>
<div>
<Label>Created Date</Label>
<p className="text-sm text-muted-foreground mt-1">
{device.creationDate ?
format(new Date(device.creationDate), 'MMM d, yyyy') :
'-'}
</p>
</div>
{device.warrantyDate && (
<div>
<Label>Warranty Expiration</Label>
<p className="text-sm text-muted-foreground mt-1">
{format(new Date(device.warrantyDate), 'MMM d, yyyy')}
</p>
</div>
)}
</div>
</div>
{/* Security Information */}
<div className="space-y-4">
<h3 className="font-semibold flex items-center gap-2">
<Shield className="w-4 h-4" />
Security Information
</h3>
<div className="space-y-3">
<div>
<Label>Antivirus</Label>
<div className="space-y-1 mt-1">
<p className="text-sm text-muted-foreground">
{device.antivirus?.antivirusProduct || 'Not detected'}
</p>
{device.antivirus?.antivirusStatus && (
<Badge
variant={device.antivirus.antivirusStatus === 'RunningAndUpToDate' ? 'default' : 'secondary'}
className={device.antivirus.antivirusStatus === 'RunningAndUpToDate' ? 'bg-green-600' : ''}
>
{device.antivirus.antivirusStatus.replace(/([A-Z])/g, ' $1').trim()}
</Badge>
)}
</div>
</div>
<div>
<Label>Patch Management</Label>
<div className="space-y-2 mt-1">
{device.patchManagement?.patchStatus && (
<Badge
variant={device.patchManagement.patchStatus === 'FullyPatched' ? 'default' : 'secondary'}
className={device.patchManagement.patchStatus === 'FullyPatched' ? 'bg-green-600' : ''}
>
{device.patchManagement.patchStatus.replace(/([A-Z])/g, ' $1').trim()}
</Badge>
)}
<div className="flex gap-4">
<div>
<p className="text-xs text-muted-foreground">Pending</p>
<p className="text-lg font-semibold text-orange-600">
{device.patchManagement?.patchesApprovedPending || 0}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Installed</p>
<p className="text-lg font-semibold text-green-600">
{device.patchManagement?.patchesInstalled || 0}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Not Approved</p>
<p className="text-lg font-semibold text-gray-600">
{device.patchManagement?.patchesNotApproved || 0}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,110 @@
'use client';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Server,
Monitor,
Power,
Clock,
CheckCircle,
XCircle
} from 'lucide-react';
import { format } from 'date-fns';
import { ConfigurationItem } from '@/lib/types/autotask';
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
interface StatusCardsProps {
device?: ConfigurationItem;
rmmDevice?: DattoRMMDevice;
}
export function StatusCards({ device, rmmDevice }: StatusCardsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="border-0 shadow-lg">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Status</p>
<div className="flex items-center gap-2 mt-1">
{device?.isActive ? (
<Badge variant="default" className="bg-green-600">
<CheckCircle className="w-3 h-3 mr-1" />
Active
</Badge>
) : (
<Badge variant="secondary">
<XCircle className="w-3 h-3 mr-1" />
Inactive
</Badge>
)}
</div>
</div>
<Power className={`h-5 w-5 ${device?.isActive ? 'text-green-600' : 'text-gray-400'}`} />
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-lg">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">PSA</p>
<div className="flex items-center gap-2 mt-1">
{device ? (
<CheckCircle className="w-4 h-4 text-green-600" />
) : (
<XCircle className="w-4 h-4 text-gray-400" />
)}
<span className="text-sm font-medium">
{device ? 'Connected' : 'Not Found'}
</span>
</div>
</div>
<Server className="h-5 w-5 text-purple-600" />
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-lg">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">RMM</p>
<div className="flex items-center gap-2 mt-1">
{rmmDevice ? (
<CheckCircle className="w-4 h-4 text-green-600" />
) : (
<XCircle className="w-4 h-4 text-gray-400" />
)}
<span className="text-sm font-medium">
{rmmDevice ? 'Connected' : 'Not Found'}
</span>
</div>
</div>
<Monitor className="h-5 w-5 text-blue-600" />
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-lg">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Last Seen</p>
<p className="text-sm font-medium mt-1">
{device?.lastModifiedTime ?
format(new Date(device.lastModifiedTime), 'MMM d, yyyy') :
rmmDevice?.lastSeen ?
format(new Date(rmmDevice.lastSeen), 'MMM d, yyyy') :
'-'}
</p>
</div>
<Clock className="h-5 w-5 text-orange-600" />
</div>
</CardContent>
</Card>
</div>
);
}