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:
parent
4d531b2f2d
commit
20a0785ec3
9 changed files with 692 additions and 0 deletions
44
app/api/kiosk/activity/route.ts
Normal file
44
app/api/kiosk/activity/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
140
app/api/kiosk/stats/route.ts
Normal file
140
app/api/kiosk/stats/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
120
app/kiosk/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue