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:
parent
f429f3af54
commit
3c3124d8c9
117 changed files with 8433 additions and 239 deletions
153
components/configuration-items/config-item-modal.tsx
Normal file
153
components/configuration-items/config-item-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
components/configuration-items/contact-cell.tsx
Normal file
59
components/configuration-items/contact-cell.tsx
Normal 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>;
|
||||
}
|
||||
456
components/configuration-items/psa-tab.tsx
Normal file
456
components/configuration-items/psa-tab.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
571
components/configuration-items/purchase-history-modal.tsx
Normal file
571
components/configuration-items/purchase-history-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
components/configuration-items/related-tickets-modal.tsx
Normal file
198
components/configuration-items/related-tickets-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
337
components/configuration-items/rmm-tab.tsx
Normal file
337
components/configuration-items/rmm-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
components/configuration-items/status-cards.tsx
Normal file
110
components/configuration-items/status-cards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue