From b222d972250c35a47ba6298c58a88b4d9396284e Mon Sep 17 00:00:00 2001 From: lorentz Date: Tue, 3 Feb 2026 20:48:08 -0500 Subject: [PATCH] feat: add classification icons sync from Autotask - Created company_classifications table to store Autotask classification icons - Added getClassificationIcons() method to AutotaskClient - Created /api/sync/classifications endpoint (GET/POST) - Updated kiosk settings UI to dynamically load classifications - Added 'Sync from Autotask' button to pull latest classifications - Removed hardcoded classification list - Display classification name and description in checkboxes - Allow excluding any classification synced from Autotask --- app/api/sync/classifications/route.ts | 117 ++++++++++++ app/kiosk/settings/page.tsx | 168 ++++++++++-------- lib/services/autotask-client.ts | 24 +++ ...1_create_company_classifications_table.sql | 22 +++ 4 files changed, 253 insertions(+), 78 deletions(-) create mode 100644 app/api/sync/classifications/route.ts create mode 100644 migrations/021_create_company_classifications_table.sql diff --git a/app/api/sync/classifications/route.ts b/app/api/sync/classifications/route.ts new file mode 100644 index 0000000..4e5fb48 --- /dev/null +++ b/app/api/sync/classifications/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { postgresClient } from '@/lib/services/postgres-client'; +import { getAutotaskClient } from '@/lib/services/autotask-factory'; + +export async function POST(request: NextRequest) { + try { + console.log('Starting classification icons sync...'); + + // Fetch classification icons from Autotask API + const autotaskClient = getAutotaskClient(); + const classifications = await autotaskClient.getClassificationIcons(); + + if (!classifications || classifications.length === 0) { + return NextResponse.json( + { error: 'No classifications found in Autotask' }, + { status: 404 } + ); + } + + console.log(`Fetched ${classifications.length} classification icons from Autotask`); + + let inserted = 0; + let updated = 0; + + // Upsert each classification + for (const classification of classifications) { + const result = await postgresClient.query( + `INSERT INTO company_classifications ( + classification_id, + name, + description, + is_active, + is_system, + updated_at, + synced_at + ) VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (classification_id) + DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + is_active = EXCLUDED.is_active, + is_system = EXCLUDED.is_system, + updated_at = CURRENT_TIMESTAMP, + synced_at = CURRENT_TIMESTAMP + RETURNING (xmax = 0) AS inserted`, + [ + classification.id, + classification.name, + classification.description || null, + classification.isActive !== false, + classification.isSystem || false + ] + ); + + if (result.rows[0]?.inserted) { + inserted++; + } else { + updated++; + } + } + + console.log(`Classification sync complete: ${inserted} inserted, ${updated} updated`); + + return NextResponse.json({ + success: true, + total: classifications.length, + inserted, + updated, + message: `Synced ${classifications.length} classification icons` + }); + + } catch (error) { + console.error('Error syncing classifications:', error); + return NextResponse.json( + { + error: 'Failed to sync classifications', + details: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ); + } +} + +export async function GET(request: NextRequest) { + try { + const result = await postgresClient.query( + `SELECT + classification_id, + name, + description, + is_active, + is_system, + synced_at + FROM company_classifications + WHERE is_active = true + ORDER BY name` + ); + + return NextResponse.json({ + classifications: result.rows.map(row => ({ + id: row.classification_id, + name: row.name, + description: row.description, + isActive: row.is_active, + isSystem: row.is_system, + syncedAt: row.synced_at + })) + }); + + } catch (error) { + console.error('Error fetching classifications:', error); + return NextResponse.json( + { error: 'Failed to fetch classifications' }, + { status: 500 } + ); + } +} diff --git a/app/kiosk/settings/page.tsx b/app/kiosk/settings/page.tsx index 156ddd1..ddb7e61 100644 --- a/app/kiosk/settings/page.tsx +++ b/app/kiosk/settings/page.tsx @@ -10,6 +10,14 @@ interface Company { companyName: string; } +interface Classification { + id: number; + name: string; + description: string; + isActive: boolean; + isSystem: boolean; +} + interface KioskSettings { excluded_company_ids: number[]; excluded_classifications: string[]; @@ -28,13 +36,16 @@ export default function KioskSettingsPage() { show_rmm_alerts: false, }); const [companies, setCompanies] = useState([]); + const [classifications, setClassifications] = useState([]); const [selectedCompanyId, setSelectedCompanyId] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [syncing, setSyncing] = useState(false); useEffect(() => { fetchSettings(); fetchCompanies(); + fetchClassifications(); }, []); const fetchSettings = async () => { @@ -73,6 +84,40 @@ export default function KioskSettingsPage() { } }; + const fetchClassifications = async () => { + try { + const res = await fetch('/api/sync/classifications'); + if (res.ok) { + const data = await res.json(); + setClassifications(data.classifications || []); + } + } catch (error) { + console.error('Error fetching classifications:', error); + } + }; + + const syncClassifications = async () => { + setSyncing(true); + try { + const res = await fetch('/api/sync/classifications', { + method: 'POST', + }); + if (res.ok) { + const data = await res.json(); + alert(`Synced ${data.total} classifications from Autotask`); + await fetchClassifications(); + } else { + const error = await res.json(); + alert(`Failed to sync: ${error.error || 'Unknown error'}`); + } + } catch (error) { + console.error('Error syncing classifications:', error); + alert('Failed to sync classifications'); + } finally { + setSyncing(false); + } + }; + const handleAddExcludedCompany = () => { if (selectedCompanyId && !settings.excluded_company_ids.includes(parseInt(selectedCompanyId))) { setSettings({ @@ -248,90 +293,57 @@ export default function KioskSettingsPage() { {/* Excluded Classifications Section */}
-

Excluded Company Classifications

+
+

Excluded Company Classifications

+ +

Tickets from companies with these classifications will not appear in the kiosk display.

- {/* Common Classifications */} -
- {['Tools Only', 'Wulff Consulting Client', 'Customer'].map(classification => ( - - ))} -
- - {/* Custom Classification Input */} -
- -
- { - if (e.key === 'Enter') { - const value = (e.target as HTMLInputElement).value.trim(); - if (value && !settings.excluded_classifications.includes(value)) { - setSettings({ - ...settings, - excluded_classifications: [...settings.excluded_classifications, value], - }); - (e.target as HTMLInputElement).value = ''; - } - } - }} - /> + {/* Classifications from Database */} + {classifications.length === 0 ? ( +
+

No classifications found.

+

Click "Sync from Autotask" to load classification icons.

-

Press Enter to add

-
- - {/* Custom Classifications List */} - {settings.excluded_classifications.filter(c => !['Tools Only', 'Wulff Consulting Client', 'Customer'].includes(c)).length > 0 && ( -
-
Custom classifications:
- {settings.excluded_classifications - .filter(c => !['Tools Only', 'Wulff Consulting Client', 'Customer'].includes(c)) - .map(classification => ( -
- {classification} - + ) : ( +
+ {classifications.map(classification => ( + + ))}
)}
diff --git a/lib/services/autotask-client.ts b/lib/services/autotask-client.ts index 1d411a6..b944277 100644 --- a/lib/services/autotask-client.ts +++ b/lib/services/autotask-client.ts @@ -486,6 +486,30 @@ export class AutotaskClient { async deleteTimeEntry(id: number): Promise { return this.deleteEntity('TimeEntries', id); } + + // Classification Icons methods + async getClassificationIcons(): Promise { + // Autotask API endpoint for classification icons + const url = `${this.config.apiUrl}/CompanyClassificationIcons`; + + const response = await this.makeApiCall(url, { + method: 'GET', + headers: this.getAuthHeaders(), + }); + + return response.items || []; + } + + async getFieldInfo(entityName: string): Promise { + const url = `${this.config.apiUrl}/${entityName}/entityInformation/fields`; + + const response = await this.makeApiCall(url, { + method: 'GET', + headers: this.getAuthHeaders(), + }); + + return response.fields || []; + } } // Rate Limiter class diff --git a/migrations/021_create_company_classifications_table.sql b/migrations/021_create_company_classifications_table.sql new file mode 100644 index 0000000..261c120 --- /dev/null +++ b/migrations/021_create_company_classifications_table.sql @@ -0,0 +1,22 @@ +-- Create company_classifications table to store Autotask classification icons +CREATE TABLE IF NOT EXISTS company_classifications ( + id SERIAL PRIMARY KEY, + classification_id INTEGER UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + is_active BOOLEAN DEFAULT true, + is_system BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + synced_at TIMESTAMP +); + +-- Create index for faster lookups +CREATE INDEX IF NOT EXISTS idx_company_classifications_name ON company_classifications(name); +CREATE INDEX IF NOT EXISTS idx_company_classifications_active ON company_classifications(is_active); + +-- Add comment +COMMENT ON TABLE company_classifications IS 'Company classification icons synced from Autotask'; +COMMENT ON COLUMN company_classifications.classification_id IS 'Autotask classification icon ID'; +COMMENT ON COLUMN company_classifications.name IS 'Classification name (e.g., Tools Only, Co-Managed)'; +COMMENT ON COLUMN company_classifications.is_system IS 'Whether this is a system classification';