Add comprehensive admin features and multi-system integration
- Add admin dashboard with sync controls and data browser - Implement RMM, Auvik, and Addigy organization mappings - Add chunked ticket sync with progress tracking - Implement entity sync service with rate limiting - Add analytics engine and performance optimizer - Create data browser for all PSA entities - Add navigation components and UI improvements - Implement background processing and sync services - Add comprehensive documentation and migration scripts - Update configuration items with multi-system support - Enhance contact management and purchase history - Add issue type assignment and LLM analyzer - Improve error handling and logging utilities
This commit is contained in:
parent
e8462ef301
commit
6eee14f8af
171 changed files with 32671 additions and 621 deletions
360
components/configuration-items/addigy-tab.tsx
Normal file
360
components/configuration-items/addigy-tab.tsx
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Smartphone,
|
||||
Info,
|
||||
HardDrive,
|
||||
Shield,
|
||||
Wifi,
|
||||
XCircle,
|
||||
Battery,
|
||||
CheckCircle,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { AddigyDevice } from '@/lib/types/addigy';
|
||||
|
||||
interface AddigyTabProps {
|
||||
device?: AddigyDevice;
|
||||
}
|
||||
|
||||
export function AddigyTab({ device }: AddigyTabProps) {
|
||||
if (!device) {
|
||||
return (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Smartphone className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No Addigy data available for this device</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const isOnline = device.online;
|
||||
const batteryPercentage = device['Battery Percentage'];
|
||||
const isCharging = device['Battery Charging'];
|
||||
const freeSpacePercentage = device['Free Disk Percentage'];
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader className="bg-gradient-to-r from-orange-50 to-orange-100 dark:from-orange-950 dark:to-orange-900 rounded-t-lg">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Smartphone className="w-5 h-5" />
|
||||
Addigy Device Information (Apple RMM)
|
||||
</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>Device Name</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">{device['Device Name']}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Model</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device['Device Model Name'] || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Serial Number</Label>
|
||||
<p className="text-sm text-muted-foreground font-mono mt-1">
|
||||
{device['Serial Number'] || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Current User</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device['Current User'] || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Status</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{isOnline ? (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{device['Last Check In'] && (
|
||||
<div>
|
||||
<Label>Last Check In</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{new Date(device['Last Check In']).toLocaleString()}
|
||||
</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['MAC OS X Version'] || device['iOS Version'] || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{device['Processor Type'] && (
|
||||
<div>
|
||||
<Label>Processor</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device['Processor Type']}
|
||||
{device['Processor Speed (GHz)'] && ` @ ${device['Processor Speed (GHz)']} GHz`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device['Total Disk Space (GB)'] && (
|
||||
<div>
|
||||
<Label>Disk Space</Label>
|
||||
<div className="mt-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{device['Free Disk Space (GB)']} GB free of {device['Total Disk Space (GB)']} GB
|
||||
</p>
|
||||
{freeSpacePercentage !== undefined && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${
|
||||
freeSpacePercentage < 10 ? 'bg-red-500' :
|
||||
freeSpacePercentage < 20 ? 'bg-orange-500' :
|
||||
'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${freeSpacePercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{freeSpacePercentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batteryPercentage !== undefined && (
|
||||
<div>
|
||||
<Label>Battery</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Battery className={`w-4 h-4 ${isCharging ? 'text-green-600' : ''}`} />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{batteryPercentage}%
|
||||
{isCharging && ' (Charging)'}
|
||||
</span>
|
||||
{device['Battery Capacity Loss Percentage'] !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({device['Battery Capacity Loss Percentage']}% capacity loss)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>Agent Version</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device['Agent Version'] || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{device.Timezone && (
|
||||
<div>
|
||||
<Label>Timezone</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device.Timezone}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security & Features */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
Security & Features
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Firewall</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{device['Firewall Enabled'] ? (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Enabled
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
Disabled
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>FileVault</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{device['FileVault Enabled'] ? (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Enabled
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
Disabled
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{device['Remote Login Enabled'] !== undefined && (
|
||||
<div>
|
||||
<Label>Remote Login</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{device['Remote Login Enabled'] ? (
|
||||
<Badge variant="outline">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Enabled
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
Disabled
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device['SMART Failing'] !== undefined && (
|
||||
<div>
|
||||
<Label>SMART Status</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{device['SMART Failing'] ? (
|
||||
<Badge variant="destructive">
|
||||
<AlertCircle className="w-3 h-3 mr-1" />
|
||||
Failing
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Healthy
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device['Has Wireless'] !== undefined && (
|
||||
<div>
|
||||
<Label>Wireless Capability</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device['Has Wireless'] ? 'Yes' : 'No'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device['XCode Installed'] !== undefined && (
|
||||
<div>
|
||||
<Label>XCode</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device['XCode Installed'] ? 'Installed' : 'Not Installed'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Information */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<Info className="w-4 h-4" />
|
||||
Additional Information
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Agent ID</Label>
|
||||
<p className="text-sm text-muted-foreground font-mono mt-1">
|
||||
{device.agentid}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Policy ID</Label>
|
||||
<p className="text-sm text-muted-foreground font-mono mt-1">
|
||||
{device.policy_id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{device['Warranty Expiration Date'] && (
|
||||
<div>
|
||||
<Label>Warranty</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Expires: {new Date(device['Warranty Expiration Date']).toLocaleDateString()}
|
||||
{device['Warranty Days Left'] !== undefined && (
|
||||
<span className="ml-2">({device['Warranty Days Left']} days left)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device['TeamViewer Client Id'] && (
|
||||
<div>
|
||||
<Label>TeamViewer ID</Label>
|
||||
<p className="text-sm text-muted-foreground font-mono mt-1">
|
||||
{device['TeamViewer Client Id']}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device['Displays Serial Number'] && device['Displays Serial Number'].length > 0 && (
|
||||
<div>
|
||||
<Label>Display Serial Numbers</Label>
|
||||
<div className="text-sm text-muted-foreground font-mono mt-1 space-y-1">
|
||||
{device['Displays Serial Number'].map((serial, idx) => (
|
||||
<div key={idx}>{serial}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
245
components/configuration-items/auvik-tab.tsx
Normal file
245
components/configuration-items/auvik-tab.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { AuvikDevice } from '@/lib/types/auvik';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Network, Info, Wifi, XCircle, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface AuvikTabProps {
|
||||
device?: AuvikDevice;
|
||||
}
|
||||
|
||||
export function AuvikTab({ device }: AuvikTabProps) {
|
||||
if (!device) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Network className="w-16 h-16 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No Auvik data available</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
This device is not monitored by Auvik or could not be matched to an Auvik device.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const getStatusBadge = () => {
|
||||
switch (device.onlineStatus) {
|
||||
case 'online':
|
||||
return (
|
||||
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100">
|
||||
<Wifi className="w-3 h-3 mr-1" />
|
||||
Online
|
||||
</Badge>
|
||||
);
|
||||
case 'offline':
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
Offline
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge variant="outline">
|
||||
<AlertCircle className="w-3 h-3 mr-1" />
|
||||
Unknown
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="w-5 h-5 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold">{device.deviceName}</h3>
|
||||
</div>
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Basic Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Info className="w-4 h-4" />
|
||||
Basic Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Device Name</Label>
|
||||
<p className="text-sm font-medium">{device.deviceName || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Device Type</Label>
|
||||
<p className="text-sm font-medium capitalize">{device.deviceType || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Serial Number</Label>
|
||||
<p className="text-sm font-medium font-mono">{device.serialNumber || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Manufacturer</Label>
|
||||
<p className="text-sm font-medium">{device.manufacturer || device.vendorName || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Model</Label>
|
||||
<p className="text-sm font-medium">{device.model || device.makeModel || 'N/A'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Network Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Network className="w-4 h-4" />
|
||||
Network Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">IP Addresses</Label>
|
||||
{device.ipAddresses && device.ipAddresses.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{device.ipAddresses.map((ip, index) => (
|
||||
<Badge key={index} variant="outline" className="font-mono text-xs">
|
||||
{ip}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">N/A</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">MAC Addresses</Label>
|
||||
{device.macAddresses && device.macAddresses.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{device.macAddresses.map((mac, index) => (
|
||||
<Badge key={index} variant="outline" className="font-mono text-xs">
|
||||
{mac}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">N/A</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Tenant</Label>
|
||||
<p className="text-sm font-medium">{device.tenantName || 'N/A'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Wifi className="w-4 h-4" />
|
||||
Status Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Online Status</Label>
|
||||
<div className="mt-1">{getStatusBadge()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Last Seen</Label>
|
||||
<p className="text-sm font-medium">{formatDate(device.lastSeenTime)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Firmware Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Info className="w-4 h-4" />
|
||||
Firmware Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Firmware Version</Label>
|
||||
<p className="text-sm font-medium font-mono">
|
||||
{device.firmwareVersion || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Software Version</Label>
|
||||
<p className="text-sm font-medium font-mono">
|
||||
{device.softwareVersion || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{device.description && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Description</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{device.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Network Interfaces */}
|
||||
{device.networkInterfaces && device.networkInterfaces.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Network className="w-4 h-4" />
|
||||
Network Interfaces
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{device.networkInterfaces.map((iface, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-2 border rounded-md"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{iface.interfaceName}</p>
|
||||
{iface.macAddress && (
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
{iface.macAddress}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{iface.ipAddress && (
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{iface.ipAddress}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant={iface.status === 'up' ? 'default' : 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
{iface.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
|
@ -12,18 +13,25 @@ import { Badge } from '@/components/ui/badge';
|
|||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PSATab } from './psa-tab';
|
||||
import { RMMTab } from './rmm-tab';
|
||||
import { AuvikTab } from './auvik-tab';
|
||||
import { AddigyTab } from './addigy-tab';
|
||||
import { StatusCards } from './status-cards';
|
||||
import {
|
||||
Server,
|
||||
Monitor,
|
||||
Network,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { ConfigurationItem } from '@/lib/types/autotask';
|
||||
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
|
||||
import { AuvikDevice } from '@/lib/types/auvik';
|
||||
import { AddigyDevice } from '@/lib/types/addigy';
|
||||
|
||||
interface ConfigItemDetail {
|
||||
autotaskDevice?: ConfigurationItem;
|
||||
rmmDevice?: DattoRMMDevice;
|
||||
auvikDevice?: AuvikDevice;
|
||||
addigyDevice?: AddigyDevice;
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
|
|
@ -32,9 +40,20 @@ interface ConfigItemModalProps {
|
|||
type?: 'autotask' | 'rmm';
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
rmmDevice?: DattoRMMDevice; // Pass RMM device directly from comparison
|
||||
auvikDevice?: AuvikDevice; // Pass Auvik device directly from comparison
|
||||
addigyDevice?: AddigyDevice; // Pass Addigy device directly from comparison
|
||||
}
|
||||
|
||||
export function ConfigItemModal({ itemId, type = 'autotask', open, onOpenChange }: ConfigItemModalProps) {
|
||||
export function ConfigItemModal({
|
||||
itemId,
|
||||
type = 'autotask',
|
||||
open,
|
||||
onOpenChange,
|
||||
rmmDevice: passedRmmDevice,
|
||||
auvikDevice: passedAuvikDevice,
|
||||
addigyDevice: passedAddigyDevice
|
||||
}: ConfigItemModalProps) {
|
||||
const [data, setData] = useState<ConfigItemDetail>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -48,14 +67,70 @@ export function ConfigItemModal({ itemId, type = 'autotask', open, onOpenChange
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log('Modal opening with:', {
|
||||
itemId,
|
||||
hasPassedRmmDevice: !!passedRmmDevice,
|
||||
hasPassedAuvikDevice: !!passedAuvikDevice,
|
||||
hasPassedAddigyDevice: !!passedAddigyDevice,
|
||||
passedRmmDevice,
|
||||
passedAuvikDevice,
|
||||
passedAddigyDevice
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/configuration-items/${itemId}?type=${type}`);
|
||||
// For RMM-only, Auvik-only, or Addigy-only devices, skip API call and use passed data
|
||||
if (itemId === 'rmm-only' || itemId === 'auvik-only' || itemId === 'addigy-only') {
|
||||
console.log('Using passed devices only (no Autotask record)');
|
||||
setData({
|
||||
autotaskDevice: undefined,
|
||||
rmmDevice: passedRmmDevice,
|
||||
auvikDevice: passedAuvikDevice,
|
||||
addigyDevice: passedAddigyDevice,
|
||||
companyName: passedRmmDevice?.siteName || passedAuvikDevice?.deviceName || passedAddigyDevice?.['Device Name'] || 'Unknown',
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have passed devices, use lightweight endpoint that only fetches PSA data
|
||||
// Otherwise use full endpoint that does RMM/Auvik/Addigy matching
|
||||
const endpoint = (passedRmmDevice || passedAuvikDevice || passedAddigyDevice)
|
||||
? `/api/configuration-items/${itemId}/lightweight`
|
||||
: `/api/configuration-items/${itemId}?type=${type}`;
|
||||
|
||||
console.log(`Using ${passedRmmDevice || passedAuvikDevice ? 'lightweight' : 'full'} endpoint`);
|
||||
|
||||
const response = await fetch(endpoint);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch configuration item');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
|
||||
console.log('Fetched result:', {
|
||||
hasAutotask: !!result.autotaskDevice,
|
||||
hasRmm: !!result.rmmDevice,
|
||||
hasAuvik: !!result.auvikDevice,
|
||||
hasAddigy: !!result.addigyDevice
|
||||
});
|
||||
|
||||
// Always prioritize passed devices over fetched data
|
||||
const finalData = {
|
||||
autotaskDevice: result.autotaskDevice,
|
||||
rmmDevice: passedRmmDevice || result.rmmDevice,
|
||||
auvikDevice: passedAuvikDevice || result.auvikDevice,
|
||||
addigyDevice: passedAddigyDevice || result.addigyDevice,
|
||||
companyName: result.companyName,
|
||||
};
|
||||
|
||||
console.log('Final data:', {
|
||||
hasAutotask: !!finalData.autotaskDevice,
|
||||
hasRmm: !!finalData.rmmDevice,
|
||||
hasAuvik: !!finalData.auvikDevice,
|
||||
hasAddigy: !!finalData.addigyDevice
|
||||
});
|
||||
|
||||
setData(finalData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
|
|
@ -64,7 +139,7 @@ export function ConfigItemModal({ itemId, type = 'autotask', open, onOpenChange
|
|||
};
|
||||
|
||||
fetchData();
|
||||
}, [itemId, type, open]);
|
||||
}, [itemId, type, open, passedRmmDevice, passedAuvikDevice, passedAddigyDevice]);
|
||||
|
||||
const handleUpdate = (updatedDevice: ConfigurationItem) => {
|
||||
setData({ ...data, autotaskDevice: updatedDevice });
|
||||
|
|
@ -72,6 +147,8 @@ export function ConfigItemModal({ itemId, type = 'autotask', open, onOpenChange
|
|||
|
||||
const device = data.autotaskDevice;
|
||||
const rmmDevice = data.rmmDevice;
|
||||
const auvikDevice = data.auvikDevice;
|
||||
const addigyDevice = data.addigyDevice;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
|
@ -95,6 +172,9 @@ export function ConfigItemModal({ itemId, type = 'autotask', open, onOpenChange
|
|||
)}
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
View and manage configuration item details from PSA, RMM, Auvik, and Addigy systems
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
|
|
@ -116,7 +196,7 @@ export function ConfigItemModal({ itemId, type = 'autotask', open, onOpenChange
|
|||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="psa" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="psa" className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4" />
|
||||
PSA Data
|
||||
|
|
@ -135,6 +215,24 @@ export function ConfigItemModal({ itemId, type = 'autotask', open, onOpenChange
|
|||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="auvik" className="flex items-center gap-2">
|
||||
<Network className="w-4 h-4" />
|
||||
Auvik Data
|
||||
{auvikDevice && (
|
||||
<Badge variant="outline" className="ml-1">
|
||||
{auvikDevice.onlineStatus === 'online' ? 'Online' : 'Offline'}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="addigy" className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
Addigy Data
|
||||
{addigyDevice && (
|
||||
<Badge variant="outline" className="ml-1">
|
||||
{addigyDevice.online ? 'Online' : 'Offline'}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="psa">
|
||||
|
|
@ -144,6 +242,14 @@ export function ConfigItemModal({ itemId, type = 'autotask', open, onOpenChange
|
|||
<TabsContent value="rmm">
|
||||
<RMMTab device={rmmDevice} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="auvik">
|
||||
<AuvikTab device={auvikDevice} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="addigy">
|
||||
<AddigyTab device={addigyDevice} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,58 +1,31 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { User } from 'lucide-react';
|
||||
|
||||
interface ContactCellProps {
|
||||
contactId?: number;
|
||||
contacts?: Record<number, any>;
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
export function ContactCell({ contactId, contacts }: ContactCellProps) {
|
||||
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>
|
||||
);
|
||||
// Get contact from the contacts map
|
||||
const contact = contacts?.[contactId];
|
||||
|
||||
if (contact) {
|
||||
const contactName = `${contact.firstName || ''} ${contact.lastName || ''}`.trim();
|
||||
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>;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,16 @@ import {
|
|||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Server,
|
||||
Edit,
|
||||
|
|
@ -29,7 +39,8 @@ import {
|
|||
RefreshCw,
|
||||
User,
|
||||
Receipt,
|
||||
Ticket as TicketIcon
|
||||
Ticket as TicketIcon,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ConfigurationItem } from '@/lib/types/autotask';
|
||||
|
|
@ -50,6 +61,15 @@ export function PSATab({ device, onUpdate }: PSATabProps) {
|
|||
const [loadingContact, setLoadingContact] = useState(false);
|
||||
const [purchaseHistoryOpen, setPurchaseHistoryOpen] = useState(false);
|
||||
const [relatedTicketsOpen, setRelatedTicketsOpen] = useState(false);
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [selectedFields, setSelectedFields] = useState<Set<string>>(new Set());
|
||||
|
||||
// Sync editedData when device prop changes
|
||||
useEffect(() => {
|
||||
if (device) {
|
||||
setEditedData(device);
|
||||
}
|
||||
}, [device]);
|
||||
|
||||
// Fetch contact information if contactID exists
|
||||
useEffect(() => {
|
||||
|
|
@ -119,12 +139,15 @@ export function PSATab({ device, onUpdate }: PSATabProps) {
|
|||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update configuration item');
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to update configuration item');
|
||||
}
|
||||
|
||||
const updated = await response.json();
|
||||
onUpdate(updated.configurationItem);
|
||||
setEditedData({ ...editedData, isActive: false });
|
||||
if (updated.configurationItem) {
|
||||
onUpdate(updated.configurationItem);
|
||||
setEditedData(updated.configurationItem);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to make inactive');
|
||||
} finally {
|
||||
|
|
@ -132,6 +155,106 @@ export function PSATab({ device, onUpdate }: PSATabProps) {
|
|||
}
|
||||
};
|
||||
|
||||
// Define available fields for export
|
||||
const availableFields = [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'referenceTitle', label: 'Reference Title' },
|
||||
{ key: 'referenceNumber', label: 'Reference Number' },
|
||||
{ key: 'serialNumber', label: 'Serial Number' },
|
||||
{ key: 'location', label: 'Location' },
|
||||
{ key: 'isActive', label: 'Active Status' },
|
||||
{ key: 'modelNumber', label: 'Model Number' },
|
||||
{ key: 'macAddress', label: 'MAC Address' },
|
||||
{ key: 'installDate', label: 'Install Date' },
|
||||
{ key: 'warrantyExpirationDate', label: 'Warranty Expiration' },
|
||||
{ key: 'rmmDeviceUID', label: 'RMM Device UID' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'companyID', label: 'Company ID' },
|
||||
{ key: 'contactID', label: 'Contact ID' },
|
||||
{ key: 'contractID', label: 'Contract ID' },
|
||||
{ key: 'createDate', label: 'Create Date' },
|
||||
{ key: 'lastModifiedTime', label: 'Last Modified Time' },
|
||||
{ key: 'productID', label: 'Product ID' },
|
||||
{ key: 'vendorName', label: 'Vendor Name' },
|
||||
{ key: 'deviceNetworkingID', label: 'Device Networking ID' },
|
||||
{ key: 'numberOfUsers', label: 'Number of Users' },
|
||||
{ key: 'setupFee', label: 'Setup Fee' },
|
||||
];
|
||||
|
||||
const toggleField = (fieldKey: string) => {
|
||||
const newSelected = new Set(selectedFields);
|
||||
if (newSelected.has(fieldKey)) {
|
||||
newSelected.delete(fieldKey);
|
||||
} else {
|
||||
newSelected.add(fieldKey);
|
||||
}
|
||||
setSelectedFields(newSelected);
|
||||
};
|
||||
|
||||
const toggleAllFields = () => {
|
||||
if (selectedFields.size === availableFields.length) {
|
||||
setSelectedFields(new Set());
|
||||
} else {
|
||||
setSelectedFields(new Set(availableFields.map(f => f.key)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (!device || selectedFields.size === 0) return;
|
||||
|
||||
// Build CSV header
|
||||
const headers = availableFields
|
||||
.filter(f => selectedFields.has(f.key))
|
||||
.map(f => f.label);
|
||||
|
||||
// Build CSV row
|
||||
const row = availableFields
|
||||
.filter(f => selectedFields.has(f.key))
|
||||
.map(f => {
|
||||
const value = device[f.key as keyof ConfigurationItem];
|
||||
|
||||
// Format dates
|
||||
if ((f.key === 'installDate' || f.key === 'warrantyExpirationDate' ||
|
||||
f.key === 'createDate' || f.key === 'lastModifiedTime') && value) {
|
||||
return format(new Date(value as string), 'yyyy-MM-dd HH:mm:ss');
|
||||
}
|
||||
|
||||
// Handle boolean
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Active' : 'Inactive';
|
||||
}
|
||||
|
||||
// Handle null/undefined
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Escape quotes and wrap in quotes if contains comma or newline
|
||||
const stringValue = String(value);
|
||||
if (stringValue.includes(',') || stringValue.includes('\n') || stringValue.includes('"')) {
|
||||
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
return stringValue;
|
||||
});
|
||||
|
||||
// Create CSV content
|
||||
const csvContent = [headers.join(','), row.join(',')].join('\n');
|
||||
|
||||
// Create and trigger download
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `config-item-${device.id}-${format(new Date(), 'yyyy-MM-dd')}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
setExportModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="border-0 shadow-lg">
|
||||
|
|
@ -141,6 +264,15 @@ export function PSATab({ device, onUpdate }: PSATabProps) {
|
|||
<div className="flex items-center gap-2">
|
||||
{!editMode ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExportModalOpen(true)}
|
||||
disabled={!device}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -451,6 +583,69 @@ export function PSATab({ device, onUpdate }: PSATabProps) {
|
|||
onOpenChange={setRelatedTicketsOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Export Modal */}
|
||||
<Dialog open={exportModalOpen} onOpenChange={setExportModalOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export Configuration Item</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select the fields you want to include in the CSV export
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between pb-2 border-b">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="select-all"
|
||||
checked={selectedFields.size === availableFields.length}
|
||||
onCheckedChange={toggleAllFields}
|
||||
/>
|
||||
<Label htmlFor="select-all" className="font-semibold cursor-pointer">
|
||||
Select All ({selectedFields.size}/{availableFields.length})
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[400px] pr-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{availableFields.map((field) => (
|
||||
<div key={field.key} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={field.key}
|
||||
checked={selectedFields.has(field.key)}
|
||||
onCheckedChange={() => toggleField(field.key)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={field.key}
|
||||
className="text-sm cursor-pointer font-normal"
|
||||
>
|
||||
{field.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setExportModalOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={selectedFields.size === 0}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -226,19 +226,19 @@ export function PurchaseHistoryModal({
|
|||
<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)}
|
||||
${data.billingItems.reduce((sum: number, item: any) => sum + (Number(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)}
|
||||
${data.billingItems.reduce((sum: number, item: any) => sum + (Number(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)}
|
||||
${data.billingItems.reduce((sum: number, item: any) => sum + (Number(item.profit) || 0), 0).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue