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:
parent
9940c20379
commit
b222d97225
4 changed files with 253 additions and 78 deletions
117
app/api/sync/classifications/route.ts
Normal file
117
app/api/sync/classifications/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
migrations/021_create_company_classifications_table.sql
Normal file
22
migrations/021_create_company_classifications_table.sql
Normal 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';
|
||||
Loading…
Add table
Add a link
Reference in a new issue