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
193
app/addigy-devices/page.tsx
Normal file
193
app/addigy-devices/page.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AddigyDevice } from '@/lib/types/addigy';
|
||||
|
||||
export default function AddigyDevicesPage() {
|
||||
const [devices, setDevices] = useState<AddigyDevice[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filterOnline, setFilterOnline] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevices();
|
||||
}, [filterOnline]);
|
||||
|
||||
const fetchDevices = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const url = filterOnline
|
||||
? '/api/addigy-devices?online=true'
|
||||
: '/api/addigy-devices';
|
||||
|
||||
const response = await fetch(url);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setDevices(result.data);
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch devices');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">Addigy Devices</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filterOnline}
|
||||
onChange={(e) => setFilterOnline(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>Online Only</span>
|
||||
</label>
|
||||
<button
|
||||
onClick={fetchDevices}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Loading...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded mb-4">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
<p className="mt-4 text-gray-600">Loading devices...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 text-gray-600">
|
||||
Found {devices.length} device{devices.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow-md rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Device Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Model
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
OS Version
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Current User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Free Disk
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Security
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{devices.map((device) => (
|
||||
<tr key={device.agentid} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{device['Device Name']}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{device['Serial Number'] || 'N/A'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{device['Device Model Name'] || 'Unknown'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{device['MAC OS X Version'] ||
|
||||
device['iOS Version'] ||
|
||||
'N/A'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{device['Current User'] || 'N/A'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
device.online
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{device.online ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{device['Free Disk Percentage'] !== undefined ? (
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className={`${
|
||||
device['Free Disk Percentage'] < 20
|
||||
? 'text-red-600'
|
||||
: device['Free Disk Percentage'] < 40
|
||||
? 'text-yellow-600'
|
||||
: 'text-green-600'
|
||||
}`}
|
||||
>
|
||||
{device['Free Disk Percentage']}%
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
'N/A'
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span
|
||||
className={`text-xs ${
|
||||
device['Firewall Enabled']
|
||||
? 'text-green-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
FW: {device['Firewall Enabled'] ? '✓' : '✗'}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs ${
|
||||
device['FileVault Enabled']
|
||||
? 'text-green-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
FV: {device['FileVault Enabled'] ? '✓' : '✗'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
app/api/addigy-devices/route.ts
Normal file
61
app/api/addigy-devices/route.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getAddigyClient } from '@/lib/services/addigy-factory';
|
||||
import { getCachedData, setCachedData } from '@/lib/services/redis-client';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const policyId = searchParams.get('policyId');
|
||||
const online = searchParams.get('online');
|
||||
|
||||
// Create cache key based on query parameters
|
||||
const cacheKey = `addigy:devices:${policyId || 'all'}:${online || 'all'}`;
|
||||
|
||||
// Try to get cached data
|
||||
const cachedDevices = await getCachedData<any[]>(cacheKey);
|
||||
if (cachedDevices) {
|
||||
console.log(`Cache hit for key: ${cacheKey}`);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: cachedDevices,
|
||||
count: cachedDevices.length,
|
||||
cached: true,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Cache miss for key: ${cacheKey}, fetching from API`);
|
||||
const addigyClient = getAddigyClient();
|
||||
|
||||
let devices;
|
||||
|
||||
if (policyId) {
|
||||
// Get devices by policy
|
||||
devices = await addigyClient.getDevicesByPolicy(policyId);
|
||||
} else if (online === 'true') {
|
||||
// Get only online devices
|
||||
devices = await addigyClient.getOnlineDevices();
|
||||
} else {
|
||||
// Get all devices
|
||||
devices = await addigyClient.getAllDevices();
|
||||
}
|
||||
|
||||
// Cache the result for 5 minutes
|
||||
await setCachedData(cacheKey, devices, 300);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: devices,
|
||||
count: devices.length,
|
||||
cached: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching Addigy devices:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
24
app/api/addigy-policies/route.ts
Normal file
24
app/api/addigy-policies/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getAddigyClient } from '@/lib/services/addigy-factory';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const addigyClient = getAddigyClient();
|
||||
const policies = await addigyClient.getAllPolicies();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: policies,
|
||||
count: policies.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching Addigy policies:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
21
app/api/companies/route.ts
Normal file
21
app/api/companies/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const client = getAutotaskClient();
|
||||
const allCompanies = await client.getAllCompanies();
|
||||
|
||||
// Filter to only show customers (companyType = 1)
|
||||
// companyType 1 = Customer, 2 = Lead, 3 = Prospect, 4 = Dead, 5 = Cancelation, 6 = Vendor, 7 = Partner
|
||||
const companies = allCompanies.filter(company => company.companyType === 1);
|
||||
|
||||
return NextResponse.json({ companies });
|
||||
} catch (error) {
|
||||
console.error('Error fetching companies:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch companies' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
201
app/api/config-enrichment/route.ts
Normal file
201
app/api/config-enrichment/route.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
|
||||
// Extract serial number from lineItemFullDescription
|
||||
function extractSerialNumber(description: string | null): string | null {
|
||||
if (!description) return null;
|
||||
|
||||
// Look for "Serial Number(s)" followed by the serial
|
||||
const match = description.match(/Serial Number\(s\)\s+([A-Z0-9]+)/i);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const configItemId = searchParams.get('configItemId');
|
||||
const invoiceId = searchParams.get('invoiceId');
|
||||
|
||||
// If invoice ID is provided, just return billing items for that invoice
|
||||
if (invoiceId) {
|
||||
const autotaskClient = getAutotaskClient();
|
||||
|
||||
const billingItems = await autotaskClient.queryEntity('BillingItems', {
|
||||
filter: [{ op: 'eq', field: 'invoiceID', value: parseInt(invoiceId) }],
|
||||
});
|
||||
|
||||
console.log(`Found ${billingItems.length} billing items for invoice ${invoiceId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
configItem: null,
|
||||
billingItems,
|
||||
invoices: [],
|
||||
tickets: [],
|
||||
summary: {
|
||||
totalBilled: billingItems.reduce((sum: number, item: any) => sum + (item.totalAmount || 0), 0),
|
||||
purchaseDate: null,
|
||||
lastInvoiceDate: null,
|
||||
ticketCount: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!configItemId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Configuration Item ID or Invoice ID is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const autotaskClient = getAutotaskClient();
|
||||
|
||||
// Get the configuration item
|
||||
const configItem = await autotaskClient.getConfigurationItemById(parseInt(configItemId));
|
||||
|
||||
if (!configItem) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Configuration item not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get date range from query params
|
||||
const startDateParam = searchParams.get('startDate');
|
||||
const endDateParam = searchParams.get('endDate');
|
||||
const daysBackParam = searchParams.get('daysBack');
|
||||
|
||||
let startDateFilter: string;
|
||||
let endDateFilter: string;
|
||||
|
||||
if (startDateParam && endDateParam) {
|
||||
// Use explicit date range
|
||||
startDateFilter = startDateParam;
|
||||
endDateFilter = endDateParam;
|
||||
console.log('Using explicit date range:', { startDateFilter, endDateFilter });
|
||||
} else {
|
||||
// Fallback to daysBack (for backward compatibility)
|
||||
const daysBack = parseInt(daysBackParam || '90');
|
||||
const dateAgo = new Date();
|
||||
dateAgo.setDate(dateAgo.getDate() - daysBack);
|
||||
startDateFilter = dateAgo.toISOString().split('T')[0];
|
||||
endDateFilter = new Date().toISOString().split('T')[0];
|
||||
console.log('Using daysBack:', daysBack);
|
||||
}
|
||||
|
||||
console.log('Searching for enrichment data:', {
|
||||
configItemId,
|
||||
companyID: configItem.companyID,
|
||||
serialNumber: configItem.serialNumber,
|
||||
startDate: startDateFilter,
|
||||
endDate: endDateFilter
|
||||
});
|
||||
|
||||
let invoices: any[] = [];
|
||||
try {
|
||||
invoices = await autotaskClient.queryEntity('Invoices', {
|
||||
filter: [
|
||||
{ op: 'eq', field: 'companyID', value: configItem.companyID },
|
||||
{ op: 'gte', field: 'invoiceDateTime', value: startDateFilter },
|
||||
{ op: 'lte', field: 'invoiceDateTime', value: endDateFilter }
|
||||
],
|
||||
});
|
||||
console.log(`Found ${invoices.length} invoices for company between ${startDateFilter} and ${endDateFilter}`);
|
||||
} catch (err) {
|
||||
console.error('Error fetching invoices:', err);
|
||||
}
|
||||
|
||||
// Get billing items (line items) for all invoices - hardware only
|
||||
let billingItems: any[] = [];
|
||||
if (invoices.length > 0) {
|
||||
try {
|
||||
const invoiceIds = invoices.map(inv => inv.id);
|
||||
// Fetch only hardware billing items (type 3) to reduce data volume
|
||||
console.log(`Fetching hardware billing items (type 3) for company ${configItem.companyID}...`);
|
||||
const allBillingItems = await autotaskClient.queryEntity('BillingItems', {
|
||||
filter: [
|
||||
{ op: 'eq', field: 'companyID', value: configItem.companyID },
|
||||
{ op: 'gte', field: 'itemDate', value: startDateFilter },
|
||||
{ op: 'lte', field: 'itemDate', value: endDateFilter },
|
||||
{ op: 'eq', field: 'billingItemType', value: 3 } // Hardware only
|
||||
],
|
||||
});
|
||||
console.log(`Fetched ${allBillingItems.length} hardware billing items`);
|
||||
if (allBillingItems.length >= 500) {
|
||||
console.warn('⚠️ Hit API limit of 500 records - older purchases may not be included');
|
||||
}
|
||||
|
||||
// Enrich items (already filtered to hardware at API level)
|
||||
const enrichedItems = allBillingItems
|
||||
.map((item: any) => {
|
||||
const serialNumber = extractSerialNumber(item.lineItemFullDescription);
|
||||
const profit = (item.totalAmount || 0) - (item.ourCost || 0);
|
||||
const margin = item.totalAmount ? ((profit / item.totalAmount) * 100).toFixed(1) : '0';
|
||||
|
||||
return {
|
||||
...item,
|
||||
extractedSerialNumber: serialNumber,
|
||||
profit,
|
||||
profitMargin: margin,
|
||||
};
|
||||
});
|
||||
|
||||
// If we have a config item serial number, filter to matching items
|
||||
if (configItem.serialNumber) {
|
||||
console.log(`Looking for serial: ${configItem.serialNumber}`);
|
||||
console.log(`Extracted serials from billing items:`, enrichedItems.map((i: any) => i.extractedSerialNumber).filter(Boolean));
|
||||
|
||||
// Match if serial appears in extracted field OR anywhere in the description
|
||||
billingItems = enrichedItems.filter((item: any) => {
|
||||
const serial = configItem.serialNumber!.toLowerCase();
|
||||
const extractedMatch = item.extractedSerialNumber &&
|
||||
item.extractedSerialNumber.toLowerCase() === serial;
|
||||
const descriptionMatch = item.lineItemFullDescription &&
|
||||
item.lineItemFullDescription.toLowerCase().includes(serial);
|
||||
return extractedMatch || descriptionMatch;
|
||||
});
|
||||
console.log(`Found ${billingItems.length} billing items matching serial ${configItem.serialNumber}`);
|
||||
|
||||
// If no exact match, return empty array (only show exact serial matches)
|
||||
if (billingItems.length === 0) {
|
||||
console.log(`No exact serial match found for ${configItem.serialNumber}`);
|
||||
}
|
||||
} else {
|
||||
billingItems = enrichedItems;
|
||||
console.log(`Found ${billingItems.length} hardware billing items (no serial filter)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching billing items:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate summary
|
||||
const totalBilled = invoices.reduce((sum: number, invoice: any) =>
|
||||
sum + (invoice.invoiceTotal || 0), 0
|
||||
);
|
||||
|
||||
const lastInvoiceDate = invoices.length > 0
|
||||
? invoices.sort((a: any, b: any) =>
|
||||
new Date(b.invoiceDateTime).getTime() - new Date(a.invoiceDateTime).getTime()
|
||||
)[0]?.invoiceDateTime
|
||||
: null;
|
||||
|
||||
return NextResponse.json({
|
||||
configItem,
|
||||
billingItems,
|
||||
invoices,
|
||||
tickets: [],
|
||||
summary: {
|
||||
totalBilled,
|
||||
purchaseDate: null,
|
||||
lastInvoiceDate,
|
||||
ticketCount: 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in config enrichment:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch enrichment data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
81
app/api/config-items/[id]/tickets/route.ts
Normal file
81
app/api/config-items/[id]/tickets/route.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const client = getAutotaskClient();
|
||||
|
||||
// Get tickets related to this configuration item
|
||||
const tickets = await client.queryEntity('Tickets', {
|
||||
filter: [
|
||||
{ op: 'eq', field: 'configurationItemID', value: parseInt(id) }
|
||||
],
|
||||
});
|
||||
|
||||
// Fetch picklist values for status and priority
|
||||
let statusPicklist: Record<string | number, string> = {};
|
||||
let priorityPicklist: Record<string | number, string> = {};
|
||||
|
||||
try {
|
||||
const [statusResponse, priorityResponse] = await Promise.all([
|
||||
client.getPicklistValues('Tickets', 'status'),
|
||||
client.getPicklistValues('Tickets', 'priority')
|
||||
]);
|
||||
statusPicklist = statusResponse;
|
||||
priorityPicklist = priorityResponse;
|
||||
console.log('Status picklist:', statusPicklist);
|
||||
console.log('Priority picklist:', priorityPicklist);
|
||||
} catch (err) {
|
||||
console.error('Error fetching picklists:', err);
|
||||
}
|
||||
|
||||
// Enrich with resource names and picklist labels
|
||||
const enrichedTickets = await Promise.all(
|
||||
tickets.map(async (ticket: any) => {
|
||||
let assignedResourceName = null;
|
||||
if (ticket.assignedResourceID) {
|
||||
try {
|
||||
const resource = await client.getEntityById('Resources', ticket.assignedResourceID) as any;
|
||||
assignedResourceName = resource ? `${resource.firstName} ${resource.lastName}` : null;
|
||||
} catch (err) {
|
||||
console.error('Error fetching resource:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Get status and priority labels from picklist object
|
||||
console.log('Ticket status value:', ticket.status, 'Label:', statusPicklist[ticket.status]);
|
||||
console.log('Ticket priority value:', ticket.priority, 'Label:', priorityPicklist[ticket.priority]);
|
||||
const statusLabel = statusPicklist[ticket.status] || ticket.status;
|
||||
const priorityLabel = priorityPicklist[ticket.priority] || ticket.priority;
|
||||
|
||||
return {
|
||||
...ticket,
|
||||
assignedResourceName,
|
||||
status: statusLabel,
|
||||
priority: priorityLabel,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Sort by created date (most recent first)
|
||||
enrichedTickets.sort((a: any, b: any) => {
|
||||
const dateA = new Date(a.createDate || 0).getTime();
|
||||
const dateB = new Date(b.createDate || 0).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
tickets: enrichedTickets,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching related tickets:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch related tickets' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
172
app/api/configuration-items/[id]/route.ts
Normal file
172
app/api/configuration-items/[id]/route.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
|
||||
import { ConfigurationItem } from '@/lib/types/autotask';
|
||||
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const type = searchParams.get('type') || 'autotask';
|
||||
|
||||
let autotaskDevice: ConfigurationItem | null = null;
|
||||
let rmmDevice: DattoRMMDevice | null = null;
|
||||
let companyName: string | null = null;
|
||||
|
||||
if (type === 'autotask') {
|
||||
// Fetch Autotask configuration item
|
||||
const autotaskClient = getAutotaskClient();
|
||||
autotaskDevice = await autotaskClient.getConfigurationItemById(parseInt(id));
|
||||
|
||||
if (autotaskDevice) {
|
||||
// Get company name
|
||||
const company = await autotaskClient.getCompanyById(autotaskDevice.companyID);
|
||||
companyName = company?.companyName || null;
|
||||
|
||||
// Try to find matching RMM device
|
||||
console.log('Looking for RMM device for Autotask item:', {
|
||||
id: autotaskDevice.id,
|
||||
companyID: autotaskDevice.companyID,
|
||||
companyName: companyName,
|
||||
rmmDeviceUID: autotaskDevice.rmmDeviceUID,
|
||||
rmmDeviceID: autotaskDevice.rmmDeviceID,
|
||||
serialNumber: autotaskDevice.serialNumber,
|
||||
hostname: autotaskDevice.rmmDeviceAuditHostname
|
||||
});
|
||||
|
||||
try {
|
||||
const rmmClient = getDattoRMMClient();
|
||||
|
||||
// First priority: Match by RMM Device UID if available
|
||||
if (autotaskDevice?.rmmDeviceUID) {
|
||||
const devices = await rmmClient.getAllDevices();
|
||||
rmmDevice = devices.find(d => d.uid === autotaskDevice?.rmmDeviceUID) || null;
|
||||
if (rmmDevice) {
|
||||
console.log('Matched by RMM UID:', rmmDevice.uid);
|
||||
}
|
||||
}
|
||||
|
||||
// Second priority: Match by RMM Device ID if available
|
||||
if (!rmmDevice && autotaskDevice?.rmmDeviceID) {
|
||||
try {
|
||||
rmmDevice = await rmmClient.getDeviceById(autotaskDevice.rmmDeviceID);
|
||||
if (rmmDevice) {
|
||||
console.log('Matched by RMM ID:', rmmDevice.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Could not find device by RMM ID:', autotaskDevice.rmmDeviceID);
|
||||
}
|
||||
}
|
||||
|
||||
// Third priority: Match by serial number
|
||||
if (!rmmDevice && autotaskDevice?.serialNumber) {
|
||||
const devices = await rmmClient.getAllDevices();
|
||||
rmmDevice = devices.find(d =>
|
||||
d.serialNumber?.toLowerCase() === autotaskDevice?.serialNumber?.toLowerCase()
|
||||
) || null;
|
||||
if (rmmDevice) {
|
||||
console.log('Matched by serial number:', rmmDevice.serialNumber);
|
||||
}
|
||||
}
|
||||
|
||||
// Fourth priority: Match by hostname
|
||||
if (!rmmDevice && autotaskDevice?.rmmDeviceAuditHostname) {
|
||||
const devices = await rmmClient.getAllDevices();
|
||||
rmmDevice = devices.find(d =>
|
||||
d.hostname?.toLowerCase() === autotaskDevice?.rmmDeviceAuditHostname?.toLowerCase()
|
||||
) || null;
|
||||
if (rmmDevice) console.log('Matched by hostname within company');
|
||||
}
|
||||
|
||||
if (!rmmDevice) {
|
||||
console.log('No RMM device match found');
|
||||
} else {
|
||||
console.log('Found RMM device:', {
|
||||
id: rmmDevice.id,
|
||||
uid: rmmDevice.uid,
|
||||
hostname: rmmDevice.hostname,
|
||||
siteName: rmmDevice.siteName
|
||||
});
|
||||
|
||||
// Try to get additional audit data for more detailed information
|
||||
try {
|
||||
const deviceWithAudit = await rmmClient.getDeviceWithAudit(rmmDevice.id);
|
||||
if (deviceWithAudit) {
|
||||
rmmDevice = deviceWithAudit;
|
||||
console.log('Enhanced device with audit data');
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Could not fetch audit data:', err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch RMM device:', err);
|
||||
}
|
||||
}
|
||||
} else if (type === 'rmm') {
|
||||
// Fetch RMM device
|
||||
try {
|
||||
const rmmClient = getDattoRMMClient();
|
||||
rmmDevice = await rmmClient.getDeviceById(id);
|
||||
|
||||
if (rmmDevice) {
|
||||
// Try to find matching Autotask device
|
||||
const autotaskClient = getAutotaskClient();
|
||||
const configItems = await autotaskClient.getAllConfigurationItems();
|
||||
autotaskDevice = configItems.find(ci =>
|
||||
ci.rmmDeviceUID === rmmDevice?.uid ||
|
||||
ci.serialNumber === rmmDevice?.serialNumber
|
||||
) || null;
|
||||
|
||||
if (autotaskDevice) {
|
||||
const company = await autotaskClient.getCompanyById(autotaskDevice.companyID);
|
||||
companyName = company?.companyName || null;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch RMM device:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
autotaskDevice,
|
||||
rmmDevice,
|
||||
companyName
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching configuration item:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch configuration item details' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const idNum = parseInt(id);
|
||||
|
||||
const autotaskClient = getAutotaskClient();
|
||||
const updatedItem = await autotaskClient.updateConfigurationItem(idNum, body);
|
||||
|
||||
return NextResponse.json({
|
||||
configurationItem: updatedItem,
|
||||
message: 'Configuration item updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating configuration item:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update configuration item' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
80
app/api/configuration-items/route.ts
Normal file
80
app/api/configuration-items/route.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
import { ConfigurationItem } from '@/lib/types/autotask';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Check if API is configured
|
||||
if (!process.env.AUTOTASK_API_URL ||
|
||||
!process.env.AUTOTASK_USERNAME ||
|
||||
!process.env.AUTOTASK_SECRET ||
|
||||
!process.env.AUTOTASK_API_INTEGRATION_CODE) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Autotask API not configured',
|
||||
message: 'Please set up your environment variables.'
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const client = getAutotaskClient();
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const companyId = searchParams.get('companyId');
|
||||
|
||||
if (!companyId) {
|
||||
// Return empty array if no company selected
|
||||
return NextResponse.json({
|
||||
configurationItems: [],
|
||||
message: 'Please select a company to view configuration items'
|
||||
});
|
||||
}
|
||||
|
||||
const configurationItems = await client.getConfigurationItemsByCompany(parseInt(companyId));
|
||||
|
||||
// Sort by reference title for better display
|
||||
configurationItems.sort((a, b) =>
|
||||
(a.referenceTitle || '').localeCompare(b.referenceTitle || '')
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
configurationItems,
|
||||
count: configurationItems.length,
|
||||
companyId: parseInt(companyId)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching configuration items:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to fetch configuration items',
|
||||
message: error.message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'An unexpected error occurred while fetching configuration items' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const client = getAutotaskClient();
|
||||
const body = await request.json();
|
||||
|
||||
const configurationItem = await client.createConfigurationItem(body);
|
||||
|
||||
return NextResponse.json({ configurationItem });
|
||||
} catch (error) {
|
||||
console.error('Error creating configuration item:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to create configuration item' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
39
app/api/contacts/[id]/route.ts
Normal file
39
app/api/contacts/[id]/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
import { apiCache } from '@/lib/services/cache';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const cacheKey = `contact:${id}`;
|
||||
|
||||
// Check cache first
|
||||
const cached = apiCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return NextResponse.json(cached);
|
||||
}
|
||||
|
||||
const autotaskClient = getAutotaskClient();
|
||||
|
||||
// Query for the contact by ID
|
||||
const contacts = await autotaskClient.queryEntity('Contacts', {
|
||||
filter: [{ op: 'eq', field: 'id', value: parseInt(id) }],
|
||||
});
|
||||
|
||||
const contact = contacts.length > 0 ? contacts[0] : null;
|
||||
|
||||
// Cache for 10 minutes
|
||||
apiCache.set(cacheKey, { contact }, 10 * 60); // corrected the cache expiration time
|
||||
|
||||
return NextResponse.json({ contact });
|
||||
} catch (error) {
|
||||
console.error('Error fetching contact:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch contact' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
35
app/api/debug-auth/route.ts
Normal file
35
app/api/debug-auth/route.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const password = process.env.AUTOTASK_SECRET || '';
|
||||
|
||||
// Test with the actual password value
|
||||
const testUrl = `${process.env.AUTOTASK_API_URL}/Tickets/entityInformation`;
|
||||
|
||||
console.log('Password length:', password.length);
|
||||
console.log('Password chars:', password.split('').map(c => `${c} (${c.charCodeAt(0)})`).join(', '));
|
||||
|
||||
const response = await fetch(testUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Username': process.env.AUTOTASK_USERNAME || '',
|
||||
'Secret': password,
|
||||
'APIIntegrationcode': process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
return NextResponse.json({
|
||||
passwordLength: password.length,
|
||||
expectedLength: 25, // The actual password should be 25 chars
|
||||
passwordMatches: password === '7g*Zf@K0Ns3#q$E4A1~n2#mW$',
|
||||
apiResponse: {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body: responseText.substring(0, 200)
|
||||
}
|
||||
});
|
||||
}
|
||||
104
app/api/health/route.ts
Normal file
104
app/api/health/route.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const hasApiUrl = !!process.env.AUTOTASK_API_URL;
|
||||
const hasUsername = !!process.env.AUTOTASK_USERNAME;
|
||||
const hasPassword = !!process.env.AUTOTASK_SECRET;
|
||||
const hasIntegrationCode = !!process.env.AUTOTASK_API_INTEGRATION_CODE;
|
||||
|
||||
const configStatus = {
|
||||
apiUrl: hasApiUrl ? 'configured' : 'missing',
|
||||
username: hasUsername ? 'configured' : 'missing',
|
||||
password: hasPassword ? 'configured' : 'missing',
|
||||
integrationCode: hasIntegrationCode ? 'configured' : 'missing',
|
||||
};
|
||||
|
||||
const isConfigured = hasApiUrl && hasUsername && hasPassword && hasIntegrationCode;
|
||||
|
||||
if (!isConfigured) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Autotask API is not properly configured',
|
||||
configuration: configStatus,
|
||||
instructions: [
|
||||
'1. Create a .env.local file in the root directory',
|
||||
'2. Add the following environment variables:',
|
||||
' - AUTOTASK_API_URL',
|
||||
' - AUTOTASK_USERNAME',
|
||||
' - AUTOTASK_SECRET',
|
||||
' - AUTOTASK_API_INTEGRATION_CODE',
|
||||
'3. Restart the development server',
|
||||
'',
|
||||
'See .env.local.example for a template'
|
||||
]
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
// Try to make a test API call
|
||||
try {
|
||||
const { getAutotaskClient } = await import('@/lib/services/autotask-factory');
|
||||
const client = getAutotaskClient();
|
||||
|
||||
// Test with a simple API call - get entity info
|
||||
const testUrl = `${process.env.AUTOTASK_API_URL}/Tickets/entityInformation`;
|
||||
|
||||
const response = await fetch(testUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Username': process.env.AUTOTASK_USERNAME || '',
|
||||
'Secret': process.env.AUTOTASK_SECRET || '',
|
||||
'APIIntegrationcode': process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return NextResponse.json({
|
||||
status: 'healthy',
|
||||
message: 'Autotask API connection successful',
|
||||
configuration: configStatus,
|
||||
apiUrl: process.env.AUTOTASK_API_URL,
|
||||
});
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Autotask API connection failed',
|
||||
configuration: configStatus,
|
||||
apiResponse: {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText
|
||||
},
|
||||
possibleIssues: [
|
||||
'Invalid API credentials',
|
||||
'Incorrect API URL or zone',
|
||||
'API user lacks permissions',
|
||||
'Integration code is invalid'
|
||||
]
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Failed to connect to Autotask API',
|
||||
configuration: configStatus,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
possibleIssues: [
|
||||
'Network connectivity issues',
|
||||
'Invalid API URL format',
|
||||
'Server configuration error'
|
||||
]
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
36
app/api/invoices/[id]/line-items/route.ts
Normal file
36
app/api/invoices/[id]/line-items/route.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const client = getAutotaskClient();
|
||||
|
||||
// Get billing items (line items) for this invoice
|
||||
const lineItems = await client.queryEntity('BillingItems', {
|
||||
filter: [
|
||||
{ op: 'eq', field: 'invoiceID', value: parseInt(id) }
|
||||
],
|
||||
});
|
||||
|
||||
// Sort by item date
|
||||
lineItems.sort((a: any, b: any) => {
|
||||
const dateA = new Date(a.itemDate || 0).getTime();
|
||||
const dateB = new Date(b.itemDate || 0).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
lineItems,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching invoice line items:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch invoice line items' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
28
app/api/picklists/route.ts
Normal file
28
app/api/picklists/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const client = getAutotaskClient();
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const entity = searchParams.get('entity');
|
||||
const field = searchParams.get('field');
|
||||
|
||||
if (!entity || !field) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Entity and field parameters are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const picklistValues = await client.getPicklistValues(entity, field);
|
||||
|
||||
return NextResponse.json({ picklistValues });
|
||||
} catch (error) {
|
||||
console.error('Error fetching picklist values:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch picklist values' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
24
app/api/resources/route.ts
Normal file
24
app/api/resources/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const client = getAutotaskClient();
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const email = searchParams.get('email');
|
||||
|
||||
if (email) {
|
||||
const resource = await client.getResourceByEmail(email);
|
||||
return NextResponse.json({ resource });
|
||||
}
|
||||
|
||||
const resources = await client.getAllResources();
|
||||
return NextResponse.json({ resources });
|
||||
} catch (error) {
|
||||
console.error('Error fetching resources:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch resources' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
49
app/api/rmm-audit/route.ts
Normal file
49
app/api/rmm-audit/route.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const deviceId = searchParams.get('deviceId');
|
||||
|
||||
if (!deviceId) {
|
||||
return NextResponse.json({
|
||||
error: 'Device ID is required'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const rmmClient = getDattoRMMClient();
|
||||
|
||||
// Get device with audit data
|
||||
const deviceWithAudit = await rmmClient.getDeviceWithAudit(deviceId);
|
||||
|
||||
if (!deviceWithAudit) {
|
||||
return NextResponse.json({
|
||||
error: 'Device not found or audit data unavailable'
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
// Also fetch raw audit data for debugging
|
||||
const auditData = await rmmClient.getDeviceAudit(deviceId);
|
||||
|
||||
return NextResponse.json({
|
||||
device: deviceWithAudit,
|
||||
auditData: auditData,
|
||||
enhanced: {
|
||||
manufacturer: deviceWithAudit.manufacturer,
|
||||
model: deviceWithAudit.model,
|
||||
serialNumber: deviceWithAudit.serialNumber,
|
||||
cpuName: deviceWithAudit.cpuName,
|
||||
cpuCores: deviceWithAudit.cpuCores,
|
||||
memory: deviceWithAudit.memory,
|
||||
diskSize: deviceWithAudit.diskSize
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching device audit:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch device audit data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
241
app/api/rmm-devices/route.ts
Normal file
241
app/api/rmm-devices/route.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
|
||||
import { apiCache } from '@/lib/services/cache';
|
||||
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
|
||||
import { ConfigurationItem } from '@/lib/types/autotask';
|
||||
|
||||
interface DeviceComparison {
|
||||
autotaskDevice?: ConfigurationItem;
|
||||
rmmDevice?: DattoRMMDevice;
|
||||
status: 'matched' | 'autotask-only' | 'rmm-only';
|
||||
matchedBy?: string; // What field was used to match
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const companyId = searchParams.get('companyId');
|
||||
const companyName = searchParams.get('companyName');
|
||||
const activeFilter = searchParams.get('activeFilter') || 'active';
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = `rmm-devices:${companyId}:${activeFilter}`;
|
||||
const cached = apiCache.get(cacheKey);
|
||||
if (cached) {
|
||||
console.log(`Cache hit for ${cacheKey}`);
|
||||
return NextResponse.json(cached);
|
||||
}
|
||||
|
||||
if (!companyId) {
|
||||
return NextResponse.json({
|
||||
rmmDevices: [],
|
||||
autotaskDevices: [],
|
||||
comparison: [],
|
||||
message: 'Please select a company to view devices'
|
||||
});
|
||||
}
|
||||
|
||||
// Get Autotask devices based on active filter
|
||||
const autotaskClient = getAutotaskClient();
|
||||
let autotaskDevices: ConfigurationItem[] = [];
|
||||
|
||||
if (activeFilter === 'all') {
|
||||
// Get all devices regardless of status
|
||||
const allItems = await autotaskClient.queryEntity<ConfigurationItem>('ConfigurationItems', {
|
||||
filter: [{ op: 'eq', field: 'companyID', value: parseInt(companyId) }],
|
||||
});
|
||||
autotaskDevices = allItems;
|
||||
} else if (activeFilter === 'inactive') {
|
||||
// Get only inactive devices
|
||||
const inactiveItems = await autotaskClient.queryEntity<ConfigurationItem>('ConfigurationItems', {
|
||||
filter: [
|
||||
{ op: 'eq', field: 'companyID', value: parseInt(companyId) },
|
||||
{ op: 'eq', field: 'isActive', value: false }
|
||||
],
|
||||
});
|
||||
autotaskDevices = inactiveItems;
|
||||
} else {
|
||||
// Default: get only active devices
|
||||
autotaskDevices = await autotaskClient.getConfigurationItemsByCompany(parseInt(companyId));
|
||||
}
|
||||
|
||||
// Get RMM devices
|
||||
let rmmDevices: DattoRMMDevice[] = [];
|
||||
try {
|
||||
const rmmClient = getDattoRMMClient();
|
||||
|
||||
if (companyName) {
|
||||
// Try to get devices by company name (matching site name)
|
||||
rmmDevices = await rmmClient.getDevicesByCompanyName(companyName);
|
||||
} else {
|
||||
// If no company name, get all devices and try to match
|
||||
rmmDevices = await rmmClient.getAllDevices();
|
||||
}
|
||||
} catch (rmmError) {
|
||||
console.error('Error fetching RMM devices:', rmmError);
|
||||
// Continue with empty RMM devices array
|
||||
}
|
||||
|
||||
// Compare and match devices
|
||||
const comparison: DeviceComparison[] = [];
|
||||
const matchedAutotaskIds = new Set<number>();
|
||||
const matchedRmmIds = new Set<string>();
|
||||
|
||||
// Try to match devices
|
||||
for (const rmmDevice of rmmDevices) {
|
||||
let matched = false;
|
||||
|
||||
// Try to match by RMM Device UID
|
||||
if (rmmDevice.uid) {
|
||||
const autotaskMatch = autotaskDevices.find(
|
||||
at => at.rmmDeviceUID === rmmDevice.uid && !matchedAutotaskIds.has(at.id)
|
||||
);
|
||||
|
||||
if (autotaskMatch) {
|
||||
comparison.push({
|
||||
autotaskDevice: autotaskMatch,
|
||||
rmmDevice: rmmDevice,
|
||||
status: 'matched',
|
||||
matchedBy: 'RMM UID'
|
||||
});
|
||||
matchedAutotaskIds.add(autotaskMatch.id);
|
||||
matchedRmmIds.add(String(rmmDevice.id));
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to match by serial number
|
||||
if (!matched && rmmDevice.serialNumber) {
|
||||
const autotaskMatch = autotaskDevices.find(
|
||||
at => (at.serialNumber === rmmDevice.serialNumber ||
|
||||
at.dattoSerialNumber === rmmDevice.serialNumber) &&
|
||||
!matchedAutotaskIds.has(at.id)
|
||||
);
|
||||
|
||||
if (autotaskMatch) {
|
||||
comparison.push({
|
||||
autotaskDevice: autotaskMatch,
|
||||
rmmDevice: rmmDevice,
|
||||
status: 'matched',
|
||||
matchedBy: 'Serial Number'
|
||||
});
|
||||
matchedAutotaskIds.add(autotaskMatch.id);
|
||||
matchedRmmIds.add(String(rmmDevice.id));
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to match by hostname
|
||||
if (!matched && rmmDevice.hostname) {
|
||||
const autotaskMatch = autotaskDevices.find(
|
||||
at => (at.rmmDeviceAuditHostname?.toLowerCase() === rmmDevice.hostname.toLowerCase() ||
|
||||
at.dattoHostname?.toLowerCase() === rmmDevice.hostname.toLowerCase() ||
|
||||
at.referenceTitle?.toLowerCase().includes(rmmDevice.hostname.toLowerCase())) &&
|
||||
!matchedAutotaskIds.has(at.id)
|
||||
);
|
||||
|
||||
if (autotaskMatch) {
|
||||
comparison.push({
|
||||
autotaskDevice: autotaskMatch,
|
||||
rmmDevice: rmmDevice,
|
||||
status: 'matched',
|
||||
matchedBy: 'Hostname'
|
||||
});
|
||||
matchedAutotaskIds.add(autotaskMatch.id);
|
||||
matchedRmmIds.add(String(rmmDevice.id));
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to match by IP address
|
||||
if (!matched && (rmmDevice.intIpAddress || rmmDevice.extIpAddress)) {
|
||||
const autotaskMatch = autotaskDevices.find(
|
||||
at => (at.rmmDeviceAuditIPAddress === rmmDevice.intIpAddress ||
|
||||
at.rmmDeviceAuditIPAddress === rmmDevice.extIpAddress ||
|
||||
at.dattoInternalIP === rmmDevice.intIpAddress ||
|
||||
at.dattoRemoteIP === rmmDevice.extIpAddress) &&
|
||||
!matchedAutotaskIds.has(at.id)
|
||||
);
|
||||
|
||||
if (autotaskMatch) {
|
||||
comparison.push({
|
||||
autotaskDevice: autotaskMatch,
|
||||
rmmDevice: rmmDevice,
|
||||
status: 'matched',
|
||||
matchedBy: 'IP Address'
|
||||
});
|
||||
matchedAutotaskIds.add(autotaskMatch.id);
|
||||
matchedRmmIds.add(String(rmmDevice.id));
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If no match found, add as RMM-only
|
||||
if (!matched) {
|
||||
comparison.push({
|
||||
rmmDevice: rmmDevice,
|
||||
status: 'rmm-only'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add Autotask-only devices
|
||||
for (const autotaskDevice of autotaskDevices) {
|
||||
if (!matchedAutotaskIds.has(autotaskDevice.id)) {
|
||||
comparison.push({
|
||||
autotaskDevice: autotaskDevice,
|
||||
status: 'autotask-only'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort comparison results
|
||||
comparison.sort((a, b) => {
|
||||
// Sort by status first (matched, then autotask-only, then rmm-only)
|
||||
const statusOrder = { 'matched': 0, 'autotask-only': 1, 'rmm-only': 2 };
|
||||
const statusDiff = statusOrder[a.status] - statusOrder[b.status];
|
||||
if (statusDiff !== 0) return statusDiff;
|
||||
|
||||
// Then sort by device name
|
||||
const aName = a.autotaskDevice?.referenceTitle || a.rmmDevice?.hostname || '';
|
||||
const bName = b.autotaskDevice?.referenceTitle || b.rmmDevice?.hostname || '';
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
|
||||
const response = {
|
||||
rmmDevices,
|
||||
autotaskDevices,
|
||||
comparison,
|
||||
stats: {
|
||||
totalRmm: rmmDevices.length,
|
||||
totalAutotask: autotaskDevices.length,
|
||||
matched: comparison.filter(c => c.status === 'matched').length,
|
||||
autotaskOnly: comparison.filter(c => c.status === 'autotask-only').length,
|
||||
rmmOnly: comparison.filter(c => c.status === 'rmm-only').length,
|
||||
}
|
||||
};
|
||||
|
||||
// Cache for 2 minutes
|
||||
apiCache.set(cacheKey, response, 120); // 120 seconds = 2 minutes
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('Error in RMM devices endpoint:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to fetch devices',
|
||||
message: error.message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'An unexpected error occurred' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
32
app/api/rmm-test/route.ts
Normal file
32
app/api/rmm-test/route.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
console.log('Testing Datto RMM connection...');
|
||||
|
||||
const rmmClient = getDattoRMMClient();
|
||||
|
||||
// Try to get sites as a test
|
||||
const sites = await rmmClient.getSites();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Successfully connected to Datto RMM',
|
||||
sitesCount: sites.length,
|
||||
sites: sites.slice(0, 5).map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('RMM test failed:', error);
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
hint: 'Check console for detailed error information'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
50
app/api/tasks/route.ts
Normal file
50
app/api/tasks/route.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
import { Task } from '@/lib/types/autotask';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const client = getAutotaskClient();
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const resourceId = searchParams.get('resourceId');
|
||||
const projectId = searchParams.get('projectId');
|
||||
|
||||
let tasks: Task[] = [];
|
||||
|
||||
if (resourceId) {
|
||||
tasks = await client.getTasksByResource(parseInt(resourceId));
|
||||
} else if (projectId) {
|
||||
tasks = await client.getTasksByProject(parseInt(projectId));
|
||||
} else {
|
||||
// Get all open tasks
|
||||
tasks = await client.queryEntity<Task>('Tasks', {
|
||||
filter: [{ op: 'noteq', field: 'status', value: 5 }],
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ tasks });
|
||||
} catch (error) {
|
||||
console.error('Error fetching tasks:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch tasks' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const client = getAutotaskClient();
|
||||
const body = await request.json();
|
||||
|
||||
const task = await client.createTask(body);
|
||||
|
||||
return NextResponse.json({ task });
|
||||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to create task' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
94
app/api/test-zones/route.ts
Normal file
94
app/api/test-zones/route.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const AUTOTASK_ZONES = [
|
||||
'https://webservices1.autotask.net/atservicesrest/v1.0',
|
||||
'https://webservices2.autotask.net/atservicesrest/v1.0',
|
||||
'https://webservices3.autotask.net/atservicesrest/v1.0',
|
||||
'https://webservices4.autotask.net/atservicesrest/v1.0',
|
||||
'https://webservices5.autotask.net/atservicesrest/v1.0',
|
||||
'https://webservices6.autotask.net/atservicesrest/v1.0',
|
||||
'https://prde.autotask.net/atservicesrest/v1.0',
|
||||
'https://ioce.autotask.net/atservicesrest/v1.0',
|
||||
];
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = process.env.AUTOTASK_USERNAME;
|
||||
const password = process.env.AUTOTASK_SECRET;
|
||||
const integrationCode = process.env.AUTOTASK_API_INTEGRATION_CODE;
|
||||
|
||||
if (!username || !password || !integrationCode) {
|
||||
return NextResponse.json({
|
||||
error: 'Missing credentials in environment variables'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Username': username,
|
||||
'Secret': password,
|
||||
'APIIntegrationcode': integrationCode,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const zoneUrl of AUTOTASK_ZONES) {
|
||||
const zoneName = zoneUrl.match(/\/\/(.*?)\./)?.[1] || 'unknown';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${zoneUrl}/Tickets/entityInformation`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
results.push({
|
||||
zone: zoneName,
|
||||
url: zoneUrl,
|
||||
status: 'SUCCESS',
|
||||
statusCode: response.status,
|
||||
message: 'Connection successful! This is your correct zone.'
|
||||
});
|
||||
|
||||
// If we found the correct zone, return immediately
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
correctZone: {
|
||||
zone: zoneName,
|
||||
url: zoneUrl,
|
||||
},
|
||||
message: `Found your Autotask zone: ${zoneName}`,
|
||||
instruction: `Update your .env.local file with: AUTOTASK_API_URL=${zoneUrl}`
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
zone: zoneName,
|
||||
url: zoneUrl,
|
||||
status: 'FAILED',
|
||||
statusCode: response.status,
|
||||
message: response.statusText
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
results.push({
|
||||
zone: zoneName,
|
||||
url: zoneUrl,
|
||||
status: 'ERROR',
|
||||
message: error instanceof Error ? error.message : 'Connection failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If no zone worked
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: 'Could not find the correct Autotask zone',
|
||||
testedZones: results,
|
||||
possibleIssues: [
|
||||
'Invalid username or password',
|
||||
'Invalid integration code',
|
||||
'API user account is disabled',
|
||||
'Your zone might not be in the standard list'
|
||||
]
|
||||
}, { status: 404 });
|
||||
}
|
||||
47
app/api/tickets/[id]/route.ts
Normal file
47
app/api/tickets/[id]/route.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const client = getAutotaskClient();
|
||||
|
||||
// Get ticket by ID
|
||||
const ticket = await client.getEntityById('Tickets', parseInt(id));
|
||||
|
||||
if (!ticket) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ticket not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get assigned resource name if available
|
||||
let assignedResourceName = null;
|
||||
const ticketData = ticket as any;
|
||||
if (ticketData.assignedResourceID) {
|
||||
try {
|
||||
const resource = await client.getEntityById('Resources', ticketData.assignedResourceID) as any;
|
||||
assignedResourceName = resource ? `${resource.firstName} ${resource.lastName}` : null;
|
||||
} catch (err) {
|
||||
console.error('Error fetching resource:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ticket: {
|
||||
...ticket,
|
||||
assignedResourceName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching ticket:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch ticket' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
55
app/api/tickets/[id]/time-entries/route.ts
Normal file
55
app/api/tickets/[id]/time-entries/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const client = getAutotaskClient();
|
||||
|
||||
// Get time entries for this ticket
|
||||
const timeEntries = await client.queryEntity('TimeEntries', {
|
||||
filter: [
|
||||
{ op: 'eq', field: 'ticketID', value: parseInt(id) }
|
||||
],
|
||||
});
|
||||
|
||||
// Enrich with resource names
|
||||
const enrichedEntries = await Promise.all(
|
||||
timeEntries.map(async (entry: any) => {
|
||||
let resourceName = null;
|
||||
if (entry.resourceID) {
|
||||
try {
|
||||
const resource = await client.getEntityById('Resources', entry.resourceID) as any;
|
||||
resourceName = resource ? `${resource.firstName} ${resource.lastName}` : null;
|
||||
} catch (err) {
|
||||
console.error('Error fetching resource:', err);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
resourceName,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Sort by date worked (most recent first)
|
||||
enrichedEntries.sort((a: any, b: any) => {
|
||||
const dateA = new Date(a.dateWorked || 0).getTime();
|
||||
const dateB = new Date(b.dateWorked || 0).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
timeEntries: enrichedEntries,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching time entries:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch time entries' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
89
app/api/tickets/route.ts
Normal file
89
app/api/tickets/route.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
import { Ticket } from '@/lib/types/autotask';
|
||||
import { getCachedData, setCachedData } from '@/lib/services/redis-client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Check if API is configured
|
||||
if (!process.env.AUTOTASK_API_URL ||
|
||||
!process.env.AUTOTASK_USERNAME ||
|
||||
!process.env.AUTOTASK_SECRET ||
|
||||
!process.env.AUTOTASK_API_INTEGRATION_CODE) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Autotask API not configured',
|
||||
message: 'Please set up your environment variables. Check /api/health for details.'
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const client = getAutotaskClient();
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const resourceId = searchParams.get('resourceId');
|
||||
const companyId = searchParams.get('companyId');
|
||||
|
||||
let tickets: Ticket[] = [];
|
||||
|
||||
if (resourceId) {
|
||||
tickets = await client.getOpenTicketsByResource(parseInt(resourceId));
|
||||
} else if (companyId) {
|
||||
tickets = await client.getTicketsByCompany(parseInt(companyId));
|
||||
} else {
|
||||
// Get all open tickets
|
||||
tickets = await client.queryEntity<Ticket>('Tickets', {
|
||||
filter: [{ op: 'noteq', field: 'status', value: 5 }],
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ tickets });
|
||||
} catch (error) {
|
||||
console.error('Error fetching tickets:', error);
|
||||
|
||||
// Provide more detailed error information
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('Missing Autotask API configuration')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'API Configuration Error',
|
||||
message: 'Autotask API credentials are not properly configured. Please check your .env.local file.',
|
||||
details: error.message
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to fetch tickets',
|
||||
message: error.message,
|
||||
hint: 'Check the console for more details or visit /api/health to diagnose the issue'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'An unexpected error occurred while fetching tickets' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const client = getAutotaskClient();
|
||||
const body = await request.json();
|
||||
|
||||
const ticket = await client.createTicket(body);
|
||||
|
||||
return NextResponse.json({ ticket });
|
||||
} catch (error) {
|
||||
console.error('Error creating ticket:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to create ticket' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
509
app/config-enrichment-test/page.tsx
Normal file
509
app/config-enrichment-test/page.tsx
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import {
|
||||
Search,
|
||||
Server,
|
||||
FileText,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Ticket,
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface EnrichmentData {
|
||||
configItem: any;
|
||||
billingItems: any[];
|
||||
invoices: any[];
|
||||
tickets: any[];
|
||||
summary: {
|
||||
totalBilled: number;
|
||||
purchaseDate?: string;
|
||||
lastInvoiceDate?: string;
|
||||
ticketCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ConfigEnrichmentTestPage() {
|
||||
const [configItemId, setConfigItemId] = useState('');
|
||||
const [invoiceId, setInvoiceId] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<EnrichmentData | null>(null);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!configItemId && !invoiceId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (invoiceId) {
|
||||
params.append('invoiceId', invoiceId);
|
||||
} else if (configItemId) {
|
||||
params.append('configItemId', configItemId);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/config-enrichment?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch enrichment data');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container flex h-16 items-center">
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-blue-600 to-blue-700 text-white shadow-lg">
|
||||
<Search className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
Config Item Enrichment Test
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Find invoices and tickets by serial number
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8 space-y-6">
|
||||
{/* Search Card */}
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Search Configuration Item</CardTitle>
|
||||
<CardDescription>
|
||||
Enter a Configuration Item ID to find related invoices and tickets (last 90 days)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="configItemId">Configuration Item ID</Label>
|
||||
<Input
|
||||
id="configItemId"
|
||||
type="number"
|
||||
placeholder="e.g., 29872931"
|
||||
value={configItemId}
|
||||
onChange={(e) => {
|
||||
setConfigItemId(e.target.value);
|
||||
if (e.target.value) setInvoiceId('');
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="invoiceId">OR Invoice ID</Label>
|
||||
<Input
|
||||
id="invoiceId"
|
||||
type="number"
|
||||
placeholder="e.g., 29884310"
|
||||
value={invoiceId}
|
||||
onChange={(e) => {
|
||||
setInvoiceId(e.target.value);
|
||||
if (e.target.value) setConfigItemId('');
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleSearch} disabled={loading || (!configItemId && !invoiceId)} className="w-full">
|
||||
{loading ? (
|
||||
<>
|
||||
<Search className="w-4 h-4 mr-2 animate-spin" />
|
||||
Searching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
Search
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Card className="border-red-500">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-red-500">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<p>Error: {error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{data && !loading && (
|
||||
<>
|
||||
{/* Config Item Info */}
|
||||
<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">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="w-5 h-5" />
|
||||
Configuration Item Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label>Device Name</Label>
|
||||
<p className="text-sm font-medium">{data.configItem?.referenceTitle || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Serial Number</Label>
|
||||
<p className="text-sm font-mono">{data.configItem?.serialNumber || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Install Date</Label>
|
||||
<p className="text-sm">
|
||||
{data.configItem?.installDate ?
|
||||
format(new Date(data.configItem.installDate), 'MMM d, yyyy') :
|
||||
'-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Status</Label>
|
||||
<Badge variant={data.configItem?.isActive ? 'default' : 'secondary'}>
|
||||
{data.configItem?.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<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">Total Billed</p>
|
||||
<p className="text-2xl font-bold">
|
||||
${data.summary.totalBilled.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-green-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">Invoices Found</p>
|
||||
<p className="text-2xl font-bold">{data.invoices.length}</p>
|
||||
</div>
|
||||
<FileText className="h-8 w-8 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">Tickets Found</p>
|
||||
<p className="text-2xl font-bold">{data.summary.ticketCount}</p>
|
||||
</div>
|
||||
<Ticket className="h-8 w-8 text-orange-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">Purchase Date</p>
|
||||
<p className="text-sm font-medium">
|
||||
{data.summary.purchaseDate ?
|
||||
format(new Date(data.summary.purchaseDate), 'MMM d, yyyy') :
|
||||
'-'}
|
||||
</p>
|
||||
</div>
|
||||
<Calendar className="h-8 w-8 text-purple-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Invoices */}
|
||||
{data.invoices.length > 0 && (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
Company Invoices (Last 90 Days)
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
All invoices for {data.configItem?.companyName || 'this company'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Invoice #</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Total</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Due Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.invoices.map((invoice) => (
|
||||
<TableRow key={invoice.id}>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{invoice.invoiceNumber || invoice.id}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{invoice.invoiceDateTime ?
|
||||
format(new Date(invoice.invoiceDateTime), 'MMM d, yyyy') :
|
||||
'-'}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
${(invoice.invoiceTotal || 0).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={invoice.paidDate ? 'default' : 'secondary'}>
|
||||
{invoice.paidDate ? 'Paid' : 'Unpaid'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{invoice.dueDate ?
|
||||
format(new Date(invoice.dueDate), 'MMM d, yyyy') :
|
||||
'-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Billing Items */}
|
||||
{data.billingItems.length > 0 && (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="w-5 h-5" />
|
||||
{data.configItem ? 'Purchase History for This Device' : 'Hardware Purchases'}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{data.configItem
|
||||
? `Billing items matching serial number ${data.configItem.serialNumber} (last 90 days)`
|
||||
: 'Hardware line items from invoice'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Serial/Notes</TableHead>
|
||||
<TableHead>Qty</TableHead>
|
||||
<TableHead>Unit Price</TableHead>
|
||||
<TableHead>Total</TableHead>
|
||||
<TableHead>Invoice</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.billingItems.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{item.itemDate ? format(new Date(item.itemDate), 'MMM d, yyyy') : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md">
|
||||
<div className="font-medium">{item.description || '-'}</div>
|
||||
{item.itemName && item.itemName !== item.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1">{item.itemName}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs">
|
||||
<div className="text-xs space-y-1">
|
||||
{item.serialNumber && (
|
||||
<div className="font-mono bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">
|
||||
SN: {item.serialNumber}
|
||||
</div>
|
||||
)}
|
||||
{item.internalNotes && (
|
||||
<div className="text-muted-foreground">{item.internalNotes}</div>
|
||||
)}
|
||||
{item.vendorInvoiceNumber && (
|
||||
<div className="text-muted-foreground">Vendor: {item.vendorInvoiceNumber}</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.quantity || 1}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">${(item.unitPrice || 0).toFixed(2)}</TableCell>
|
||||
<TableCell className="font-medium whitespace-nowrap">
|
||||
${(item.totalAmount || 0).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{item.invoiceID}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tickets */}
|
||||
{data.tickets.length > 0 && (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Ticket className="w-5 h-5" />
|
||||
Related Tickets
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Tickets mentioning this serial number (last 90 days)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Ticket #</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Priority</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.tickets.map((ticket) => (
|
||||
<TableRow key={ticket.id}>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{ticket.ticketNumber}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{ticket.title}</TableCell>
|
||||
<TableCell>
|
||||
<Badge>{ticket.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(ticket.createDate), 'MMM d, yyyy')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ticket.priority === 'Critical' ? 'destructive' : 'secondary'}>
|
||||
{ticket.priority}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Debug: Show all fields from first billing item - only when searching by invoice */}
|
||||
{data.billingItems.length > 0 && !data.configItem && invoiceId && (
|
||||
<Card className="border-0 shadow-lg border-l-4 border-l-blue-500">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Info className="w-5 h-5" />
|
||||
Debug: Available Fields (First Item)
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
All fields available in BillingItems entity
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="text-xs bg-gray-100 dark:bg-gray-900 p-4 rounded overflow-auto max-h-96">
|
||||
{JSON.stringify(data.billingItems[0], null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* No Results */}
|
||||
{data.billingItems.length === 0 && data.tickets.length === 0 && (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<AlertCircle className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
{data.configItem ? (
|
||||
<>
|
||||
<p className="font-medium">No purchase records found for this device</p>
|
||||
<p className="text-sm mt-2">
|
||||
Serial number: {data.configItem.serialNumber || 'Not set'}
|
||||
</p>
|
||||
<p className="text-sm mt-2">
|
||||
This device may have been:
|
||||
</p>
|
||||
<ul className="text-sm mt-2 space-y-1">
|
||||
<li>• Purchased more than 1 year ago</li>
|
||||
<li>• Added manually without an invoice</li>
|
||||
<li>• Invoiced without serial number in description</li>
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>No billing items or tickets found in the last 90 days</p>
|
||||
<p className="text-sm mt-2">Try a different configuration item or expand the date range</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
app/configuration-items/[id]/page.tsx
Normal file
195
app/configuration-items/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { PSATab } from '@/components/configuration-items/psa-tab';
|
||||
import { RMMTab } from '@/components/configuration-items/rmm-tab';
|
||||
import { StatusCards } from '@/components/configuration-items/status-cards';
|
||||
import {
|
||||
Server,
|
||||
Monitor,
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { ConfigurationItem } from '@/lib/types/autotask';
|
||||
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
|
||||
|
||||
interface ConfigItemDetail {
|
||||
autotaskDevice?: ConfigurationItem;
|
||||
rmmDevice?: DattoRMMDevice;
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
export default function ConfigurationItemDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [data, setData] = useState<ConfigItemDetail>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Get company context from URL params
|
||||
const companyId = searchParams.get('companyId');
|
||||
const companyName = searchParams.get('companyName');
|
||||
|
||||
// Parse the ID parameter - it might be "at-123" or "rmm-abc" or "123"
|
||||
const parseItemId = () => {
|
||||
const id = params.id as string;
|
||||
if (id.startsWith('at-')) {
|
||||
return { type: 'autotask', id: id.substring(3) };
|
||||
} else if (id.startsWith('rmm-')) {
|
||||
return { type: 'rmm', id: id.substring(4) };
|
||||
} else {
|
||||
return { type: 'autotask', id }; // Default to Autotask
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { type, id } = parseItemId();
|
||||
|
||||
// Fetch the configuration item details
|
||||
const response = await fetch(`/api/configuration-items/${id}?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();
|
||||
}, [params.id]);
|
||||
|
||||
const handleUpdate = (updatedDevice: ConfigurationItem) => {
|
||||
setData({ ...data, autotaskDevice: updatedDevice });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 w-64" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card className="border-red-500">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-red-500">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<p>Error: {error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const device = data.autotaskDevice;
|
||||
const rmmDevice = data.rmmDevice;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container flex h-16 items-center">
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Navigate back to configuration items with company context
|
||||
if (companyId) {
|
||||
const params = new URLSearchParams({
|
||||
companyId: companyId,
|
||||
companyName: companyName || ''
|
||||
});
|
||||
router.push(`/configuration-items?${params.toString()}`);
|
||||
} else {
|
||||
router.push('/configuration-items');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to List
|
||||
</Button>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-purple-600 to-purple-700 text-white shadow-lg">
|
||||
<Server className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
{device?.referenceTitle || rmmDevice?.hostname || 'Configuration Item'}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.companyName || 'Configuration Item Details'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{/* Status Cards */}
|
||||
<StatusCards device={device} rmmDevice={rmmDevice} />
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="psa" className="space-y-4 mt-6">
|
||||
<TabsList className="grid w-full grid-cols-2 max-w-md">
|
||||
<TabsTrigger value="psa" className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4" />
|
||||
PSA Data
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rmm" className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
RMM Data
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="psa">
|
||||
<PSATab device={device} onUpdate={handleUpdate} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rmm">
|
||||
<RMMTab device={rmmDevice} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
766
app/configuration-items/page.tsx
Normal file
766
app/configuration-items/page.tsx
Normal file
|
|
@ -0,0 +1,766 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { CompanySelectorEnhanced } from '@/components/companies/company-selector-enhanced';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { ConfigItemModal } from '@/components/configuration-items/config-item-modal';
|
||||
import { ContactCell } from '@/components/configuration-items/contact-cell';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Calendar as CalendarComponent } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
Server,
|
||||
Monitor,
|
||||
HardDrive,
|
||||
Network,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Activity,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
Wifi,
|
||||
Shield,
|
||||
Calendar as CalendarIcon,
|
||||
Hash,
|
||||
Building2,
|
||||
ArrowLeft,
|
||||
Filter,
|
||||
Download,
|
||||
ChevronRight,
|
||||
Info,
|
||||
Power
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ConfigurationItem } from '@/lib/types/autotask';
|
||||
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
|
||||
import { useApi } from '@/lib/hooks/use-api';
|
||||
|
||||
interface DeviceComparison {
|
||||
autotaskDevice?: ConfigurationItem;
|
||||
rmmDevice?: DattoRMMDevice;
|
||||
status: 'matched' | 'autotask-only' | 'rmm-only';
|
||||
matchedBy?: string;
|
||||
}
|
||||
|
||||
function ConfigurationItemsContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const [selectedCompany, setSelectedCompany] = useState<number | undefined>();
|
||||
const [selectedCompanyName, setSelectedCompanyName] = useState<string>('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterType, setFilterType] = useState<string>('all');
|
||||
const [configItems, setConfigItems] = useState<ConfigurationItem[]>([]);
|
||||
const [comparison, setComparison] = useState<DeviceComparison[]>([]);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'autotask' | 'comparison'>('comparison');
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | number | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [adminExpanded, setAdminExpanded] = useState(false);
|
||||
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
|
||||
const [bulkProcessing, setBulkProcessing] = useState(false);
|
||||
const [lastSeenAfterDate, setLastSeenAfterDate] = useState<Date | undefined>();
|
||||
const [activeFilter, setActiveFilter] = useState<'active' | 'inactive' | 'all'>('active');
|
||||
const [displayLimit, setDisplayLimit] = useState(50); // Start with 50 items
|
||||
|
||||
// Initialize company from URL params on mount
|
||||
useEffect(() => {
|
||||
const companyIdParam = searchParams.get('companyId');
|
||||
const companyNameParam = searchParams.get('companyName');
|
||||
|
||||
if (companyIdParam) {
|
||||
setSelectedCompany(parseInt(companyIdParam));
|
||||
setSelectedCompanyName(companyNameParam || '');
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Fetch configuration items when company changes
|
||||
useEffect(() => {
|
||||
if (!selectedCompany) {
|
||||
setConfigItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchConfigItems = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Fetch comparison data (includes both Autotask and RMM)
|
||||
const response = await fetch(
|
||||
`/api/rmm-devices?companyId=${selectedCompany}&companyName=${encodeURIComponent(selectedCompanyName)}&activeFilter=${activeFilter}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch devices');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setComparison(data.comparison || []);
|
||||
setStats(data.stats);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
setComparison([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchConfigItems();
|
||||
}, [selectedCompany, selectedCompanyName, activeFilter]);
|
||||
|
||||
// Filter comparison items based on search and type
|
||||
const filteredComparison = comparison.filter((item: DeviceComparison) => {
|
||||
const deviceName = item.autotaskDevice?.referenceTitle || item.rmmDevice?.hostname || '';
|
||||
const serialNumber = item.autotaskDevice?.serialNumber || item.rmmDevice?.serialNumber || '';
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
|
||||
const matchesSearch = deviceName.toLowerCase().includes(searchLower) ||
|
||||
serialNumber.toLowerCase().includes(searchLower);
|
||||
|
||||
const matchesType = filterType === 'all' ||
|
||||
(filterType === 'matched' && item.status === 'matched') ||
|
||||
(filterType === 'autotask-only' && item.status === 'autotask-only') ||
|
||||
(filterType === 'rmm-only' && item.status === 'rmm-only');
|
||||
|
||||
// Filter by last seen date in RMM (after specified date)
|
||||
let matchesLastSeen = true;
|
||||
if (lastSeenAfterDate && item.rmmDevice?.lastSeen) {
|
||||
const lastSeenDate = new Date(item.rmmDevice.lastSeen);
|
||||
matchesLastSeen = lastSeenDate >= lastSeenAfterDate;
|
||||
}
|
||||
|
||||
return matchesSearch && matchesType && matchesLastSeen;
|
||||
});
|
||||
|
||||
// Handle company selection
|
||||
const handleCompanyChange = (companyId: number | undefined, companyName?: string) => {
|
||||
setSelectedCompany(companyId);
|
||||
setSelectedCompanyName(companyName || '');
|
||||
setSelectedItems(new Set()); // Clear selections when company changes
|
||||
};
|
||||
|
||||
const handleSelectItem = (itemId: number, checked: boolean) => {
|
||||
const newSelected = new Set(selectedItems);
|
||||
if (checked) {
|
||||
newSelected.add(itemId);
|
||||
} else {
|
||||
newSelected.delete(itemId);
|
||||
}
|
||||
setSelectedItems(newSelected);
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const allIds = new Set(
|
||||
filteredComparison
|
||||
.filter(item => item.autotaskDevice?.id)
|
||||
.map(item => item.autotaskDevice!.id)
|
||||
);
|
||||
setSelectedItems(allIds);
|
||||
} else {
|
||||
setSelectedItems(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkMakeInactive = async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
setBulkProcessing(true);
|
||||
try {
|
||||
const promises = Array.from(selectedItems).map(itemId =>
|
||||
fetch(`/api/configuration-items/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isActive: false }),
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Refresh the data
|
||||
if (selectedCompany) {
|
||||
const response = await fetch(
|
||||
`/api/rmm-devices?companyId=${selectedCompany}&companyName=${encodeURIComponent(selectedCompanyName)}`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setComparison(data.comparison || []);
|
||||
setStats(data.stats);
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedItems(new Set());
|
||||
setAdminExpanded(false);
|
||||
} catch (err) {
|
||||
console.error('Bulk operation failed:', err);
|
||||
setError('Failed to make items inactive');
|
||||
} finally {
|
||||
setBulkProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getDeviceIcon = (item: ConfigurationItem) => {
|
||||
if (item.rmmDeviceAuditDeviceTypeID) {
|
||||
// You can map device type IDs to specific icons
|
||||
return <Monitor className="w-4 h-4" />;
|
||||
}
|
||||
if (item.dattoSerialNumber) {
|
||||
return <HardDrive className="w-4 h-4" />;
|
||||
}
|
||||
return <Server className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
const getRMMStatus = (item: ConfigurationItem) => {
|
||||
if (item.rmmDeviceUID) {
|
||||
return (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
RMM Connected
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
No RMM
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getDattoStatus = (item: ConfigurationItem) => {
|
||||
if (item.dattoSerialNumber) {
|
||||
return (
|
||||
<Badge variant="default" className="bg-blue-600">
|
||||
<Shield className="w-3 h-3 mr-1" />
|
||||
Datto Protected
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container flex h-16 items-center">
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a href="/">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Dashboard
|
||||
</a>
|
||||
</Button>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-purple-600 to-purple-700 text-white shadow-lg">
|
||||
<Server className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
Configuration Items
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">Autotask & RMM Device Management</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => selectedCompany && setSelectedCompany(selectedCompany)}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
<Button size="sm" className="bg-gradient-to-r from-purple-600 to-purple-700 text-white hover:from-purple-700 hover:to-purple-800">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{/* Company Selector Card */}
|
||||
<Card className="mb-6 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">
|
||||
<div>
|
||||
<CardTitle className="text-lg">Select Company</CardTitle>
|
||||
<CardDescription>
|
||||
Choose a company to view their configuration items
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-white dark:bg-gray-900 shadow-sm">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<CompanySelectorEnhanced
|
||||
value={selectedCompany}
|
||||
onValueChange={handleCompanyChange}
|
||||
label="Select Company"
|
||||
/>
|
||||
</div>
|
||||
{selectedCompany && (
|
||||
<div className="flex items-end">
|
||||
<Card className="w-full border-0 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-950 dark:to-purple-900">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Devices</p>
|
||||
<p className="text-2xl font-bold">
|
||||
PSA: {stats?.totalAutotask || 0} | RMM: {stats?.totalRmm || 0}
|
||||
</p>
|
||||
</div>
|
||||
<Server className="h-8 w-8 text-purple-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Admin Section */}
|
||||
{selectedCompany && filteredComparison.length > 0 && (
|
||||
<Card className="border-0 shadow-lg border-l-4 border-l-orange-500">
|
||||
<Collapsible open={adminExpanded} onOpenChange={setAdminExpanded}>
|
||||
<CardHeader className="pb-3">
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full hover:opacity-70 transition-opacity">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-orange-600" />
|
||||
Admin Actions
|
||||
{selectedItems.size > 0 && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{selectedItems.size} selected
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<ChevronRight className={`w-5 h-5 transition-transform ${adminExpanded ? 'rotate-90' : ''}`} />
|
||||
</CollapsibleTrigger>
|
||||
</CardHeader>
|
||||
<CollapsibleContent>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between p-4 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-orange-200 dark:border-orange-800">
|
||||
<div>
|
||||
<p className="font-medium">Bulk Actions</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedItems.size} device{selectedItems.size !== 1 ? 's' : ''} selected
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleBulkMakeInactive}
|
||||
disabled={selectedItems.size === 0 || bulkProcessing}
|
||||
>
|
||||
{bulkProcessing ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Power className="w-4 h-4 mr-2" />
|
||||
Make Inactive ({selectedItems.size})
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Filters and Search */}
|
||||
{selectedCompany && (
|
||||
<Card className="mb-6 border-0 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Filter className="w-5 h-5" />
|
||||
Filters & Search
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search">Search Devices</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="Search by name, serial, IP..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>PSA Status</Label>
|
||||
<Select value={activeFilter} onValueChange={(value: 'active' | 'inactive' | 'all') => setActiveFilter(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active Only</SelectItem>
|
||||
<SelectItem value="inactive">Inactive Only</SelectItem>
|
||||
<SelectItem value="all">All (Active & Inactive)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Device Type</Label>
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Devices</SelectItem>
|
||||
<SelectItem value="matched">Matched (In Both)</SelectItem>
|
||||
<SelectItem value="autotask-only">Autotask Only</SelectItem>
|
||||
<SelectItem value="rmm-only">RMM Only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Last Seen After</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`w-full justify-start text-left font-normal ${!lastSeenAfterDate && "text-muted-foreground"}`}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{lastSeenAfterDate ? format(lastSeenAfterDate, "PPP") : "Pick a date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<CalendarComponent
|
||||
mode="single"
|
||||
selected={lastSeenAfterDate}
|
||||
onSelect={setLastSeenAfterDate}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{lastSeenAfterDate && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setLastSeenAfterDate(undefined)}
|
||||
className="w-full"
|
||||
>
|
||||
Clear Filter
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Card className="flex-1 border-0 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950 dark:to-green-900">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Matched</p>
|
||||
<p className="text-lg font-bold">
|
||||
{stats?.matched || 0}
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="flex-1 border-0 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-950 dark:to-blue-900">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">RMM Only</p>
|
||||
<p className="text-lg font-bold">
|
||||
{stats?.rmmOnly || 0}
|
||||
</p>
|
||||
</div>
|
||||
<Monitor className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Configuration Items Table */}
|
||||
{selectedCompany && (
|
||||
<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 flex items-center gap-2">
|
||||
<Server className="w-5 h-5 text-purple-600" />
|
||||
Device Comparison
|
||||
<Badge variant="secondary" className="ml-2">{filteredComparison.length}</Badge>
|
||||
</CardTitle>
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map(i => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-red-500 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
Error: {error}
|
||||
</div>
|
||||
) : filteredComparison.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{!selectedCompany ? (
|
||||
<div>
|
||||
<Server className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>Select a company to view configuration items</p>
|
||||
</div>
|
||||
) : searchTerm || filterType !== 'all' ? (
|
||||
<div>
|
||||
<Search className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No devices found matching your filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Server className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No configuration items found for this company</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedItems.size === filteredComparison.filter(i => i.autotaskDevice).length && selectedItems.size > 0}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Device Name</TableHead>
|
||||
<TableHead>Serial Number</TableHead>
|
||||
<TableHead>IP Address</TableHead>
|
||||
<TableHead>Contact</TableHead>
|
||||
<TableHead>PSA</TableHead>
|
||||
<TableHead>RMM</TableHead>
|
||||
<TableHead>Match Type</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredComparison.slice(0, displayLimit).map((item: DeviceComparison, index: number) => (
|
||||
<TableRow key={`comparison-${index}`}>
|
||||
<TableCell>
|
||||
{item.autotaskDevice?.id && (
|
||||
<Checkbox
|
||||
checked={selectedItems.has(item.autotaskDevice.id)}
|
||||
onCheckedChange={(checked) => handleSelectItem(item.autotaskDevice!.id, checked as boolean)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.status === 'matched' && (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Matched
|
||||
</Badge>
|
||||
)}
|
||||
{item.status === 'autotask-only' && (
|
||||
<Badge variant="secondary">
|
||||
<Server className="w-3 h-3 mr-1" />
|
||||
AT Only
|
||||
</Badge>
|
||||
)}
|
||||
{item.status === 'rmm-only' && (
|
||||
<Badge variant="outline">
|
||||
<Monitor className="w-3 h-3 mr-1" />
|
||||
RMM Only
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{item.autotaskDevice?.referenceTitle ||
|
||||
item.rmmDevice?.hostname ||
|
||||
'Unknown Device'}
|
||||
</div>
|
||||
{(item.autotaskDevice?.rmmDeviceAuditHostname || item.rmmDevice?.description) && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.autotaskDevice?.rmmDeviceAuditHostname || item.rmmDevice?.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{item.autotaskDevice?.serialNumber ||
|
||||
item.rmmDevice?.serialNumber ||
|
||||
'-'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{item.autotaskDevice?.rmmDeviceAuditIPAddress ||
|
||||
item.rmmDevice?.intIpAddress ||
|
||||
'-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ContactCell contactId={item.autotaskDevice?.contactID} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.autotaskDevice ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.rmmDevice ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.matchedBy && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{item.matchedBy}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const itemId = item.autotaskDevice?.id || item.rmmDevice?.id;
|
||||
if (itemId) {
|
||||
setSelectedItemId(itemId);
|
||||
setModalOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Load More Button */}
|
||||
{filteredComparison.length > displayLimit && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDisplayLimit(prev => prev + 50)}
|
||||
>
|
||||
Load More ({filteredComparison.length - displayLimit} remaining)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Info Card when no company selected */}
|
||||
{!selectedCompany && (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="py-12">
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-purple-100 dark:bg-purple-900">
|
||||
<Info className="h-8 w-8 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Get Started</h3>
|
||||
<p className="text-muted-foreground mb-4 max-w-md mx-auto">
|
||||
Select a company from the dropdown above to view and manage their configuration items.
|
||||
You can compare devices between Autotask and RMM systems.
|
||||
</p>
|
||||
<div className="flex justify-center gap-4 mt-6">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
View Autotask devices
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
Check RMM status
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
Monitor Datto devices
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Configuration Item Detail Modal */}
|
||||
<ConfigItemModal
|
||||
itemId={selectedItemId}
|
||||
type="autotask"
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConfigurationItemsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-8">Loading...</div>}>
|
||||
<ConfigurationItemsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
122
app/globals.css
Normal file
122
app/globals.css
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
32
app/layout.tsx
Normal file
32
app/layout.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Autotask Dashboard",
|
||||
description: "Modern dashboard for Autotask PSA integration",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
236
app/page.tsx
Normal file
236
app/page.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { TicketList } from '@/components/tickets/ticket-list';
|
||||
import { TaskList } from '@/components/tasks/task-list';
|
||||
import { CompanySelector } from '@/components/companies/company-selector';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Ticket,
|
||||
User,
|
||||
ListTodo,
|
||||
Building2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Plus,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
Server
|
||||
} from 'lucide-react';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
|
||||
export default function Home() {
|
||||
const [selectedCompany, setSelectedCompany] = useState<number | undefined>();
|
||||
const [selectedResource, setSelectedResource] = useState<number | undefined>();
|
||||
const [activeTab, setActiveTab] = useState('tickets');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container flex h-16 items-center">
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-blue-600 to-blue-700 text-white shadow-lg">
|
||||
<Activity className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
Autotask Dashboard
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">PSA Management System</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a href="/configuration-items">
|
||||
<Server className="w-4 h-4 mr-2" />
|
||||
Config Items
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a href="/setup">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Setup
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="relative">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
<Button size="sm" className="bg-gradient-to-r from-blue-600 to-blue-700 text-white hover:from-blue-700 hover:to-blue-800">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Ticket
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{/* Filters */}
|
||||
<Card className="mb-6 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">
|
||||
<div>
|
||||
<CardTitle className="text-lg">Quick Filters</CardTitle>
|
||||
<CardDescription>
|
||||
Narrow down your view by company or resource
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-white dark:bg-gray-900 shadow-sm">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<CompanySelector
|
||||
value={selectedCompany}
|
||||
onValueChange={setSelectedCompany}
|
||||
label="Filter by Company"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="resource-search" className="flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
Filter by Resource
|
||||
</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
id="resource-search"
|
||||
placeholder="Enter resource email..."
|
||||
type="email"
|
||||
className="bg-background"
|
||||
/>
|
||||
<Button variant="secondary" size="icon">
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setSelectedCompany(undefined);
|
||||
setSelectedResource(undefined);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs for Tickets and Tasks */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2 max-w-md bg-muted/50">
|
||||
<TabsTrigger value="tickets" className="flex items-center gap-2 data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
||||
<Ticket className="w-4 h-4" />
|
||||
Tickets
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tasks" className="flex items-center gap-2 data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
||||
<ListTodo className="w-4 h-4" />
|
||||
Tasks
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="tickets" className="space-y-4">
|
||||
<TicketList
|
||||
companyId={selectedCompany}
|
||||
resourceId={selectedResource}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tasks" className="space-y-4">
|
||||
<TaskList
|
||||
resourceId={selectedResource}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-8">
|
||||
<Card className="border-0 shadow-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-950 dark:to-blue-900">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Open Tickets
|
||||
</CardTitle>
|
||||
<div className="h-8 w-8 rounded-full bg-blue-600/10 flex items-center justify-center">
|
||||
<Ticket className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">-</div>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||
<TrendingUp className="h-3 w-3 mr-1 text-green-600" />
|
||||
<span>12% from last month</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-lg bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-950 dark:to-purple-900">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Active Tasks
|
||||
</CardTitle>
|
||||
<div className="h-8 w-8 rounded-full bg-purple-600/10 flex items-center justify-center">
|
||||
<ListTodo className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">-</div>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||
<Activity className="h-3 w-3 mr-1 text-orange-600" />
|
||||
<span>In progress</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-lg bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950 dark:to-green-900">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Companies
|
||||
</CardTitle>
|
||||
<div className="h-8 w-8 rounded-full bg-green-600/10 flex items-center justify-center">
|
||||
<Building2 className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">-</div>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||
<User className="h-3 w-3 mr-1" />
|
||||
<span>Active clients</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-lg bg-gradient-to-br from-orange-50 to-orange-100 dark:from-orange-950 dark:to-orange-900">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Response Time
|
||||
</CardTitle>
|
||||
<div className="h-8 w-8 rounded-full bg-orange-600/10 flex items-center justify-center">
|
||||
<Activity className="h-4 w-4 text-orange-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">2.4h</div>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||
<TrendingUp className="h-3 w-3 mr-1 text-green-600" />
|
||||
<span>15% faster</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
336
app/setup/page.tsx
Normal file
336
app/setup/page.tsx
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
|
||||
interface HealthStatus {
|
||||
status: 'healthy' | 'error';
|
||||
message: string;
|
||||
configuration?: {
|
||||
apiUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
integrationCode: string;
|
||||
};
|
||||
instructions?: string[];
|
||||
apiResponse?: {
|
||||
status: number;
|
||||
statusText: string;
|
||||
error: string;
|
||||
};
|
||||
possibleIssues?: string[];
|
||||
}
|
||||
|
||||
export default function SetupPage() {
|
||||
const [healthStatus, setHealthStatus] = useState<HealthStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const checkHealth = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/health');
|
||||
const data = await response.json();
|
||||
setHealthStatus(data);
|
||||
} catch (error) {
|
||||
setHealthStatus({
|
||||
status: 'error',
|
||||
message: 'Failed to check API health',
|
||||
instructions: ['Make sure the Next.js server is running']
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkHealth();
|
||||
}, []);
|
||||
|
||||
const copyEnvTemplate = () => {
|
||||
const template = `# Autotask API Configuration
|
||||
AUTOTASK_API_URL=https://webservices1.autotask.net/atservicesrest/v1.0
|
||||
AUTOTASK_USERNAME=your-api-username@yourdomain.com
|
||||
AUTOTASK_SECRET=your-api-password
|
||||
AUTOTASK_API_INTEGRATION_CODE=your-tracking-code`;
|
||||
|
||||
navigator.clipboard.writeText(template);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
if (status === 'configured') {
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
}
|
||||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-950 p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Autotask API Setup & Diagnostics
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Configure and test your Autotask API connection
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Health Status Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>API Connection Status</CardTitle>
|
||||
<CardDescription>
|
||||
Current status of your Autotask API configuration
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
onClick={checkHealth}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{healthStatus && (
|
||||
<div className="space-y-4">
|
||||
{/* Overall Status */}
|
||||
<div className="flex items-center gap-3">
|
||||
{healthStatus.status === 'healthy' ? (
|
||||
<>
|
||||
<CheckCircle className="w-6 h-6 text-green-500" />
|
||||
<span className="text-lg font-semibold text-green-600">
|
||||
{healthStatus.message}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-6 h-6 text-red-500" />
|
||||
<span className="text-lg font-semibold text-red-600">
|
||||
{healthStatus.message}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Configuration Status */}
|
||||
{healthStatus.configuration && (
|
||||
<div className="border rounded-lg p-4 space-y-2">
|
||||
<h3 className="font-semibold mb-2">Configuration Status:</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(healthStatus.configuration.apiUrl)}
|
||||
<span>API URL</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(healthStatus.configuration.username)}
|
||||
<span>Username</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(healthStatus.configuration.password)}
|
||||
<span>Password</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(healthStatus.configuration.integrationCode)}
|
||||
<span>Integration Code</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Response Error */}
|
||||
{healthStatus.apiResponse && (
|
||||
<div className="border border-red-200 bg-red-50 dark:bg-red-900/20 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-red-700 dark:text-red-400 mb-2">
|
||||
API Response Error:
|
||||
</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p>Status: {healthStatus.apiResponse.status} - {healthStatus.apiResponse.statusText}</p>
|
||||
{healthStatus.apiResponse.error && (
|
||||
<p className="text-xs font-mono bg-red-100 dark:bg-red-900/30 p-2 rounded mt-2">
|
||||
{healthStatus.apiResponse.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Possible Issues */}
|
||||
{healthStatus.possibleIssues && (
|
||||
<div className="border border-yellow-200 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-yellow-700 dark:text-yellow-400 mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
Possible Issues:
|
||||
</h3>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||
{healthStatus.possibleIssues.map((issue, index) => (
|
||||
<li key={index}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Setup Instructions */}
|
||||
{healthStatus.instructions && (
|
||||
<div className="border border-blue-200 bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-blue-700 dark:text-blue-400 mb-2">
|
||||
Setup Instructions:
|
||||
</h3>
|
||||
<ol className="space-y-1 text-sm">
|
||||
{healthStatus.instructions.map((instruction, index) => (
|
||||
<li key={index} className={instruction.startsWith(' ') ? 'ml-4 font-mono' : ''}>
|
||||
{instruction}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Environment Template Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Environment Variables Template</CardTitle>
|
||||
<CardDescription>
|
||||
Copy this template to create your .env.local file
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative">
|
||||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||
<code>{`# Autotask API Configuration
|
||||
AUTOTASK_API_URL=https://webservices1.autotask.net/atservicesrest/v1.0
|
||||
AUTOTASK_USERNAME=your-api-username@yourdomain.com
|
||||
AUTOTASK_SECRET=your-api-password
|
||||
AUTOTASK_API_INTEGRATION_CODE=your-tracking-code`}</code>
|
||||
</pre>
|
||||
<Button
|
||||
onClick={copyEnvTemplate}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
Save this as <code className="bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">.env.local</code> in the project root
|
||||
</p>
|
||||
<p className="flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-yellow-500" />
|
||||
Remember to restart the server after adding the file
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Resources Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Helpful Resources</CardTitle>
|
||||
<CardDescription>
|
||||
Documentation and guides for Autotask API integration
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<a
|
||||
href="https://ww1.autotask.net/help/DeveloperHelp/Content/APIs/REST/REST_API_Home.htm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Autotask REST API Documentation
|
||||
</a>
|
||||
<a
|
||||
href="/api/health"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
API Health Check Endpoint
|
||||
</a>
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* API Zone Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Autotask API Zones</CardTitle>
|
||||
<CardDescription>
|
||||
Make sure you're using the correct zone URL for your Autotask instance
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">North America</h4>
|
||||
<ul className="space-y-1 font-mono text-xs">
|
||||
<li>Zone 1: webservices1.autotask.net</li>
|
||||
<li>Zone 2: webservices2.autotask.net</li>
|
||||
<li>Zone 3: webservices3.autotask.net</li>
|
||||
<li>Zone 4: webservices4.autotask.net</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">Other Regions</h4>
|
||||
<ul className="space-y-1 font-mono text-xs">
|
||||
<li>Zone 5: webservices5.autotask.net</li>
|
||||
<li>Zone 6: webservices6.autotask.net</li>
|
||||
<li>PRD: prde.autotask.net</li>
|
||||
<li>IOC: ioce.autotask.net</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue