Restructure: rename to Pulse and move app to root

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

193
app/addigy-devices/page.tsx Normal file
View 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>
);
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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
View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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
View 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
View 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 }
);
}
}

View 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 });
}

View 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 }
);
}
}

View 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
View 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 }
);
}
}

View 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>
);
}

View 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>
);
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

122
app/globals.css Normal file
View 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
View 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
View 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
View 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>
);
}