wulf-pulse/app/api/kiosk/stats/route.ts
lorentz 07067bef19 feat: Display Settings UI + Company Category/Type sync
- Add /admin/display-settings page with Kiosk and Mobile sections
- Company category checkbox filter + excluded companies searchable multi-select
- New DB tables: company_categories, company_types (migration 064)
- Sync COMPANY_CATEGORIES via CompanyCategories entity (id/name/isActive)
- Sync COMPANY_TYPES via Companies.companyType picklist
- Add to EntityType, ENTITY_DEPENDENCIES, sync-helpers, entity-mapper, entity-sync
- New API routes: /api/admin/display-settings (GET/POST), /api/data/company-categories, /api/data/companies-list
- Update all 4 routes (kiosk/stats, kiosk/activity, mobile/tickets, mobile/dashboard)
  to filter by kiosk_settings company_category_ids + excluded_company_ids
- Add Display Settings nav link (SlidersHorizontal icon) to Admin menu
- Seed kiosk_settings: kiosk_company_category_ids=1, mobile_company_category_ids=1
2026-04-06 09:03:19 -04:00

439 lines
16 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
async function getKioskCompanyFilter(): Promise<string> {
try {
const result = await postgresClient.query(
`SELECT setting_key, setting_value FROM kiosk_settings WHERE setting_key IN ('kiosk_company_category_ids', 'kiosk_excluded_company_ids')`
);
const map: Record<string, string> = {};
result.rows.forEach((r: any) => { map[r.setting_key] = r.setting_value || ''; });
const catIds = (map['kiosk_company_category_ids'] || '1')
.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n));
const exclIds = (map['kiosk_excluded_company_ids'] || '')
.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n));
const catFilter = catIds.length > 0
? `company_id IN (SELECT id FROM companies WHERE company_category_id IN (${catIds.join(',')}))`
: 'true';
const exclFilter = exclIds.length > 0
? `company_id NOT IN (${exclIds.join(',')})`
: '';
return [catFilter, exclFilter].filter(Boolean).join(' AND ');
} catch (error) {
console.error('Error fetching kiosk company filter:', error);
return 'company_id IN (SELECT id FROM companies WHERE company_category_id = 1)';
}
}
export async function GET(request: NextRequest) {
try {
const companyFilter = await getKioskCompanyFilter();
const excludeCompanyFilter = `AND (${companyFilter})`;
// 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 is_deleted = false
AND priority <= 3
AND (source IS NULL OR source != 8)
${excludeCompanyFilter}`
);
// Top 10 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.is_deleted = false
AND t.priority <= 3
AND (t.source IS NULL OR t.source != 8)
${excludeCompanyFilter}
ORDER BY t.priority ASC, t.create_date ASC
LIMIT 10`
);
// 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 is_deleted = false
AND status IN (21, 9, 19)
AND (source IS NULL OR source != 8)
${excludeCompanyFilter}`
);
// Top 10 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.is_deleted = false
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 10`
);
// 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 is_deleted = false
AND last_activity_date < NOW() - INTERVAL '7 days'
AND (source IS NULL OR source != 8)
${excludeCompanyFilter}`
);
// Top 10 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.is_deleted = false
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 10`
);
// Overdue tickets - exclude RMM alerts
const overdueTicketsResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets
WHERE completed_date IS NULL
AND is_deleted = false
AND due_date_time < NOW()
AND (source IS NULL OR source != 8)
${excludeCompanyFilter}`
);
// Top 10 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.is_deleted = false
AND t.due_date_time < NOW()
AND (t.source IS NULL OR t.source != 8)
${excludeCompanyFilter}
ORDER BY t.due_date_time ASC
LIMIT 10`
);
// Total open tickets - exclude RMM alerts
const openTicketsResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets
WHERE completed_date IS NULL
AND is_deleted = false
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 is_deleted = false
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.is_deleted = false
AND (t.source IS NULL OR t.source != 8)
${excludeCompanyFilter}
ORDER BY t.completed_date DESC
LIMIT 3`
);
// Average closed tickets per day last week
const avgClosedLastWeekResult = await postgresClient.query(
`SELECT ROUND(COUNT(*)::numeric / 7, 1) as avg_per_day
FROM tickets
WHERE completed_date >= NOW() - INTERVAL '7 days'
AND completed_date < NOW()
AND is_deleted = false
AND (source IS NULL OR source != 8)
${excludeCompanyFilter}`
);
// Average closed tickets per day last month
const avgClosedLastMonthResult = await postgresClient.query(
`SELECT ROUND(COUNT(*)::numeric / 30, 1) as avg_per_day
FROM tickets
WHERE completed_date >= NOW() - INTERVAL '30 days'
AND completed_date < NOW()
AND is_deleted = false
AND (source IS NULL OR source != 8)
${excludeCompanyFilter}`
);
// 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 is_deleted = false
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)`
);
// Top ticket closers (last 30 days) - using last_activity_resource_id, excluding Autotask system user
// Only count tickets where the resource has at least 10 minutes (0.167 hours) of time worked
const topTicketClosersResult = await postgresClient.query(
`SELECT r.first_name || ' ' || r.last_name as resource_name,
COUNT(DISTINCT t.id) as tickets_closed
FROM tickets t
INNER JOIN resources r ON t.last_activity_resource_id = r.id
INNER JOIN (
SELECT ticket_id, resource_id, SUM(hours_worked) as total_hours
FROM time_entries
GROUP BY ticket_id, resource_id
HAVING SUM(hours_worked) >= 0.167
) te ON t.id = te.ticket_id AND r.id = te.resource_id
WHERE t.completed_date >= NOW() - INTERVAL '30 days'
AND t.completed_date IS NOT NULL
AND t.is_deleted = false
AND (t.source IS NULL OR t.source != 8)
AND r.id != 4
${excludeCompanyFilter}
GROUP BY r.id, r.first_name, r.last_name
ORDER BY tickets_closed DESC
LIMIT 10`
);
// 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
}
// Service Desk - Managed (using primary service desk queues, excluding vendor and alerts)
const serviceDeskManagedResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets t
WHERE t.completed_date IS NULL
AND t.is_deleted = false
AND t.status NOT IN (21, 9, 19)
AND t.ticket_type != 4
AND t.queue_id IN (29682833, 29749490)
AND (t.ticket_category IS NULL OR t.ticket_category != 171)
AND (t.source IS NULL OR t.source != 8)
${excludeCompanyFilter}`
);
// Top Issue/Sub-Issue types for Managed Service tickets
const topManagedIssueTypesResult = await postgresClient.query(
`SELECT
COALESCE(it.label, 'Not Set') as issue_type,
COALESCE(sit.label, 'Not Set') as sub_issue_type,
COUNT(*) as count
FROM tickets t
LEFT JOIN issue_types it ON t.issue_type = it.value
LEFT JOIN sub_issue_types sit ON t.sub_issue_type = sit.value
WHERE t.completed_date IS NULL
AND t.is_deleted = false
AND t.status NOT IN (21, 9, 19)
AND t.ticket_type != 4
AND t.queue_id IN (29682833, 29749490)
AND (t.ticket_category IS NULL OR t.ticket_category != 171)
AND (t.source IS NULL OR t.source != 8)
${excludeCompanyFilter}
GROUP BY it.label, sit.label
ORDER BY count DESC
LIMIT 10`
);
// Service Desk - T&M (Level 1/2 Support queues)
const serviceDeskTMResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets t
WHERE t.completed_date IS NULL
AND t.is_deleted = false
AND t.status NOT IN (21, 9, 19)
AND t.ticket_type != 4
AND t.queue_id IN (29853766, 29853700)
AND (t.ticket_category IS NULL OR t.ticket_category != 171)
AND (t.source IS NULL OR t.source != 8)
${excludeCompanyFilter}`
);
// Vendor Service Desk (All)
const serviceDeskVendorResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets t
WHERE t.completed_date IS NULL
AND t.is_deleted = false
AND t.ticket_category = 171
AND (t.source IS NULL OR t.source != 8)
${excludeCompanyFilter}`
);
// Key Accounts - Hynes Industries
const hynesTicketsResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets t
WHERE t.completed_date IS NULL
AND t.is_deleted = false
AND t.company_id = 29861375
AND (t.source IS NULL OR t.source != 8)`
);
// Key Accounts - Seubert and Associates
const seubertTicketsResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets t
WHERE t.completed_date IS NULL
AND t.is_deleted = false
AND t.company_id = 29683407
AND (t.source IS NULL OR t.source != 8)`
);
// Key Accounts - Universal Plastics (both locations)
const universalTicketsResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets t
WHERE t.completed_date IS NULL
AND t.is_deleted = false
AND t.company_id IN (29861395, 29861424)
AND (t.source IS NULL OR t.source != 8)`
);
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'),
avgClosedLastWeek: parseFloat(avgClosedLastWeekResult.rows[0]?.avg_per_day || '0'),
avgClosedLastMonth: parseFloat(avgClosedLastMonthResult.rows[0]?.avg_per_day || '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,
topTicketClosers: topTicketClosersResult.rows.map((r: any) => ({
resourceName: r.resource_name,
ticketsClosed: parseInt(r.tickets_closed),
})),
nmsCoverage: nmsPercentage,
rmmCoverage: rmmPercentage,
serviceDeskManaged: parseInt(serviceDeskManagedResult.rows[0]?.count || '0'),
topManagedIssueTypes: topManagedIssueTypesResult.rows.map((r: any) => ({
issueType: r.issue_type,
subIssueType: r.sub_issue_type,
count: parseInt(r.count),
})),
serviceDeskTM: parseInt(serviceDeskTMResult.rows[0]?.count || '0'),
serviceDeskVendor: parseInt(serviceDeskVendorResult.rows[0]?.count || '0'),
hynesTickets: parseInt(hynesTicketsResult.rows[0]?.count || '0'),
seubertTickets: parseInt(seubertTicketsResult.rows[0]?.count || '0'),
universalTickets: parseInt(universalTicketsResult.rows[0]?.count || '0'),
};
return NextResponse.json(stats);
} catch (error) {
console.error('Error fetching kiosk stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch kiosk stats' },
{ status: 500 }
);
}
}