diff --git a/app/api/kiosk/activity/route.ts b/app/api/kiosk/activity/route.ts new file mode 100644 index 0000000..673a232 --- /dev/null +++ b/app/api/kiosk/activity/route.ts @@ -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 } + ); + } +} diff --git a/app/api/kiosk/stats/route.ts b/app/api/kiosk/stats/route.ts new file mode 100644 index 0000000..58dc32b --- /dev/null +++ b/app/api/kiosk/stats/route.ts @@ -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 } + ); + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 9a40fd0..7045910 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -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', diff --git a/app/kiosk/page.tsx b/app/kiosk/page.tsx new file mode 100644 index 0000000..dd7096e --- /dev/null +++ b/app/kiosk/page.tsx @@ -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({ + 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([]); + 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 ( +
+ {/* Settings Panel */} + + + {/* Main Content Area */} + + + {/* Ticker */} + + + {/* Custom CSS for ticker animation */} + +
+ ); +} diff --git a/components/kiosk/cycling-display.tsx b/components/kiosk/cycling-display.tsx new file mode 100644 index 0000000..c1a9199 --- /dev/null +++ b/components/kiosk/cycling-display.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { KpiCard } from './kpi-card'; +import { + AlertTriangle, + Clock, + Timer, + AlertCircle, + TicketCheck, + CheckCircle2, + TrendingUp, + Briefcase, + FileText, + Building2, + Network, + Server +} from 'lucide-react'; + +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 CyclingDisplayProps { + stats: KpiStats; + cycleInterval: number; +} + +export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) { + const [currentIndex, setCurrentIndex] = useState(0); + const [isTransitioning, setIsTransitioning] = useState(false); + + const kpis = [ + { + title: 'Critical Tickets', + value: stats.criticalTickets, + icon: AlertTriangle, + color: stats.criticalTickets > 10 ? 'red' : stats.criticalTickets > 5 ? 'yellow' : 'green', + trend: 'Priority 1-3', + }, + { + title: 'Tickets Waiting Engagement', + value: stats.waitingTickets, + icon: Clock, + color: stats.waitingTickets > 20 ? 'yellow' : 'blue', + trend: 'Awaiting Response', + }, + { + title: 'Stale Tickets', + value: stats.staleTickets, + icon: Timer, + color: stats.staleTickets > 15 ? 'yellow' : 'blue', + trend: '7+ Days No Activity', + }, + { + title: 'Overdue Tickets', + value: stats.overdueTickets, + icon: AlertCircle, + color: stats.overdueTickets > 10 ? 'red' : stats.overdueTickets > 5 ? 'yellow' : 'green', + trend: 'Past Due Date', + }, + { + title: 'Total Open Tickets', + value: stats.openTickets, + icon: TicketCheck, + color: 'blue', + trend: 'Currently Open', + }, + { + title: 'Tickets Closed Today', + value: stats.closedToday, + icon: CheckCircle2, + color: 'green', + trend: 'Last 24 Hours', + }, + { + title: 'Avg Resolution Time', + value: Math.round(stats.avgResolutionHours), + icon: TrendingUp, + color: 'purple', + suffix: 'h', + trend: 'Last 30 Days', + }, + { + title: 'Hours Logged This Week', + value: Math.round(stats.hoursThisWeek), + icon: Briefcase, + color: 'blue', + suffix: 'h', + trend: 'Billable Hours', + }, + { + title: 'Open Quotes', + value: stats.openQuotes, + icon: FileText, + color: 'yellow', + trend: 'Pending Approval', + }, + { + title: 'Active Companies', + value: stats.activeCompanies, + icon: Building2, + color: 'blue', + trend: 'Total Customers', + }, + { + title: 'NMS Coverage', + value: stats.nmsCoverage, + icon: Network, + color: stats.nmsCoverage >= 90 ? 'green' : stats.nmsCoverage >= 70 ? 'yellow' : 'red', + suffix: '%', + trend: 'Auvik Tenants', + }, + { + title: 'RMM Coverage', + value: stats.rmmCoverage, + icon: Server, + color: stats.rmmCoverage >= 90 ? 'green' : stats.rmmCoverage >= 70 ? 'yellow' : 'red', + suffix: '%', + trend: 'Datto Sites', + }, + ]; + + useEffect(() => { + const interval = setInterval(() => { + setIsTransitioning(true); + setTimeout(() => { + setCurrentIndex((prev) => (prev + 1) % kpis.length); + setIsTransitioning(false); + }, 300); + }, cycleInterval * 1000); + + return () => clearInterval(interval); + }, [cycleInterval, kpis.length]); + + const currentKpi = kpis[currentIndex]; + + return ( +
+
+ +
+
+ ); +} diff --git a/components/kiosk/kpi-card.tsx b/components/kiosk/kpi-card.tsx new file mode 100644 index 0000000..a312bc1 --- /dev/null +++ b/components/kiosk/kpi-card.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { LucideIcon } from 'lucide-react'; + +interface KpiCardProps { + title: string; + value: number | string; + icon: LucideIcon; + trend?: string; + color?: 'red' | 'yellow' | 'green' | 'blue' | 'purple'; + suffix?: string; +} + +export function KpiCard({ title, value, icon: Icon, trend, color = 'blue', suffix = '' }: KpiCardProps) { + const colorClasses = { + red: 'text-red-500 border-red-500', + yellow: 'text-yellow-500 border-yellow-500', + green: 'text-green-500 border-green-500', + blue: 'text-blue-500 border-blue-500', + purple: 'text-purple-500 border-purple-500', + }; + + const bgClasses = { + red: 'bg-red-500/10', + yellow: 'bg-yellow-500/10', + green: 'bg-green-500/10', + blue: 'bg-blue-500/10', + purple: 'bg-purple-500/10', + }; + + return ( +
+ +
+ {value}{suffix} +
+
+ {title} +
+ {trend && ( +
+ {trend} +
+ )} +
+ ); +} diff --git a/components/kiosk/settings-panel.tsx b/components/kiosk/settings-panel.tsx new file mode 100644 index 0000000..169ef40 --- /dev/null +++ b/components/kiosk/settings-panel.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Settings, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Slider } from '@/components/ui/slider'; + +interface SettingsPanelProps { + cycleInterval: number; + onIntervalChange: (interval: number) => void; +} + +export function SettingsPanel({ cycleInterval, onIntervalChange }: SettingsPanelProps) { + const [isOpen, setIsOpen] = useState(false); + const [localInterval, setLocalInterval] = useState(cycleInterval); + + useEffect(() => { + if (!isOpen) { + const timer = setTimeout(() => { + // Auto-hide after 3 seconds of inactivity + }, 3000); + return () => clearTimeout(timer); + } + }, [isOpen]); + + const handleSave = () => { + onIntervalChange(localInterval); + localStorage.setItem('kiosk-cycle-interval', localInterval.toString()); + setIsOpen(false); + }; + + return ( + <> + + + {isOpen && ( +
+

Display Settings

+ +
+
+ + setLocalInterval(value[0])} + min={3} + max={15} + step={1} + className="w-full" + /> +
+ 3s + 15s +
+
+ + +
+
+ )} + + ); +} diff --git a/components/kiosk/ticker.tsx b/components/kiosk/ticker.tsx new file mode 100644 index 0000000..1f76e1b --- /dev/null +++ b/components/kiosk/ticker.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +interface TickerActivity { + ticketNumber: string; + title: string; + priority: number; + statusLabel: string; + companyName: string; +} + +interface TickerProps { + activities: TickerActivity[]; +} + +export function Ticker({ activities }: TickerProps) { + const tickerRef = useRef(null); + const [isPaused, setIsPaused] = useState(false); + + const getPriorityColor = (priority: number) => { + if (priority <= 3) return 'text-red-500'; + if (priority <= 5) return 'text-orange-500'; + return 'text-blue-500'; + }; + + const getPriorityIcon = (priority: number) => { + if (priority <= 3) return '🔴'; + if (priority <= 5) return '🟠'; + return '🔵'; + }; + + return ( +
+
setIsPaused(true)} + onMouseLeave={() => setIsPaused(false)} + > + {/* Duplicate activities for seamless loop */} + {[...activities, ...activities].map((activity, index) => ( +
+ {getPriorityIcon(activity.priority)} + + #{activity.ticketNumber} + + - + {activity.companyName} + - + {activity.title.substring(0, 60)} + ({activity.statusLabel}) +
+ ))} +
+
+ ); +} diff --git a/components/ui/slider.tsx b/components/ui/slider.tsx new file mode 100644 index 0000000..c31c2b3 --- /dev/null +++ b/components/ui/slider.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as SliderPrimitive from "@radix-ui/react-slider" + +import { cn } from "@/lib/utils" + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)) +Slider.displayName = SliderPrimitive.Root.displayName + +export { Slider }