feat: add executive kiosk dashboard for TV display

- Created /kiosk route with full-screen display
- Added API endpoints for stats and activity feed
- Built cycling KPI display with 12 metrics
- Added scrolling ticker for ticket activity
- Implemented UI-configurable cycle interval (3-15s, default 7s)
- Focus on critical tickets and business metrics
- Added navigation link from dashboard
- Dark theme optimized for TV viewing
This commit is contained in:
root 2026-02-02 23:36:35 -05:00
parent 4d531b2f2d
commit 20a0785ec3
9 changed files with 692 additions and 0 deletions

View file

@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
try {
// Get recent ticket activity for ticker feed
const activityResult = await postgresClient.query(
`SELECT
t.ticket_number,
t.title,
t.priority,
t.status,
t.create_date,
t.last_activity_date,
c.company_name,
s.label as status_label
FROM tickets t
LEFT JOIN companies c ON t.company_id = c.id
LEFT JOIN statuses s ON t.status = s.value
WHERE t.completed_date IS NULL
ORDER BY t.last_activity_date DESC NULLS LAST
LIMIT 50`
);
const activities = activityResult.rows.map((row: any) => ({
ticketNumber: row.ticket_number,
title: row.title,
priority: row.priority,
status: row.status,
statusLabel: row.status_label,
companyName: row.company_name,
createDate: row.create_date,
lastActivityDate: row.last_activity_date,
}));
return NextResponse.json({ activities });
} catch (error) {
console.error('Error fetching kiosk activity:', error);
return NextResponse.json(
{ error: 'Failed to fetch kiosk activity' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,140 @@
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
try {
// Critical tickets (Priority 1-3)
const criticalTicketsResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets
WHERE completed_date IS NULL
AND priority <= 3`
);
// Tickets waiting engagement (specific statuses)
const waitingTicketsResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets
WHERE completed_date IS NULL
AND status IN (21, 9, 19)`
);
// Stale tickets (no activity in 7+ days)
const staleTicketsResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets
WHERE completed_date IS NULL
AND last_activity_date < NOW() - INTERVAL '7 days'`
);
// Overdue tickets
const overdueTicketsResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets
WHERE completed_date IS NULL
AND due_date_time < NOW()`
);
// Total open tickets
const openTicketsResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets
WHERE completed_date IS NULL`
);
// Tickets closed today
const closedTodayResult = await postgresClient.query(
`SELECT COUNT(*) as count
FROM tickets
WHERE DATE(completed_date) = CURRENT_DATE`
);
// Average resolution time (last 30 days)
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`
);
// Time entries this week
const timeEntriesResult = await postgresClient.query(
`SELECT COALESCE(SUM(hours_worked), 0) as hours
FROM time_entries
WHERE date_worked >= 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'),
waitingTickets: parseInt(waitingTicketsResult.rows[0]?.count || '0'),
staleTickets: parseInt(staleTicketsResult.rows[0]?.count || '0'),
overdueTickets: parseInt(overdueTicketsResult.rows[0]?.count || '0'),
openTickets: parseInt(openTicketsResult.rows[0]?.count || '0'),
closedToday: parseInt(closedTodayResult.rows[0]?.count || '0'),
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 }
);
}
}

View file

@ -101,6 +101,14 @@ export default function DashboardPage() {
color: 'blue',
stats: `${stats.configurationItems.active} active items`
},
{
title: 'Kiosk Display',
description: 'Full-screen executive dashboard for TV display',
href: '/kiosk',
icon: Activity,
color: 'blue',
stats: 'Live metrics display'
},
{
title: 'Sync Management',
description: 'Synchronize data from external systems',

120
app/kiosk/page.tsx Normal file
View file

@ -0,0 +1,120 @@
'use client';
import { useEffect, useState } from 'react';
import { CyclingDisplay } from '@/components/kiosk/cycling-display';
import { Ticker } from '@/components/kiosk/ticker';
import { SettingsPanel } from '@/components/kiosk/settings-panel';
interface KpiStats {
criticalTickets: number;
waitingTickets: number;
staleTickets: number;
overdueTickets: number;
openTickets: number;
closedToday: number;
avgResolutionHours: number;
hoursThisWeek: number;
activeCompanies: number;
openQuotes: number;
nmsCoverage: number;
rmmCoverage: number;
}
interface TickerActivity {
ticketNumber: string;
title: string;
priority: number;
statusLabel: string;
companyName: string;
}
export default function KioskPage() {
const [stats, setStats] = useState<KpiStats>({
criticalTickets: 0,
waitingTickets: 0,
staleTickets: 0,
overdueTickets: 0,
openTickets: 0,
closedToday: 0,
avgResolutionHours: 0,
hoursThisWeek: 0,
activeCompanies: 0,
openQuotes: 0,
nmsCoverage: 0,
rmmCoverage: 0,
});
const [activities, setActivities] = useState<TickerActivity[]>([]);
const [cycleInterval, setCycleInterval] = useState(7);
useEffect(() => {
// Load saved cycle interval from localStorage
const savedInterval = localStorage.getItem('kiosk-cycle-interval');
if (savedInterval) {
setCycleInterval(parseInt(savedInterval));
}
}, []);
useEffect(() => {
// Fetch initial data
fetchData();
// Set up auto-refresh every 60 seconds
const statsInterval = setInterval(fetchData, 60000);
return () => {
clearInterval(statsInterval);
};
}, []);
const fetchData = async () => {
try {
// Fetch stats
const statsRes = await fetch('/api/kiosk/stats');
if (statsRes.ok) {
const statsData = await statsRes.json();
setStats(statsData);
}
// Fetch activity feed
const activityRes = await fetch('/api/kiosk/activity');
if (activityRes.ok) {
const activityData = await activityRes.json();
setActivities(activityData.activities || []);
}
} catch (error) {
console.error('Error fetching kiosk data:', error);
}
};
return (
<div className="h-screen w-screen bg-black text-white overflow-hidden flex flex-col">
{/* Settings Panel */}
<SettingsPanel
cycleInterval={cycleInterval}
onIntervalChange={setCycleInterval}
/>
{/* Main Content Area */}
<CyclingDisplay stats={stats} cycleInterval={cycleInterval} />
{/* Ticker */}
<Ticker activities={activities} />
{/* Custom CSS for ticker animation */}
<style jsx global>{`
@keyframes ticker {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
.animate-ticker {
animation: ticker 60s linear infinite;
}
`}</style>
</div>
);
}