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;
|
companyName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Classification {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
isActive: boolean;
|
||||||
|
isSystem: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface KioskSettings {
|
interface KioskSettings {
|
||||||
excluded_company_ids: number[];
|
excluded_company_ids: number[];
|
||||||
excluded_classifications: string[];
|
excluded_classifications: string[];
|
||||||
|
|
@ -28,13 +36,16 @@ export default function KioskSettingsPage() {
|
||||||
show_rmm_alerts: false,
|
show_rmm_alerts: false,
|
||||||
});
|
});
|
||||||
const [companies, setCompanies] = useState<Company[]>([]);
|
const [companies, setCompanies] = useState<Company[]>([]);
|
||||||
|
const [classifications, setClassifications] = useState<Classification[]>([]);
|
||||||
const [selectedCompanyId, setSelectedCompanyId] = useState<string>('');
|
const [selectedCompanyId, setSelectedCompanyId] = useState<string>('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
fetchCompanies();
|
fetchCompanies();
|
||||||
|
fetchClassifications();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchSettings = async () => {
|
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 = () => {
|
const handleAddExcludedCompany = () => {
|
||||||
if (selectedCompanyId && !settings.excluded_company_ids.includes(parseInt(selectedCompanyId))) {
|
if (selectedCompanyId && !settings.excluded_company_ids.includes(parseInt(selectedCompanyId))) {
|
||||||
setSettings({
|
setSettings({
|
||||||
|
|
@ -248,90 +293,57 @@ export default function KioskSettingsPage() {
|
||||||
|
|
||||||
{/* Excluded Classifications Section */}
|
{/* Excluded Classifications Section */}
|
||||||
<div className="bg-gray-900 rounded-lg p-6 border border-gray-800">
|
<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">
|
<p className="text-gray-400 mb-4">
|
||||||
Tickets from companies with these classifications will not appear in the kiosk display.
|
Tickets from companies with these classifications will not appear in the kiosk display.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Common Classifications */}
|
{/* Classifications from Database */}
|
||||||
<div className="space-y-2 mb-4">
|
{classifications.length === 0 ? (
|
||||||
{['Tools Only', 'Wulff Consulting Client', 'Customer'].map(classification => (
|
<div className="text-center py-8 text-gray-500">
|
||||||
<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">
|
<p className="mb-2">No classifications found.</p>
|
||||||
<input
|
<p className="text-sm">Click "Sync from Autotask" to load classification icons.</p>
|
||||||
type="checkbox"
|
|
||||||
checked={settings.excluded_classifications.includes(classification)}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
setSettings({
|
|
||||||
...settings,
|
|
||||||
excluded_classifications: [...settings.excluded_classifications, classification],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setSettings({
|
|
||||||
...settings,
|
|
||||||
excluded_classifications: settings.excluded_classifications.filter(c => c !== classification),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-5 h-5 rounded border-gray-600 text-blue-600 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<span className="flex-1">{classification}</span>
|
|
||||||
</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>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-1">Press Enter to add</p>
|
) : (
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
|
{classifications.map(classification => (
|
||||||
{/* Custom Classifications List */}
|
<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">
|
||||||
{settings.excluded_classifications.filter(c => !['Tools Only', 'Wulff Consulting Client', 'Customer'].includes(c)).length > 0 && (
|
<input
|
||||||
<div className="mt-4 space-y-2">
|
type="checkbox"
|
||||||
<div className="text-sm font-medium text-gray-400">Custom classifications:</div>
|
checked={settings.excluded_classifications.includes(classification.name)}
|
||||||
{settings.excluded_classifications
|
onChange={(e) => {
|
||||||
.filter(c => !['Tools Only', 'Wulff Consulting Client', 'Customer'].includes(c))
|
if (e.target.checked) {
|
||||||
.map(classification => (
|
setSettings({
|
||||||
<div
|
...settings,
|
||||||
key={classification}
|
excluded_classifications: [...settings.excluded_classifications, classification.name],
|
||||||
className="flex items-center justify-between bg-gray-800 rounded px-4 py-3 border border-gray-700"
|
});
|
||||||
>
|
} else {
|
||||||
<span>{classification}</span>
|
setSettings({
|
||||||
<Button
|
...settings,
|
||||||
variant="ghost"
|
excluded_classifications: settings.excluded_classifications.filter(c => c !== classification.name),
|
||||||
size="sm"
|
});
|
||||||
onClick={() => setSettings({
|
}
|
||||||
...settings,
|
}}
|
||||||
excluded_classifications: settings.excluded_classifications.filter(c => c !== classification),
|
className="w-5 h-5 rounded border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||||
})}
|
/>
|
||||||
className="text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
<div className="flex-1">
|
||||||
>
|
<div className="font-medium">{classification.name}</div>
|
||||||
<Trash2 className="w-4 h-4" />
|
{classification.description && (
|
||||||
</Button>
|
<div className="text-xs text-gray-500">{classification.description}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</label>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -486,6 +486,30 @@ export class AutotaskClient {
|
||||||
async deleteTimeEntry(id: number): Promise<void> {
|
async deleteTimeEntry(id: number): Promise<void> {
|
||||||
return this.deleteEntity('TimeEntries', id);
|
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
|
// 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