From cf5fabe3067dd40f672ee482c5d0fe67b299213a Mon Sep 17 00:00:00 2001 From: Lorentz Hinrichsen Date: Tue, 28 Oct 2025 11:21:04 -0400 Subject: [PATCH] Add purchase history and related tickets features --- autotask-app/.gitignore | 41 + autotask-app/README.md | 193 +++++ autotask-app/app/api/companies/route.ts | 21 + .../app/api/config-enrichment/route.ts | 201 +++++ .../api/config-items/[id]/tickets/route.ts | 81 ++ .../app/api/configuration-items/[id]/route.ts | 173 ++++ .../app/api/configuration-items/route.ts | 80 ++ autotask-app/app/api/contacts/[id]/route.ts | 39 + autotask-app/app/api/debug-auth/route.ts | 35 + autotask-app/app/api/health/route.ts | 104 +++ .../app/api/invoices/[id]/line-items/route.ts | 36 + autotask-app/app/api/picklists/route.ts | 28 + autotask-app/app/api/resources/route.ts | 24 + autotask-app/app/api/rmm-audit/route.ts | 49 ++ autotask-app/app/api/rmm-devices/route.ts | 241 ++++++ autotask-app/app/api/rmm-test/route.ts | 32 + autotask-app/app/api/tasks/route.ts | 50 ++ autotask-app/app/api/test-zones/route.ts | 94 +++ autotask-app/app/api/tickets/[id]/route.ts | 46 ++ .../api/tickets/[id]/time-entries/route.ts | 55 ++ autotask-app/app/api/tickets/route.ts | 88 ++ .../app/config-enrichment-test/page.tsx | 509 ++++++++++++ .../app/configuration-items/[id]/page.tsx | 195 +++++ autotask-app/app/configuration-items/page.tsx | 758 ++++++++++++++++++ autotask-app/app/favicon.ico | Bin 0 -> 25931 bytes autotask-app/app/globals.css | 122 +++ autotask-app/app/layout.tsx | 32 + autotask-app/app/page.tsx | 236 ++++++ autotask-app/app/setup/page.tsx | 336 ++++++++ autotask-app/components.json | 22 + .../companies/company-selector-enhanced.tsx | 61 ++ .../components/companies/company-selector.tsx | 60 ++ .../configuration-items/config-item-modal.tsx | 153 ++++ .../configuration-items/contact-cell.tsx | 59 ++ .../configuration-items/psa-tab.tsx | 456 +++++++++++ .../purchase-history-modal.tsx | 571 +++++++++++++ .../related-tickets-modal.tsx | 198 +++++ .../configuration-items/rmm-tab.tsx | 337 ++++++++ .../configuration-items/status-cards.tsx | 110 +++ autotask-app/components/tasks/task-list.tsx | 208 +++++ autotask-app/components/theme-provider.tsx | 8 + autotask-app/components/theme-toggle.tsx | 43 + .../components/tickets/ticket-list.tsx | 221 +++++ autotask-app/components/ui/alert-dialog.tsx | 157 ++++ autotask-app/components/ui/badge.tsx | 46 ++ autotask-app/components/ui/button.tsx | 60 ++ autotask-app/components/ui/calendar.tsx | 216 +++++ autotask-app/components/ui/card.tsx | 92 +++ autotask-app/components/ui/checkbox.tsx | 32 + autotask-app/components/ui/collapsible.tsx | 33 + autotask-app/components/ui/dialog.tsx | 143 ++++ autotask-app/components/ui/dropdown-menu.tsx | 257 ++++++ autotask-app/components/ui/form.tsx | 167 ++++ autotask-app/components/ui/input.tsx | 21 + autotask-app/components/ui/label.tsx | 24 + autotask-app/components/ui/popover.tsx | 48 ++ autotask-app/components/ui/select.tsx | 187 +++++ autotask-app/components/ui/skeleton.tsx | 13 + autotask-app/components/ui/sonner.tsx | 40 + autotask-app/components/ui/switch.tsx | 31 + autotask-app/components/ui/table.tsx | 116 +++ autotask-app/components/ui/tabs.tsx | 66 ++ autotask-app/components/ui/textarea.tsx | 18 + autotask-app/eslint.config.mjs | 18 + autotask-app/lib/hooks/use-api.ts | 62 ++ autotask-app/lib/services/autotask-client.ts | 433 ++++++++++ autotask-app/lib/services/autotask-factory.ts | 26 + autotask-app/lib/services/cache.ts | 62 ++ .../lib/services/datto-rmm-client-simple.ts | 133 +++ autotask-app/lib/services/datto-rmm-client.ts | 340 ++++++++ .../lib/services/datto-rmm-factory.ts | 25 + autotask-app/lib/types/autotask.ts | 262 ++++++ autotask-app/lib/types/datto-rmm.ts | 105 +++ autotask-app/lib/utils.ts | 6 + autotask-app/lib/utils/device-lookup.ts | 174 ++++ autotask-app/next.config.ts | 8 + autotask-app/package.json | 51 ++ autotask-app/postcss.config.mjs | 7 + autotask-app/public/file.svg | 1 + autotask-app/public/globe.svg | 1 + autotask-app/public/next.svg | 1 + autotask-app/public/vercel.svg | 1 + autotask-app/public/window.svg | 1 + autotask-app/tsconfig.json | 34 + dev/create-prd.mdc | 61 ++ dev/generate-tasks.mdc | 64 ++ dev/process-task-list.mdc | 38 + lib/services/cache.ts | 63 ++ 88 files changed, 10150 insertions(+) create mode 100644 autotask-app/.gitignore create mode 100644 autotask-app/README.md create mode 100644 autotask-app/app/api/companies/route.ts create mode 100644 autotask-app/app/api/config-enrichment/route.ts create mode 100644 autotask-app/app/api/config-items/[id]/tickets/route.ts create mode 100644 autotask-app/app/api/configuration-items/[id]/route.ts create mode 100644 autotask-app/app/api/configuration-items/route.ts create mode 100644 autotask-app/app/api/contacts/[id]/route.ts create mode 100644 autotask-app/app/api/debug-auth/route.ts create mode 100644 autotask-app/app/api/health/route.ts create mode 100644 autotask-app/app/api/invoices/[id]/line-items/route.ts create mode 100644 autotask-app/app/api/picklists/route.ts create mode 100644 autotask-app/app/api/resources/route.ts create mode 100644 autotask-app/app/api/rmm-audit/route.ts create mode 100644 autotask-app/app/api/rmm-devices/route.ts create mode 100644 autotask-app/app/api/rmm-test/route.ts create mode 100644 autotask-app/app/api/tasks/route.ts create mode 100644 autotask-app/app/api/test-zones/route.ts create mode 100644 autotask-app/app/api/tickets/[id]/route.ts create mode 100644 autotask-app/app/api/tickets/[id]/time-entries/route.ts create mode 100644 autotask-app/app/api/tickets/route.ts create mode 100644 autotask-app/app/config-enrichment-test/page.tsx create mode 100644 autotask-app/app/configuration-items/[id]/page.tsx create mode 100644 autotask-app/app/configuration-items/page.tsx create mode 100644 autotask-app/app/favicon.ico create mode 100644 autotask-app/app/globals.css create mode 100644 autotask-app/app/layout.tsx create mode 100644 autotask-app/app/page.tsx create mode 100644 autotask-app/app/setup/page.tsx create mode 100644 autotask-app/components.json create mode 100644 autotask-app/components/companies/company-selector-enhanced.tsx create mode 100644 autotask-app/components/companies/company-selector.tsx create mode 100644 autotask-app/components/configuration-items/config-item-modal.tsx create mode 100644 autotask-app/components/configuration-items/contact-cell.tsx create mode 100644 autotask-app/components/configuration-items/psa-tab.tsx create mode 100644 autotask-app/components/configuration-items/purchase-history-modal.tsx create mode 100644 autotask-app/components/configuration-items/related-tickets-modal.tsx create mode 100644 autotask-app/components/configuration-items/rmm-tab.tsx create mode 100644 autotask-app/components/configuration-items/status-cards.tsx create mode 100644 autotask-app/components/tasks/task-list.tsx create mode 100644 autotask-app/components/theme-provider.tsx create mode 100644 autotask-app/components/theme-toggle.tsx create mode 100644 autotask-app/components/tickets/ticket-list.tsx create mode 100644 autotask-app/components/ui/alert-dialog.tsx create mode 100644 autotask-app/components/ui/badge.tsx create mode 100644 autotask-app/components/ui/button.tsx create mode 100644 autotask-app/components/ui/calendar.tsx create mode 100644 autotask-app/components/ui/card.tsx create mode 100644 autotask-app/components/ui/checkbox.tsx create mode 100644 autotask-app/components/ui/collapsible.tsx create mode 100644 autotask-app/components/ui/dialog.tsx create mode 100644 autotask-app/components/ui/dropdown-menu.tsx create mode 100644 autotask-app/components/ui/form.tsx create mode 100644 autotask-app/components/ui/input.tsx create mode 100644 autotask-app/components/ui/label.tsx create mode 100644 autotask-app/components/ui/popover.tsx create mode 100644 autotask-app/components/ui/select.tsx create mode 100644 autotask-app/components/ui/skeleton.tsx create mode 100644 autotask-app/components/ui/sonner.tsx create mode 100644 autotask-app/components/ui/switch.tsx create mode 100644 autotask-app/components/ui/table.tsx create mode 100644 autotask-app/components/ui/tabs.tsx create mode 100644 autotask-app/components/ui/textarea.tsx create mode 100644 autotask-app/eslint.config.mjs create mode 100644 autotask-app/lib/hooks/use-api.ts create mode 100644 autotask-app/lib/services/autotask-client.ts create mode 100644 autotask-app/lib/services/autotask-factory.ts create mode 100644 autotask-app/lib/services/cache.ts create mode 100644 autotask-app/lib/services/datto-rmm-client-simple.ts create mode 100644 autotask-app/lib/services/datto-rmm-client.ts create mode 100644 autotask-app/lib/services/datto-rmm-factory.ts create mode 100644 autotask-app/lib/types/autotask.ts create mode 100644 autotask-app/lib/types/datto-rmm.ts create mode 100644 autotask-app/lib/utils.ts create mode 100644 autotask-app/lib/utils/device-lookup.ts create mode 100644 autotask-app/next.config.ts create mode 100644 autotask-app/package.json create mode 100644 autotask-app/postcss.config.mjs create mode 100644 autotask-app/public/file.svg create mode 100644 autotask-app/public/globe.svg create mode 100644 autotask-app/public/next.svg create mode 100644 autotask-app/public/vercel.svg create mode 100644 autotask-app/public/window.svg create mode 100644 autotask-app/tsconfig.json create mode 100644 dev/create-prd.mdc create mode 100644 dev/generate-tasks.mdc create mode 100644 dev/process-task-list.mdc create mode 100644 lib/services/cache.ts diff --git a/autotask-app/.gitignore b/autotask-app/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/autotask-app/.gitignore @@ -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 diff --git a/autotask-app/README.md b/autotask-app/README.md new file mode 100644 index 0000000..65d70d2 --- /dev/null +++ b/autotask-app/README.md @@ -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 diff --git a/autotask-app/app/api/companies/route.ts b/autotask-app/app/api/companies/route.ts new file mode 100644 index 0000000..cc5f591 --- /dev/null +++ b/autotask-app/app/api/companies/route.ts @@ -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 } + ); + } +} diff --git a/autotask-app/app/api/config-enrichment/route.ts b/autotask-app/app/api/config-enrichment/route.ts new file mode 100644 index 0000000..0d39ad7 --- /dev/null +++ b/autotask-app/app/api/config-enrichment/route.ts @@ -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 } + ); + } +} diff --git a/autotask-app/app/api/config-items/[id]/tickets/route.ts b/autotask-app/app/api/config-items/[id]/tickets/route.ts new file mode 100644 index 0000000..75a675b --- /dev/null +++ b/autotask-app/app/api/config-items/[id]/tickets/route.ts @@ -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 = {}; + let priorityPicklist: Record = {}; + + 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 } + ); + } +} diff --git a/autotask-app/app/api/configuration-items/[id]/route.ts b/autotask-app/app/api/configuration-items/[id]/route.ts new file mode 100644 index 0000000..81cf5a0 --- /dev/null +++ b/autotask-app/app/api/configuration-items/[id]/route.ts @@ -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 } + ); + } +} diff --git a/autotask-app/app/api/configuration-items/route.ts b/autotask-app/app/api/configuration-items/route.ts new file mode 100644 index 0000000..5263211 --- /dev/null +++ b/autotask-app/app/api/configuration-items/route.ts @@ -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 } + ); + } +} diff --git a/autotask-app/app/api/contacts/[id]/route.ts b/autotask-app/app/api/contacts/[id]/route.ts new file mode 100644 index 0000000..6d67085 --- /dev/null +++ b/autotask-app/app/api/contacts/[id]/route.ts @@ -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 } + ); + } +} diff --git a/autotask-app/app/api/debug-auth/route.ts b/autotask-app/app/api/debug-auth/route.ts new file mode 100644 index 0000000..6754741 --- /dev/null +++ b/autotask-app/app/api/debug-auth/route.ts @@ -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) + } + }); +} diff --git a/autotask-app/app/api/health/route.ts b/autotask-app/app/api/health/route.ts new file mode 100644 index 0000000..58ab4f0 --- /dev/null +++ b/autotask-app/app/api/health/route.ts @@ -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 } + ); + } +} diff --git a/autotask-app/app/api/invoices/[id]/line-items/route.ts b/autotask-app/app/api/invoices/[id]/line-items/route.ts new file mode 100644 index 0000000..95c3561 --- /dev/null +++ b/autotask-app/app/api/invoices/[id]/line-items/route.ts @@ -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 } + ); + } +} diff --git a/autotask-app/app/api/picklists/route.ts b/autotask-app/app/api/picklists/route.ts new file mode 100644 index 0000000..e1e1404 --- /dev/null +++ b/autotask-app/app/api/picklists/route.ts @@ -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 } + ); + } +} diff --git a/autotask-app/app/api/resources/route.ts b/autotask-app/app/api/resources/route.ts new file mode 100644 index 0000000..c3c1d18 --- /dev/null +++ b/autotask-app/app/api/resources/route.ts @@ -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 } + ); + } +} diff --git a/autotask-app/app/api/rmm-audit/route.ts b/autotask-app/app/api/rmm-audit/route.ts new file mode 100644 index 0000000..d095070 --- /dev/null +++ b/autotask-app/app/api/rmm-audit/route.ts @@ -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 } + ); + } +} diff --git a/autotask-app/app/api/rmm-devices/route.ts b/autotask-app/app/api/rmm-devices/route.ts new file mode 100644 index 0000000..b77618d --- /dev/null +++ b/autotask-app/app/api/rmm-devices/route.ts @@ -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('ConfigurationItems', { + filter: [{ op: 'eq', field: 'companyID', value: parseInt(companyId) }], + }); + autotaskDevices = allItems; + } else if (activeFilter === 'inactive') { + // Get only inactive devices + const inactiveItems = await autotaskClient.queryEntity('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(); + const matchedRmmIds = new Set(); + + // 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 } + ); + } +} diff --git a/autotask-app/app/api/rmm-test/route.ts b/autotask-app/app/api/rmm-test/route.ts new file mode 100644 index 0000000..b6538af --- /dev/null +++ b/autotask-app/app/api/rmm-test/route.ts @@ -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 }); + } +} diff --git a/autotask-app/app/api/tasks/route.ts b/autotask-app/app/api/tasks/route.ts new file mode 100644 index 0000000..c0e0f66 --- /dev/null +++ b/autotask-app/app/api/tasks/route.ts @@ -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('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 } + ); + } +} diff --git a/autotask-app/app/api/test-zones/route.ts b/autotask-app/app/api/test-zones/route.ts new file mode 100644 index 0000000..09aca41 --- /dev/null +++ b/autotask-app/app/api/test-zones/route.ts @@ -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 }); +} diff --git a/autotask-app/app/api/tickets/[id]/route.ts b/autotask-app/app/api/tickets/[id]/route.ts new file mode 100644 index 0000000..6dd2f3e --- /dev/null +++ b/autotask-app/app/api/tickets/[id]/route.ts @@ -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 } + ); + } +} diff --git a/autotask-app/app/api/tickets/[id]/time-entries/route.ts b/autotask-app/app/api/tickets/[id]/time-entries/route.ts new file mode 100644 index 0000000..082b547 --- /dev/null +++ b/autotask-app/app/api/tickets/[id]/time-entries/route.ts @@ -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 } + ); + } +} diff --git a/autotask-app/app/api/tickets/route.ts b/autotask-app/app/api/tickets/route.ts new file mode 100644 index 0000000..db485a2 --- /dev/null +++ b/autotask-app/app/api/tickets/route.ts @@ -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('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 } + ); + } +} diff --git a/autotask-app/app/config-enrichment-test/page.tsx b/autotask-app/app/config-enrichment-test/page.tsx new file mode 100644 index 0000000..0667ddd --- /dev/null +++ b/autotask-app/app/config-enrichment-test/page.tsx @@ -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(null); + const [data, setData] = useState(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 ( +
+ {/* Header */} +
+
+
+
+
+ +
+
+

+ Config Item Enrichment Test +

+

+ Find invoices and tickets by serial number +

+
+
+ +
+
+
+ + {/* Main Content */} +
+ {/* Search Card */} + + + Search Configuration Item + + Enter a Configuration Item ID to find related invoices and tickets (last 90 days) + + + +
+
+
+ + { + setConfigItemId(e.target.value); + if (e.target.value) setInvoiceId(''); + }} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + /> +
+
+ + { + setInvoiceId(e.target.value); + if (e.target.value) setConfigItemId(''); + }} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + /> +
+
+ +
+
+
+ + {/* Error Display */} + {error && ( + + +
+ +

Error: {error}

+
+
+
+ )} + + {/* Loading State */} + {loading && ( +
+ + +
+ )} + + {/* Results */} + {data && !loading && ( + <> + {/* Config Item Info */} + + + + + Configuration Item Details + + + +
+
+ +

{data.configItem?.referenceTitle || '-'}

+
+
+ +

{data.configItem?.serialNumber || '-'}

+
+
+ +

+ {data.configItem?.installDate ? + format(new Date(data.configItem.installDate), 'MMM d, yyyy') : + '-'} +

+
+
+ + + {data.configItem?.isActive ? 'Active' : 'Inactive'} + +
+
+
+
+ + {/* Summary Cards */} +
+ + +
+
+

Total Billed

+

+ ${data.summary.totalBilled.toFixed(2)} +

+
+ +
+
+
+ + + +
+
+

Invoices Found

+

{data.invoices.length}

+
+ +
+
+
+ + + +
+
+

Tickets Found

+

{data.summary.ticketCount}

+
+ +
+
+
+ + + +
+
+

Purchase Date

+

+ {data.summary.purchaseDate ? + format(new Date(data.summary.purchaseDate), 'MMM d, yyyy') : + '-'} +

+
+ +
+
+
+
+ + {/* Invoices */} + {data.invoices.length > 0 && ( + + + + + Company Invoices (Last 90 Days) + + + All invoices for {data.configItem?.companyName || 'this company'} + + + + + + + Invoice # + Date + Total + Status + Due Date + + + + {data.invoices.map((invoice) => ( + + + {invoice.invoiceNumber || invoice.id} + + + {invoice.invoiceDateTime ? + format(new Date(invoice.invoiceDateTime), 'MMM d, yyyy') : + '-'} + + + ${(invoice.invoiceTotal || 0).toFixed(2)} + + + + {invoice.paidDate ? 'Paid' : 'Unpaid'} + + + + {invoice.dueDate ? + format(new Date(invoice.dueDate), 'MMM d, yyyy') : + '-'} + + + ))} + +
+
+
+ )} + + {/* Billing Items */} + {data.billingItems.length > 0 && ( + + + + + {data.configItem ? 'Purchase History for This Device' : 'Hardware Purchases'} + + + {data.configItem + ? `Billing items matching serial number ${data.configItem.serialNumber} (last 90 days)` + : 'Hardware line items from invoice'} + + + + + + + Date + Description + Serial/Notes + Qty + Unit Price + Total + Invoice + + + + {data.billingItems.map((item, index) => ( + + + {item.itemDate ? format(new Date(item.itemDate), 'MMM d, yyyy') : '-'} + + +
{item.description || '-'}
+ {item.itemName && item.itemName !== item.description && ( +
{item.itemName}
+ )} +
+ +
+ {item.serialNumber && ( +
+ SN: {item.serialNumber} +
+ )} + {item.internalNotes && ( +
{item.internalNotes}
+ )} + {item.vendorInvoiceNumber && ( +
Vendor: {item.vendorInvoiceNumber}
+ )} +
+
+ {item.quantity || 1} + ${(item.unitPrice || 0).toFixed(2)} + + ${(item.totalAmount || 0).toFixed(2)} + + + {item.invoiceID} + +
+ ))} +
+
+
+
+ )} + + {/* Tickets */} + {data.tickets.length > 0 && ( + + + + + Related Tickets + + + Tickets mentioning this serial number (last 90 days) + + + + + + + Ticket # + Title + Status + Created + Priority + + + + {data.tickets.map((ticket) => ( + + + {ticket.ticketNumber} + + {ticket.title} + + {ticket.status} + + + {format(new Date(ticket.createDate), 'MMM d, yyyy')} + + + + {ticket.priority} + + + + ))} + +
+
+
+ )} + + {/* Debug: Show all fields from first billing item - only when searching by invoice */} + {data.billingItems.length > 0 && !data.configItem && invoiceId && ( + + + + + Debug: Available Fields (First Item) + + + All fields available in BillingItems entity + + + +
+                    {JSON.stringify(data.billingItems[0], null, 2)}
+                  
+
+
+ )} + + {/* No Results */} + {data.billingItems.length === 0 && data.tickets.length === 0 && ( + + +
+ + {data.configItem ? ( + <> +

No purchase records found for this device

+

+ Serial number: {data.configItem.serialNumber || 'Not set'} +

+

+ This device may have been: +

+
    +
  • • Purchased more than 1 year ago
  • +
  • • Added manually without an invoice
  • +
  • • Invoiced without serial number in description
  • +
+ + ) : ( + <> +

No billing items or tickets found in the last 90 days

+

Try a different configuration item or expand the date range

+ + )} +
+
+
+ )} + + )} +
+
+ ); +} diff --git a/autotask-app/app/configuration-items/[id]/page.tsx b/autotask-app/app/configuration-items/[id]/page.tsx new file mode 100644 index 0000000..5549802 --- /dev/null +++ b/autotask-app/app/configuration-items/[id]/page.tsx @@ -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({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+ + +
+
+
+ ); + } + + if (error) { + return ( +
+
+ + +
+ +

Error: {error}

+
+
+
+
+
+ ); + } + + const device = data.autotaskDevice; + const rmmDevice = data.rmmDevice; + + return ( +
+ {/* Header */} +
+
+
+
+ +
+
+ +
+
+

+ {device?.referenceTitle || rmmDevice?.hostname || 'Configuration Item'} +

+

+ {data.companyName || 'Configuration Item Details'} +

+
+
+
+
+ + +
+
+
+
+ + {/* Main Content */} +
+ {/* Status Cards */} + + + {/* Tabs */} + + + + + PSA Data + + + + RMM Data + + + + + + + + + + + +
+
+ ); +} diff --git a/autotask-app/app/configuration-items/page.tsx b/autotask-app/app/configuration-items/page.tsx new file mode 100644 index 0000000..d39c9b1 --- /dev/null +++ b/autotask-app/app/configuration-items/page.tsx @@ -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(); + const [selectedCompanyName, setSelectedCompanyName] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [filterType, setFilterType] = useState('all'); + const [configItems, setConfigItems] = useState([]); + const [comparison, setComparison] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [viewMode, setViewMode] = useState<'autotask' | 'comparison'>('comparison'); + const [selectedItemId, setSelectedItemId] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [adminExpanded, setAdminExpanded] = useState(false); + const [selectedItems, setSelectedItems] = useState>(new Set()); + const [bulkProcessing, setBulkProcessing] = useState(false); + const [lastSeenAfterDate, setLastSeenAfterDate] = useState(); + 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 ; + } + if (item.dattoSerialNumber) { + return ; + } + return ; + }; + + const getRMMStatus = (item: ConfigurationItem) => { + if (item.rmmDeviceUID) { + return ( + + + RMM Connected + + ); + } + return ( + + + No RMM + + ); + }; + + const getDattoStatus = (item: ConfigurationItem) => { + if (item.dattoSerialNumber) { + return ( + + + Datto Protected + + ); + } + return null; + }; + + return ( +
+ {/* Header */} +
+
+
+
+ +
+
+ +
+
+

+ Configuration Items +

+

Autotask & RMM Device Management

+
+
+
+
+ + + +
+
+
+
+ + {/* Main Content */} +
+ {/* Company Selector Card */} + + +
+
+ Select Company + + Choose a company to view their configuration items + +
+
+ +
+
+
+ +
+
+ +
+ {selectedCompany && ( +
+ + +
+
+

Total Devices

+

+ PSA: {stats?.totalAutotask || 0} | RMM: {stats?.totalRmm || 0} +

+
+ +
+
+
+
+ )} +
+
+
+ + {/* Admin Section */} + {selectedCompany && filteredComparison.length > 0 && ( + + + + + + + Admin Actions + {selectedItems.size > 0 && ( + + {selectedItems.size} selected + + )} + + + + + + +
+
+

Bulk Actions

+

+ {selectedItems.size} device{selectedItems.size !== 1 ? 's' : ''} selected +

+
+
+ +
+
+
+
+
+
+ )} + + {/* Filters and Search */} + {selectedCompany && ( + + + + + Filters & Search + + + +
+
+ +
+ + setSearchTerm(e.target.value)} + className="pl-8" + /> +
+
+
+ + +
+
+ + +
+
+ + + + + + + + + + {lastSeenAfterDate && ( + + )} +
+
+ + +
+
+

Matched

+

+ {stats?.matched || 0} +

+
+ +
+
+
+ + +
+
+

RMM Only

+

+ {stats?.rmmOnly || 0} +

+
+ +
+
+
+
+
+
+
+ )} + + {/* Configuration Items Table */} + {selectedCompany && ( + + +
+ + + Device Comparison + {filteredComparison.length} + + {loading && ( +
+ + Loading... +
+ )} +
+
+ + {loading ? ( +
+ {[1, 2, 3].map(i => ( + + ))} +
+ ) : error ? ( +
+ + Error: {error} +
+ ) : filteredComparison.length === 0 ? ( +
+ {!selectedCompany ? ( +
+ +

Select a company to view configuration items

+
+ ) : searchTerm || filterType !== 'all' ? ( +
+ +

No devices found matching your filters

+
+ ) : ( +
+ +

No configuration items found for this company

+
+ )} +
+ ) : ( +
+ + + + + i.autotaskDevice).length && selectedItems.size > 0} + onCheckedChange={handleSelectAll} + /> + + Status + Device Name + Serial Number + IP Address + Contact + PSA + RMM + Match Type + + + + + {filteredComparison.slice(0, displayLimit).map((item: DeviceComparison, index: number) => ( + + + {item.autotaskDevice?.id && ( + handleSelectItem(item.autotaskDevice!.id, checked as boolean)} + /> + )} + + + {item.status === 'matched' && ( + + + Matched + + )} + {item.status === 'autotask-only' && ( + + + AT Only + + )} + {item.status === 'rmm-only' && ( + + + RMM Only + + )} + + +
+ +
+
+ {item.autotaskDevice?.referenceTitle || + item.rmmDevice?.hostname || + 'Unknown Device'} +
+ {(item.autotaskDevice?.rmmDeviceAuditHostname || item.rmmDevice?.description) && ( +
+ {item.autotaskDevice?.rmmDeviceAuditHostname || item.rmmDevice?.description} +
+ )} +
+
+
+ + {item.autotaskDevice?.serialNumber || + item.rmmDevice?.serialNumber || + '-'} + + + {item.autotaskDevice?.rmmDeviceAuditIPAddress || + item.rmmDevice?.intIpAddress || + '-'} + + + + + + {item.autotaskDevice ? ( + + ) : ( + + )} + + + {item.rmmDevice ? ( + + ) : ( + + )} + + + {item.matchedBy && ( + + {item.matchedBy} + + )} + + + + +
+ ))} +
+
+ + {/* Load More Button */} + {filteredComparison.length > displayLimit && ( +
+ +
+ )} +
+ )} +
+
+ )} + + {/* Info Card when no company selected */} + {!selectedCompany && ( + + +
+
+
+ +
+
+

Get Started

+

+ Select a company from the dropdown above to view and manage their configuration items. + You can compare devices between Autotask and RMM systems. +

+
+
+ + View Autotask devices +
+
+ + Check RMM status +
+
+ + Monitor Datto devices +
+
+
+
+
+ )} +
+ + {/* Configuration Item Detail Modal */} + +
+ ); +} diff --git a/autotask-app/app/favicon.ico b/autotask-app/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/autotask-app/app/globals.css b/autotask-app/app/globals.css new file mode 100644 index 0000000..dc98be7 --- /dev/null +++ b/autotask-app/app/globals.css @@ -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; + } +} diff --git a/autotask-app/app/layout.tsx b/autotask-app/app/layout.tsx new file mode 100644 index 0000000..013c111 --- /dev/null +++ b/autotask-app/app/layout.tsx @@ -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 ( + + + + {children} + + + + ); +} diff --git a/autotask-app/app/page.tsx b/autotask-app/app/page.tsx new file mode 100644 index 0000000..cbf64a1 --- /dev/null +++ b/autotask-app/app/page.tsx @@ -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(); + const [selectedResource, setSelectedResource] = useState(); + const [activeTab, setActiveTab] = useState('tickets'); + + return ( +
+ {/* Header */} +
+
+
+
+
+
+ +
+
+

+ Autotask Dashboard +

+

PSA Management System

+
+
+
+
+ + + + + +
+
+
+
+ + {/* Main Content */} +
+ {/* Filters */} + + +
+
+ Quick Filters + + Narrow down your view by company or resource + +
+
+ +
+
+
+ +
+ +
+ +
+ + +
+
+
+ +
+
+
+
+ + {/* Tabs for Tickets and Tasks */} + + + + + Tickets + + + + Tasks + + + + + + + + + + + + + {/* Stats Cards */} +
+ + + + Open Tickets + +
+ +
+
+ +
-
+
+ + 12% from last month +
+
+
+ + + + Active Tasks + +
+ +
+
+ +
-
+
+ + In progress +
+
+
+ + + + Companies + +
+ +
+
+ +
-
+
+ + Active clients +
+
+
+ + + + Response Time + +
+ +
+
+ +
2.4h
+
+ + 15% faster +
+
+
+
+
+
+ ); +} diff --git a/autotask-app/app/setup/page.tsx b/autotask-app/app/setup/page.tsx new file mode 100644 index 0000000..fdeb439 --- /dev/null +++ b/autotask-app/app/setup/page.tsx @@ -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(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 ; + } + return ; + }; + + return ( +
+
+
+

+ Autotask API Setup & Diagnostics +

+

+ Configure and test your Autotask API connection +

+
+ + {/* Health Status Card */} + + +
+
+ API Connection Status + + Current status of your Autotask API configuration + +
+ +
+
+ + {healthStatus && ( +
+ {/* Overall Status */} +
+ {healthStatus.status === 'healthy' ? ( + <> + + + {healthStatus.message} + + + ) : ( + <> + + + {healthStatus.message} + + + )} +
+ + {/* Configuration Status */} + {healthStatus.configuration && ( +
+

Configuration Status:

+
+
+ {getStatusIcon(healthStatus.configuration.apiUrl)} + API URL +
+
+ {getStatusIcon(healthStatus.configuration.username)} + Username +
+
+ {getStatusIcon(healthStatus.configuration.password)} + Password +
+
+ {getStatusIcon(healthStatus.configuration.integrationCode)} + Integration Code +
+
+
+ )} + + {/* API Response Error */} + {healthStatus.apiResponse && ( +
+

+ API Response Error: +

+
+

Status: {healthStatus.apiResponse.status} - {healthStatus.apiResponse.statusText}

+ {healthStatus.apiResponse.error && ( +

+ {healthStatus.apiResponse.error} +

+ )} +
+
+ )} + + {/* Possible Issues */} + {healthStatus.possibleIssues && ( +
+

+ + Possible Issues: +

+
    + {healthStatus.possibleIssues.map((issue, index) => ( +
  • {issue}
  • + ))} +
+
+ )} + + {/* Setup Instructions */} + {healthStatus.instructions && ( +
+

+ Setup Instructions: +

+
    + {healthStatus.instructions.map((instruction, index) => ( +
  1. + {instruction} +
  2. + ))} +
+
+ )} +
+ )} +
+
+ + {/* Environment Template Card */} + + + Environment Variables Template + + Copy this template to create your .env.local file + + + +
+
+                {`# 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`}
+              
+ +
+
+

+ + Save this as .env.local in the project root +

+

+ + Remember to restart the server after adding the file +

+
+
+
+ + {/* Resources Card */} + + + Helpful Resources + + Documentation and guides for Autotask API integration + + + + + + + + {/* API Zone Information */} + + + Autotask API Zones + + Make sure you're using the correct zone URL for your Autotask instance + + + +
+
+

North America

+
    +
  • Zone 1: webservices1.autotask.net
  • +
  • Zone 2: webservices2.autotask.net
  • +
  • Zone 3: webservices3.autotask.net
  • +
  • Zone 4: webservices4.autotask.net
  • +
+
+
+

Other Regions

+
    +
  • Zone 5: webservices5.autotask.net
  • +
  • Zone 6: webservices6.autotask.net
  • +
  • PRD: prde.autotask.net
  • +
  • IOC: ioce.autotask.net
  • +
+
+
+
+
+
+
+ ); +} diff --git a/autotask-app/components.json b/autotask-app/components.json new file mode 100644 index 0000000..b7b9791 --- /dev/null +++ b/autotask-app/components.json @@ -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": {} +} diff --git a/autotask-app/components/companies/company-selector-enhanced.tsx b/autotask-app/components/companies/company-selector-enhanced.tsx new file mode 100644 index 0000000..1daeab4 --- /dev/null +++ b/autotask-app/components/companies/company-selector-enhanced.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/autotask-app/components/companies/company-selector.tsx b/autotask-app/components/companies/company-selector.tsx new file mode 100644 index 0000000..df0159f --- /dev/null +++ b/autotask-app/components/companies/company-selector.tsx @@ -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 ( +
+ + + {error && ( +

+ Error loading companies: {error} +

+ )} +
+ ); +} diff --git a/autotask-app/components/configuration-items/config-item-modal.tsx b/autotask-app/components/configuration-items/config-item-modal.tsx new file mode 100644 index 0000000..2cd5d07 --- /dev/null +++ b/autotask-app/components/configuration-items/config-item-modal.tsx @@ -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({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + + + + +
+ +
+
+
+ {device?.referenceTitle || rmmDevice?.hostname || 'Configuration Item'} +
+ {data.companyName && ( +
+ {data.companyName} +
+ )} +
+
+
+ + {loading ? ( +
+ + +
+ ) : error ? ( +
+
+ +

Error: {error}

+
+
+ ) : ( +
+ {/* Status Cards */} + + + {/* Tabs */} + + + + + PSA Data + {device && ( + + {device.isActive ? 'Active' : 'Inactive'} + + )} + + + + RMM Data + {rmmDevice && ( + + {rmmDevice.online ? 'Online' : 'Offline'} + + )} + + + + + + + + + + + +
+ )} +
+
+ ); +} diff --git a/autotask-app/components/configuration-items/contact-cell.tsx b/autotask-app/components/configuration-items/contact-cell.tsx new file mode 100644 index 0000000..3ede698 --- /dev/null +++ b/autotask-app/components/configuration-items/contact-cell.tsx @@ -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(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 Loading...; + } + + if (!contactId) { + return -; + } + + if (contactName) { + return ( + + + {contactName} + + ); + } + + return ID: {contactId}; +} diff --git a/autotask-app/components/configuration-items/psa-tab.tsx b/autotask-app/components/configuration-items/psa-tab.tsx new file mode 100644 index 0000000..05fdd00 --- /dev/null +++ b/autotask-app/components/configuration-items/psa-tab.tsx @@ -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>(device || {}); + const [error, setError] = useState(null); + const [contactName, setContactName] = useState(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 ( + <> + + +
+ PSA Configuration Item +
+ {!editMode ? ( + <> + + + + {device?.isActive && ( + + + + + + + Make Configuration Item Inactive? + + This will mark the configuration item as inactive in Autotask PSA. + You can reactivate it later if needed. + + + + Cancel + + Make Inactive + + + + + )} + + ) : ( + <> + + + + )} +
+
+
+ + {error && ( +
+ {error} +
+ )} + + {device ? ( +
+ {/* Basic Information */} +
+

+ + Basic Information +

+ +
+
+ + {editMode ? ( + setEditedData({...editedData, referenceTitle: e.target.value})} + className="mt-1" + /> + ) : ( +

{device.referenceTitle}

+ )} +
+ +
+ + {editMode ? ( + setEditedData({...editedData, referenceNumber: e.target.value})} + className="mt-1" + /> + ) : ( +

+ {device.referenceNumber || '-'} +

+ )} +
+ +
+ + {editMode ? ( + setEditedData({...editedData, serialNumber: e.target.value})} + className="mt-1" + /> + ) : ( +

+ {device.serialNumber || '-'} +

+ )} +
+ +
+ + {editMode ? ( + setEditedData({...editedData, location: e.target.value})} + className="mt-1" + /> + ) : ( +

+ {device.location || '-'} +

+ )} +
+ +
+ + {editMode ? ( +
+ setEditedData({...editedData, isActive: checked})} + /> + +
+ ) : ( +
+ {device.isActive ? ( + Active + ) : ( + Inactive + )} +
+ )} +
+ +
+ + {loadingContact ? ( +

Loading...

+ ) : contactName ? ( +
+ + + {contactName} + +
+ ) : device.contactID ? ( +

Contact ID: {device.contactID}

+ ) : ( +

No contact assigned

+ )} +
+
+
+ + {/* Technical Details */} +
+

+ + Technical Details +

+ +
+
+ + {editMode ? ( + setEditedData({...editedData, modelNumber: e.target.value})} + className="mt-1" + /> + ) : ( +

+ {device.modelNumber || '-'} +

+ )} +
+ +
+ + {editMode ? ( + setEditedData({...editedData, macAddress: e.target.value})} + className="mt-1" + /> + ) : ( +

+ {device.macAddress || '-'} +

+ )} +
+ +
+ +

+ {device.installDate ? + format(new Date(device.installDate), 'MMM d, yyyy') : + '-'} +

+
+ +
+ +

+ {device.warrantyExpirationDate ? + format(new Date(device.warrantyExpirationDate), 'MMM d, yyyy') : + '-'} +

+
+ +
+ +

+ {device.rmmDeviceUID || '-'} +

+
+
+
+ + {/* Notes */} +
+

Notes

+ {editMode ? ( +