wulf-pulse/app/api/kiosk/stats/route.ts
root 1d99a8f659 feat: add classification-based filtering and fix company name display
- Added classification column to companies table
- Created excluded_classifications setting (default: Tools Only)
- Updated kiosk stats and activity APIs to filter by classification
- Added comprehensive classification filtering UI with checkboxes
- Support for common classifications (Tools Only, Wulff Consulting Client, Customer)
- Allow custom classification entry
- Fixed company name display in settings (convert string IDs properly)
- Classification filtering works alongside company ID exclusions
2026-02-03 09:17:07 -05:00

287 lines
10 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
async function getExcludedCompanyIds(): Promise<number[]> {
try {
const result = await postgresClient.query(
`SELECT setting_value FROM kiosk_settings WHERE setting_key = 'excluded_company_ids'`
);
const value = result.rows[0]?.setting_value || '';
return value ? value.split(',').map((id: string) => parseInt(id.trim())).filter((id: number) => !isNaN(id)) : [];
} catch (error) {
console.error('Error fetching excluded company IDs:', error);
return [];
}
}
async function getExcludedClassifications(): Promise<string[]> {
try {
const result = await postgresClient.query(
`SELECT setting_value FROM kiosk_settings WHERE setting_key = 'excluded_classifications'`
);
const value = result.rows[0]?.setting_value || '';
return value ? value.split(',').map((c: string) => c.trim()).filter(Boolean) : [];
} catch (error) {
console.error('Error fetching excluded classifications:', error);
return [];
}
}
export async function GET(request: NextRequest) {
try {
// Get excluded company IDs (co-managed clients)
const excludedCompanyIds = await getExcludedCompanyIds();
const excludedClassifications = await getExcludedClassifications();
// Build company exclusion filter
let excludeCompanyFilter = '';
if (excludedCompanyIds.length > 0 || excludedClassifications.length > 0) {
const conditions = [];
if (excludedCompanyIds.length > 0) {
conditions.push(`company_id NOT IN (${excludedCompanyIds.join(',')})`);
}
if (excludedClassifications.length > 0) {
const classificationList = excludedClassifications.map(c => `'${c.replace(/'/g, "''")}'`).join(',');
conditions.push(`company_id NOT IN (SELECT id FROM companies WHERE classification IN (${classificationList}))`);
}
excludeCompanyFilter = `AND (${conditions.join(' AND ')})`;
}
// Critical tickets (Priority 1-3) - exclude RMM alerts (source = 8), co-managed clients, and excluded classifications
const criticalTicketsResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets
WHERE completed_date IS NULL
AND priority <= 3
AND (source IS NULL OR source != 8)
${excludeCompanyFilter}`
);
// Top 3 critical tickets
const topCriticalResult = await postgresClient.query(
`SELECT t.ticket_number, t.title, c.company_name, t.priority
FROM tickets t
LEFT JOIN companies c ON t.company_id = c.id
WHERE t.completed_date IS NULL
AND t.priority <= 3
AND (t.source IS NULL OR t.source != 8)
${excludeCompanyFilter}
ORDER BY t.priority ASC, t.create_date ASC
LIMIT 3`
);
// Tickets waiting engagement (specific statuses) - exclude RMM alerts
const waitingTicketsResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets
WHERE completed_date IS NULL
AND status IN (21, 9, 19)
AND (source IS NULL OR source != 8)
${excludeCompanyFilter}`
);
// Top 3 waiting tickets
const topWaitingResult = await postgresClient.query(
`SELECT t.ticket_number, t.title, c.company_name, t.last_activity_date
FROM tickets t
LEFT JOIN companies c ON t.company_id = c.id
WHERE t.completed_date IS NULL
AND t.status IN (21, 9, 19)
AND (t.source IS NULL OR t.source != 8)
${excludeCompanyFilter}
ORDER BY t.last_activity_date ASC NULLS FIRST
LIMIT 3`
);
// Stale tickets (no activity in 7+ days) - exclude RMM alerts
const staleTicketsResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets
WHERE completed_date IS NULL
AND last_activity_date < NOW() - INTERVAL '7 days'
AND (source IS NULL OR source != 8)
${excludeCompanyFilter}`
);
// Top 3 stale tickets (oldest activity first)
const topStaleResult = await postgresClient.query(
`SELECT t.ticket_number, t.title, c.company_name, t.last_activity_date
FROM tickets t
LEFT JOIN companies c ON t.company_id = c.id
WHERE t.completed_date IS NULL
AND t.last_activity_date < NOW() - INTERVAL '7 days'
AND (t.source IS NULL OR t.source != 8)
${excludeCompanyFilter}
ORDER BY t.last_activity_date ASC NULLS FIRST
LIMIT 3`
);
// Overdue tickets - exclude RMM alerts
const overdueTicketsResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets
WHERE completed_date IS NULL
AND due_date_time < NOW()
AND (source IS NULL OR source != 8)
${excludeCompanyFilter}`
);
// Top 3 overdue tickets (most overdue first)
const topOverdueResult = await postgresClient.query(
`SELECT t.ticket_number, t.title, c.company_name, t.due_date_time
FROM tickets t
LEFT JOIN companies c ON t.company_id = c.id
WHERE t.completed_date IS NULL
AND t.due_date_time < NOW()
AND (t.source IS NULL OR t.source != 8)
${excludeCompanyFilter}
ORDER BY t.due_date_time ASC
LIMIT 3`
);
// Total open tickets - exclude RMM alerts
const openTicketsResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets
WHERE completed_date IS NULL
AND (source IS NULL OR source != 8)
${excludeCompanyFilter}`
);
// Tickets closed today - exclude RMM alerts
const closedTodayResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets
WHERE DATE(completed_date) = CURRENT_DATE
AND (source IS NULL OR source != 8)
${excludeCompanyFilter}`
);
// Top 3 recently closed tickets
const topClosedResult = await postgresClient.query(
`SELECT t.ticket_number, t.title, c.company_name, t.completed_date
FROM tickets t
LEFT JOIN companies c ON t.company_id = c.id
WHERE DATE(t.completed_date) = CURRENT_DATE
AND (t.source IS NULL OR t.source != 8)
${excludeCompanyFilter}
ORDER BY t.completed_date DESC
LIMIT 3`
);
// Average resolution time (last 30 days) - exclude RMM alerts
const avgResolutionResult = await postgresClient.query(
`SELECT AVG(EXTRACT(EPOCH FROM (completed_date - create_date))/3600) as avg_hours
FROM tickets
WHERE completed_date >= NOW() - INTERVAL '30 days'
AND completed_date IS NOT NULL
AND (source IS NULL OR source != 8)
${excludeCompanyFilter}`
);
// Time entries this week
const timeEntriesResult = await postgresClient.query(
`SELECT COALESCE(SUM(hours_worked), 0) as hours
FROM time_entries
WHERE entry_date >= DATE_TRUNC('week', CURRENT_DATE)`
);
// Active companies
const companiesResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM companies
WHERE is_active = true AND company_type = 1`
);
// Quotes (if table exists)
let quotesOpen = 0;
try {
const quotesResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM quotes
WHERE status = 'open'`
);
quotesOpen = parseInt(quotesResult.rows[0]?.count || '0');
} catch {
// Quotes table may not exist
}
// NMS Coverage
let nmsPercentage = 0;
try {
const nmsResult = await postgresClient.query(
`SELECT
(SELECT COUNT(*) FROM auvik_tenant_mappings WHERE autotask_company_id IS NOT NULL) as mapped,
(SELECT COUNT(*) FROM auvik_tenants) as total`
);
const mapped = parseInt(nmsResult.rows[0]?.mapped || '0');
const total = parseInt(nmsResult.rows[0]?.total || '0');
nmsPercentage = total > 0 ? Math.round((mapped / total) * 100) : 0;
} catch {
// Tables may not exist
}
// RMM Coverage
let rmmPercentage = 0;
try {
const rmmResult = await postgresClient.query(
`SELECT
(SELECT COUNT(*) FROM rmm_site_mappings WHERE company_id IS NOT NULL) as mapped,
(SELECT COUNT(*) FROM rmm_sites) as total`
);
const mapped = parseInt(rmmResult.rows[0]?.mapped || '0');
const total = parseInt(rmmResult.rows[0]?.total || '0');
rmmPercentage = total > 0 ? Math.round((mapped / total) * 100) : 0;
} catch {
// Tables may not exist
}
const stats = {
criticalTickets: parseInt(criticalTicketsResult.rows[0]?.count || '0'),
topCritical: topCriticalResult.rows.map((r: any) => ({
ticketNumber: r.ticket_number,
title: r.title,
companyName: r.company_name,
})),
waitingTickets: parseInt(waitingTicketsResult.rows[0]?.count || '0'),
topWaiting: topWaitingResult.rows.map((r: any) => ({
ticketNumber: r.ticket_number,
title: r.title,
companyName: r.company_name,
})),
staleTickets: parseInt(staleTicketsResult.rows[0]?.count || '0'),
topStale: topStaleResult.rows.map((r: any) => ({
ticketNumber: r.ticket_number,
title: r.title,
companyName: r.company_name,
})),
overdueTickets: parseInt(overdueTicketsResult.rows[0]?.count || '0'),
topOverdue: topOverdueResult.rows.map((r: any) => ({
ticketNumber: r.ticket_number,
title: r.title,
companyName: r.company_name,
})),
openTickets: parseInt(openTicketsResult.rows[0]?.count || '0'),
closedToday: parseInt(closedTodayResult.rows[0]?.count || '0'),
topClosed: topClosedResult.rows.map((r: any) => ({
ticketNumber: r.ticket_number,
title: r.title,
companyName: r.company_name,
})),
avgResolutionHours: parseFloat(avgResolutionResult.rows[0]?.avg_hours || '0'),
hoursThisWeek: parseFloat(timeEntriesResult.rows[0]?.hours || '0'),
activeCompanies: parseInt(companiesResult.rows[0]?.count || '0'),
openQuotes: quotesOpen,
nmsCoverage: nmsPercentage,
rmmCoverage: rmmPercentage,
};
return NextResponse.json(stats);
} catch (error) {
console.error('Error fetching kiosk stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch kiosk stats' },
{ status: 500 }
);
}
}