Add purchase history and related tickets features
This commit is contained in:
parent
47589b0742
commit
cf5fabe306
88 changed files with 10150 additions and 0 deletions
41
autotask-app/.gitignore
vendored
Normal file
41
autotask-app/.gitignore
vendored
Normal 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
193
autotask-app/README.md
Normal 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
|
||||
21
autotask-app/app/api/companies/route.ts
Normal file
21
autotask-app/app/api/companies/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const client = getAutotaskClient();
|
||||
const allCompanies = await client.getAllCompanies();
|
||||
|
||||
// Filter to only show customers (companyType = 1)
|
||||
// companyType 1 = Customer, 2 = Lead, 3 = Prospect, 4 = Dead, 5 = Cancelation, 6 = Vendor, 7 = Partner
|
||||
const companies = allCompanies.filter(company => company.companyType === 1);
|
||||
|
||||
return NextResponse.json({ companies });
|
||||
} catch (error) {
|
||||
console.error('Error fetching companies:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch companies' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
201
autotask-app/app/api/config-enrichment/route.ts
Normal file
201
autotask-app/app/api/config-enrichment/route.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
|
||||
// Extract serial number from lineItemFullDescription
|
||||
function extractSerialNumber(description: string | null): string | null {
|
||||
if (!description) return null;
|
||||
|
||||
// Look for "Serial Number(s)" followed by the serial
|
||||
const match = description.match(/Serial Number\(s\)\s+([A-Z0-9]+)/i);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const configItemId = searchParams.get('configItemId');
|
||||
const invoiceId = searchParams.get('invoiceId');
|
||||
|
||||
// If invoice ID is provided, just return billing items for that invoice
|
||||
if (invoiceId) {
|
||||
const autotaskClient = getAutotaskClient();
|
||||
|
||||
const billingItems = await autotaskClient.queryEntity('BillingItems', {
|
||||
filter: [{ op: 'eq', field: 'invoiceID', value: parseInt(invoiceId) }],
|
||||
});
|
||||
|
||||
console.log(`Found ${billingItems.length} billing items for invoice ${invoiceId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
configItem: null,
|
||||
billingItems,
|
||||
invoices: [],
|
||||
tickets: [],
|
||||
summary: {
|
||||
totalBilled: billingItems.reduce((sum: number, item: any) => sum + (item.totalAmount || 0), 0),
|
||||
purchaseDate: null,
|
||||
lastInvoiceDate: null,
|
||||
ticketCount: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!configItemId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Configuration Item ID or Invoice ID is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const autotaskClient = getAutotaskClient();
|
||||
|
||||
// Get the configuration item
|
||||
const configItem = await autotaskClient.getConfigurationItemById(parseInt(configItemId));
|
||||
|
||||
if (!configItem) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Configuration item not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get date range from query params
|
||||
const startDateParam = searchParams.get('startDate');
|
||||
const endDateParam = searchParams.get('endDate');
|
||||
const daysBackParam = searchParams.get('daysBack');
|
||||
|
||||
let startDateFilter: string;
|
||||
let endDateFilter: string;
|
||||
|
||||
if (startDateParam && endDateParam) {
|
||||
// Use explicit date range
|
||||
startDateFilter = startDateParam;
|
||||
endDateFilter = endDateParam;
|
||||
console.log('Using explicit date range:', { startDateFilter, endDateFilter });
|
||||
} else {
|
||||
// Fallback to daysBack (for backward compatibility)
|
||||
const daysBack = parseInt(daysBackParam || '90');
|
||||
const dateAgo = new Date();
|
||||
dateAgo.setDate(dateAgo.getDate() - daysBack);
|
||||
startDateFilter = dateAgo.toISOString().split('T')[0];
|
||||
endDateFilter = new Date().toISOString().split('T')[0];
|
||||
console.log('Using daysBack:', daysBack);
|
||||
}
|
||||
|
||||
console.log('Searching for enrichment data:', {
|
||||
configItemId,
|
||||
companyID: configItem.companyID,
|
||||
serialNumber: configItem.serialNumber,
|
||||
startDate: startDateFilter,
|
||||
endDate: endDateFilter
|
||||
});
|
||||
|
||||
let invoices: any[] = [];
|
||||
try {
|
||||
invoices = await autotaskClient.queryEntity('Invoices', {
|
||||
filter: [
|
||||
{ op: 'eq', field: 'companyID', value: configItem.companyID },
|
||||
{ op: 'gte', field: 'invoiceDateTime', value: startDateFilter },
|
||||
{ op: 'lte', field: 'invoiceDateTime', value: endDateFilter }
|
||||
],
|
||||
});
|
||||
console.log(`Found ${invoices.length} invoices for company between ${startDateFilter} and ${endDateFilter}`);
|
||||
} catch (err) {
|
||||
console.error('Error fetching invoices:', err);
|
||||
}
|
||||
|
||||
// Get billing items (line items) for all invoices - hardware only
|
||||
let billingItems: any[] = [];
|
||||
if (invoices.length > 0) {
|
||||
try {
|
||||
const invoiceIds = invoices.map(inv => inv.id);
|
||||
// Fetch only hardware billing items (type 3) to reduce data volume
|
||||
console.log(`Fetching hardware billing items (type 3) for company ${configItem.companyID}...`);
|
||||
const allBillingItems = await autotaskClient.queryEntity('BillingItems', {
|
||||
filter: [
|
||||
{ op: 'eq', field: 'companyID', value: configItem.companyID },
|
||||
{ op: 'gte', field: 'itemDate', value: startDateFilter },
|
||||
{ op: 'lte', field: 'itemDate', value: endDateFilter },
|
||||
{ op: 'eq', field: 'billingItemType', value: 3 } // Hardware only
|
||||
],
|
||||
});
|
||||
console.log(`Fetched ${allBillingItems.length} hardware billing items`);
|
||||
if (allBillingItems.length >= 500) {
|
||||
console.warn('⚠️ Hit API limit of 500 records - older purchases may not be included');
|
||||
}
|
||||
|
||||
// Enrich items (already filtered to hardware at API level)
|
||||
const enrichedItems = allBillingItems
|
||||
.map((item: any) => {
|
||||
const serialNumber = extractSerialNumber(item.lineItemFullDescription);
|
||||
const profit = (item.totalAmount || 0) - (item.ourCost || 0);
|
||||
const margin = item.totalAmount ? ((profit / item.totalAmount) * 100).toFixed(1) : '0';
|
||||
|
||||
return {
|
||||
...item,
|
||||
extractedSerialNumber: serialNumber,
|
||||
profit,
|
||||
profitMargin: margin,
|
||||
};
|
||||
});
|
||||
|
||||
// If we have a config item serial number, filter to matching items
|
||||
if (configItem.serialNumber) {
|
||||
console.log(`Looking for serial: ${configItem.serialNumber}`);
|
||||
console.log(`Extracted serials from billing items:`, enrichedItems.map((i: any) => i.extractedSerialNumber).filter(Boolean));
|
||||
|
||||
// Match if serial appears in extracted field OR anywhere in the description
|
||||
billingItems = enrichedItems.filter((item: any) => {
|
||||
const serial = configItem.serialNumber!.toLowerCase();
|
||||
const extractedMatch = item.extractedSerialNumber &&
|
||||
item.extractedSerialNumber.toLowerCase() === serial;
|
||||
const descriptionMatch = item.lineItemFullDescription &&
|
||||
item.lineItemFullDescription.toLowerCase().includes(serial);
|
||||
return extractedMatch || descriptionMatch;
|
||||
});
|
||||
console.log(`Found ${billingItems.length} billing items matching serial ${configItem.serialNumber}`);
|
||||
|
||||
// If no exact match, return empty array (only show exact serial matches)
|
||||
if (billingItems.length === 0) {
|
||||
console.log(`No exact serial match found for ${configItem.serialNumber}`);
|
||||
}
|
||||
} else {
|
||||
billingItems = enrichedItems;
|
||||
console.log(`Found ${billingItems.length} hardware billing items (no serial filter)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching billing items:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate summary
|
||||
const totalBilled = invoices.reduce((sum: number, invoice: any) =>
|
||||
sum + (invoice.invoiceTotal || 0), 0
|
||||
);
|
||||
|
||||
const lastInvoiceDate = invoices.length > 0
|
||||
? invoices.sort((a: any, b: any) =>
|
||||
new Date(b.invoiceDateTime).getTime() - new Date(a.invoiceDateTime).getTime()
|
||||
)[0]?.invoiceDateTime
|
||||
: null;
|
||||
|
||||
return NextResponse.json({
|
||||
configItem,
|
||||
billingItems,
|
||||
invoices,
|
||||
tickets: [],
|
||||
summary: {
|
||||
totalBilled,
|
||||
purchaseDate: null,
|
||||
lastInvoiceDate,
|
||||
ticketCount: 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in config enrichment:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch enrichment data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
81
autotask-app/app/api/config-items/[id]/tickets/route.ts
Normal file
81
autotask-app/app/api/config-items/[id]/tickets/route.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const client = getAutotaskClient();
|
||||
|
||||
// Get tickets related to this configuration item
|
||||
const tickets = await client.queryEntity('Tickets', {
|
||||
filter: [
|
||||
{ op: 'eq', field: 'configurationItemID', value: parseInt(id) }
|
||||
],
|
||||
});
|
||||
|
||||
// Fetch picklist values for status and priority
|
||||
let statusPicklist: Record<string | number, string> = {};
|
||||
let priorityPicklist: Record<string | number, string> = {};
|
||||
|
||||
try {
|
||||
const [statusResponse, priorityResponse] = await Promise.all([
|
||||
client.getPicklistValues('Tickets', 'status'),
|
||||
client.getPicklistValues('Tickets', 'priority')
|
||||
]);
|
||||
statusPicklist = statusResponse;
|
||||
priorityPicklist = priorityResponse;
|
||||
console.log('Status picklist:', statusPicklist);
|
||||
console.log('Priority picklist:', priorityPicklist);
|
||||
} catch (err) {
|
||||
console.error('Error fetching picklists:', err);
|
||||
}
|
||||
|
||||
// Enrich with resource names and picklist labels
|
||||
const enrichedTickets = await Promise.all(
|
||||
tickets.map(async (ticket: any) => {
|
||||
let assignedResourceName = null;
|
||||
if (ticket.assignedResourceID) {
|
||||
try {
|
||||
const resource = await client.getEntityById('Resources', ticket.assignedResourceID);
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
173
autotask-app/app/api/configuration-items/[id]/route.ts
Normal file
173
autotask-app/app/api/configuration-items/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
80
autotask-app/app/api/configuration-items/route.ts
Normal file
80
autotask-app/app/api/configuration-items/route.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
import { ConfigurationItem } from '@/lib/types/autotask';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Check if API is configured
|
||||
if (!process.env.AUTOTASK_API_URL ||
|
||||
!process.env.AUTOTASK_USERNAME ||
|
||||
!process.env.AUTOTASK_SECRET ||
|
||||
!process.env.AUTOTASK_API_INTEGRATION_CODE) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Autotask API not configured',
|
||||
message: 'Please set up your environment variables.'
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const client = getAutotaskClient();
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const companyId = searchParams.get('companyId');
|
||||
|
||||
if (!companyId) {
|
||||
// Return empty array if no company selected
|
||||
return NextResponse.json({
|
||||
configurationItems: [],
|
||||
message: 'Please select a company to view configuration items'
|
||||
});
|
||||
}
|
||||
|
||||
const configurationItems = await client.getConfigurationItemsByCompany(parseInt(companyId));
|
||||
|
||||
// Sort by reference title for better display
|
||||
configurationItems.sort((a, b) =>
|
||||
(a.referenceTitle || '').localeCompare(b.referenceTitle || '')
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
configurationItems,
|
||||
count: configurationItems.length,
|
||||
companyId: parseInt(companyId)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching configuration items:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to fetch configuration items',
|
||||
message: error.message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'An unexpected error occurred while fetching configuration items' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const client = getAutotaskClient();
|
||||
const body = await request.json();
|
||||
|
||||
const configurationItem = await client.createConfigurationItem(body);
|
||||
|
||||
return NextResponse.json({ configurationItem });
|
||||
} catch (error) {
|
||||
console.error('Error creating configuration item:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to create configuration item' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
39
autotask-app/app/api/contacts/[id]/route.ts
Normal file
39
autotask-app/app/api/contacts/[id]/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
import { apiCache } from '@/lib/services/cache';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const cacheKey = `contact:${id}`;
|
||||
|
||||
// Check cache first
|
||||
const cached = apiCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return NextResponse.json(cached);
|
||||
}
|
||||
|
||||
const autotaskClient = getAutotaskClient();
|
||||
|
||||
// Query for the contact by ID
|
||||
const contacts = await autotaskClient.queryEntity('Contacts', {
|
||||
filter: [{ op: 'eq', field: 'id', value: parseInt(id) }],
|
||||
});
|
||||
|
||||
const contact = contacts.length > 0 ? contacts[0] : null;
|
||||
|
||||
// Cache for 10 minutes
|
||||
apiCache.set(cacheKey, { contact }, 10 * 60); // corrected the cache expiration time
|
||||
|
||||
return NextResponse.json({ contact });
|
||||
} catch (error) {
|
||||
console.error('Error fetching contact:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch contact' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
35
autotask-app/app/api/debug-auth/route.ts
Normal file
35
autotask-app/app/api/debug-auth/route.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const password = process.env.AUTOTASK_SECRET || '';
|
||||
|
||||
// Test with the actual password value
|
||||
const testUrl = `${process.env.AUTOTASK_API_URL}/Tickets/entityInformation`;
|
||||
|
||||
console.log('Password length:', password.length);
|
||||
console.log('Password chars:', password.split('').map(c => `${c} (${c.charCodeAt(0)})`).join(', '));
|
||||
|
||||
const response = await fetch(testUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Username': process.env.AUTOTASK_USERNAME || '',
|
||||
'Secret': password,
|
||||
'APIIntegrationcode': process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
return NextResponse.json({
|
||||
passwordLength: password.length,
|
||||
expectedLength: 25, // The actual password should be 25 chars
|
||||
passwordMatches: password === '7g*Zf@K0Ns3#q$E4A1~n2#mW$',
|
||||
apiResponse: {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body: responseText.substring(0, 200)
|
||||
}
|
||||
});
|
||||
}
|
||||
104
autotask-app/app/api/health/route.ts
Normal file
104
autotask-app/app/api/health/route.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const hasApiUrl = !!process.env.AUTOTASK_API_URL;
|
||||
const hasUsername = !!process.env.AUTOTASK_USERNAME;
|
||||
const hasPassword = !!process.env.AUTOTASK_SECRET;
|
||||
const hasIntegrationCode = !!process.env.AUTOTASK_API_INTEGRATION_CODE;
|
||||
|
||||
const configStatus = {
|
||||
apiUrl: hasApiUrl ? 'configured' : 'missing',
|
||||
username: hasUsername ? 'configured' : 'missing',
|
||||
password: hasPassword ? 'configured' : 'missing',
|
||||
integrationCode: hasIntegrationCode ? 'configured' : 'missing',
|
||||
};
|
||||
|
||||
const isConfigured = hasApiUrl && hasUsername && hasPassword && hasIntegrationCode;
|
||||
|
||||
if (!isConfigured) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Autotask API is not properly configured',
|
||||
configuration: configStatus,
|
||||
instructions: [
|
||||
'1. Create a .env.local file in the root directory',
|
||||
'2. Add the following environment variables:',
|
||||
' - AUTOTASK_API_URL',
|
||||
' - AUTOTASK_USERNAME',
|
||||
' - AUTOTASK_SECRET',
|
||||
' - AUTOTASK_API_INTEGRATION_CODE',
|
||||
'3. Restart the development server',
|
||||
'',
|
||||
'See .env.local.example for a template'
|
||||
]
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
// Try to make a test API call
|
||||
try {
|
||||
const { getAutotaskClient } = await import('@/lib/services/autotask-factory');
|
||||
const client = getAutotaskClient();
|
||||
|
||||
// Test with a simple API call - get entity info
|
||||
const testUrl = `${process.env.AUTOTASK_API_URL}/Tickets/entityInformation`;
|
||||
|
||||
const response = await fetch(testUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Username': process.env.AUTOTASK_USERNAME || '',
|
||||
'Secret': process.env.AUTOTASK_SECRET || '',
|
||||
'APIIntegrationcode': process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return NextResponse.json({
|
||||
status: 'healthy',
|
||||
message: 'Autotask API connection successful',
|
||||
configuration: configStatus,
|
||||
apiUrl: process.env.AUTOTASK_API_URL,
|
||||
});
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Autotask API connection failed',
|
||||
configuration: configStatus,
|
||||
apiResponse: {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText
|
||||
},
|
||||
possibleIssues: [
|
||||
'Invalid API credentials',
|
||||
'Incorrect API URL or zone',
|
||||
'API user lacks permissions',
|
||||
'Integration code is invalid'
|
||||
]
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Failed to connect to Autotask API',
|
||||
configuration: configStatus,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
possibleIssues: [
|
||||
'Network connectivity issues',
|
||||
'Invalid API URL format',
|
||||
'Server configuration error'
|
||||
]
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
36
autotask-app/app/api/invoices/[id]/line-items/route.ts
Normal file
36
autotask-app/app/api/invoices/[id]/line-items/route.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const client = getAutotaskClient();
|
||||
|
||||
// Get billing items (line items) for this invoice
|
||||
const lineItems = await client.queryEntity('BillingItems', {
|
||||
filter: [
|
||||
{ op: 'eq', field: 'invoiceID', value: parseInt(id) }
|
||||
],
|
||||
});
|
||||
|
||||
// Sort by item date
|
||||
lineItems.sort((a: any, b: any) => {
|
||||
const dateA = new Date(a.itemDate || 0).getTime();
|
||||
const dateB = new Date(b.itemDate || 0).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
lineItems,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching invoice line items:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch invoice line items' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
28
autotask-app/app/api/picklists/route.ts
Normal file
28
autotask-app/app/api/picklists/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const client = getAutotaskClient();
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const entity = searchParams.get('entity');
|
||||
const field = searchParams.get('field');
|
||||
|
||||
if (!entity || !field) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Entity and field parameters are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const picklistValues = await client.getPicklistValues(entity, field);
|
||||
|
||||
return NextResponse.json({ picklistValues });
|
||||
} catch (error) {
|
||||
console.error('Error fetching picklist values:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch picklist values' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
24
autotask-app/app/api/resources/route.ts
Normal file
24
autotask-app/app/api/resources/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const client = getAutotaskClient();
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const email = searchParams.get('email');
|
||||
|
||||
if (email) {
|
||||
const resource = await client.getResourceByEmail(email);
|
||||
return NextResponse.json({ resource });
|
||||
}
|
||||
|
||||
const resources = await client.getAllResources();
|
||||
return NextResponse.json({ resources });
|
||||
} catch (error) {
|
||||
console.error('Error fetching resources:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch resources' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
49
autotask-app/app/api/rmm-audit/route.ts
Normal file
49
autotask-app/app/api/rmm-audit/route.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const deviceId = searchParams.get('deviceId');
|
||||
|
||||
if (!deviceId) {
|
||||
return NextResponse.json({
|
||||
error: 'Device ID is required'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const rmmClient = getDattoRMMClient();
|
||||
|
||||
// Get device with audit data
|
||||
const deviceWithAudit = await rmmClient.getDeviceWithAudit(deviceId);
|
||||
|
||||
if (!deviceWithAudit) {
|
||||
return NextResponse.json({
|
||||
error: 'Device not found or audit data unavailable'
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
// Also fetch raw audit data for debugging
|
||||
const auditData = await rmmClient.getDeviceAudit(deviceId);
|
||||
|
||||
return NextResponse.json({
|
||||
device: deviceWithAudit,
|
||||
auditData: auditData,
|
||||
enhanced: {
|
||||
manufacturer: deviceWithAudit.manufacturer,
|
||||
model: deviceWithAudit.model,
|
||||
serialNumber: deviceWithAudit.serialNumber,
|
||||
cpuName: deviceWithAudit.cpuName,
|
||||
cpuCores: deviceWithAudit.cpuCores,
|
||||
memory: deviceWithAudit.memory,
|
||||
diskSize: deviceWithAudit.diskSize
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching device audit:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch device audit data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
241
autotask-app/app/api/rmm-devices/route.ts
Normal file
241
autotask-app/app/api/rmm-devices/route.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
|
||||
import { apiCache } from '@/lib/services/cache';
|
||||
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
|
||||
import { ConfigurationItem } from '@/lib/types/autotask';
|
||||
|
||||
interface DeviceComparison {
|
||||
autotaskDevice?: ConfigurationItem;
|
||||
rmmDevice?: DattoRMMDevice;
|
||||
status: 'matched' | 'autotask-only' | 'rmm-only';
|
||||
matchedBy?: string; // What field was used to match
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const companyId = searchParams.get('companyId');
|
||||
const companyName = searchParams.get('companyName');
|
||||
const activeFilter = searchParams.get('activeFilter') || 'active';
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = `rmm-devices:${companyId}:${activeFilter}`;
|
||||
const cached = apiCache.get(cacheKey);
|
||||
if (cached) {
|
||||
console.log(`Cache hit for ${cacheKey}`);
|
||||
return NextResponse.json(cached);
|
||||
}
|
||||
|
||||
if (!companyId) {
|
||||
return NextResponse.json({
|
||||
rmmDevices: [],
|
||||
autotaskDevices: [],
|
||||
comparison: [],
|
||||
message: 'Please select a company to view devices'
|
||||
});
|
||||
}
|
||||
|
||||
// Get Autotask devices based on active filter
|
||||
const autotaskClient = getAutotaskClient();
|
||||
let autotaskDevices: ConfigurationItem[] = [];
|
||||
|
||||
if (activeFilter === 'all') {
|
||||
// Get all devices regardless of status
|
||||
const allItems = await autotaskClient.queryEntity<ConfigurationItem>('ConfigurationItems', {
|
||||
filter: [{ op: 'eq', field: 'companyID', value: parseInt(companyId) }],
|
||||
});
|
||||
autotaskDevices = allItems;
|
||||
} else if (activeFilter === 'inactive') {
|
||||
// Get only inactive devices
|
||||
const inactiveItems = await autotaskClient.queryEntity<ConfigurationItem>('ConfigurationItems', {
|
||||
filter: [
|
||||
{ op: 'eq', field: 'companyID', value: parseInt(companyId) },
|
||||
{ op: 'eq', field: 'isActive', value: false }
|
||||
],
|
||||
});
|
||||
autotaskDevices = inactiveItems;
|
||||
} else {
|
||||
// Default: get only active devices
|
||||
autotaskDevices = await autotaskClient.getConfigurationItemsByCompany(parseInt(companyId));
|
||||
}
|
||||
|
||||
// Get RMM devices
|
||||
let rmmDevices: DattoRMMDevice[] = [];
|
||||
try {
|
||||
const rmmClient = getDattoRMMClient();
|
||||
|
||||
if (companyName) {
|
||||
// Try to get devices by company name (matching site name)
|
||||
rmmDevices = await rmmClient.getDevicesByCompanyName(companyName);
|
||||
} else {
|
||||
// If no company name, get all devices and try to match
|
||||
rmmDevices = await rmmClient.getAllDevices();
|
||||
}
|
||||
} catch (rmmError) {
|
||||
console.error('Error fetching RMM devices:', rmmError);
|
||||
// Continue with empty RMM devices array
|
||||
}
|
||||
|
||||
// Compare and match devices
|
||||
const comparison: DeviceComparison[] = [];
|
||||
const matchedAutotaskIds = new Set<number>();
|
||||
const matchedRmmIds = new Set<string>();
|
||||
|
||||
// Try to match devices
|
||||
for (const rmmDevice of rmmDevices) {
|
||||
let matched = false;
|
||||
|
||||
// Try to match by RMM Device UID
|
||||
if (rmmDevice.uid) {
|
||||
const autotaskMatch = autotaskDevices.find(
|
||||
at => at.rmmDeviceUID === rmmDevice.uid && !matchedAutotaskIds.has(at.id)
|
||||
);
|
||||
|
||||
if (autotaskMatch) {
|
||||
comparison.push({
|
||||
autotaskDevice: autotaskMatch,
|
||||
rmmDevice: rmmDevice,
|
||||
status: 'matched',
|
||||
matchedBy: 'RMM UID'
|
||||
});
|
||||
matchedAutotaskIds.add(autotaskMatch.id);
|
||||
matchedRmmIds.add(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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
32
autotask-app/app/api/rmm-test/route.ts
Normal file
32
autotask-app/app/api/rmm-test/route.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
console.log('Testing Datto RMM connection...');
|
||||
|
||||
const rmmClient = getDattoRMMClient();
|
||||
|
||||
// Try to get sites as a test
|
||||
const sites = await rmmClient.getSites();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Successfully connected to Datto RMM',
|
||||
sitesCount: sites.length,
|
||||
sites: sites.slice(0, 5).map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('RMM test failed:', error);
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
hint: 'Check console for detailed error information'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
50
autotask-app/app/api/tasks/route.ts
Normal file
50
autotask-app/app/api/tasks/route.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
import { Task } from '@/lib/types/autotask';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const client = getAutotaskClient();
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const resourceId = searchParams.get('resourceId');
|
||||
const projectId = searchParams.get('projectId');
|
||||
|
||||
let tasks: Task[] = [];
|
||||
|
||||
if (resourceId) {
|
||||
tasks = await client.getTasksByResource(parseInt(resourceId));
|
||||
} else if (projectId) {
|
||||
tasks = await client.getTasksByProject(parseInt(projectId));
|
||||
} else {
|
||||
// Get all open tasks
|
||||
tasks = await client.queryEntity<Task>('Tasks', {
|
||||
filter: [{ op: 'noteq', field: 'status', value: 5 }],
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ tasks });
|
||||
} catch (error) {
|
||||
console.error('Error fetching tasks:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch tasks' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const client = getAutotaskClient();
|
||||
const body = await request.json();
|
||||
|
||||
const task = await client.createTask(body);
|
||||
|
||||
return NextResponse.json({ task });
|
||||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to create task' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
94
autotask-app/app/api/test-zones/route.ts
Normal file
94
autotask-app/app/api/test-zones/route.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const AUTOTASK_ZONES = [
|
||||
'https://webservices1.autotask.net/atservicesrest/v1.0',
|
||||
'https://webservices2.autotask.net/atservicesrest/v1.0',
|
||||
'https://webservices3.autotask.net/atservicesrest/v1.0',
|
||||
'https://webservices4.autotask.net/atservicesrest/v1.0',
|
||||
'https://webservices5.autotask.net/atservicesrest/v1.0',
|
||||
'https://webservices6.autotask.net/atservicesrest/v1.0',
|
||||
'https://prde.autotask.net/atservicesrest/v1.0',
|
||||
'https://ioce.autotask.net/atservicesrest/v1.0',
|
||||
];
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = process.env.AUTOTASK_USERNAME;
|
||||
const password = process.env.AUTOTASK_SECRET;
|
||||
const integrationCode = process.env.AUTOTASK_API_INTEGRATION_CODE;
|
||||
|
||||
if (!username || !password || !integrationCode) {
|
||||
return NextResponse.json({
|
||||
error: 'Missing credentials in environment variables'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Username': username,
|
||||
'Secret': password,
|
||||
'APIIntegrationcode': integrationCode,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const zoneUrl of AUTOTASK_ZONES) {
|
||||
const zoneName = zoneUrl.match(/\/\/(.*?)\./)?.[1] || 'unknown';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${zoneUrl}/Tickets/entityInformation`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
results.push({
|
||||
zone: zoneName,
|
||||
url: zoneUrl,
|
||||
status: 'SUCCESS',
|
||||
statusCode: response.status,
|
||||
message: 'Connection successful! This is your correct zone.'
|
||||
});
|
||||
|
||||
// If we found the correct zone, return immediately
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
correctZone: {
|
||||
zone: zoneName,
|
||||
url: zoneUrl,
|
||||
},
|
||||
message: `Found your Autotask zone: ${zoneName}`,
|
||||
instruction: `Update your .env.local file with: AUTOTASK_API_URL=${zoneUrl}`
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
zone: zoneName,
|
||||
url: zoneUrl,
|
||||
status: 'FAILED',
|
||||
statusCode: response.status,
|
||||
message: response.statusText
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
results.push({
|
||||
zone: zoneName,
|
||||
url: zoneUrl,
|
||||
status: 'ERROR',
|
||||
message: error instanceof Error ? error.message : 'Connection failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If no zone worked
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: 'Could not find the correct Autotask zone',
|
||||
testedZones: results,
|
||||
possibleIssues: [
|
||||
'Invalid username or password',
|
||||
'Invalid integration code',
|
||||
'API user account is disabled',
|
||||
'Your zone might not be in the standard list'
|
||||
]
|
||||
}, { status: 404 });
|
||||
}
|
||||
46
autotask-app/app/api/tickets/[id]/route.ts
Normal file
46
autotask-app/app/api/tickets/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
55
autotask-app/app/api/tickets/[id]/time-entries/route.ts
Normal file
55
autotask-app/app/api/tickets/[id]/time-entries/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const client = getAutotaskClient();
|
||||
|
||||
// Get time entries for this ticket
|
||||
const timeEntries = await client.queryEntity('TimeEntries', {
|
||||
filter: [
|
||||
{ op: 'eq', field: 'ticketID', value: parseInt(id) }
|
||||
],
|
||||
});
|
||||
|
||||
// Enrich with resource names
|
||||
const enrichedEntries = await Promise.all(
|
||||
timeEntries.map(async (entry: any) => {
|
||||
let resourceName = null;
|
||||
if (entry.resourceID) {
|
||||
try {
|
||||
const resource = await client.getEntityById('Resources', entry.resourceID);
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
88
autotask-app/app/api/tickets/route.ts
Normal file
88
autotask-app/app/api/tickets/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
509
autotask-app/app/config-enrichment-test/page.tsx
Normal file
509
autotask-app/app/config-enrichment-test/page.tsx
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import {
|
||||
Search,
|
||||
Server,
|
||||
FileText,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Ticket,
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface EnrichmentData {
|
||||
configItem: any;
|
||||
billingItems: any[];
|
||||
invoices: any[];
|
||||
tickets: any[];
|
||||
summary: {
|
||||
totalBilled: number;
|
||||
purchaseDate?: string;
|
||||
lastInvoiceDate?: string;
|
||||
ticketCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ConfigEnrichmentTestPage() {
|
||||
const [configItemId, setConfigItemId] = useState('');
|
||||
const [invoiceId, setInvoiceId] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<EnrichmentData | null>(null);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!configItemId && !invoiceId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (invoiceId) {
|
||||
params.append('invoiceId', invoiceId);
|
||||
} else if (configItemId) {
|
||||
params.append('configItemId', configItemId);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/config-enrichment?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch enrichment data');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container flex h-16 items-center">
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-blue-600 to-blue-700 text-white shadow-lg">
|
||||
<Search className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
Config Item Enrichment Test
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Find invoices and tickets by serial number
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8 space-y-6">
|
||||
{/* Search Card */}
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Search Configuration Item</CardTitle>
|
||||
<CardDescription>
|
||||
Enter a Configuration Item ID to find related invoices and tickets (last 90 days)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="configItemId">Configuration Item ID</Label>
|
||||
<Input
|
||||
id="configItemId"
|
||||
type="number"
|
||||
placeholder="e.g., 29872931"
|
||||
value={configItemId}
|
||||
onChange={(e) => {
|
||||
setConfigItemId(e.target.value);
|
||||
if (e.target.value) setInvoiceId('');
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="invoiceId">OR Invoice ID</Label>
|
||||
<Input
|
||||
id="invoiceId"
|
||||
type="number"
|
||||
placeholder="e.g., 29884310"
|
||||
value={invoiceId}
|
||||
onChange={(e) => {
|
||||
setInvoiceId(e.target.value);
|
||||
if (e.target.value) setConfigItemId('');
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleSearch} disabled={loading || (!configItemId && !invoiceId)} className="w-full">
|
||||
{loading ? (
|
||||
<>
|
||||
<Search className="w-4 h-4 mr-2 animate-spin" />
|
||||
Searching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
Search
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Card className="border-red-500">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-red-500">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<p>Error: {error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{data && !loading && (
|
||||
<>
|
||||
{/* Config Item Info */}
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader className="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="w-5 h-5" />
|
||||
Configuration Item Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label>Device Name</Label>
|
||||
<p className="text-sm font-medium">{data.configItem?.referenceTitle || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Serial Number</Label>
|
||||
<p className="text-sm font-mono">{data.configItem?.serialNumber || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Install Date</Label>
|
||||
<p className="text-sm">
|
||||
{data.configItem?.installDate ?
|
||||
format(new Date(data.configItem.installDate), 'MMM d, yyyy') :
|
||||
'-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Status</Label>
|
||||
<Badge variant={data.configItem?.isActive ? 'default' : 'secondary'}>
|
||||
{data.configItem?.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Billed</p>
|
||||
<p className="text-2xl font-bold">
|
||||
${data.summary.totalBilled.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Invoices Found</p>
|
||||
<p className="text-2xl font-bold">{data.invoices.length}</p>
|
||||
</div>
|
||||
<FileText className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Tickets Found</p>
|
||||
<p className="text-2xl font-bold">{data.summary.ticketCount}</p>
|
||||
</div>
|
||||
<Ticket className="h-8 w-8 text-orange-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Purchase Date</p>
|
||||
<p className="text-sm font-medium">
|
||||
{data.summary.purchaseDate ?
|
||||
format(new Date(data.summary.purchaseDate), 'MMM d, yyyy') :
|
||||
'-'}
|
||||
</p>
|
||||
</div>
|
||||
<Calendar className="h-8 w-8 text-purple-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Invoices */}
|
||||
{data.invoices.length > 0 && (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
Company Invoices (Last 90 Days)
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
All invoices for {data.configItem?.companyName || 'this company'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Invoice #</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Total</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Due Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.invoices.map((invoice) => (
|
||||
<TableRow key={invoice.id}>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{invoice.invoiceNumber || invoice.id}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{invoice.invoiceDateTime ?
|
||||
format(new Date(invoice.invoiceDateTime), 'MMM d, yyyy') :
|
||||
'-'}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
${(invoice.invoiceTotal || 0).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={invoice.paidDate ? 'default' : 'secondary'}>
|
||||
{invoice.paidDate ? 'Paid' : 'Unpaid'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{invoice.dueDate ?
|
||||
format(new Date(invoice.dueDate), 'MMM d, yyyy') :
|
||||
'-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Billing Items */}
|
||||
{data.billingItems.length > 0 && (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="w-5 h-5" />
|
||||
{data.configItem ? 'Purchase History for This Device' : 'Hardware Purchases'}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{data.configItem
|
||||
? `Billing items matching serial number ${data.configItem.serialNumber} (last 90 days)`
|
||||
: 'Hardware line items from invoice'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Serial/Notes</TableHead>
|
||||
<TableHead>Qty</TableHead>
|
||||
<TableHead>Unit Price</TableHead>
|
||||
<TableHead>Total</TableHead>
|
||||
<TableHead>Invoice</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.billingItems.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{item.itemDate ? format(new Date(item.itemDate), 'MMM d, yyyy') : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md">
|
||||
<div className="font-medium">{item.description || '-'}</div>
|
||||
{item.itemName && item.itemName !== item.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1">{item.itemName}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs">
|
||||
<div className="text-xs space-y-1">
|
||||
{item.serialNumber && (
|
||||
<div className="font-mono bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">
|
||||
SN: {item.serialNumber}
|
||||
</div>
|
||||
)}
|
||||
{item.internalNotes && (
|
||||
<div className="text-muted-foreground">{item.internalNotes}</div>
|
||||
)}
|
||||
{item.vendorInvoiceNumber && (
|
||||
<div className="text-muted-foreground">Vendor: {item.vendorInvoiceNumber}</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.quantity || 1}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">${(item.unitPrice || 0).toFixed(2)}</TableCell>
|
||||
<TableCell className="font-medium whitespace-nowrap">
|
||||
${(item.totalAmount || 0).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{item.invoiceID}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tickets */}
|
||||
{data.tickets.length > 0 && (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Ticket className="w-5 h-5" />
|
||||
Related Tickets
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Tickets mentioning this serial number (last 90 days)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Ticket #</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Priority</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.tickets.map((ticket) => (
|
||||
<TableRow key={ticket.id}>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{ticket.ticketNumber}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{ticket.title}</TableCell>
|
||||
<TableCell>
|
||||
<Badge>{ticket.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(ticket.createDate), 'MMM d, yyyy')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ticket.priority === 'Critical' ? 'destructive' : 'secondary'}>
|
||||
{ticket.priority}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Debug: Show all fields from first billing item - only when searching by invoice */}
|
||||
{data.billingItems.length > 0 && !data.configItem && invoiceId && (
|
||||
<Card className="border-0 shadow-lg border-l-4 border-l-blue-500">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Info className="w-5 h-5" />
|
||||
Debug: Available Fields (First Item)
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
All fields available in BillingItems entity
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="text-xs bg-gray-100 dark:bg-gray-900 p-4 rounded overflow-auto max-h-96">
|
||||
{JSON.stringify(data.billingItems[0], null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* No Results */}
|
||||
{data.billingItems.length === 0 && data.tickets.length === 0 && (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<AlertCircle className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
{data.configItem ? (
|
||||
<>
|
||||
<p className="font-medium">No purchase records found for this device</p>
|
||||
<p className="text-sm mt-2">
|
||||
Serial number: {data.configItem.serialNumber || 'Not set'}
|
||||
</p>
|
||||
<p className="text-sm mt-2">
|
||||
This device may have been:
|
||||
</p>
|
||||
<ul className="text-sm mt-2 space-y-1">
|
||||
<li>• Purchased more than 1 year ago</li>
|
||||
<li>• Added manually without an invoice</li>
|
||||
<li>• Invoiced without serial number in description</li>
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>No billing items or tickets found in the last 90 days</p>
|
||||
<p className="text-sm mt-2">Try a different configuration item or expand the date range</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
autotask-app/app/configuration-items/[id]/page.tsx
Normal file
195
autotask-app/app/configuration-items/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { PSATab } from '@/components/configuration-items/psa-tab';
|
||||
import { RMMTab } from '@/components/configuration-items/rmm-tab';
|
||||
import { StatusCards } from '@/components/configuration-items/status-cards';
|
||||
import {
|
||||
Server,
|
||||
Monitor,
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { ConfigurationItem } from '@/lib/types/autotask';
|
||||
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
|
||||
|
||||
interface ConfigItemDetail {
|
||||
autotaskDevice?: ConfigurationItem;
|
||||
rmmDevice?: DattoRMMDevice;
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
export default function ConfigurationItemDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [data, setData] = useState<ConfigItemDetail>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Get company context from URL params
|
||||
const companyId = searchParams.get('companyId');
|
||||
const companyName = searchParams.get('companyName');
|
||||
|
||||
// Parse the ID parameter - it might be "at-123" or "rmm-abc" or "123"
|
||||
const parseItemId = () => {
|
||||
const id = params.id as string;
|
||||
if (id.startsWith('at-')) {
|
||||
return { type: 'autotask', id: id.substring(3) };
|
||||
} else if (id.startsWith('rmm-')) {
|
||||
return { type: 'rmm', id: id.substring(4) };
|
||||
} else {
|
||||
return { type: 'autotask', id }; // Default to Autotask
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { type, id } = parseItemId();
|
||||
|
||||
// Fetch the configuration item details
|
||||
const response = await fetch(`/api/configuration-items/${id}?type=${type}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch configuration item');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [params.id]);
|
||||
|
||||
const handleUpdate = (updatedDevice: ConfigurationItem) => {
|
||||
setData({ ...data, autotaskDevice: updatedDevice });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 w-64" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card className="border-red-500">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-red-500">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<p>Error: {error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const device = data.autotaskDevice;
|
||||
const rmmDevice = data.rmmDevice;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container flex h-16 items-center">
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Navigate back to configuration items with company context
|
||||
if (companyId) {
|
||||
const params = new URLSearchParams({
|
||||
companyId: companyId,
|
||||
companyName: companyName || ''
|
||||
});
|
||||
router.push(`/configuration-items?${params.toString()}`);
|
||||
} else {
|
||||
router.push('/configuration-items');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to List
|
||||
</Button>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-purple-600 to-purple-700 text-white shadow-lg">
|
||||
<Server className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
{device?.referenceTitle || rmmDevice?.hostname || 'Configuration Item'}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.companyName || 'Configuration Item Details'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{/* Status Cards */}
|
||||
<StatusCards device={device} rmmDevice={rmmDevice} />
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="psa" className="space-y-4 mt-6">
|
||||
<TabsList className="grid w-full grid-cols-2 max-w-md">
|
||||
<TabsTrigger value="psa" className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4" />
|
||||
PSA Data
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rmm" className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
RMM Data
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="psa">
|
||||
<PSATab device={device} onUpdate={handleUpdate} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rmm">
|
||||
<RMMTab device={rmmDevice} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
758
autotask-app/app/configuration-items/page.tsx
Normal file
758
autotask-app/app/configuration-items/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
autotask-app/app/favicon.ico
Normal file
BIN
autotask-app/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
122
autotask-app/app/globals.css
Normal file
122
autotask-app/app/globals.css
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
32
autotask-app/app/layout.tsx
Normal file
32
autotask-app/app/layout.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Autotask Dashboard",
|
||||
description: "Modern dashboard for Autotask PSA integration",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
236
autotask-app/app/page.tsx
Normal file
236
autotask-app/app/page.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { TicketList } from '@/components/tickets/ticket-list';
|
||||
import { TaskList } from '@/components/tasks/task-list';
|
||||
import { CompanySelector } from '@/components/companies/company-selector';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Ticket,
|
||||
User,
|
||||
ListTodo,
|
||||
Building2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Plus,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
Server
|
||||
} from 'lucide-react';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
|
||||
export default function Home() {
|
||||
const [selectedCompany, setSelectedCompany] = useState<number | undefined>();
|
||||
const [selectedResource, setSelectedResource] = useState<number | undefined>();
|
||||
const [activeTab, setActiveTab] = useState('tickets');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container flex h-16 items-center">
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-blue-600 to-blue-700 text-white shadow-lg">
|
||||
<Activity className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
Autotask Dashboard
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">PSA Management System</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a href="/configuration-items">
|
||||
<Server className="w-4 h-4 mr-2" />
|
||||
Config Items
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a href="/setup">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Setup
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="relative">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
<Button size="sm" className="bg-gradient-to-r from-blue-600 to-blue-700 text-white hover:from-blue-700 hover:to-blue-800">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Ticket
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{/* Filters */}
|
||||
<Card className="mb-6 border-0 shadow-lg">
|
||||
<CardHeader className="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 rounded-t-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">Quick Filters</CardTitle>
|
||||
<CardDescription>
|
||||
Narrow down your view by company or resource
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-white dark:bg-gray-900 shadow-sm">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<CompanySelector
|
||||
value={selectedCompany}
|
||||
onValueChange={setSelectedCompany}
|
||||
label="Filter by Company"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="resource-search" className="flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
Filter by Resource
|
||||
</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
id="resource-search"
|
||||
placeholder="Enter resource email..."
|
||||
type="email"
|
||||
className="bg-background"
|
||||
/>
|
||||
<Button variant="secondary" size="icon">
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setSelectedCompany(undefined);
|
||||
setSelectedResource(undefined);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs for Tickets and Tasks */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2 max-w-md bg-muted/50">
|
||||
<TabsTrigger value="tickets" className="flex items-center gap-2 data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
||||
<Ticket className="w-4 h-4" />
|
||||
Tickets
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tasks" className="flex items-center gap-2 data-[state=active]:bg-background data-[state=active]:shadow-sm">
|
||||
<ListTodo className="w-4 h-4" />
|
||||
Tasks
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="tickets" className="space-y-4">
|
||||
<TicketList
|
||||
companyId={selectedCompany}
|
||||
resourceId={selectedResource}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tasks" className="space-y-4">
|
||||
<TaskList
|
||||
resourceId={selectedResource}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-8">
|
||||
<Card className="border-0 shadow-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-950 dark:to-blue-900">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Open Tickets
|
||||
</CardTitle>
|
||||
<div className="h-8 w-8 rounded-full bg-blue-600/10 flex items-center justify-center">
|
||||
<Ticket className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">-</div>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||
<TrendingUp className="h-3 w-3 mr-1 text-green-600" />
|
||||
<span>12% from last month</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-lg bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-950 dark:to-purple-900">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Active Tasks
|
||||
</CardTitle>
|
||||
<div className="h-8 w-8 rounded-full bg-purple-600/10 flex items-center justify-center">
|
||||
<ListTodo className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">-</div>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||
<Activity className="h-3 w-3 mr-1 text-orange-600" />
|
||||
<span>In progress</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-lg bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950 dark:to-green-900">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Companies
|
||||
</CardTitle>
|
||||
<div className="h-8 w-8 rounded-full bg-green-600/10 flex items-center justify-center">
|
||||
<Building2 className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">-</div>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||
<User className="h-3 w-3 mr-1" />
|
||||
<span>Active clients</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-lg bg-gradient-to-br from-orange-50 to-orange-100 dark:from-orange-950 dark:to-orange-900">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Response Time
|
||||
</CardTitle>
|
||||
<div className="h-8 w-8 rounded-full bg-orange-600/10 flex items-center justify-center">
|
||||
<Activity className="h-4 w-4 text-orange-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">2.4h</div>
|
||||
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||
<TrendingUp className="h-3 w-3 mr-1 text-green-600" />
|
||||
<span>15% faster</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
336
autotask-app/app/setup/page.tsx
Normal file
336
autotask-app/app/setup/page.tsx
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
|
||||
interface HealthStatus {
|
||||
status: 'healthy' | 'error';
|
||||
message: string;
|
||||
configuration?: {
|
||||
apiUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
integrationCode: string;
|
||||
};
|
||||
instructions?: string[];
|
||||
apiResponse?: {
|
||||
status: number;
|
||||
statusText: string;
|
||||
error: string;
|
||||
};
|
||||
possibleIssues?: string[];
|
||||
}
|
||||
|
||||
export default function SetupPage() {
|
||||
const [healthStatus, setHealthStatus] = useState<HealthStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const checkHealth = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/health');
|
||||
const data = await response.json();
|
||||
setHealthStatus(data);
|
||||
} catch (error) {
|
||||
setHealthStatus({
|
||||
status: 'error',
|
||||
message: 'Failed to check API health',
|
||||
instructions: ['Make sure the Next.js server is running']
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkHealth();
|
||||
}, []);
|
||||
|
||||
const copyEnvTemplate = () => {
|
||||
const template = `# Autotask API Configuration
|
||||
AUTOTASK_API_URL=https://webservices1.autotask.net/atservicesrest/v1.0
|
||||
AUTOTASK_USERNAME=your-api-username@yourdomain.com
|
||||
AUTOTASK_SECRET=your-api-password
|
||||
AUTOTASK_API_INTEGRATION_CODE=your-tracking-code`;
|
||||
|
||||
navigator.clipboard.writeText(template);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
if (status === 'configured') {
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
}
|
||||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-950 p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Autotask API Setup & Diagnostics
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Configure and test your Autotask API connection
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Health Status Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>API Connection Status</CardTitle>
|
||||
<CardDescription>
|
||||
Current status of your Autotask API configuration
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
onClick={checkHealth}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{healthStatus && (
|
||||
<div className="space-y-4">
|
||||
{/* Overall Status */}
|
||||
<div className="flex items-center gap-3">
|
||||
{healthStatus.status === 'healthy' ? (
|
||||
<>
|
||||
<CheckCircle className="w-6 h-6 text-green-500" />
|
||||
<span className="text-lg font-semibold text-green-600">
|
||||
{healthStatus.message}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-6 h-6 text-red-500" />
|
||||
<span className="text-lg font-semibold text-red-600">
|
||||
{healthStatus.message}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Configuration Status */}
|
||||
{healthStatus.configuration && (
|
||||
<div className="border rounded-lg p-4 space-y-2">
|
||||
<h3 className="font-semibold mb-2">Configuration Status:</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(healthStatus.configuration.apiUrl)}
|
||||
<span>API URL</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(healthStatus.configuration.username)}
|
||||
<span>Username</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(healthStatus.configuration.password)}
|
||||
<span>Password</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(healthStatus.configuration.integrationCode)}
|
||||
<span>Integration Code</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Response Error */}
|
||||
{healthStatus.apiResponse && (
|
||||
<div className="border border-red-200 bg-red-50 dark:bg-red-900/20 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-red-700 dark:text-red-400 mb-2">
|
||||
API Response Error:
|
||||
</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p>Status: {healthStatus.apiResponse.status} - {healthStatus.apiResponse.statusText}</p>
|
||||
{healthStatus.apiResponse.error && (
|
||||
<p className="text-xs font-mono bg-red-100 dark:bg-red-900/30 p-2 rounded mt-2">
|
||||
{healthStatus.apiResponse.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Possible Issues */}
|
||||
{healthStatus.possibleIssues && (
|
||||
<div className="border border-yellow-200 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-yellow-700 dark:text-yellow-400 mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
Possible Issues:
|
||||
</h3>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||
{healthStatus.possibleIssues.map((issue, index) => (
|
||||
<li key={index}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Setup Instructions */}
|
||||
{healthStatus.instructions && (
|
||||
<div className="border border-blue-200 bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-blue-700 dark:text-blue-400 mb-2">
|
||||
Setup Instructions:
|
||||
</h3>
|
||||
<ol className="space-y-1 text-sm">
|
||||
{healthStatus.instructions.map((instruction, index) => (
|
||||
<li key={index} className={instruction.startsWith(' ') ? 'ml-4 font-mono' : ''}>
|
||||
{instruction}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Environment Template Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Environment Variables Template</CardTitle>
|
||||
<CardDescription>
|
||||
Copy this template to create your .env.local file
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative">
|
||||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||
<code>{`# Autotask API Configuration
|
||||
AUTOTASK_API_URL=https://webservices1.autotask.net/atservicesrest/v1.0
|
||||
AUTOTASK_USERNAME=your-api-username@yourdomain.com
|
||||
AUTOTASK_SECRET=your-api-password
|
||||
AUTOTASK_API_INTEGRATION_CODE=your-tracking-code`}</code>
|
||||
</pre>
|
||||
<Button
|
||||
onClick={copyEnvTemplate}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
Save this as <code className="bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">.env.local</code> in the project root
|
||||
</p>
|
||||
<p className="flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-yellow-500" />
|
||||
Remember to restart the server after adding the file
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Resources Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Helpful Resources</CardTitle>
|
||||
<CardDescription>
|
||||
Documentation and guides for Autotask API integration
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<a
|
||||
href="https://ww1.autotask.net/help/DeveloperHelp/Content/APIs/REST/REST_API_Home.htm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Autotask REST API Documentation
|
||||
</a>
|
||||
<a
|
||||
href="/api/health"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
API Health Check Endpoint
|
||||
</a>
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* API Zone Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Autotask API Zones</CardTitle>
|
||||
<CardDescription>
|
||||
Make sure you're using the correct zone URL for your Autotask instance
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">North America</h4>
|
||||
<ul className="space-y-1 font-mono text-xs">
|
||||
<li>Zone 1: webservices1.autotask.net</li>
|
||||
<li>Zone 2: webservices2.autotask.net</li>
|
||||
<li>Zone 3: webservices3.autotask.net</li>
|
||||
<li>Zone 4: webservices4.autotask.net</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">Other Regions</h4>
|
||||
<ul className="space-y-1 font-mono text-xs">
|
||||
<li>Zone 5: webservices5.autotask.net</li>
|
||||
<li>Zone 6: webservices6.autotask.net</li>
|
||||
<li>PRD: prde.autotask.net</li>
|
||||
<li>IOC: ioce.autotask.net</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
autotask-app/components.json
Normal file
22
autotask-app/components.json
Normal 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": {}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
60
autotask-app/components/companies/company-selector.tsx
Normal file
60
autotask-app/components/companies/company-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PSATab } from './psa-tab';
|
||||
import { RMMTab } from './rmm-tab';
|
||||
import { StatusCards } from './status-cards';
|
||||
import {
|
||||
Server,
|
||||
Monitor,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { ConfigurationItem } from '@/lib/types/autotask';
|
||||
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
|
||||
|
||||
interface ConfigItemDetail {
|
||||
autotaskDevice?: ConfigurationItem;
|
||||
rmmDevice?: DattoRMMDevice;
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
interface ConfigItemModalProps {
|
||||
itemId: string | number | null;
|
||||
type?: 'autotask' | 'rmm';
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ConfigItemModal({ itemId, type = 'autotask', open, onOpenChange }: ConfigItemModalProps) {
|
||||
const [data, setData] = useState<ConfigItemDetail>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !itemId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/configuration-items/${itemId}?type=${type}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch configuration item');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [itemId, type, open]);
|
||||
|
||||
const handleUpdate = (updatedDevice: ConfigurationItem) => {
|
||||
setData({ ...data, autotaskDevice: updatedDevice });
|
||||
};
|
||||
|
||||
const device = data.autotaskDevice;
|
||||
const rmmDevice = data.rmmDevice;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="w-[95vw] sm:w-[90vw] md:w-[85vw] lg:w-[80vw] xl:w-[75vw] h-[90vh] overflow-y-auto"
|
||||
style={{ maxWidth: '1400px' }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-purple-600 to-purple-700 text-white">
|
||||
<Server className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold">
|
||||
{device?.referenceTitle || rmmDevice?.hostname || 'Configuration Item'}
|
||||
</div>
|
||||
{data.companyName && (
|
||||
<div className="text-sm font-normal text-muted-foreground">
|
||||
{data.companyName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-8">
|
||||
<div className="flex items-center gap-2 text-red-500 justify-center">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<p>Error: {error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Status Cards */}
|
||||
<StatusCards device={device} rmmDevice={rmmDevice} />
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="psa" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="psa" className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4" />
|
||||
PSA Data
|
||||
{device && (
|
||||
<Badge variant="outline" className="ml-1">
|
||||
{device.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rmm" className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
RMM Data
|
||||
{rmmDevice && (
|
||||
<Badge variant="outline" className="ml-1">
|
||||
{rmmDevice.online ? 'Online' : 'Offline'}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="psa">
|
||||
<PSATab device={device} onUpdate={handleUpdate} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rmm">
|
||||
<RMMTab device={rmmDevice} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
59
autotask-app/components/configuration-items/contact-cell.tsx
Normal file
59
autotask-app/components/configuration-items/contact-cell.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { User } from 'lucide-react';
|
||||
|
||||
interface ContactCellProps {
|
||||
contactId?: number;
|
||||
}
|
||||
|
||||
export function ContactCell({ contactId }: ContactCellProps) {
|
||||
const [contactName, setContactName] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contactId) {
|
||||
setContactName(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchContact = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/contacts/${contactId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.contact) {
|
||||
setContactName(`${data.contact.firstName} ${data.contact.lastName}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch contact:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchContact();
|
||||
}, [contactId]);
|
||||
|
||||
if (loading) {
|
||||
return <span className="text-xs text-muted-foreground">Loading...</span>;
|
||||
}
|
||||
|
||||
if (!contactId) {
|
||||
return <span className="text-xs text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
if (contactName) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<User className="w-3 h-3 mr-1" />
|
||||
{contactName}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-xs text-muted-foreground">ID: {contactId}</span>;
|
||||
}
|
||||
456
autotask-app/components/configuration-items/psa-tab.tsx
Normal file
456
autotask-app/components/configuration-items/psa-tab.tsx
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Server,
|
||||
Edit,
|
||||
Save,
|
||||
Power,
|
||||
Info,
|
||||
Cpu,
|
||||
RefreshCw,
|
||||
User,
|
||||
Receipt,
|
||||
Ticket as TicketIcon
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ConfigurationItem } from '@/lib/types/autotask';
|
||||
import { PurchaseHistoryModal } from './purchase-history-modal';
|
||||
import { RelatedTicketsModal } from './related-tickets-modal';
|
||||
|
||||
interface PSATabProps {
|
||||
device?: ConfigurationItem;
|
||||
onUpdate: (device: ConfigurationItem) => void;
|
||||
}
|
||||
|
||||
export function PSATab({ device, onUpdate }: PSATabProps) {
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editedData, setEditedData] = useState<Partial<ConfigurationItem>>(device || {});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [contactName, setContactName] = useState<string | null>(null);
|
||||
const [loadingContact, setLoadingContact] = useState(false);
|
||||
const [purchaseHistoryOpen, setPurchaseHistoryOpen] = useState(false);
|
||||
const [relatedTicketsOpen, setRelatedTicketsOpen] = useState(false);
|
||||
|
||||
// Fetch contact information if contactID exists
|
||||
useEffect(() => {
|
||||
if (!device?.contactID) {
|
||||
setContactName(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchContact = async () => {
|
||||
setLoadingContact(true);
|
||||
try {
|
||||
const response = await fetch(`/api/contacts/${device.contactID}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setContactName(data.contact ? `${data.contact.firstName} ${data.contact.lastName}` : null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch contact:', err);
|
||||
} finally {
|
||||
setLoadingContact(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchContact();
|
||||
}, [device?.contactID]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!device) return;
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/configuration-items/${device.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(editedData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update configuration item');
|
||||
}
|
||||
|
||||
const updated = await response.json();
|
||||
onUpdate(updated.configurationItem);
|
||||
setEditMode(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save changes');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMakeInactive = async () => {
|
||||
if (!device) return;
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/configuration-items/${device.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ isActive: false }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update configuration item');
|
||||
}
|
||||
|
||||
const updated = await response.json();
|
||||
onUpdate(updated.configurationItem);
|
||||
setEditedData({ ...editedData, isActive: false });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to make inactive');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader className="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 rounded-t-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">PSA Configuration Item</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{!editMode ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPurchaseHistoryOpen(true)}
|
||||
>
|
||||
<Receipt className="w-4 h-4 mr-2" />
|
||||
Purchase History
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRelatedTicketsOpen(true)}
|
||||
>
|
||||
<TicketIcon className="w-4 h-4 mr-2" />
|
||||
Related Tickets
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditMode(true);
|
||||
setEditedData(device || {});
|
||||
}}
|
||||
disabled={!device}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
{device?.isActive && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Power className="w-4 h-4 mr-2" />
|
||||
Make Inactive
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Make Configuration Item Inactive?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will mark the configuration item as inactive in Autotask PSA.
|
||||
You can reactivate it later if needed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleMakeInactive}>
|
||||
Make Inactive
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditMode(false);
|
||||
setEditedData(device || {});
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Basic Information */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<Info className="w-4 h-4" />
|
||||
Basic Information
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Reference Title</Label>
|
||||
{editMode ? (
|
||||
<Input
|
||||
value={editedData.referenceTitle || ''}
|
||||
onChange={(e) => setEditedData({...editedData, referenceTitle: e.target.value})}
|
||||
className="mt-1"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mt-1">{device.referenceTitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Reference Number</Label>
|
||||
{editMode ? (
|
||||
<Input
|
||||
value={editedData.referenceNumber || ''}
|
||||
onChange={(e) => setEditedData({...editedData, referenceNumber: e.target.value})}
|
||||
className="mt-1"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground font-mono mt-1">
|
||||
{device.referenceNumber || '-'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Serial Number</Label>
|
||||
{editMode ? (
|
||||
<Input
|
||||
value={editedData.serialNumber || ''}
|
||||
onChange={(e) => setEditedData({...editedData, serialNumber: e.target.value})}
|
||||
className="mt-1"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground font-mono mt-1">
|
||||
{device.serialNumber || '-'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Location</Label>
|
||||
{editMode ? (
|
||||
<Input
|
||||
value={editedData.location || ''}
|
||||
onChange={(e) => setEditedData({...editedData, location: e.target.value})}
|
||||
className="mt-1"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device.location || '-'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Active Status</Label>
|
||||
{editMode ? (
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<Switch
|
||||
checked={editedData.isActive}
|
||||
onCheckedChange={(checked) => setEditedData({...editedData, isActive: checked})}
|
||||
/>
|
||||
<Label>{editedData.isActive ? 'Active' : 'Inactive'}</Label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1">
|
||||
{device.isActive ? (
|
||||
<Badge variant="default" className="bg-green-600">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Inactive</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Associated Contact</Label>
|
||||
{loadingContact ? (
|
||||
<p className="text-sm text-muted-foreground mt-1">Loading...</p>
|
||||
) : contactName ? (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="default" className="bg-blue-600">
|
||||
<User className="w-3 h-3 mr-1" />
|
||||
{contactName}
|
||||
</Badge>
|
||||
</div>
|
||||
) : device.contactID ? (
|
||||
<p className="text-sm text-muted-foreground mt-1">Contact ID: {device.contactID}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mt-1">No contact assigned</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Details */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4" />
|
||||
Technical Details
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Model Number</Label>
|
||||
{editMode ? (
|
||||
<Input
|
||||
value={editedData.modelNumber || ''}
|
||||
onChange={(e) => setEditedData({...editedData, modelNumber: e.target.value})}
|
||||
className="mt-1"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device.modelNumber || '-'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>MAC Address</Label>
|
||||
{editMode ? (
|
||||
<Input
|
||||
value={editedData.macAddress || ''}
|
||||
onChange={(e) => setEditedData({...editedData, macAddress: e.target.value})}
|
||||
className="mt-1"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground font-mono mt-1">
|
||||
{device.macAddress || '-'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Install Date</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device.installDate ?
|
||||
format(new Date(device.installDate), 'MMM d, yyyy') :
|
||||
'-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Warranty Expiration</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device.warrantyExpirationDate ?
|
||||
format(new Date(device.warrantyExpirationDate), 'MMM d, yyyy') :
|
||||
'-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>RMM Device UID</Label>
|
||||
<p className="text-sm text-muted-foreground font-mono mt-1">
|
||||
{device.rmmDeviceUID || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<h3 className="font-semibold">Notes</h3>
|
||||
{editMode ? (
|
||||
<Textarea
|
||||
value={editedData.notes || ''}
|
||||
onChange={(e) => setEditedData({...editedData, notes: e.target.value})}
|
||||
rows={4}
|
||||
placeholder="Add notes..."
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{device.notes || 'No notes available'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Server className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No PSA data available for this device</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Purchase History Modal */}
|
||||
{device && (
|
||||
<PurchaseHistoryModal
|
||||
configItemId={device.id}
|
||||
serialNumber={device.serialNumber}
|
||||
createDate={device.createDate}
|
||||
open={purchaseHistoryOpen}
|
||||
onOpenChange={setPurchaseHistoryOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Related Tickets Modal */}
|
||||
{device && (
|
||||
<RelatedTicketsModal
|
||||
configItemId={device.id}
|
||||
open={relatedTicketsOpen}
|
||||
onOpenChange={setRelatedTicketsOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Ticket, AlertCircle, Filter } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface RelatedTicketsModalProps {
|
||||
configItemId: number;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function RelatedTicketsModal({
|
||||
configItemId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: RelatedTicketsModalProps) {
|
||||
const [tickets, setTickets] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hideRmmAlerts, setHideRmmAlerts] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && configItemId) {
|
||||
fetchRelatedTickets();
|
||||
}
|
||||
}, [open, configItemId]);
|
||||
|
||||
const fetchRelatedTickets = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/config-items/${configItemId}/tickets`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch related tickets');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setTickets(result.tickets || []);
|
||||
} catch (err) {
|
||||
console.error('Error fetching related tickets:', err);
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="!max-w-[95vw] !w-[95vw] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Ticket className="w-5 h-5" />
|
||||
Related Tickets
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tickets associated with this configuration item
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{!loading && !error && tickets.length > 0 && (
|
||||
<div className="flex gap-3 mr-8">
|
||||
<div className="px-3 py-2 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-950 dark:to-blue-900 rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">Total Tickets</p>
|
||||
<p className="text-sm font-bold">{tickets.length}</p>
|
||||
</div>
|
||||
<div className="px-3 py-2 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950 dark:to-green-900 rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">Open</p>
|
||||
<p className="text-sm font-bold">
|
||||
{tickets.filter((t: any) => t.status !== 'Complete' && t.status !== 'Closed').length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{loading && (
|
||||
<div className="space-y-4 py-4">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-red-500 p-4 bg-red-50 dark:bg-red-950/20 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<p>Error: {error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="py-4">
|
||||
{/* Filter Toggle */}
|
||||
<div className="flex items-center gap-2 mb-4 p-3 bg-muted/50 rounded-lg">
|
||||
<Filter className="w-4 h-4 text-muted-foreground" />
|
||||
<Label htmlFor="hide-rmm-alerts" className="text-sm cursor-pointer flex-1">
|
||||
Hide RMM Alert Tickets
|
||||
</Label>
|
||||
<Switch
|
||||
id="hide-rmm-alerts"
|
||||
checked={hideRmmAlerts}
|
||||
onCheckedChange={setHideRmmAlerts}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tickets.filter((ticket: any) => !hideRmmAlerts || ticket.source !== 'RMM Alert').length > 0 ? (
|
||||
<div className="border rounded-lg overflow-hidden shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-28">Ticket #</TableHead>
|
||||
<TableHead className="min-w-[300px]">Title</TableHead>
|
||||
<TableHead className="w-32">Status</TableHead>
|
||||
<TableHead className="w-32">Priority</TableHead>
|
||||
<TableHead className="w-40">Assigned To</TableHead>
|
||||
<TableHead className="w-32">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tickets
|
||||
.filter((ticket: any) => !hideRmmAlerts || ticket.source !== 'RMM Alert')
|
||||
.map((ticket: any) => (
|
||||
<TableRow key={ticket.id} className="hover:bg-muted/50 transition-colors">
|
||||
<TableCell className="font-mono font-semibold text-blue-600 dark:text-blue-400">
|
||||
{ticket.ticketNumber}
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[300px]">
|
||||
<div className="text-sm font-medium whitespace-normal break-words">
|
||||
{ticket.title || '-'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={ticket.status === 'Complete' || ticket.status === 'Closed' ? 'secondary' : 'default'}
|
||||
>
|
||||
{ticket.status || '-'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={ticket.priority === 'High' || ticket.priority === 'Critical' ? 'destructive' : 'secondary'}
|
||||
>
|
||||
{ticket.priority || '-'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{ticket.assignedResourceName || '-'}</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
||||
{ticket.createDate ? format(new Date(ticket.createDate), 'MMM d, yyyy') : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<AlertCircle className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="font-medium">
|
||||
{hideRmmAlerts && tickets.length > 0
|
||||
? 'All tickets are RMM Alerts (filtered out)'
|
||||
: 'No related tickets found'
|
||||
}
|
||||
</p>
|
||||
<p className="text-sm mt-2">
|
||||
{hideRmmAlerts && tickets.length > 0
|
||||
? 'Toggle the filter above to show RMM Alert tickets'
|
||||
: 'This configuration item has no associated tickets'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
337
autotask-app/components/configuration-items/rmm-tab.tsx
Normal file
337
autotask-app/components/configuration-items/rmm-tab.tsx
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Monitor,
|
||||
Info,
|
||||
Network,
|
||||
HardDrive,
|
||||
Shield,
|
||||
Wifi,
|
||||
XCircle
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
|
||||
import { lookupDeviceBySerial, formatDeviceInfo } from '@/lib/utils/device-lookup';
|
||||
|
||||
interface RMMTabProps {
|
||||
device?: DattoRMMDevice;
|
||||
}
|
||||
|
||||
export function RMMTab({ device }: RMMTabProps) {
|
||||
if (!device) {
|
||||
return (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Monitor className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No RMM data available for this device</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardHeader className="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 rounded-t-lg">
|
||||
<CardTitle className="text-lg">RMM Device Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Basic Information */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<Info className="w-4 h-4" />
|
||||
Basic Information
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Hostname</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">{device.hostname}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device.description || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Serial Number</Label>
|
||||
<p className="text-sm text-muted-foreground font-mono mt-1">
|
||||
{device.serialNumber || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Device Type</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device.deviceType?.type || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Status</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{device.online ? (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
<Wifi className="w-3 h-3 mr-1" />
|
||||
Online
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
Offline
|
||||
</Badge>
|
||||
)}
|
||||
{device.rebootRequired && (
|
||||
<Badge variant="outline" className="text-orange-600">
|
||||
Reboot Required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Network Information */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<Network className="w-4 h-4" />
|
||||
Network Information
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Internal IP</Label>
|
||||
<p className="text-sm text-muted-foreground font-mono mt-1">
|
||||
{device.intIpAddress || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>External IP</Label>
|
||||
<p className="text-sm text-muted-foreground font-mono mt-1">
|
||||
{device.extIpAddress || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>MAC Addresses</Label>
|
||||
<div className="text-sm text-muted-foreground font-mono mt-1">
|
||||
{device.macAddresses && device.macAddresses.length > 0 ? (
|
||||
device.macAddresses.map((mac, i) => (
|
||||
<div key={i}>{mac}</div>
|
||||
))
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Domain</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device.domain || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Last Seen</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device.lastSeen ?
|
||||
format(new Date(device.lastSeen), 'MMM d, yyyy h:mm a') :
|
||||
'-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Last User</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device.lastLoggedInUser || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Information */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<HardDrive className="w-4 h-4" />
|
||||
System Information
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Operating System</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device.operatingSystem || '-'}
|
||||
</p>
|
||||
{device.a64Bit !== undefined && (
|
||||
<Badge variant="outline" className="text-xs mt-1">
|
||||
{device.a64Bit ? '64-bit' : '32-bit'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Device Category</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device.deviceType?.category || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Manufacturer</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{(() => {
|
||||
if (device.manufacturer) return device.manufacturer;
|
||||
const deviceInfo = lookupDeviceBySerial(device.serialNumber);
|
||||
return deviceInfo?.manufacturer || '-';
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Model</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{(() => {
|
||||
if (device.model) return device.model;
|
||||
const deviceInfo = lookupDeviceBySerial(device.serialNumber);
|
||||
return deviceInfo?.estimatedModel || deviceInfo?.modelFamily || '-';
|
||||
})()}
|
||||
</p>
|
||||
{!device.model && device.serialNumber && (
|
||||
<p className="text-xs text-muted-foreground mt-1 italic">
|
||||
Estimated from serial: {device.serialNumber}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>CPU</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device.cpuName || '-'} {device.cpuCores ? `(${device.cpuCores} cores)` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Memory</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device.memory ? `${(device.memory / 1024).toFixed(2)} GB` : '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Total Disk Size</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device.diskSize ? `${(device.diskSize / (1024 * 1024 * 1024)).toFixed(2)} GB` : '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Agent Version</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device.displayVersion || device.cagVersion || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Last Reboot</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device.lastReboot ?
|
||||
format(new Date(device.lastReboot), 'MMM d, yyyy h:mm a') :
|
||||
'-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Created Date</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{device.creationDate ?
|
||||
format(new Date(device.creationDate), 'MMM d, yyyy') :
|
||||
'-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{device.warrantyDate && (
|
||||
<div>
|
||||
<Label>Warranty Expiration</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{format(new Date(device.warrantyDate), 'MMM d, yyyy')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Information */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
Security Information
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Antivirus</Label>
|
||||
<div className="space-y-1 mt-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{device.antivirus?.antivirusProduct || 'Not detected'}
|
||||
</p>
|
||||
{device.antivirus?.antivirusStatus && (
|
||||
<Badge
|
||||
variant={device.antivirus.antivirusStatus === 'RunningAndUpToDate' ? 'default' : 'secondary'}
|
||||
className={device.antivirus.antivirusStatus === 'RunningAndUpToDate' ? 'bg-green-600' : ''}
|
||||
>
|
||||
{device.antivirus.antivirusStatus.replace(/([A-Z])/g, ' $1').trim()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Patch Management</Label>
|
||||
<div className="space-y-2 mt-1">
|
||||
{device.patchManagement?.patchStatus && (
|
||||
<Badge
|
||||
variant={device.patchManagement.patchStatus === 'FullyPatched' ? 'default' : 'secondary'}
|
||||
className={device.patchManagement.patchStatus === 'FullyPatched' ? 'bg-green-600' : ''}
|
||||
>
|
||||
{device.patchManagement.patchStatus.replace(/([A-Z])/g, ' $1').trim()}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Pending</p>
|
||||
<p className="text-lg font-semibold text-orange-600">
|
||||
{device.patchManagement?.patchesApprovedPending || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Installed</p>
|
||||
<p className="text-lg font-semibold text-green-600">
|
||||
{device.patchManagement?.patchesInstalled || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Not Approved</p>
|
||||
<p className="text-lg font-semibold text-gray-600">
|
||||
{device.patchManagement?.patchesNotApproved || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
110
autotask-app/components/configuration-items/status-cards.tsx
Normal file
110
autotask-app/components/configuration-items/status-cards.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Server,
|
||||
Monitor,
|
||||
Power,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ConfigurationItem } from '@/lib/types/autotask';
|
||||
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
|
||||
|
||||
interface StatusCardsProps {
|
||||
device?: ConfigurationItem;
|
||||
rmmDevice?: DattoRMMDevice;
|
||||
}
|
||||
|
||||
export function StatusCards({ device, rmmDevice }: StatusCardsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Status</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{device?.isActive ? (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Active
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
Inactive
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Power className={`h-5 w-5 ${device?.isActive ? 'text-green-600' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">PSA</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{device ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{device ? 'Connected' : 'Not Found'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Server className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">RMM</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{rmmDevice ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{rmmDevice ? 'Connected' : 'Not Found'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Monitor className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-lg">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Last Seen</p>
|
||||
<p className="text-sm font-medium mt-1">
|
||||
{device?.lastModifiedTime ?
|
||||
format(new Date(device.lastModifiedTime), 'MMM d, yyyy') :
|
||||
rmmDevice?.lastSeen ?
|
||||
format(new Date(rmmDevice.lastSeen), 'MMM d, yyyy') :
|
||||
'-'}
|
||||
</p>
|
||||
</div>
|
||||
<Clock className="h-5 w-5 text-orange-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
autotask-app/components/tasks/task-list.tsx
Normal file
208
autotask-app/components/tasks/task-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
autotask-app/components/theme-provider.tsx
Normal file
8
autotask-app/components/theme-provider.tsx
Normal 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>
|
||||
}
|
||||
43
autotask-app/components/theme-toggle.tsx
Normal file
43
autotask-app/components/theme-toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
221
autotask-app/components/tickets/ticket-list.tsx
Normal file
221
autotask-app/components/tickets/ticket-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
autotask-app/components/ui/alert-dialog.tsx
Normal file
157
autotask-app/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
46
autotask-app/components/ui/badge.tsx
Normal file
46
autotask-app/components/ui/badge.tsx
Normal 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 }
|
||||
60
autotask-app/components/ui/button.tsx
Normal file
60
autotask-app/components/ui/button.tsx
Normal 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 }
|
||||
216
autotask-app/components/ui/calendar.tsx
Normal file
216
autotask-app/components/ui/calendar.tsx
Normal 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 }
|
||||
92
autotask-app/components/ui/card.tsx
Normal file
92
autotask-app/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
32
autotask-app/components/ui/checkbox.tsx
Normal file
32
autotask-app/components/ui/checkbox.tsx
Normal 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 }
|
||||
33
autotask-app/components/ui/collapsible.tsx
Normal file
33
autotask-app/components/ui/collapsible.tsx
Normal 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 }
|
||||
143
autotask-app/components/ui/dialog.tsx
Normal file
143
autotask-app/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
257
autotask-app/components/ui/dropdown-menu.tsx
Normal file
257
autotask-app/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
167
autotask-app/components/ui/form.tsx
Normal file
167
autotask-app/components/ui/form.tsx
Normal 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,
|
||||
}
|
||||
21
autotask-app/components/ui/input.tsx
Normal file
21
autotask-app/components/ui/input.tsx
Normal 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 }
|
||||
24
autotask-app/components/ui/label.tsx
Normal file
24
autotask-app/components/ui/label.tsx
Normal 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 }
|
||||
48
autotask-app/components/ui/popover.tsx
Normal file
48
autotask-app/components/ui/popover.tsx
Normal 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 }
|
||||
187
autotask-app/components/ui/select.tsx
Normal file
187
autotask-app/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
13
autotask-app/components/ui/skeleton.tsx
Normal file
13
autotask-app/components/ui/skeleton.tsx
Normal 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 }
|
||||
40
autotask-app/components/ui/sonner.tsx
Normal file
40
autotask-app/components/ui/sonner.tsx
Normal 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 }
|
||||
31
autotask-app/components/ui/switch.tsx
Normal file
31
autotask-app/components/ui/switch.tsx
Normal 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 }
|
||||
116
autotask-app/components/ui/table.tsx
Normal file
116
autotask-app/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
66
autotask-app/components/ui/tabs.tsx
Normal file
66
autotask-app/components/ui/tabs.tsx
Normal 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 }
|
||||
18
autotask-app/components/ui/textarea.tsx
Normal file
18
autotask-app/components/ui/textarea.tsx
Normal 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 }
|
||||
18
autotask-app/eslint.config.mjs
Normal file
18
autotask-app/eslint.config.mjs
Normal 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;
|
||||
62
autotask-app/lib/hooks/use-api.ts
Normal file
62
autotask-app/lib/hooks/use-api.ts
Normal 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();
|
||||
}
|
||||
433
autotask-app/lib/services/autotask-client.ts
Normal file
433
autotask-app/lib/services/autotask-client.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
26
autotask-app/lib/services/autotask-factory.ts
Normal file
26
autotask-app/lib/services/autotask-factory.ts
Normal 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;
|
||||
}
|
||||
62
autotask-app/lib/services/cache.ts
Normal file
62
autotask-app/lib/services/cache.ts
Normal 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);
|
||||
}
|
||||
133
autotask-app/lib/services/datto-rmm-client-simple.ts
Normal file
133
autotask-app/lib/services/datto-rmm-client-simple.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
340
autotask-app/lib/services/datto-rmm-client.ts
Normal file
340
autotask-app/lib/services/datto-rmm-client.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
25
autotask-app/lib/services/datto-rmm-factory.ts
Normal file
25
autotask-app/lib/services/datto-rmm-factory.ts
Normal 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;
|
||||
}
|
||||
262
autotask-app/lib/types/autotask.ts
Normal file
262
autotask-app/lib/types/autotask.ts
Normal 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,
|
||||
}
|
||||
105
autotask-app/lib/types/datto-rmm.ts
Normal file
105
autotask-app/lib/types/datto-rmm.ts
Normal 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;
|
||||
}[];
|
||||
}
|
||||
6
autotask-app/lib/utils.ts
Normal file
6
autotask-app/lib/utils.ts
Normal 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))
|
||||
}
|
||||
174
autotask-app/lib/utils/device-lookup.ts
Normal file
174
autotask-app/lib/utils/device-lookup.ts
Normal 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;
|
||||
}
|
||||
8
autotask-app/next.config.ts
Normal file
8
autotask-app/next.config.ts
Normal 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
51
autotask-app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
autotask-app/postcss.config.mjs
Normal file
7
autotask-app/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
autotask-app/public/file.svg
Normal file
1
autotask-app/public/file.svg
Normal 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 |
1
autotask-app/public/globe.svg
Normal file
1
autotask-app/public/globe.svg
Normal 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 |
1
autotask-app/public/next.svg
Normal file
1
autotask-app/public/next.svg
Normal 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 |
1
autotask-app/public/vercel.svg
Normal file
1
autotask-app/public/vercel.svg
Normal 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 |
1
autotask-app/public/window.svg
Normal file
1
autotask-app/public/window.svg
Normal 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 |
34
autotask-app/tsconfig.json
Normal file
34
autotask-app/tsconfig.json
Normal 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
61
dev/create-prd.mdc
Normal 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
64
dev/generate-tasks.mdc
Normal 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
38
dev/process-task-list.mdc
Normal 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 sub‑task until you ask the user for permission and they say “yes” or "y"
|
||||
- **Completion protocol:**
|
||||
1. When you finish a **sub‑task**, 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 sub‑task and wait for the user’s go‑ahead.
|
||||
|
||||
## 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 one‑line 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 **sub‑task** `[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 sub‑task is next.
|
||||
6. After implementing a sub‑task, update the file and then pause for user approval.
|
||||
63
lib/services/cache.ts
Normal file
63
lib/services/cache.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue