- 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
439 lines
16 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|