Add purchase history and related tickets features

This commit is contained in:
Lorentz Hinrichsen 2025-10-28 11:21:04 -04:00
parent 47589b0742
commit cf5fabe306
88 changed files with 10150 additions and 0 deletions

41
autotask-app/.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

193
autotask-app/README.md Normal file
View file

@ -0,0 +1,193 @@
# Autotask API Integration Dashboard
A modern Next.js application for interacting with the Autotask PSA REST API, built with React, TypeScript, shadcn/ui, and Tailwind CSS.
## Features
- **Dashboard Overview** - View tickets, tasks, and company information
- **Ticket Management** - List, filter, and manage support tickets
- **Task Tracking** - Monitor and update project tasks
- **Company Selector** - Filter data by company
- **Resource Management** - Filter by assigned resources
- **Modern UI** - Beautiful interface built with shadcn/ui components
- **Dark Mode Support** - Automatic dark/light theme
- **Real-time Updates** - Fetch latest data from Autotask API
- **Secure Authentication** - API credentials stored in environment variables
## Tech Stack
- **Framework**: Next.js 15 with App Router
- **Language**: TypeScript
- **Styling**: Tailwind CSS v4
- **UI Components**: shadcn/ui
- **Icons**: Lucide React
- **Date Handling**: date-fns
- **API Integration**: Native fetch with custom client
## Prerequisites
Before you begin, ensure you have:
1. Node.js 18+ installed
2. Autotask API credentials:
- API Username (format: `apiuser@YOURDOMAIN.COM`)
- API Secret/Password
- API Integration Code
- API Base URL (e.g., `https://webservices1.autotask.net/atservicesrest/v1.0`)
## Installation
1. Clone the repository and navigate to the app directory:
```bash
cd /Users/lorentz/projects/PSA-Utils/autotask-app
```
2. Install dependencies:
```bash
npm install
```
3. Create a `.env.local` file in the root directory:
```env
# 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
```
4. Run the development server:
```bash
npm run dev
```
5. Open [http://localhost:3000](http://localhost:3000) in your browser
## Project Structure
```
autotask-app/
├── app/
│ ├── api/ # API route handlers
│ │ ├── tickets/ # Ticket endpoints
│ │ ├── tasks/ # Task endpoints
│ │ ├── companies/ # Company endpoints
│ │ ├── resources/ # Resource endpoints
│ │ └── picklists/ # Picklist endpoints
│ └── page.tsx # Main dashboard page
├── components/
│ ├── tickets/ # Ticket-related components
│ ├── tasks/ # Task-related components
│ ├── companies/ # Company-related components
│ └── ui/ # shadcn/ui components
├── lib/
│ ├── services/ # API client and services
│ │ ├── autotask-client.ts
│ │ └── autotask-factory.ts
│ ├── types/ # TypeScript type definitions
│ │ └── autotask.ts
│ └── hooks/ # Custom React hooks
│ └── use-api.ts
└── public/ # Static assets
```
## API Endpoints
The application provides the following API routes:
- `GET /api/tickets` - Fetch tickets (with optional filters)
- `POST /api/tickets` - Create a new ticket
- `GET /api/tasks` - Fetch tasks (with optional filters)
- `POST /api/tasks` - Create a new task
- `GET /api/companies` - Fetch all active companies
- `GET /api/resources` - Fetch resources/users
- `GET /api/picklists` - Fetch picklist values for dropdowns
## Key Features Implementation
### Rate Limiting
The API client includes built-in rate limiting (10 requests/second) to comply with Autotask API limits.
### Error Handling
Comprehensive error handling with user-friendly error messages and retry capabilities.
### Type Safety
Full TypeScript support with detailed type definitions for all Autotask entities.
### Responsive Design
Mobile-friendly interface that works on all device sizes.
## Development
### Adding New Features
1. **New API Endpoints**: Add route handlers in `app/api/`
2. **New Components**: Create components in `components/`
3. **New Entity Types**: Update types in `lib/types/autotask.ts`
4. **New API Methods**: Extend `lib/services/autotask-client.ts`
### Testing
Run the development server and test with your Autotask sandbox environment:
```bash
npm run dev
```
### Building for Production
```bash
npm run build
npm start
```
## Security Considerations
- Never commit `.env.local` or any file containing API credentials
- Use environment variables for all sensitive configuration
- Implement proper authentication for production deployment
- Consider adding user authentication layer
- Use HTTPS in production
## Common Issues & Solutions
### API Connection Issues
- Verify your API credentials are correct
- Check the API URL matches your Autotask zone
- Ensure your API user has appropriate permissions
### Rate Limiting
- The client automatically handles rate limiting
- If you encounter 429 errors, the client will retry
### CORS Issues
- API routes act as a proxy to avoid CORS issues
- All Autotask API calls go through Next.js API routes
## Future Enhancements
- [ ] Add Redis caching for improved performance
- [ ] Implement real-time updates with WebSockets
- [ ] Add attachment upload functionality
- [ ] Create ticket/task editing forms
- [ ] Add user authentication and session management
- [ ] Implement advanced search and filtering
- [ ] Add data export functionality
- [ ] Create dashboard widgets and analytics
## Contributing
Feel free to submit issues and enhancement requests!
## License
This project is for internal use. Please refer to your organization's policies.
## Support
For issues related to:
- **Autotask API**: Consult the [Autotask REST API Documentation](https://ww1.autotask.net/help/DeveloperHelp/Content/APIs/REST/REST_API_Home.htm)
- **Application Issues**: Create an issue in this repository
---
Built with ❤️ using Next.js, React, and shadcn/ui

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);
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,173 @@
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 Device ID:', autotaskDevice.rmmDeviceID);
}
} catch (err) {
console.log('Could not fetch by RMM Device ID');
}
}
// Third priority: Get devices for this specific company and match
if (!rmmDevice && companyName) {
const companyDevices = await rmmClient.getDevicesByCompanyName(companyName);
console.log(`Found ${companyDevices.length} RMM devices for company: ${companyName}`);
// Try to match within company devices only
if (autotaskDevice.serialNumber) {
rmmDevice = companyDevices.find(d =>
d.serialNumber === autotaskDevice.serialNumber
) || null;
if (rmmDevice) console.log('Matched by serial number within company');
}
if (!rmmDevice && autotaskDevice.rmmDeviceAuditHostname) {
rmmDevice = companyDevices.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)
}
});
}

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

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

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,46 @@
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;
if (ticket.assignedResourceID) {
try {
const resource = await client.getEntityById('Resources', ticket.assignedResourceID);
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);
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 }
);
}
}

View file

@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAutotaskClient } from '@/lib/services/autotask-factory';
import { Ticket } 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. 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,758 @@
'use client';
import { useState, useEffect } 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;
}
export default function ConfigurationItemsPage() {
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>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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

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
autotask-app/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>
);
}

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

View file

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View file

