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
This commit is contained in:
lorentz 2026-02-03 20:48:08 -05:00
parent 9940c20379
commit b222d97225
4 changed files with 253 additions and 78 deletions

View file

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

View file

@ -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<Company[]>([]);
const [classifications, setClassifications] = useState<Classification[]>([]);
const [selectedCompanyId, setSelectedCompanyId] = useState<string>('');
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,91 +293,58 @@ export default function KioskSettingsPage() {
{/* Excluded Classifications Section */}
<div className="bg-gray-900 rounded-lg p-6 border border-gray-800">
<h2 className="text-xl font-semibold mb-4">Excluded Company Classifications</h2>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Excluded Company Classifications</h2>
<Button
onClick={syncClassifications}
disabled={syncing}
className="bg-green-600 hover:bg-green-700"
size="sm"
>
{syncing ? 'Syncing...' : 'Sync from Autotask'}
</Button>
</div>
<p className="text-gray-400 mb-4">
Tickets from companies with these classifications will not appear in the kiosk display.
</p>
{/* Common Classifications */}
<div className="space-y-2 mb-4">
{['Tools Only', 'Wulff Consulting Client', 'Customer'].map(classification => (
<label key={classification} className="flex items-center gap-3 bg-gray-800 rounded px-4 py-3 border border-gray-700 cursor-pointer hover:bg-gray-750">
{/* Classifications from Database */}
{classifications.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<p className="mb-2">No classifications found.</p>
<p className="text-sm">Click "Sync from Autotask" to load classification icons.</p>
</div>
) : (
<div className="space-y-2">
{classifications.map(classification => (
<label key={classification.id} className="flex items-center gap-3 bg-gray-800 rounded px-4 py-3 border border-gray-700 cursor-pointer hover:bg-gray-750">
<input
type="checkbox"
checked={settings.excluded_classifications.includes(classification)}
checked={settings.excluded_classifications.includes(classification.name)}
onChange={(e) => {
if (e.target.checked) {
setSettings({
...settings,
excluded_classifications: [...settings.excluded_classifications, classification],
excluded_classifications: [...settings.excluded_classifications, classification.name],
});
} else {
setSettings({
...settings,
excluded_classifications: settings.excluded_classifications.filter(c => c !== classification),
excluded_classifications: settings.excluded_classifications.filter(c => c !== classification.name),
});
}
}}
className="w-5 h-5 rounded border-gray-600 text-blue-600 focus:ring-blue-500"
/>
<span className="flex-1">{classification}</span>
<div className="flex-1">
<div className="font-medium">{classification.name}</div>
{classification.description && (
<div className="text-xs text-gray-500">{classification.description}</div>
)}
</div>
</label>
))}
</div>
{/* Custom Classification Input */}
<div className="mt-4">
<label className="block text-sm font-medium mb-2 text-gray-400">
Add custom classification:
</label>
<div className="flex gap-2">
<input
type="text"
placeholder="Enter classification name..."
className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white"
onKeyPress={(e) => {
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 = '';
}
}
}}
/>
</div>
<p className="text-xs text-gray-500 mt-1">Press Enter to add</p>
</div>
{/* Custom Classifications List */}
{settings.excluded_classifications.filter(c => !['Tools Only', 'Wulff Consulting Client', 'Customer'].includes(c)).length > 0 && (
<div className="mt-4 space-y-2">
<div className="text-sm font-medium text-gray-400">Custom classifications:</div>
{settings.excluded_classifications
.filter(c => !['Tools Only', 'Wulff Consulting Client', 'Customer'].includes(c))
.map(classification => (
<div
key={classification}
className="flex items-center justify-between bg-gray-800 rounded px-4 py-3 border border-gray-700"
>
<span>{classification}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSettings({
...settings,
excluded_classifications: settings.excluded_classifications.filter(c => c !== classification),
})}
className="text-red-400 hover:text-red-300 hover:bg-red-900/20"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</div>

View file

@ -486,6 +486,30 @@ export class AutotaskClient {
async deleteTimeEntry(id: number): Promise<void> {
return this.deleteEntity('TimeEntries', id);
}
// Classification Icons methods
async getClassificationIcons(): Promise<any[]> {
// Autotask API endpoint for classification icons
const url = `${this.config.apiUrl}/CompanyClassificationIcons`;
const response = await this.makeApiCall<any>(url, {
method: 'GET',
headers: this.getAuthHeaders(),
});
return response.items || [];
}
async getFieldInfo(entityName: string): Promise<EntityField[]> {
const url = `${this.config.apiUrl}/${entityName}/entityInformation/fields`;
const response = await this.makeApiCall<any>(url, {
method: 'GET',
headers: this.getAuthHeaders(),
});
return response.fields || [];
}
}
// Rate Limiter class

View file

@ -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';