@ -0,0 +1,61 @@
'use client';
import { useState, useEffect } from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Company } from '@/lib/types/autotask';
import { useApi } from '@/lib/hooks/use-api';
import { Building2 } from 'lucide-react';
interface CompanySelectorEnhancedProps {
value?: number;
onValueChange: (value: number | undefined, companyName?: string) => void;
label?: string;
}
export function CompanySelectorEnhanced({
value,
onValueChange,
label = 'Select Company'
}: CompanySelectorEnhancedProps) {
const { data, loading, error } = useApi<{ companies: Company[] }>('/api/companies');
const companies = data?.companies || [];
const handleChange = (val: string) => {
const companyId = parseInt(val);
const company = companies.find(c => c.id === companyId);
onValueChange(companyId, company?.companyName);
};
return (
<div className="space-y-2">
<Label htmlFor="company-select" className="flex items-center gap-2">
<Building2 className="w-4 h-4" />
{label}
</Label>
<Select
value={value?.toString()}
onValueChange={handleChange}
disabled={loading || !!error}
>
<SelectTrigger id="company-select">
<SelectValue placeholder={loading ? 'Loading...' : 'Select a company'} />
</SelectTrigger>
<SelectContent>
{companies.map((company) => (
<SelectItem key={company.id} value={company.id.toString()}>
{company.companyName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View file

@ -0,0 +1,60 @@
'use client';
import { useState, useEffect } from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Company } from '@/lib/types/autotask';
import { useApi } from '@/lib/hooks/use-api';
import { Building2 } from 'lucide-react';
interface CompanySelectorProps {
value?: number;
onValueChange: (value: number) => void;
label?: string;
}
export function CompanySelector({
value,
onValueChange,
label = 'Select Company'
}: CompanySelectorProps) {
const { data, loading, error } = useApi<{ companies: Company[] }>('/api/companies');
const companies = data?.companies || [];
return (
<div className="space-y-2">
<Label htmlFor="company-select" className="flex items-center gap-2">
<Building2 className="w-4 h-4" />
{label}
</Label>
<Select
value={value?.toString()}
onValueChange={(val) => onValueChange(parseInt(val))}
disabled={loading || !!error}
>
<SelectTrigger id="company-select">
<SelectValue placeholder={loading ? 'Loading...' : 'Select a company'} />
</SelectTrigger>
<SelectContent>
{companies.map((company) => (
<SelectItem key={company.id} value={company.id.toString()}>
{company.companyName}
</SelectItem>
))}
</SelectContent>
</Select>
{error && (
<p className="text-sm text-red-500">
Error loading companies: {error}
</p>
)}
</div>
);
}

View file

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

View file

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

View file

@ -0,0 +1,456 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
Server,
Edit,
Save,
Power,
Info,
Cpu,
RefreshCw,
User,
Receipt,
Ticket as TicketIcon
} from 'lucide-react';
import { format } from 'date-fns';
import { ConfigurationItem } from '@/lib/types/autotask';
import { PurchaseHistoryModal } from './purchase-history-modal';
import { RelatedTicketsModal } from './related-tickets-modal';
interface PSATabProps {
device?: ConfigurationItem;
onUpdate: (device: ConfigurationItem) => void;
}
export function PSATab({ device, onUpdate }: PSATabProps) {
const [editMode, setEditMode] = useState(false);
const [saving, setSaving] = useState(false);
const [editedData, setEditedData] = useState<Partial<ConfigurationItem>>(device || {});
const [error, setError] = useState<string | null>(null);
const [contactName, setContactName] = useState<string | null>(null);
const [loadingContact, setLoadingContact] = useState(false);
const [purchaseHistoryOpen, setPurchaseHistoryOpen] = useState(false);
const [relatedTicketsOpen, setRelatedTicketsOpen] = useState(false);
// Fetch contact information if contactID exists
useEffect(() => {
if (!device?.contactID) {
setContactName(null);
return;
}
const fetchContact = async () => {
setLoadingContact(true);
try {
const response = await fetch(`/api/contacts/${device.contactID}`);
if (response.ok) {
const data = await response.json();
setContactName(data.contact ? `${data.contact.firstName} ${data.contact.lastName}` : null);
}
} catch (err) {
console.error('Failed to fetch contact:', err);
} finally {
setLoadingContact(false);
}
};
fetchContact();
}, [device?.contactID]);
const handleSave = async () => {
if (!device) return;
setSaving(true);
setError(null);
try {
const response = await fetch(`/api/configuration-items/${device.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(editedData),
});
if (!response.ok) {
throw new Error('Failed to update configuration item');
}
const updated = await response.json();
onUpdate(updated.configurationItem);
setEditMode(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save changes');
} finally {
setSaving(false);
}
};
const handleMakeInactive = async () => {
if (!device) return;
setSaving(true);
setError(null);
try {
const response = await fetch(`/api/configuration-items/${device.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ isActive: false }),
});
if (!response.ok) {
throw new Error('Failed to update configuration item');
}
const updated = await response.json();
onUpdate(updated.configurationItem);
setEditedData({ ...editedData, isActive: false });
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to make inactive');
} finally {
setSaving(false);
}
};
return (
<>
<Card className="border-0 shadow-lg">
<CardHeader className="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 rounded-t-lg">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">PSA Configuration Item</CardTitle>
<div className="flex items-center gap-2">
{!editMode ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => setPurchaseHistoryOpen(true)}
>
<Receipt className="w-4 h-4 mr-2" />
Purchase History
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setRelatedTicketsOpen(true)}
>
<TicketIcon className="w-4 h-4 mr-2" />
Related Tickets
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditMode(true);
setEditedData(device || {});
}}
disabled={!device}
>
<Edit className="w-4 h-4 mr-2" />
Edit
</Button>
{device?.isActive && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
<Power className="w-4 h-4 mr-2" />
Make Inactive
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Make Configuration Item Inactive?</AlertDialogTitle>
<AlertDialogDescription>
This will mark the configuration item as inactive in Autotask PSA.
You can reactivate it later if needed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleMakeInactive}>
Make Inactive
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditMode(false);
setEditedData(device || {});
setError(null);
}}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={saving}
>
{saving ? (
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
) : (
<Save className="w-4 h-4 mr-2" />
)}
Save
</Button>
</>
)}
</div>
</div>
</CardHeader>
<CardContent className="pt-6">
{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-md">
{error}
</div>
)}
{device ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Basic Information */}
<div className="space-y-4">
<h3 className="font-semibold flex items-center gap-2">
<Info className="w-4 h-4" />
Basic Information
</h3>
<div className="space-y-3">
<div>
<Label>Reference Title</Label>
{editMode ? (
<Input
value={editedData.referenceTitle || ''}
onChange={(e) => setEditedData({...editedData, referenceTitle: e.target.value})}
className="mt-1"
/>
) : (
<p className="text-sm text-muted-foreground mt-1">{device.referenceTitle}</p>
)}
</div>
<div>
<Label>Reference Number</Label>
{editMode ? (
<Input
value={editedData.referenceNumber || ''}
onChange={(e) => setEditedData({...editedData, referenceNumber: e.target.value})}
className="mt-1"
/>
) : (
<p className="text-sm text-muted-foreground font-mono mt-1">
{device.referenceNumber || '-'}
</p>
)}
</div>
<div>
<Label>Serial Number</Label>
{editMode ? (
<Input
value={editedData.serialNumber || ''}
onChange={(e) => setEditedData({...editedData, serialNumber: e.target.value})}
className="mt-1"
/>
) : (
<p className="text-sm text-muted-foreground font-mono mt-1">
{device.serialNumber || '-'}
</p>
)}
</div>
<div>
<Label>Location</Label>
{editMode ? (
<Input
value={editedData.location || ''}
onChange={(e) => setEditedData({...editedData, location: e.target.value})}
className="mt-1"
/>
) : (
<p className="text-sm text-muted-foreground mt-1">
{device.location || '-'}
</p>
)}
</div>
<div>
<Label>Active Status</Label>
{editMode ? (
<div className="flex items-center space-x-2 mt-1">
<Switch
checked={editedData.isActive}
onCheckedChange={(checked) => setEditedData({...editedData, isActive: checked})}
/>
<Label>{editedData.isActive ? 'Active' : 'Inactive'}</Label>
</div>
) : (
<div className="mt-1">
{device.isActive ? (
<Badge variant="default" className="bg-green-600">Active</Badge>
) : (
<Badge variant="secondary">Inactive</Badge>
)}
</div>
)}
</div>
<div>
<Label>Associated Contact</Label>
{loadingContact ? (
<p className="text-sm text-muted-foreground mt-1">Loading...</p>
) : contactName ? (
<div className="flex items-center gap-2 mt-1">
<Badge variant="default" className="bg-blue-600">
<User className="w-3 h-3 mr-1" />
{contactName}
</Badge>
</div>
) : device.contactID ? (
<p className="text-sm text-muted-foreground mt-1">Contact ID: {device.contactID}</p>
) : (
<p className="text-sm text-muted-foreground mt-1">No contact assigned</p>
)}
</div>
</div>
</div>
{/* Technical Details */}
<div className="space-y-4">
<h3 className="font-semibold flex items-center gap-2">
<Cpu className="w-4 h-4" />
Technical Details
</h3>
<div className="space-y-3">
<div>
<Label>Model Number</Label>
{editMode ? (
<Input
value={editedData.modelNumber || ''}
onChange={(e) => setEditedData({...editedData, modelNumber: e.target.value})}
className="mt-1"
/>
) : (
<p className="text-sm text-muted-foreground mt-1">
{device.modelNumber || '-'}
</p>
)}
</div>
<div>
<Label>MAC Address</Label>
{editMode ? (
<Input
value={editedData.macAddress || ''}
onChange={(e) => setEditedData({...editedData, macAddress: e.target.value})}
className="mt-1"
/>
) : (
<p className="text-sm text-muted-foreground font-mono mt-1">
{device.macAddress || '-'}
</p>
)}
</div>
<div>
<Label>Install Date</Label>
<p className="text-sm text-muted-foreground mt-1">
{device.installDate ?
format(new Date(device.installDate), 'MMM d, yyyy') :
'-'}
</p>
</div>
<div>
<Label>Warranty Expiration</Label>
<p className="text-sm text-muted-foreground mt-1">
{device.warrantyExpirationDate ?
format(new Date(device.warrantyExpirationDate), 'MMM d, yyyy') :
'-'}
</p>
</div>
<div>
<Label>RMM Device UID</Label>
<p className="text-sm text-muted-foreground font-mono mt-1">
{device.rmmDeviceUID || '-'}
</p>
</div>
</div>
</div>
{/* Notes */}
<div className="md:col-span-2 space-y-4">
<h3 className="font-semibold">Notes</h3>
{editMode ? (
<Textarea
value={editedData.notes || ''}
onChange={(e) => setEditedData({...editedData, notes: e.target.value})}
rows={4}
placeholder="Add notes..."
/>
) : (
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{device.notes || 'No notes available'}
</p>
)}
</div>
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<Server className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No PSA data available for this device</p>
</div>
)}
</CardContent>
</Card>
{/* Purchase History Modal */}
{device && (
<PurchaseHistoryModal
configItemId={device.id}
serialNumber={device.serialNumber}
createDate={device.createDate}
open={purchaseHistoryOpen}
onOpenChange={setPurchaseHistoryOpen}
/>
)}
{/* Related Tickets Modal */}
{device && (
<RelatedTicketsModal
configItemId={device.id}
open={relatedTicketsOpen}
onOpenChange={setRelatedTicketsOpen}
/>
)}
</>
);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,208 @@
'use client';
import { useState, useEffect } from 'react';
import { format } from 'date-fns';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Task, TaskStatus, Priority } from '@/lib/types/autotask';
import { useApi } from '@/lib/hooks/use-api';
import { ChevronRight, AlertCircle, Clock, CheckCircle, ListTodo } from 'lucide-react';
interface TaskListProps {
resourceId?: number;
projectId?: number;
}
export function TaskList({ resourceId, projectId }: TaskListProps) {
const [statusLabels, setStatusLabels] = useState<Record<number, string>>({});
let url = '/api/tasks';
if (resourceId) url += `?resourceId=${resourceId}`;
else if (projectId) url += `?projectId=${projectId}`;
const { data, loading, error, refetch } = useApi<{ tasks: Task[] }>(url);
// Fetch picklist values for status
useEffect(() => {
fetch('/api/picklists?entity=Tasks&field=status')
.then(res => res.json())
.then(data => setStatusLabels(data.picklistValues || {}))
.catch(console.error);
}, []);
const getStatusBadge = (status: number) => {
const label = statusLabels[status] || `Status ${status}`;
let variant: 'default' | 'secondary' | 'destructive' | 'outline' = 'default';
let icon = null;
switch (status) {
case TaskStatus.New:
variant = 'destructive';
icon = <AlertCircle className="w-3 h-3 mr-1" />;
break;
case TaskStatus.InProgress:
variant = 'default';
icon = <Clock className="w-3 h-3 mr-1" />;
break;
case TaskStatus.Complete:
variant = 'secondary';
icon = <CheckCircle className="w-3 h-3 mr-1" />;
break;
default:
variant = 'outline';
}
return (
<Badge variant={variant} className="flex items-center">
{icon}
{label}
</Badge>
);
};
const getPriorityBadge = (priority: number) => {
let variant: 'default' | 'secondary' | 'destructive' | 'outline' = 'default';
let label = 'Normal';
switch (priority) {
case Priority.Critical:
variant = 'destructive';
label = 'Critical';
break;
case Priority.High:
variant = 'default';
label = 'High';
break;
case Priority.Medium:
variant = 'secondary';
label = 'Medium';
break;
case Priority.Low:
variant = 'outline';
label = 'Low';
break;
}
return <Badge variant={variant}>{label}</Badge>;
};
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle>Tasks</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{[1, 2, 3].map(i => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card>
<CardHeader>
<CardTitle>Tasks</CardTitle>
</CardHeader>
<CardContent>
<div className="text-red-500 flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
Error loading tasks: {error}
</div>
<Button onClick={refetch} className="mt-4">
Retry
</Button>
</CardContent>
</Card>
);
}
const tasks = data?.tasks || [];
return (
<Card className="border-0 shadow-lg">
<CardHeader className="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 rounded-t-lg">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<ListTodo className="w-5 h-5 text-purple-600" />
Tasks
<Badge variant="secondary" className="ml-2">{tasks.length}</Badge>
</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-6">
{tasks.length === 0 ? (
<div className="text-muted-foreground text-center py-8">
No tasks found
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>Priority</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Created</TableHead>
<TableHead>Due</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tasks.map((task) => (
<TableRow key={task.id}>
<TableCell className="max-w-md truncate">
{task.title}
</TableCell>
<TableCell>{getStatusBadge(task.status)}</TableCell>
<TableCell>{getPriorityBadge(task.priority)}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="w-20 bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full"
style={{ width: `${task.percentComplete || 0}%` }}
/>
</div>
<span className="text-sm text-muted-foreground">
{task.percentComplete || 0}%
</span>
</div>
</TableCell>
<TableCell>
{format(new Date(task.createDateTime), 'MMM d, yyyy')}
</TableCell>
<TableCell>
{task.endDateTime
? format(new Date(task.endDateTime), 'MMM d, yyyy')
: '-'}
</TableCell>
<TableCell>
<Button variant="ghost" size="sm">
<ChevronRight className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,8 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider, type ThemeProviderProps } from "next-themes"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View file

@ -0,0 +1,43 @@
"use client"
import * as React from "react"
import { Moon, Sun, Monitor } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ThemeToggle() {
const { setTheme, theme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 h-4 w-4" />
<span>Light</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="mr-2 h-4 w-4" />
<span>Dark</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="mr-2 h-4 w-4" />
<span>System</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -0,0 +1,221 @@
'use client';
import { useState, useEffect } from 'react';
import { format } from 'date-fns';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Ticket as TicketType, TicketStatus, Priority } from '@/lib/types/autotask';
import { useApi } from '@/lib/hooks/use-api';
import { ChevronRight, AlertCircle, Clock, CheckCircle, Ticket } from 'lucide-react';
interface TicketListProps {
resourceId?: number;
companyId?: number;
}
export function TicketList({ resourceId, companyId }: TicketListProps) {
const [statusLabels, setStatusLabels] = useState<Record<number, string>>({});
const [priorityLabels, setPriorityLabels] = useState<Record<number, string>>({});
let url = '/api/tickets';
if (resourceId) url += `?resourceId=${resourceId}`;
else if (companyId) url += `?companyId=${companyId}`;
const { data, loading, error, refetch } = useApi<{ tickets: TicketType[] }>(url);
// Fetch picklist values for status and priority
useEffect(() => {
fetch('/api/picklists?entity=Tickets&field=status')
.then(res => res.json())
.then(data => setStatusLabels(data.picklistValues || {}))
.catch(console.error);
fetch('/api/picklists?entity=Tickets&field=priority')
.then(res => res.json())
.then(data => setPriorityLabels(data.picklistValues || {}))
.catch(console.error);
}, []);
const getStatusBadge = (status: number) => {
const label = statusLabels[status] || `Status ${status}`;
let variant: 'default' | 'secondary' | 'destructive' | 'outline' = 'default';
let icon = null;
switch (status) {
case TicketStatus.New:
variant = 'destructive';
icon = <AlertCircle className="w-3 h-3 mr-1" />;
break;
case TicketStatus.InProgress:
variant = 'default';
icon = <Clock className="w-3 h-3 mr-1" />;
break;
case TicketStatus.Complete:
variant = 'secondary';
icon = <CheckCircle className="w-3 h-3 mr-1" />;
break;
default:
variant = 'outline';
}
return (
<Badge variant={variant} className="flex items-center">
{icon}
{label}
</Badge>
);
};
const getPriorityBadge = (priority: number) => {
const label = priorityLabels[priority] || `Priority ${priority}`;
let variant: 'default' | 'secondary' | 'destructive' | 'outline' = 'default';
switch (priority) {
case Priority.Critical:
variant = 'destructive';
break;
case Priority.High:
variant = 'default';
break;
case Priority.Medium:
variant = 'secondary';
break;
case Priority.Low:
variant = 'outline';
break;
}
return <Badge variant={variant}>{label}</Badge>;
};
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle>Tickets</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{[1, 2, 3].map(i => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</CardContent>
</Card>
);
}
if (error) {
const isConfigError = error.includes('not configured') || error.includes('configuration');
return (
<Card>
<CardHeader>
<CardTitle>Tickets</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="text-red-500 flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{isConfigError ? 'API Configuration Required' : `Error loading tickets: ${error}`}
</div>
{isConfigError && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-sm mb-3">
The Autotask API is not configured. Please set up your credentials to start using the dashboard.
</p>
<div className="flex gap-2">
<Button asChild size="sm">
<a href="/setup">Go to Setup</a>
</Button>
<Button onClick={refetch} variant="outline" size="sm">
Retry
</Button>
</div>
</div>
)}
{!isConfigError && (
<Button onClick={refetch} className="mt-4">
Retry
</Button>
)}
</div>
</CardContent>
</Card>
);
}
const tickets = data?.tickets || [];
return (
<Card className="border-0 shadow-lg">
<CardHeader className="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 rounded-t-lg">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Ticket className="w-5 h-5 text-blue-600" />
Tickets
<Badge variant="secondary" className="ml-2">{tickets.length}</Badge>
</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-6">
{tickets.length === 0 ? (
<div className="text-muted-foreground text-center py-8">
No tickets found
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Number</TableHead>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>Priority</TableHead>
<TableHead>Created</TableHead>
<TableHead>Due</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tickets.map((ticket) => (
<TableRow key={ticket.id}>
<TableCell className="font-mono">
{ticket.ticketNumber}
</TableCell>
<TableCell className="max-w-md truncate">
{ticket.title}
</TableCell>
<TableCell>{getStatusBadge(ticket.status)}</TableCell>
<TableCell>{getPriorityBadge(ticket.priority)}</TableCell>
<TableCell>
{format(new Date(ticket.createDate), 'MMM d, yyyy')}
</TableCell>
<TableCell>
{ticket.dueDateTime
? format(new Date(ticket.dueDateTime), 'MMM d, yyyy')
: '-'}
</TableCell>
<TableCell>
<Button variant="ghost" size="sm">
<ChevronRight className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View file

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,216 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View file

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View file

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View file

@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View file

@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View file

@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View file

@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View file

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View file

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View file

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View file

@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View file

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View file

@ -0,0 +1,40 @@
"use client"
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View file

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View file

@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View file

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View file

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View file

@ -0,0 +1,62 @@
import { useState, useEffect, useCallback } from 'react';
interface UseApiOptions {
autoFetch?: boolean;
}
interface UseApiResult<T> {
data: T | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
export function useApi<T>(
url: string,
options: UseApiOptions = { autoFetch: true }
): UseApiResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to fetch data');
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
if (options.autoFetch) {
fetchData();
}
}, [fetchData, options.autoFetch]);
return { data, loading, error, refetch: fetchData };
}
export async function apiCall<T>(
url: string,
options?: RequestInit
): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'API call failed');
}
return response.json();
}

View file

@ -0,0 +1,433 @@
import {
AutotaskConfig,
AutotaskHeaders,
QueryParams,
ApiResponse,
ApiError,
Resource,
Ticket,
Task,
Company,
ConfigurationItem,
Attachment,
EntityField,
PicklistValue,
} from '@/lib/types/autotask';
export class AutotaskClient {
private config: AutotaskConfig;
private rateLimiter: RateLimiter;
constructor(config: AutotaskConfig) {
this.config = config;
this.rateLimiter = new RateLimiter(10); // 10 requests per second
}
private getAuthHeaders(impersonationResourceId?: number): Record<string, string> {
// Using the direct header authentication method (not Basic auth)
const headers: Record<string, string> = {
'Username': this.config.username,
'Secret': this.config.password,
'APIIntegrationcode': this.config.apiIntegrationCode,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (impersonationResourceId) {
headers.ImpersonationResourceID = impersonationResourceId.toString();
}
return headers;
}
private async makeApiCall<T>(
url: string,
options: RequestInit
): Promise<T> {
await this.rateLimiter.throttle();
try {
const response = await fetch(url, options);
const responseText = await response.text();
if (!response.ok) {
console.error(`Autotask API error: ${response.status} - ${responseText}`);
let errorMessage = `API Error: ${response.statusText}`;
try {
const errorData: ApiError = JSON.parse(responseText);
if (errorData.errors && errorData.errors.length > 0) {
errorMessage = errorData.errors.map((e) => e.message).join(', ');
} else if (errorData.message) {
errorMessage = errorData.message;
}
} catch (parseError) {
errorMessage = responseText || errorMessage;
}
throw new Error(errorMessage);
}
try {
return JSON.parse(responseText);
} catch (parseError) {
console.error('Failed to parse API response:', parseError);
throw new Error('Invalid JSON response from Autotask API');
}
} catch (error) {
console.error('API call failed:', error);
throw error;
}
}
private buildQueryString(params: QueryParams): string {
if (!params.filter || params.filter.length === 0) {
return '';
}
const query = { filter: params.filter };
return `?search=${encodeURIComponent(JSON.stringify(query))}`;
}
async queryEntity<T>(
entityName: string,
params: QueryParams = {}
): Promise<T[]> {
const queryString = this.buildQueryString(params);
const url = `${this.config.apiUrl}/${entityName}/query${queryString}`;
const response = await this.makeApiCall<ApiResponse<T>>(url, {
method: 'GET',
headers: this.getAuthHeaders(),
});
return response.items || [];
}
// Paginated query to handle large datasets
async queryEntityPaginated<T>(
entityName: string,
params: QueryParams = {},
pageSize: number = 500
): Promise<T[]> {
const allItems: T[] = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const paginatedParams = {
...params,
MaxRecords: pageSize,
// Autotask uses MaxRecords for page size
};
console.log(`Fetching ${entityName} page ${page} (max ${pageSize} records)...`);
const queryString = this.buildQueryString(paginatedParams);
const url = `${this.config.apiUrl}/${entityName}/query${queryString}`;
const response = await this.makeApiCall<ApiResponse<T>>(url, {
method: 'GET',
headers: this.getAuthHeaders(),
});
const items = response.items || [];
allItems.push(...items);
console.log(`Fetched ${items.length} ${entityName}, total so far: ${allItems.length}`);
// If we got fewer items than pageSize, we've reached the end
hasMore = items.length === pageSize;
page++;
// Safety limit to prevent infinite loops
if (page > 100) {
console.warn(`Reached page limit (100) for ${entityName}`);
break;
}
}
console.log(`Total ${entityName} fetched: ${allItems.length}`);
return allItems;
}
async getEntityById<T>(entityName: string, id: number): Promise<T | null> {
const url = `${this.config.apiUrl}/${entityName}/${id}`;
const response = await this.makeApiCall<ApiResponse<T>>(url, {
method: 'GET',
headers: this.getAuthHeaders(),
});
return response.item || null;
}
async createEntity<T>(entityName: string, data: Partial<T>): Promise<T> {
const url = `${this.config.apiUrl}/${entityName}`;
const response = await this.makeApiCall<ApiResponse<T>>(url, {
method: 'POST',
headers: this.getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.item) {
throw new Error('Failed to create entity');
}
return response.item;
}
async updateEntity<T>(
entityName: string,
id: number,
data: Partial<T>
): Promise<T> {
const url = `${this.config.apiUrl}/${entityName}/${id}`;
const response = await this.makeApiCall<ApiResponse<T>>(url, {
method: 'PATCH',
headers: this.getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.item) {
throw new Error('Failed to update entity');
}
return response.item;
}
// Resource-specific methods
async getResourceByEmail(email: string): Promise<Resource | null> {
const resources = await this.queryEntity<Resource>('Resources', {
filter: [{ op: 'eq', field: 'email', value: email }],
});
return resources.length > 0 ? resources[0] : null;
}
async getAllResources(): Promise<Resource[]> {
return this.queryEntity<Resource>('Resources', {
filter: [{ op: 'eq', field: 'isActive', value: true }],
});
}
// Ticket-specific methods
async getOpenTicketsByResource(resourceId: number): Promise<Ticket[]> {
return this.queryEntity<Ticket>('Tickets', {
filter: [
{ op: 'eq', field: 'assignedResourceID', value: resourceId },
{ op: 'noteq', field: 'status', value: 5 }, // Exclude completed
],
});
}
async getTicketsByCompany(companyId: number): Promise<Ticket[]> {
return this.queryEntity<Ticket>('Tickets', {
filter: [{ op: 'eq', field: 'companyID', value: companyId }],
});
}
async createTicket(ticket: Partial<Ticket>): Promise<Ticket> {
return this.createEntity<Ticket>('Tickets', ticket);
}
async updateTicket(id: number, updates: Partial<Ticket>): Promise<Ticket> {
return this.updateEntity<Ticket>('Tickets', id, updates);
}
// Task-specific methods
async getTasksByResource(resourceId: number): Promise<Task[]> {
return this.queryEntity<Task>('Tasks', {
filter: [
{ op: 'eq', field: 'assignedResourceID', value: resourceId },
{ op: 'noteq', field: 'status', value: 5 }, // Exclude completed
],
});
}
async getTasksByProject(projectId: number): Promise<Task[]> {
return this.queryEntity<Task>('Tasks', {
filter: [{ op: 'eq', field: 'projectID', value: projectId }],
});
}
async createTask(task: Partial<Task>): Promise<Task> {
return this.createEntity<Task>('Tasks', task);
}
async updateTask(id: number, updates: Partial<Task>): Promise<Task> {
return this.updateEntity<Task>('Tasks', id, updates);
}
// Company-specific methods
async getAllCompanies(): Promise<Company[]> {
const companies = await this.queryEntity<Company>('Companies', {
filter: [{ op: 'eq', field: 'isActive', value: true }],
});
return companies.sort((a, b) =>
a.companyName.localeCompare(b.companyName)
);
}
async getCompanyById(id: number): Promise<Company | null> {
return this.getEntityById<Company>('Companies', id);
}
// Configuration Item methods
async getConfigurationItemsByCompany(companyId: number): Promise<ConfigurationItem[]> {
return this.queryEntity<ConfigurationItem>('ConfigurationItems', {
filter: [
{ op: 'eq', field: 'companyID', value: companyId },
{ op: 'eq', field: 'isActive', value: true }
],
});
}
async getAllConfigurationItems(): Promise<ConfigurationItem[]> {
return this.queryEntity<ConfigurationItem>('ConfigurationItems', {
filter: [{ op: 'eq', field: 'isActive', value: true }],
});
}
async getConfigurationItemById(id: number): Promise<ConfigurationItem | null> {
// Use query instead of direct GET as ConfigurationItems might not support direct ID access
const items = await this.queryEntity<ConfigurationItem>('ConfigurationItems', {
filter: [{ op: 'eq', field: 'id', value: id }],
});
return items.length > 0 ? items[0] : null;
}
async createConfigurationItem(item: Partial<ConfigurationItem>): Promise<ConfigurationItem> {
return this.createEntity<ConfigurationItem>('ConfigurationItems', item);
}
async updateConfigurationItem(id: number, updates: Partial<ConfigurationItem>): Promise<ConfigurationItem> {
return this.updateEntity<ConfigurationItem>('ConfigurationItems', id, updates);
}
// Picklist methods
async getPicklistValues(
entityName: string,
fieldName: string
): Promise<Record<string | number, string>> {
const url = `${this.config.apiUrl}/${entityName}/entityInformation/fields`;
const response = await this.makeApiCall<{ fields: EntityField[] }>(url, {
method: 'GET',
headers: this.getAuthHeaders(),
});
const field = response.fields.find((f) => f.name === fieldName);
if (field && field.picklistValues) {
const picklistMap: Record<string | number, string> = {};
field.picklistValues.forEach((item) => {
picklistMap[item.value] = item.label;
});
return picklistMap;
}
return {};
}
async getTicketStatusPicklist(): Promise<Record<number, string>> {
return this.getPicklistValues('Tickets', 'status');
}
async getTicketPriorityPicklist(): Promise<Record<number, string>> {
return this.getPicklistValues('Tickets', 'priority');
}
async getTaskStatusPicklist(): Promise<Record<number, string>> {
return this.getPicklistValues('Tasks', 'status');
}
// Attachment methods
async uploadAttachment(
entityName: string,
entityId: number,
fileBuffer: Buffer,
fileName: string,
impersonatorEmail?: string
): Promise<Attachment> {
let impersonationResourceId: number | undefined;
if (impersonatorEmail) {
const resource = await this.getResourceByEmail(impersonatorEmail);
if (resource) {
impersonationResourceId = resource.id;
}
}
const base64Data = fileBuffer.toString('base64');
const payload: Partial<Attachment> = {
id: 0,
attachmentType: 'FILE_ATTACHMENT',
fullPath: fileName,
title: fileName,
publish: 1, // All Autotask Users
data: base64Data,
};
const url = `${this.config.apiUrl}/${entityName}/${entityId}/Attachments`;
const response = await this.makeApiCall<ApiResponse<Attachment>>(url, {
method: 'POST',
headers: this.getAuthHeaders(impersonationResourceId),
body: JSON.stringify(payload),
});
if (!response.item) {
throw new Error('Failed to upload attachment');
}
return response.item;
}
async getAttachments(
entityName: string,
entityId: number
): Promise<Attachment[]> {
const url = `${this.config.apiUrl}/${entityName}/${entityId}/Attachments`;
const response = await this.makeApiCall<ApiResponse<Attachment>>(url, {
method: 'GET',
headers: this.getAuthHeaders(),
});
return response.items || [];
}
}
// Rate Limiter class
class RateLimiter {
private maxRequestsPerSecond: number;
private requestTimes: number[];
constructor(maxRequestsPerSecond = 10) {
this.maxRequestsPerSecond = maxRequestsPerSecond;
this.requestTimes = [];
}
async throttle(): Promise<void> {
const now = Date.now();
const oneSecondAgo = now - 1000;
// Remove old request times
this.requestTimes = this.requestTimes.filter((t) => t > oneSecondAgo);
// If at limit, wait
if (this.requestTimes.length >= this.maxRequestsPerSecond) {
const oldestRequest = this.requestTimes[0];
const waitTime = 1000 - (now - oldestRequest);
if (waitTime > 0) {
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
}
this.requestTimes.push(Date.now());
}
}

View file

@ -0,0 +1,26 @@
import { AutotaskClient } from './autotask-client';
import { AutotaskConfig } from '@/lib/types/autotask';
let clientInstance: AutotaskClient | null = null;
export function getAutotaskClient(): AutotaskClient {
if (!clientInstance) {
const config: AutotaskConfig = {
apiUrl: process.env.AUTOTASK_API_URL || '',
username: process.env.AUTOTASK_USERNAME || '',
password: process.env.AUTOTASK_SECRET || '',
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
};
// Validate configuration
if (!config.apiUrl || !config.username || !config.password || !config.apiIntegrationCode) {
throw new Error(
'Missing Autotask API configuration. Please check your environment variables.'
);
}
clientInstance = new AutotaskClient(config);
}
return clientInstance;
}

View file

@ -0,0 +1,62 @@
// Simple in-memory cache with TTL
interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number;
}
class SimpleCache {
private cache: Map<string, CacheEntry<any>> = new Map();
set<T>(key: string, data: T, ttlMinutes: number = 5): void {
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl: ttlMinutes * 60 * 1000, // Convert to milliseconds
});
}
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
// Check if expired
if (Date.now() - entry.timestamp > entry.ttl) {
this.cache.delete(key);
return null;
}
return entry.data as T;
}
delete(key: string): void {
this.cache.delete(key);
}
clear(): void {
this.cache.clear();
}
// Clean up expired entries
cleanup(): void {
const now = Date.now();
for (const [key, entry] of this.cache.entries()) {
if (now - entry.timestamp > entry.ttl) {
this.cache.delete(key);
}
}
}
}
// Global cache instance
export const apiCache = new SimpleCache();
// Run cleanup every 5 minutes (server-side only)
if (typeof window === 'undefined') {
setInterval(() => {
apiCache.cleanup();
}, 5 * 60 * 1000);
}

View file

@ -0,0 +1,133 @@
import {
DattoRMMConfig,
DattoRMMDevice,
DattoRMMSite,
DattoRMMApiResponse,
DattoRMMError,
} from '@/lib/types/datto-rmm';
export class DattoRMMClientSimple {
private config: DattoRMMConfig;
constructor(config: DattoRMMConfig) {
this.config = config;
}
/**
* Make an API request using Basic authentication directly
*/
private async makeApiCall<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
// Create Basic auth header using API key and secret
const credentials = Buffer.from(`${this.config.apiKey}:${this.config.apiSecret}`).toString('base64');
// Use the base URL directly with v2 API
const url = `https://concord-api.centrastage.net/api/v2${endpoint}`;
console.log(`Making API call to: ${url}`);
const response = await fetch(url, {
...options,
headers: {
'Authorization': `Basic ${credentials}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
let errorMessage = `API Error: ${response.status}`;
try {
const errorData = JSON.parse(errorText) as DattoRMMError;
errorMessage = `${errorData.error}: ${errorData.message}`;
} catch {
errorMessage += ` - ${errorText}`;
}
console.error('API call failed:', errorMessage);
throw new Error(errorMessage);
}
const responseText = await response.text();
if (!responseText) {
return {} as T;
}
try {
return JSON.parse(responseText) as T;
} catch (error) {
console.error('Failed to parse response:', responseText);
throw new Error('Invalid JSON response from API');
}
}
/**
* Get all sites
*/
async getSites(): Promise<DattoRMMSite[]> {
const response = await this.makeApiCall<DattoRMMApiResponse<DattoRMMSite>>(
'/sites',
{ method: 'GET' }
);
return response.items || [];
}
/**
* Get site by name (for matching with Autotask company)
*/
async getSiteByName(name: string): Promise<DattoRMMSite | null> {
const sites = await this.getSites();
const site = sites.find(s =>
s.name.toLowerCase() === name.toLowerCase() ||
s.name.toLowerCase().includes(name.toLowerCase())
);
return site || null;
}
/**
* Get all devices
*/
async getAllDevices(): Promise<DattoRMMDevice[]> {
const response = await this.makeApiCall<DattoRMMApiResponse<DattoRMMDevice>>(
'/devices',
{ method: 'GET' }
);
return response.items || [];
}
/**
* Get devices for a specific site
*/
async getDevicesBySite(siteId: string): Promise<DattoRMMDevice[]> {
const response = await this.makeApiCall<DattoRMMApiResponse<DattoRMMDevice>>(
`/sites/${siteId}/devices`,
{ method: 'GET' }
);
return response.items || [];
}
/**
* Get devices by site name (matches with company name)
*/
async getDevicesByCompanyName(companyName: string): Promise<DattoRMMDevice[]> {
// First, find the site that matches the company name
const site = await this.getSiteByName(companyName);
if (!site) {
console.warn(`No RMM site found for company: ${companyName}`);
return [];
}
// Then get devices for that site
return this.getDevicesBySite(site.id);
}
}

View file

@ -0,0 +1,340 @@
import {
DattoRMMConfig,
DattoRMMDevice,
DattoRMMSite,
DattoRMMApiResponse,
DattoRMMError,
} from '@/lib/types/datto-rmm';
export class DattoRMMClient {
private config: DattoRMMConfig;
private accessToken: string | null = null;
private tokenExpiry: Date | null = null;
constructor(config: DattoRMMConfig) {
this.config = config;
}
/**
* Get OAuth2 access token
* Datto RMM API v2 uses OAuth2 with password grant type
* The API Access Key is the username and API Secret Key is the password
*/
private async getAccessToken(): Promise<string> {
// Check if we have a valid token
if (this.accessToken && this.tokenExpiry && this.tokenExpiry > new Date()) {
return this.accessToken;
}
// OAuth2 token endpoint
const authUrl = 'https://concord-api.centrastage.net/auth/oauth/token';
// According to Datto RMM API v2 docs:
// - Basic auth with client_id: "public-client" and client_secret: "public"
// - grant_type: "password"
// - username: Your API Access Key
// - password: Your API Secret Key
const clientCredentials = Buffer.from('public-client:public').toString('base64');
const formData = new URLSearchParams();
formData.append('grant_type', 'password');
formData.append('username', this.config.apiKey);
formData.append('password', this.config.apiSecret);
try {
console.log('Authenticating with Datto RMM API v2...');
const response = await fetch(authUrl, {
method: 'POST',
headers: {
'Authorization': `Basic ${clientCredentials}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
});
if (!response.ok) {
const error = await response.text();
console.error('Datto RMM auth failed:', response.status, error);
throw new Error(`Authentication failed: ${response.status} - ${error}`);
}
const responseText = await response.text();
let data;
try {
data = JSON.parse(responseText);
} catch (parseError) {
console.error('Failed to parse auth response. Response was:', responseText.substring(0, 500));
throw new Error('Invalid response from auth server - expected JSON but got HTML/text');
}
this.accessToken = data.access_token;
// Set token expiry (usually 1 hour, but we'll refresh after 50 minutes to be safe)
this.tokenExpiry = new Date(Date.now() + 50 * 60 * 1000);
console.log('Successfully authenticated with Datto RMM');
return this.accessToken as string;
} catch (error) {
console.error('Failed to get Datto RMM access token:', error);
throw error;
}
}
/**
* Make an authenticated API request
*/
private async makeApiCall<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const token = await this.getAccessToken();
// Use the base URL directly with v2 API
const url = `https://concord-api.centrastage.net/api/v2${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
console.error(`API call failed: ${response.status} ${response.statusText}`);
console.error('Error response:', errorText);
let errorMessage = `API Error: ${response.status}`;
try {
const errorData = JSON.parse(errorText) as DattoRMMError;
errorMessage = `${errorData.error}: ${errorData.message}`;
} catch {
errorMessage += ` - ${errorText.substring(0, 200)}`;
}
throw new Error(errorMessage);
}
const responseText = await response.text();
if (!responseText) {
return {} as T;
}
try {
return JSON.parse(responseText) as T;
} catch (error) {
console.error('Failed to parse response:', responseText);
throw new Error('Invalid JSON response from API');
}
}
/**
* Get all sites
*/
async getSites(): Promise<DattoRMMSite[]> {
const response = await this.makeApiCall<any>(
'/account/sites',
{ method: 'GET' }
);
// The API returns sites in a 'sites' field
return response.sites || [];
}
/**
* Get site by name (for matching with Autotask company)
*/
async getSiteByName(name: string): Promise<DattoRMMSite | null> {
const sites = await this.getSites();
const site = sites.find(s =>
s.name.toLowerCase() === name.toLowerCase() ||
s.name.toLowerCase().includes(name.toLowerCase())
);
return site || null;
}
/**
* Get all devices
*/
async getAllDevices(): Promise<DattoRMMDevice[]> {
const response = await this.makeApiCall<any>(
'/account/devices',
{ method: 'GET' }
);
// The API returns devices in a 'devices' field
return response.devices || [];
}
/**
* Get devices for a specific site
*/
async getDevicesBySite(siteUid: string): Promise<DattoRMMDevice[]> {
const response = await this.makeApiCall<any>(
`/site/${siteUid}/devices`,
{ method: 'GET' }
);
// The API returns devices in a 'devices' field
return response.devices || [];
}
/**
* Get devices by site name (matches with company name)
*/
async getDevicesByCompanyName(companyName: string): Promise<DattoRMMDevice[]> {
// First, find the site that matches the company name
const site = await this.getSiteByName(companyName);
if (!site) {
console.warn(`No RMM site found for company: ${companyName}`);
return [];
}
// Then get devices for that site using the UID
return this.getDevicesBySite(site.uid);
}
/**
* Get device by ID
*/
async getDeviceById(deviceId: string): Promise<DattoRMMDevice | null> {
try {
const response = await this.makeApiCall<{ item: DattoRMMDevice }>(
`/devices/${deviceId}`,
{ method: 'GET' }
);
return response.item || null;
} catch (error) {
console.error(`Failed to get device ${deviceId}:`, error);
return null;
}
}
/**
* Search devices by various criteria
*/
async searchDevices(criteria: {
hostname?: string;
serialNumber?: string;
ipAddress?: string;
macAddress?: string;
}): Promise<DattoRMMDevice[]> {
const allDevices = await this.getAllDevices();
return allDevices.filter(device => {
if (criteria.hostname &&
!device.hostname.toLowerCase().includes(criteria.hostname.toLowerCase())) {
return false;
}
if (criteria.serialNumber &&
device.serialNumber !== criteria.serialNumber) {
return false;
}
if (criteria.ipAddress &&
device.intIpAddress !== criteria.ipAddress &&
device.extIpAddress !== criteria.ipAddress) {
return false;
}
if (criteria.macAddress &&
!device.macAddresses.some(mac =>
mac.toLowerCase() === criteria.macAddress!.toLowerCase()
)) {
return false;
}
return true;
});
}
/**
* Get device audit data with detailed hardware information
*/
async getDeviceAudit(deviceId: string | number): Promise<any> {
try {
const response = await this.makeApiCall<any>(
`/device/${deviceId}/auditdata`,
{ method: 'GET' }
);
return response;
} catch (error) {
console.error(`Failed to get audit for device ${deviceId}:`, error);
return null;
}
}
/**
* Get device with audit data
*/
async getDeviceWithAudit(deviceId: string | number): Promise<DattoRMMDevice | null> {
try {
// Get basic device info
const device = await this.getDeviceById(deviceId.toString());
if (!device) return null;
// Try to get audit data for more details
const audit = await this.getDeviceAudit(deviceId);
if (audit) {
// Merge audit data into device
if (audit.bios) {
device.manufacturer = audit.bios.manufacturer || device.manufacturer;
device.model = audit.bios.model || device.model;
device.serialNumber = audit.bios.serialNumber || device.serialNumber;
}
if (audit.system) {
device.manufacturer = audit.system.manufacturer || device.manufacturer;
device.model = audit.system.model || device.model;
}
if (audit.processors && audit.processors.length > 0) {
device.cpuName = audit.processors[0].name;
device.cpuCores = audit.processors[0].cores;
}
if (audit.memory) {
device.memory = audit.memory.totalPhysicalMemory;
}
if (audit.disks && audit.disks.length > 0) {
// Sum up all disk sizes
device.diskSize = audit.disks.reduce((total: number, disk: any) =>
total + (disk.size || 0), 0
);
}
}
return device;
} catch (error) {
console.error(`Failed to get device with audit ${deviceId}:`, error);
return null;
}
}
/**
* Get device alerts
*/
async getDeviceAlerts(deviceId: string): Promise<any[]> {
try {
const response = await this.makeApiCall<DattoRMMApiResponse<any>>(
`/devices/${deviceId}/alerts`,
{ method: 'GET' }
);
return response.items || [];
} catch (error) {
console.error(`Failed to get alerts for device ${deviceId}:`, error);
return [];
}
}
}

View file

@ -0,0 +1,25 @@
import { DattoRMMClient } from './datto-rmm-client';
import { DattoRMMConfig } from '@/lib/types/datto-rmm';
let clientInstance: DattoRMMClient | null = null;
export function getDattoRMMClient(): DattoRMMClient {
if (!clientInstance) {
const config: DattoRMMConfig = {
apiUrl: process.env.DATTO_RMM_API_URL || '',
apiKey: process.env.DATTO_RMM_API_KEY || '',
apiSecret: process.env.DATTO_RMM_API_SECRET || '',
};
// Validate configuration
if (!config.apiUrl || !config.apiKey || !config.apiSecret) {
throw new Error(
'Missing Datto RMM API configuration. Please check your environment variables.'
);
}
clientInstance = new DattoRMMClient(config);
}
return clientInstance;
}

View file

@ -0,0 +1,262 @@
// Autotask API Types
export interface AutotaskConfig {
apiUrl: string;
username: string;
password: string;
apiIntegrationCode: string;
}
export type AutotaskHeaders = {
Authorization: string;
ApiIntegrationcode: string;
'Content-Type': string;
Accept: string;
ImpersonationResourceId?: string;
}
export interface QueryFilter {
op: 'eq' | 'noteq' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'beginsWith' | 'endsWith';
field: string;
value: string | number | boolean;
}
export interface QueryParams {
filter?: QueryFilter[];
maxRecords?: number;
includeFields?: string[];
excludeFields?: string[];
}
export interface Resource {
id: number;
firstName: string;
lastName: string;
email: string;
userName?: string;
isActive?: boolean;
title?: string;
mobilePhone?: string;
officePhone?: string;
officeExtension?: string;
}
export interface Ticket {
id: number;
ticketNumber: string;
title: string;
description?: string;
status: number;
priority: number;
assignedResourceID?: number;
companyID: number;
companyLocationID?: number;
contactID?: number;
createDate: string;
dueDateTime?: string;
lastActivityDate?: string;
completedDate?: string;
queueID?: number;
issueType?: number;
subIssueType?: number;
}
export interface Task {
id: number;
title: string;
description?: string;
status: number;
priority: number;
assignedResourceID?: number;
projectID?: number;
phaseID?: number;
createDateTime: string;
startDateTime?: string;
endDateTime?: string;
completedDateTime?: string;
percentComplete?: number;
estimatedHours?: number;
actualHours?: number;
}
export interface Company {
id: number;
companyName: string;
companyNumber?: string;
isActive: boolean;
phone?: string;
alternatePhone1?: string;
alternatePhone2?: string;
fax?: string;
webSiteURL?: string;
address1?: string;
address2?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
companyType?: number;
territoryID?: number;
marketSegmentID?: number;
competitorID?: number;
}
export interface ConfigurationItem {
id: number;
companyID: number;
companyLocationID?: number;
contactID?: number;
contractID?: number;
contractServiceID?: number;
createDate: string;
createdByPersonType?: number;
createdByResourceID?: number;
dattoAvailableKilobytes?: number;
dattoDeviceMemoryMegabytes?: number;
dattoHostname?: string;
dattoInternalIP?: string;
dattoKernelVersionID?: number;
dattoLastCheckInDateTime?: string;
dattoNumberOfCPUs?: number;
dattoNumberOfLogicalProcessors?: number;
dattoOSVersionID?: number;
dattoProtectedKilobytes?: number;
dattoRemoteIP?: string;
dattoSerialNumber?: string;
dattoUDF?: string;
dattoUsedKilobytes?: number;
dattoZfsPoolZpoolVersionID?: number;
deviceNetworkingID?: string;
dnsServer1?: string;
dnsServer2?: string;
installDate?: string;
installedByContactID?: number;
installedByID?: number;
installedProductCategoryID?: number;
isActive: boolean;
lastActivityPersonResourceID?: number;
lastActivityPersonType?: number;
lastModifiedTime?: string;
location?: string;
macAddress?: string;
modelNumber?: string;
notes?: string;
numberOfUsers?: number;
parentConfigurationItemID?: number;
productID?: number;
referenceNumber?: string;
referenceTitle: string;
rmmDeviceAuditAntivirusStatusID?: number;
rmmDeviceAuditArchitectureID?: number;
rmmDeviceAuditBackupStatusID?: number;
rmmDeviceAuditDescription?: string;
rmmDeviceAuditDeviceTypeID?: number;
rmmDeviceAuditDisplayAdaptorID?: number;
rmmDeviceAuditDomainID?: number;
rmmDeviceAuditHostname?: string;
rmmDeviceAuditIPAddress?: string;
rmmDeviceAuditLastUser?: string;
rmmDeviceAuditMacAddress?: string;
rmmDeviceAuditManufacturerID?: number;
rmmDeviceAuditMemoryBytes?: number;
rmmDeviceAuditMissingPatchCount?: number;
rmmDeviceAuditMobileNetworkOperatorID?: number;
rmmDeviceAuditMobileNumber?: string;
rmmDeviceAuditModelID?: number;
rmmDeviceAuditMotherboardID?: number;
rmmDeviceAuditOperatingSystemID?: number;
rmmDeviceAuditPatchStatusID?: number;
rmmDeviceAuditProcessorID?: number;
rmmDeviceAuditServicePackID?: number;
rmmDeviceAuditSNMPContact?: string;
rmmDeviceAuditSNMPLocation?: string;
rmmDeviceAuditSNMPName?: string;
rmmDeviceAuditSoftwareStatusID?: number;
rmmDeviceAuditStorageBytes?: number;
rmmDeviceID?: string;
rmmDeviceUID?: string;
rmmOpenAlertCount?: number;
serialNumber?: string;
serviceBundleID?: number;
serviceID?: number;
serviceLevelAgreementID?: number;
setupFee?: number;
sourceProductID?: number;
type?: number;
vendorID?: number;
vendorName?: string;
warrantyExpirationDate?: string;
}
export interface Attachment {
id: number;
attachmentType: 'FILE_ATTACHMENT' | 'FILE_LINK' | 'URL' | 'NOTE';
fullPath: string;
title: string;
publish: 1 | 2; // 1 = All Autotask Users, 2 = Internal Users Only
data?: string; // Base64 encoded file data
contentType?: string;
createDate?: string;
creatorResourceID?: number;
}
export interface PicklistValue {
value: number | string;
label: string;
isDefaultValue?: boolean;
sortOrder?: number;
isActive?: boolean;
isSystem?: boolean;
}
export interface EntityField {
name: string;
dataType: string;
length?: number;
isRequired?: boolean;
isReadOnly?: boolean;
isQueryable?: boolean;
isReference?: boolean;
referenceEntityType?: string;
picklistValues?: PicklistValue[];
}
export interface ApiResponse<T> {
item?: T;
items?: T[];
pageDetails?: {
count: number;
requestCount: number;
prevPageUrl?: string;
nextPageUrl?: string;
};
}
export interface ApiError {
message: string;
errors?: Array<{
message: string;
field?: string;
}>;
}
export enum TicketStatus {
New = 1,
InProgress = 8,
Waiting = 9,
Complete = 5,
}
export enum TaskStatus {
New = 1,
InProgress = 11,
Waiting = 12,
Complete = 5,
}
export enum Priority {
Critical = 1,
High = 2,
Medium = 3,
Low = 4,
}

View file

@ -0,0 +1,105 @@
// Datto RMM API Types
export interface DattoRMMConfig {
apiUrl: string;
apiKey: string;
apiSecret: string;
}
export interface DattoRMMDevice {
id: number;
uid: string;
siteId: number;
siteUid: string;
siteName: string;
deviceType: {
category: string;
type: string;
};
hostname: string;
description: string;
intIpAddress: string;
extIpAddress: string;
macAddresses?: string[];
domain: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
lastSeen: number; // timestamp in milliseconds
lastLoggedInUser?: string;
lastReboot?: number;
lastAuditDate?: number;
creationDate?: number;
online: boolean;
suspended: boolean;
deleted: boolean;
rebootRequired?: boolean;
a64Bit?: boolean;
operatingSystem: string;
cagVersion?: string;
displayVersion?: string;
memory?: number; // in MB
cpuCores?: number;
cpuName?: string;
diskSize?: number; // in GB
antivirus?: {
antivirusProduct: string;
antivirusStatus: string;
};
patchManagement?: {
patchStatus: string;
patchesApprovedPending: number;
patchesNotApproved: number;
patchesInstalled: number;
};
softwareStatus?: string;
portalUrl?: string;
webRemoteUrl?: string;
warrantyDate?: string | null;
snmpEnabled?: boolean;
deviceClass?: string;
udf?: Record<string, any>;
}
export interface DattoRMMSite {
id: string;
uid: string;
name: string;
description: string;
notes: string;
onDemand: boolean;
proxySettings?: {
host: string;
port: number;
username?: string;
};
devices?: DattoRMMDevice[];
}
export interface DattoRMMApiResponse<T> {
items?: T[];
item?: T;
pageDetails?: {
page: number;
perPage: number;
totalPages: number;
totalItems: number;
};
}
export interface DattoRMMError {
error: string;
message: string;
code?: string;
}
export interface DeviceComparison {
autotaskDevice?: any; // ConfigurationItem from Autotask
rmmDevice?: DattoRMMDevice;
status: 'matched' | 'autotask-only' | 'rmm-only' | 'mismatch';
discrepancies?: {
field: string;
autotaskValue: any;
rmmValue: any;
}[];
}

View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -0,0 +1,174 @@
/**
* Device information lookup utilities
* Provides manufacturer and model information based on serial number patterns
*/
interface DeviceInfo {
manufacturer: string;
modelFamily?: string;
estimatedModel?: string;
}
/**
* Look up device information based on serial number
*/
export function lookupDeviceBySerial(serialNumber: string | undefined): DeviceInfo | null {
if (!serialNumber) return null;
const serial = serialNumber.toUpperCase().trim();
// Dell serial numbers (7 characters, alphanumeric)
if (/^[A-Z0-9]{7}$/.test(serial)) {
return {
manufacturer: 'Dell Inc.',
modelFamily: 'Dell',
estimatedModel: identifyDellModel(serial)
};
}
// HP serial numbers (often start with specific patterns)
if (/^(CNU|2UA|CND|CNF|MXL|SGH|USE|5CD|5CG)[A-Z0-9]+/.test(serial)) {
return {
manufacturer: 'HP',
modelFamily: 'HP',
estimatedModel: identifyHPModel(serial)
};
}
// Lenovo serial numbers (often start with specific patterns)
if (/^(MJ|PF|MP|R9|S4|PC|PB)[A-Z0-9]+/.test(serial)) {
return {
manufacturer: 'Lenovo',
modelFamily: 'ThinkPad/ThinkCentre',
estimatedModel: identifyLenovoModel(serial)
};
}
// Apple serial numbers (12 characters)
if (/^[A-Z0-9]{12}$/.test(serial)) {
const yearCode = serial.substring(3, 4);
if (['P', 'Q', 'R', 'S', 'T', 'V', 'W', 'X', 'Y', 'Z'].includes(yearCode)) {
return {
manufacturer: 'Apple Inc.',
modelFamily: 'Mac',
estimatedModel: identifyAppleModel(serial)
};
}
}
// Microsoft Surface (often starts with specific patterns)
if (/^[0-9]{12}$/.test(serial) || serial.startsWith('00')) {
return {
manufacturer: 'Microsoft',
modelFamily: 'Surface',
estimatedModel: 'Surface Device'
};
}
return null;
}
/**
* Identify Dell model based on service tag patterns
*/
function identifyDellModel(serial: string): string {
// Dell service tags can indicate model families
// This is a simplified example - real implementation would need more patterns
const firstChar = serial[0];
if (['H', 'J', 'K'].includes(firstChar)) {
return 'OptiPlex Desktop';
} else if (['F', 'G'].includes(firstChar)) {
return 'Latitude Laptop';
} else if (['D', 'C'].includes(firstChar)) {
return 'Precision Workstation';
}
return 'Dell Computer';
}
/**
* Identify HP model based on serial patterns
*/
function identifyHPModel(serial: string): string {
const prefix = serial.substring(0, 3);
switch (prefix) {
case 'CNU':
case 'CND':
return 'HP ProBook/EliteBook';
case '2UA':
case '5CD':
case '5CG':
return 'HP Desktop/Workstation';
case 'MXL':
return 'HP ProDesk';
case 'SGH':
return 'HP Server';
default:
return 'HP Computer';
}
}
/**
* Identify Lenovo model based on serial patterns
*/
function identifyLenovoModel(serial: string): string {
const prefix = serial.substring(0, 2);
switch (prefix) {
case 'MJ':
return 'ThinkCentre Desktop';
case 'PF':
case 'PC':
case 'PB':
return 'ThinkPad Laptop';
case 'MP':
return 'ThinkStation';
case 'R9':
case 'S4':
return 'IdeaPad/Yoga';
default:
return 'Lenovo Computer';
}
}
/**
* Identify Apple model based on serial number
*/
function identifyAppleModel(serial: string): string {
// Apple serial numbers encode model info in positions 4-5
const modelCode = serial.substring(4, 6);
// This is a simplified mapping - real implementation would need comprehensive list
if (modelCode.startsWith('M')) {
return 'MacBook Pro';
} else if (modelCode.startsWith('F')) {
return 'MacBook Air';
} else if (modelCode.startsWith('G')) {
return 'iMac';
} else if (modelCode.startsWith('J')) {
return 'Mac mini';
} else if (modelCode.startsWith('P')) {
return 'Mac Studio/Pro';
}
return 'Mac Computer';
}
/**
* Format device information for display
*/
export function formatDeviceInfo(info: DeviceInfo | null): string {
if (!info) return 'Unknown Device';
if (info.estimatedModel) {
return `${info.manufacturer} - ${info.estimatedModel}`;
}
if (info.modelFamily) {
return `${info.manufacturer} ${info.modelFamily}`;
}
return info.manufacturer;
}

View file

@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
};
export default nextConfig;

51
autotask-app/package.json Normal file
View file

@ -0,0 +1,51 @@
{
"name": "autotask-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.548.0",
"next": "16.0.0",
"next-themes": "^0.4.6",
"react": "19.2.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.2.0",
"react-hook-form": "^7.65.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.1.12"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.0.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View file

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

61
dev/create-prd.mdc Normal file
View file

@ -0,0 +1,61 @@
---
description:
globs:
alwaysApply: false
---
# Rule: Generating a Product Requirements Document (PRD)
## Goal
To guide an AI assistant in creating a detailed Product Requirements Document (PRD) in Markdown format, based on an initial user prompt. The PRD should be clear, actionable, and suitable for a junior developer to understand and implement the feature.
## Process
1. **Receive Initial Prompt:** The user provides a brief description or request for a new feature or functionality.
2. **Ask Clarifying Questions:** Before writing the PRD, the AI *must* ask clarifying questions to gather sufficient detail. The goal is to understand the "what" and "why" of the feature, not necessarily the "how" (which the developer will figure out).
3. **Generate PRD:** Based on the initial prompt and the user's answers to the clarifying questions, generate a PRD using the structure outlined below.
4. **Save PRD:** Save the generated document as `prd-[feature-name].md` inside the `/tasks` directory.
## Clarifying Questions (Examples)
The AI should adapt its questions based on the prompt, but here are some common areas to explore:
* **Problem/Goal:** "What problem does this feature solve for the user?" or "What is the main goal we want to achieve with this feature?"
* **Target User:** "Who is the primary user of this feature?"
* **Core Functionality:** "Can you describe the key actions a user should be able to perform with this feature?"
* **User Stories:** "Could you provide a few user stories? (e.g., As a [type of user], I want to [perform an action] so that [benefit].)"
* **Acceptance Criteria:** "How will we know when this feature is successfully implemented? What are the key success criteria?"
* **Scope/Boundaries:** "Are there any specific things this feature *should not* do (non-goals)?"
* **Data Requirements:** "What kind of data does this feature need to display or manipulate?"
* **Design/UI:** "Are there any existing design mockups or UI guidelines to follow?" or "Can you describe the desired look and feel?"
* **Edge Cases:** "Are there any potential edge cases or error conditions we should consider?"
## PRD Structure
The generated PRD should include the following sections:
1. **Introduction/Overview:** Briefly describe the feature and the problem it solves. State the goal.
2. **Goals:** List the specific, measurable objectives for this feature.
3. **User Stories:** Detail the user narratives describing feature usage and benefits.
4. **Functional Requirements:** List the specific functionalities the feature must have. Use clear, concise language (e.g., "The system must allow users to upload a profile picture."). Number these requirements.
5. **Non-Goals (Out of Scope):** Clearly state what this feature will *not* include to manage scope.
6. **Design Considerations (Optional):** Link to mockups, describe UI/UX requirements, or mention relevant components/styles if applicable.
7. **Technical Considerations (Optional):** Mention any known technical constraints, dependencies, or suggestions (e.g., "Should integrate with the existing Auth module").
8. **Success Metrics:** How will the success of this feature be measured? (e.g., "Increase user engagement by 10%", "Reduce support tickets related to X").
9. **Open Questions:** List any remaining questions or areas needing further clarification.
## Target Audience
Assume the primary reader of the PRD is a **junior developer**. Therefore, requirements should be explicit, unambiguous, and avoid jargon where possible. Provide enough detail for them to understand the feature's purpose and core logic.
## Output
* **Format:** Markdown (`.md`)
* **Location:** `/tasks/`
* **Filename:** `prd-[feature-name].md`
## Final instructions
1. Do NOT start implementing the PRD
2. Make sure to ask the user clarifying questions
3. Take the user's answers to the clarifying questions and improve the PRD

64
dev/generate-tasks.mdc Normal file
View file

@ -0,0 +1,64 @@
---
description:
globs:
alwaysApply: false
---
# Rule: Generating a Task List from a PRD
## Goal
To guide an AI assistant in creating a detailed, step-by-step task list in Markdown format based on an existing Product Requirements Document (PRD). The task list should guide a developer through implementation.
## Output
- **Format:** Markdown (`.md`)
- **Location:** `/tasks/`
- **Filename:** `tasks-[prd-file-name].md` (e.g., `tasks-prd-user-profile-editing.md`)
## Process
1. **Receive PRD Reference:** The user points the AI to a specific PRD file
2. **Analyze PRD:** The AI reads and analyzes the functional requirements, user stories, and other sections of the specified PRD.
3. **Phase 1: Generate Parent Tasks:** Based on the PRD analysis, create the file and generate the main, high-level tasks required to implement the feature. Use your judgement on how many high-level tasks to use. It's likely to be about 5. Present these tasks to the user in the specified format (without sub-tasks yet). Inform the user: "I have generated the high-level tasks based on the PRD. Ready to generate the sub-tasks? Respond with 'Go' to proceed."
4. **Wait for Confirmation:** Pause and wait for the user to respond with "Go".
5. **Phase 2: Generate Sub-Tasks:** Once the user confirms, break down each parent task into smaller, actionable sub-tasks necessary to complete the parent task. Ensure sub-tasks logically follow from the parent task and cover the implementation details implied by the PRD.
6. **Identify Relevant Files:** Based on the tasks and PRD, identify potential files that will need to be created or modified. List these under the `Relevant Files` section, including corresponding test files if applicable.
7. **Generate Final Output:** Combine the parent tasks, sub-tasks, relevant files, and notes into the final Markdown structure.
8. **Save Task List:** Save the generated document in the `/tasks/` directory with the filename `tasks-[prd-file-name].md`, where `[prd-file-name]` matches the base name of the input PRD file (e.g., if the input was `prd-user-profile-editing.md`, the output is `tasks-prd-user-profile-editing.md`).
## Output Format
The generated task list _must_ follow this structure:
```markdown
## Relevant Files
- `path/to/potential/file1.ts` - Brief description of why this file is relevant (e.g., Contains the main component for this feature).
- `path/to/file1.test.ts` - Unit tests for `file1.ts`.
- `path/to/another/file.tsx` - Brief description (e.g., API route handler for data submission).
- `path/to/another/file.test.tsx` - Unit tests for `another/file.tsx`.
- `lib/utils/helpers.ts` - Brief description (e.g., Utility functions needed for calculations).
- `lib/utils/helpers.test.ts` - Unit tests for `helpers.ts`.
### Notes
- Unit tests should typically be placed alongside the code files they are testing (e.g., `MyComponent.tsx` and `MyComponent.test.tsx` in the same directory).
- Use `npx jest [optional/path/to/test/file]` to run tests. Running without a path executes all tests found by the Jest configuration.
## Tasks
- [ ] 1.0 Parent Task Title
- [ ] 1.1 [Sub-task description 1.1]
- [ ] 1.2 [Sub-task description 1.2]
- [ ] 2.0 Parent Task Title
- [ ] 2.1 [Sub-task description 2.1]
- [ ] 3.0 Parent Task Title (may not require sub-tasks if purely structural or configuration)
```
## Interaction Model
The process explicitly requires a pause after generating parent tasks to get user confirmation ("Go") before proceeding to generate the detailed sub-tasks. This ensures the high-level plan aligns with user expectations before diving into details.
## Target Audience
Assume the primary reader of the task list is a **junior developer** who will implement the feature.

38
dev/process-task-list.mdc Normal file
View file

@ -0,0 +1,38 @@
---
description:
globs:
alwaysApply: false
---
# Task List Management
Guidelines for managing task lists in markdown files to track progress on completing a PRD
## Task Implementation
- **One sub-task at a time:** Do **NOT** start the next subtask until you ask the user for permission and they say “yes” or "y"
- **Completion protocol:**
1. When you finish a **subtask**, immediately mark it as completed by changing `[ ]` to `[x]`.
2. If **all** subtasks underneath a parent task are now `[x]`, also mark the **parent task** as completed.
- Stop after each subtask and wait for the users goahead.
## Task List Maintenance
1. **Update the task list as you work:**
- Mark tasks and subtasks as completed (`[x]`) per the protocol above.
- Add new tasks as they emerge.
2. **Maintain the “Relevant Files” section:**
- List every file created or modified.
- Give each file a oneline description of its purpose.
## AI Instructions
When working with task lists, the AI must:
1. Regularly update the task list file after finishing any significant work.
2. Follow the completion protocol:
- Mark each finished **subtask** `[x]`.
- Mark the **parent task** `[x]` once **all** its subtasks are `[x]`.
3. Add newly discovered tasks.
4. Keep “Relevant Files” accurate and up to date.
5. Before starting work, check which subtask is next.
6. After implementing a subtask, update the file and then pause for user approval.

63
lib/services/cache.ts Normal file
View file

@ -0,0 +1,63 @@
// Simple in-memory cache with TTL
interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number;
}
class SimpleCache {
private cache: Map<string, CacheEntry<any>> = new Map();
set<T>(key: string, data: T, ttlMinutes: number = 5): void {
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl: ttlMinutes * 60 * 1000, // Convert to milliseconds
});
}
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
// Check if expired
if (Date.now() - entry.timestamp > entry.ttl) {
this.cache.delete(key);
return null;
}
return entry.data as T;
}
delete(key: string): void {
this.cache.delete(key);
}
clear(): void {
this.cache.clear();
}
// Clean up expired entries
cleanup(): void {
const now = Date.now();
for (const [key, entry] of this.cache.entries()) {
if (now - entry.timestamp > entry.ttl) {
this.cache.delete(key);
}
}
}
}
// Global cache instance
export const apiCache = new SimpleCache();
// Run cleanup every 5 minutes
if (typeof window === 'undefined') {
// Server-side only
setInterval(() => {
apiCache.cleanup();
}, 5 * 60 * 1000);
}