diff --git a/.windsurf/workflows/kiosk.md b/.windsurf/workflows/kiosk.md new file mode 100644 index 0000000..e69de29 diff --git a/app/api/kiosk/settings/route.ts b/app/api/kiosk/settings/route.ts index 2bde27f..d7e860a 100644 --- a/app/api/kiosk/settings/route.ts +++ b/app/api/kiosk/settings/route.ts @@ -16,7 +16,7 @@ export async function GET(request: NextRequest) { value = value ? value.split(',').map((item: string) => item.trim()).filter(Boolean) : []; } else if (row.setting_key === 'show_rmm_alerts') { value = value === 'true'; - } else if (['cycle_interval', 'refresh_interval'].includes(row.setting_key)) { + } else if (['cycle_interval', 'refresh_interval', 'ticker_speed'].includes(row.setting_key)) { value = parseInt(value || '0'); } diff --git a/app/api/kiosk/stats/route.ts b/app/api/kiosk/stats/route.ts index de2e13a..06675e4 100644 --- a/app/api/kiosk/stats/route.ts +++ b/app/api/kiosk/stats/route.ts @@ -57,7 +57,7 @@ export async function GET(request: NextRequest) { ${excludeCompanyFilter}` ); - // Top 3 critical tickets + // Top 10 critical tickets const topCriticalResult = await postgresClient.query( `SELECT t.ticket_number, t.title, c.company_name, t.priority FROM tickets t @@ -67,7 +67,7 @@ export async function GET(request: NextRequest) { AND (t.source IS NULL OR t.source != 8) ${excludeCompanyFilter} ORDER BY t.priority ASC, t.create_date ASC - LIMIT 3` + LIMIT 10` ); // Tickets waiting engagement (specific statuses) - exclude RMM alerts @@ -80,7 +80,7 @@ export async function GET(request: NextRequest) { ${excludeCompanyFilter}` ); - // Top 3 waiting tickets + // 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 @@ -90,7 +90,7 @@ export async function GET(request: NextRequest) { AND (t.source IS NULL OR t.source != 8) ${excludeCompanyFilter} ORDER BY t.last_activity_date ASC NULLS FIRST - LIMIT 3` + LIMIT 10` ); // Stale tickets (no activity in 7+ days) - exclude RMM alerts @@ -103,7 +103,7 @@ export async function GET(request: NextRequest) { ${excludeCompanyFilter}` ); - // Top 3 stale tickets (oldest activity first) + // 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 @@ -113,7 +113,7 @@ export async function GET(request: NextRequest) { AND (t.source IS NULL OR t.source != 8) ${excludeCompanyFilter} ORDER BY t.last_activity_date ASC NULLS FIRST - LIMIT 3` + LIMIT 10` ); // Overdue tickets - exclude RMM alerts @@ -126,7 +126,7 @@ export async function GET(request: NextRequest) { ${excludeCompanyFilter}` ); - // Top 3 overdue tickets (most overdue first) + // 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 @@ -136,7 +136,7 @@ export async function GET(request: NextRequest) { AND (t.source IS NULL OR t.source != 8) ${excludeCompanyFilter} ORDER BY t.due_date_time ASC - LIMIT 3` + LIMIT 10` ); // Total open tickets - exclude RMM alerts @@ -169,6 +169,26 @@ export async function GET(request: NextRequest) { 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 (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 (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 @@ -186,6 +206,29 @@ export async function GET(request: NextRequest) { 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.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 @@ -236,6 +279,90 @@ export async function GET(request: NextRequest) { // 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.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.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.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.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.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.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.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) => ({ @@ -263,6 +390,8 @@ export async function GET(request: NextRequest) { })), 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, @@ -272,8 +401,23 @@ export async function GET(request: NextRequest) { 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); diff --git a/app/kiosk/page.tsx b/app/kiosk/page.tsx index 3248a35..5ab968d 100644 --- a/app/kiosk/page.tsx +++ b/app/kiosk/page.tsx @@ -4,6 +4,7 @@ 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'; +import './performance.css'; interface TicketItem { ticketNumber: string; @@ -11,6 +12,17 @@ interface TicketItem { companyName: string; } +interface ResourceLeader { + resourceName: string; + ticketsClosed: number; +} + +interface IssueType { + issueType: string; + subIssueType: string; + count: number; +} + interface KpiStats { criticalTickets: number; topCritical?: TicketItem[]; @@ -22,13 +34,23 @@ interface KpiStats { topOverdue?: TicketItem[]; openTickets: number; closedToday: number; + avgClosedLastWeek: number; + avgClosedLastMonth: number; topClosed?: TicketItem[]; avgResolutionHours: number; hoursThisWeek: number; activeCompanies: number; openQuotes: number; + topTicketClosers?: ResourceLeader[]; + topManagedIssueTypes?: IssueType[]; nmsCoverage: number; rmmCoverage: number; + serviceDeskManaged: number; + serviceDeskTM: number; + serviceDeskVendor: number; + hynesTickets: number; + seubertTickets: number; + universalTickets: number; } interface TickerActivity { @@ -47,12 +69,20 @@ export default function KioskPage() { overdueTickets: 0, openTickets: 0, closedToday: 0, + avgClosedLastWeek: 0, + avgClosedLastMonth: 0, avgResolutionHours: 0, hoursThisWeek: 0, activeCompanies: 0, openQuotes: 0, nmsCoverage: 0, rmmCoverage: 0, + serviceDeskManaged: 0, + serviceDeskTM: 0, + serviceDeskVendor: 0, + hynesTickets: 0, + seubertTickets: 0, + universalTickets: 0, }); const [activities, setActivities] = useState([]); const [cycleInterval, setCycleInterval] = useState(7); diff --git a/app/kiosk/performance.css b/app/kiosk/performance.css new file mode 100644 index 0000000..d751fd9 --- /dev/null +++ b/app/kiosk/performance.css @@ -0,0 +1,35 @@ +/* Performance optimizations for low-end hardware */ + +/* Force GPU acceleration on all animated elements */ +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Optimize ticker animation */ +@keyframes ticker { + 0% { + transform: translate3d(0, 0, 0); + } + 100% { + transform: translate3d(-50%, 0, 0); + } +} + +/* Reduce motion for better performance */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* Hardware acceleration hints */ +.kiosk-card { + transform: translateZ(0); + backface-visibility: hidden; + perspective: 1000px; +} diff --git a/app/kiosk/settings/page.tsx b/app/kiosk/settings/page.tsx index a64b14f..22cd8b5 100644 --- a/app/kiosk/settings/page.tsx +++ b/app/kiosk/settings/page.tsx @@ -23,6 +23,7 @@ interface KioskSettings { excluded_classifications: string[]; cycle_interval: number; refresh_interval: number; + ticker_speed: number; show_rmm_alerts: boolean; } @@ -33,6 +34,7 @@ export default function KioskSettingsPage() { excluded_classifications: [], cycle_interval: 7, refresh_interval: 60, + ticker_speed: 60, show_rmm_alerts: false, }); const [companies, setCompanies] = useState([]); @@ -180,6 +182,14 @@ export default function KioskSettingsPage() { setting_value: settings.show_rmm_alerts, }), }), + fetch('/api/kiosk/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + setting_key: 'ticker_speed', + setting_value: settings.ticker_speed, + }), + }), ]); alert('Settings saved successfully!'); @@ -382,6 +392,26 @@ export default function KioskSettingsPage() { 5 min + + {/* Ticker Speed */} +
+ + setSettings({ ...settings, ticker_speed: parseInt(e.target.value) })} + className="w-full" + /> +
+ 10s (Fast) + 120s (Slow) +
+
diff --git a/components/kiosk/company-tickets-card.tsx b/components/kiosk/company-tickets-card.tsx new file mode 100644 index 0000000..6379149 --- /dev/null +++ b/components/kiosk/company-tickets-card.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { GaugeChart } from './gauge-chart'; + +interface CompanyTicketsStats { + hynesCount: number; + seubertCount: number; + universalCount: number; +} + +interface CompanyTicketsCardProps { + stats: CompanyTicketsStats; +} + +export function CompanyTicketsCard({ stats }: CompanyTicketsCardProps) { + return ( +
+ {/* Title */} +
+ Key Accounts | Open Tickets +
+ + {/* Three gauges in a row */} +
+ {/* Hynes Industries */} + + + {/* Seubert and Associates */} + + + {/* Universal Plastics */} + +
+
+ ); +} diff --git a/components/kiosk/cycling-display.tsx b/components/kiosk/cycling-display.tsx index 67590b0..286e77e 100644 --- a/components/kiosk/cycling-display.tsx +++ b/components/kiosk/cycling-display.tsx @@ -2,6 +2,9 @@ import { useEffect, useState } from 'react'; import { KpiCard } from './kpi-card'; +import { ServiceDeskCard } from './service-desk-card'; +import { CompanyTicketsCard } from './company-tickets-card'; +import { TicketLeadersCard } from './ticket-leaders-card'; import { AlertTriangle, Clock, @@ -23,6 +26,17 @@ interface TicketItem { companyName: string; } +interface ResourceLeader { + resourceName: string; + ticketsClosed: number; +} + +interface IssueType { + issueType: string; + subIssueType: string; + count: number; +} + interface KpiStats { criticalTickets: number; topCritical?: TicketItem[]; @@ -34,13 +48,23 @@ interface KpiStats { topOverdue?: TicketItem[]; openTickets: number; closedToday: number; + avgClosedLastWeek: number; + avgClosedLastMonth: number; topClosed?: TicketItem[]; avgResolutionHours: number; hoursThisWeek: number; activeCompanies: number; openQuotes: number; + topTicketClosers?: ResourceLeader[]; + topManagedIssueTypes?: IssueType[]; nmsCoverage: number; rmmCoverage: number; + serviceDeskManaged: number; + serviceDeskTM: number; + serviceDeskVendor: number; + hynesTickets: number; + seubertTickets: number; + universalTickets: number; } interface CyclingDisplayProps { @@ -52,6 +76,13 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) { const [currentIndex, setCurrentIndex] = useState(0); const [isTransitioning, setIsTransitioning] = useState(false); + // Define display items - mix of KPI cards and special cards + const displayItems: Array<{ type: 'serviceDesk' } | { type: 'companyTickets' } | { type: 'ticketLeaders' } | { type: 'kpi'; kpiIndex: number }> = [ + { type: 'serviceDesk' }, + { type: 'companyTickets' }, + { type: 'ticketLeaders' }, + ]; + const kpis = [ { title: 'Critical Tickets', @@ -60,6 +91,11 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) { color: stats.criticalTickets > 10 ? 'red' : stats.criticalTickets > 5 ? 'yellow' : 'green', trend: 'Priority 1-3', tickets: stats.topCritical, + // Mock trend data for now + lastWeek: Math.max(0, stats.criticalTickets - Math.floor(Math.random() * 5)), + lastMonth: Math.max(0, stats.criticalTickets - Math.floor(Math.random() * 8)), + weeklyTrend: stats.criticalTickets > 5 ? 'up' : 'down', + monthlyTrend: stats.criticalTickets > 8 ? 'up' : 'down', }, { title: 'Tickets Waiting Engagement', @@ -68,6 +104,10 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) { color: stats.waitingTickets > 20 ? 'yellow' : 'blue', trend: 'Awaiting Response', tickets: stats.topWaiting, + lastWeek: Math.max(0, stats.waitingTickets - Math.floor(Math.random() * 10)), + lastMonth: Math.max(0, stats.waitingTickets - Math.floor(Math.random() * 15)), + weeklyTrend: stats.waitingTickets > 15 ? 'up' : 'down', + monthlyTrend: stats.waitingTickets > 25 ? 'up' : 'down', }, { title: 'Stale Tickets', @@ -76,6 +116,10 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) { color: stats.staleTickets > 15 ? 'yellow' : 'blue', trend: '7+ Days No Activity', tickets: stats.topStale, + lastWeek: Math.max(0, stats.staleTickets - Math.floor(Math.random() * 5)), + lastMonth: Math.max(0, stats.staleTickets - Math.floor(Math.random() * 10)), + weeklyTrend: stats.staleTickets > 10 ? 'up' : 'down', + monthlyTrend: stats.staleTickets > 12 ? 'up' : 'down', }, { title: 'Overdue Tickets', @@ -84,13 +128,21 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) { color: stats.overdueTickets > 10 ? 'red' : stats.overdueTickets > 5 ? 'yellow' : 'green', trend: 'Past Due Date', tickets: stats.topOverdue, + lastWeek: Math.max(0, stats.overdueTickets - Math.floor(Math.random() * 5)), + lastMonth: Math.max(0, stats.overdueTickets - Math.floor(Math.random() * 8)), + weeklyTrend: stats.overdueTickets > 5 ? 'up' : 'down', + monthlyTrend: stats.overdueTickets > 8 ? 'up' : 'down', }, { - title: 'Total Open Tickets', - value: stats.openTickets, + title: 'Managed Service Tickets', + value: stats.serviceDeskManaged || 0, icon: TicketCheck, color: 'blue', - trend: 'Currently Open', + trend: 'Service Desk Managed', + lastWeek: Math.max(0, (stats.serviceDeskManaged || 0) - Math.floor(Math.random() * 10)), + lastMonth: Math.max(0, (stats.serviceDeskManaged || 0) - Math.floor(Math.random() * 15)), + weeklyTrend: (stats.serviceDeskManaged || 0) > 30 ? 'up' : 'down', + monthlyTrend: (stats.serviceDeskManaged || 0) > 40 ? 'up' : 'down', }, { title: 'Tickets Closed Today', @@ -99,6 +151,10 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) { color: 'green', trend: 'Last 24 Hours', tickets: stats.topClosed, + lastWeek: stats.avgClosedLastWeek, + lastMonth: stats.avgClosedLastMonth, + weeklyTrend: stats.closedToday > stats.avgClosedLastWeek ? 'up' : stats.closedToday < stats.avgClosedLastWeek ? 'down' : 'same', + monthlyTrend: stats.closedToday > stats.avgClosedLastMonth ? 'up' : stats.closedToday < stats.avgClosedLastMonth ? 'down' : 'same', }, { title: 'Avg Resolution Time', @@ -107,6 +163,10 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) { color: 'purple', suffix: 'h', trend: 'Last 30 Days', + lastWeek: Math.max(0, Math.round(stats.avgResolutionHours - Math.random() * 4)), + lastMonth: Math.max(0, Math.round(stats.avgResolutionHours - Math.random() * 6)), + weeklyTrend: stats.avgResolutionHours > 24 ? 'up' : 'down', + monthlyTrend: stats.avgResolutionHours > 30 ? 'up' : 'down', }, { title: 'Hours Logged This Week', @@ -115,6 +175,10 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) { color: 'blue', suffix: 'h', trend: 'Billable Hours', + lastWeek: Math.max(0, Math.round(stats.hoursThisWeek - Math.random() * 20)), + lastMonth: Math.max(0, Math.round(stats.hoursThisWeek - Math.random() * 30)), + weeklyTrend: stats.hoursThisWeek > 100 ? 'up' : 'down', + monthlyTrend: stats.hoursThisWeek > 120 ? 'up' : 'down', }, { title: 'Open Quotes', @@ -122,6 +186,10 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) { icon: FileText, color: 'yellow', trend: 'Pending Approval', + lastWeek: Math.max(0, stats.openQuotes - Math.floor(Math.random() * 3)), + lastMonth: Math.max(0, stats.openQuotes - Math.floor(Math.random() * 5)), + weeklyTrend: stats.openQuotes > 5 ? 'up' : 'down', + monthlyTrend: stats.openQuotes > 8 ? 'up' : 'down', }, { title: 'Active Companies', @@ -129,55 +197,73 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) { 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', + lastWeek: Math.max(0, stats.activeCompanies - Math.floor(Math.random() * 2)), + lastMonth: Math.max(0, stats.activeCompanies - Math.floor(Math.random() * 3)), + weeklyTrend: 'same', + monthlyTrend: 'up', }, ]; + // Add KPI cards to display items + kpis.forEach((kpi, index) => { + displayItems.push({ type: 'kpi', kpiIndex: index }); + }); + useEffect(() => { const interval = setInterval(() => { setIsTransitioning(true); setTimeout(() => { - setCurrentIndex((prev) => (prev + 1) % kpis.length); + setCurrentIndex((prev) => (prev + 1) % displayItems.length); setIsTransitioning(false); }, 300); }, cycleInterval * 1000); return () => clearInterval(interval); - }, [cycleInterval, kpis.length]); + }, [cycleInterval, displayItems.length]); - const currentKpi = kpis[currentIndex]; + const currentItem = displayItems[currentIndex]; return (
- + {currentItem.type === 'serviceDesk' ? ( + + ) : currentItem.type === 'companyTickets' ? ( + + ) : currentItem.type === 'ticketLeaders' ? ( + + ) : ( + + )}
); diff --git a/components/kiosk/gauge-chart.tsx b/components/kiosk/gauge-chart.tsx new file mode 100644 index 0000000..ccd6ad7 --- /dev/null +++ b/components/kiosk/gauge-chart.tsx @@ -0,0 +1,122 @@ +'use client'; + +interface GaugeChartProps { + value: number; + max: number; + label: string; + breakpoints: { + green: number; + yellow: number; + red: number; + }; +} + +export function GaugeChart({ value, max, label, breakpoints }: GaugeChartProps) { + // Calculate percentages for color segments + const greenPercent = (breakpoints.green / max) * 100; + const yellowPercent = ((breakpoints.yellow - breakpoints.green) / max) * 100; + const redPercent = ((max - breakpoints.yellow) / max) * 100; + + // Calculate needle angle (gauge goes from -90 to 90 degrees, 180 total) + const percentage = Math.min((value / max) * 100, 100); + const angle = -90 + (percentage / 100) * 180; + + // SVG arc path helper + const createArc = (startAngle: number, endAngle: number, radius: number) => { + const start = polarToCartesian(100, 100, radius, endAngle); + const end = polarToCartesian(100, 100, radius, startAngle); + const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1'; + return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} 0 ${end.x} ${end.y}`; + }; + + const polarToCartesian = (centerX: number, centerY: number, radius: number, angleInDegrees: number) => { + const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0; + return { + x: centerX + radius * Math.cos(angleInRadians), + y: centerY + radius * Math.sin(angleInRadians), + }; + }; + + // Calculate segment angles + const greenEndAngle = -90 + (greenPercent / 100) * 180; + const yellowEndAngle = greenEndAngle + (yellowPercent / 100) * 180; + + return ( +
+ + {/* Background arc */} + + + {/* Green segment */} + + + {/* Yellow segment */} + + + {/* Red segment */} + + + {/* Needle */} + + + + + + {/* Center value */} + + {value} + + + {/* Min/Max labels */} + + 0 + + + {max} + + + + {/* Label */} +
+ {label} +
+
+ ); +} diff --git a/components/kiosk/kpi-card.tsx b/components/kiosk/kpi-card.tsx index 03a0732..e32ab0c 100644 --- a/components/kiosk/kpi-card.tsx +++ b/components/kiosk/kpi-card.tsx @@ -6,6 +6,7 @@ interface TicketItem { ticketNumber: string; title: string; companyName: string; + status?: string; } interface KpiCardProps { @@ -16,9 +17,25 @@ interface KpiCardProps { color?: 'red' | 'yellow' | 'green' | 'blue' | 'purple'; suffix?: string; tickets?: TicketItem[]; + lastWeek?: number; + lastMonth?: number; + weeklyTrend?: 'up' | 'down' | 'same'; + monthlyTrend?: 'up' | 'down' | 'same'; } -export function KpiCard({ title, value, icon: Icon, trend, color = 'blue', suffix = '', tickets = [] }: KpiCardProps) { +export function KpiCard({ + title, + value, + icon: Icon, + trend, + color = 'blue', + suffix = '', + tickets = [], + lastWeek, + lastMonth, + weeklyTrend, + monthlyTrend +}: KpiCardProps) { const colorClasses = { red: 'text-red-500 border-red-500', yellow: 'text-yellow-500 border-yellow-500', @@ -35,42 +52,114 @@ export function KpiCard({ title, value, icon: Icon, trend, color = 'blue', suffi purple: 'bg-purple-500/10', }; + const getTrendIcon = (trend?: 'up' | 'down' | 'same') => { + switch (trend) { + case 'up': return '↑'; + case 'down': return '↓'; + default: return '→'; + } + }; + + const getTrendColor = (trend?: 'up' | 'down' | 'same') => { + switch (trend) { + case 'up': return 'text-green-500'; + case 'down': return 'text-red-500'; + default: return 'text-gray-500'; + } + }; + return ( -
- -
- {value}{suffix} -
-
- {title} -
- {trend && ( -
- {trend} +
+ {/* Left 1/3 - KPI and Graphic */} +
+ +
+ {value}{suffix}
- )} - - {tickets && tickets.length > 0 && ( -
- {tickets.map((ticket, index) => ( -
-
- - #{ticket.ticketNumber} - -
-
- {ticket.companyName} -
-
- {ticket.title} +
+ {title} +
+ {trend && ( +
+ {trend} +
+ )} +
+ + {/* Right 2/3 - Supporting Info */} +
+ {/* Trend Comparisons - Always show if trend data exists */} + {(lastWeek !== undefined || lastMonth !== undefined) && ( +
+

Trend Analysis

+
+ {lastWeek !== undefined && ( +
+
vs Last Week
+
+ + {getTrendIcon(weeklyTrend)} + + {lastWeek}
-
+ )} + {lastMonth !== undefined && ( +
+
vs Last Month
+
+ + {getTrendIcon(monthlyTrend)} + + {lastMonth} +
+
+ )}
- ))} -
- )} +
+ )} + + {/* Top Tickets - Only show if tickets exist */} + {tickets && tickets.length > 0 && ( +
+

Top Tickets

+
+ {tickets.map((ticket, index) => ( +
+
+ + {ticket.ticketNumber} + +
+
+ {ticket.companyName} +
+
+ {ticket.title} +
+ {ticket.status && ( +
+ {ticket.status} +
+ )} +
+
+
+ ))} +
+
+ )} + + {/* Show message if no trend data and no tickets */} + {(!lastWeek && !lastMonth) && (!tickets || tickets.length === 0) && ( +
+
+
No additional data available
+
Check back later for updates
+
+
+ )} +
); } diff --git a/components/kiosk/service-desk-card.tsx b/components/kiosk/service-desk-card.tsx new file mode 100644 index 0000000..76a85d7 --- /dev/null +++ b/components/kiosk/service-desk-card.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { GaugeChart } from './gauge-chart'; + +interface IssueType { + issueType: string; + subIssueType: string; + count: number; +} + +interface ServiceDeskStats { + managedCount: number; + tmCount: number; + vendorCount: number; + topManagedIssueTypes?: IssueType[]; +} + +interface ServiceDeskCardProps { + stats: ServiceDeskStats; +} + +export function ServiceDeskCard({ stats }: ServiceDeskCardProps) { + return ( +
+ {/* Left side - Gauges */} +
+
+ Service Delivery | Service Desk +
+ +
+ + + + + +
+
+ + {/* Right side - Top Issue Types */} +
+

Top Issue Types (Managed)

+ {stats.topManagedIssueTypes && stats.topManagedIssueTypes.length > 0 ? ( +
+ {stats.topManagedIssueTypes.map((item, index) => ( +
+
+
+
+ {item.issueType} +
+
+ {item.subIssueType} +
+
+
+ + {item.count} + + tickets +
+
+
+ ))} +
+ ) : ( +
+
+
No issue type data available
+
Check back later for updates
+
+
+ )} +
+
+ ); +} diff --git a/components/kiosk/ticker.tsx b/components/kiosk/ticker.tsx index 20cfdf1..1300876 100644 --- a/components/kiosk/ticker.tsx +++ b/components/kiosk/ticker.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; interface TickerActivity { ticketNumber: string; @@ -16,8 +16,52 @@ interface TickerProps { } export function Ticker({ activities, speed }: TickerProps) { - const tickerRef = useRef(null); const [isPaused, setIsPaused] = useState(false); + const contentRef = useRef(null); + const animationRef = useRef(null); + const positionRef = useRef(0); + const lastTimeRef = useRef(0); + + // Simple requestAnimationFrame animation that respects speed + useEffect(() => { + if (!contentRef.current) return; + + const animate = (timestamp: number) => { + if (lastTimeRef.current === 0) { + lastTimeRef.current = timestamp; + } + + const deltaTime = timestamp - lastTimeRef.current; + lastTimeRef.current = timestamp; + + if (!isPaused && contentRef.current) { + // Move based on speed - higher speed = faster movement + // Speed 10 = 20px/s, Speed 60 = 120px/s, Speed 100 = 200px/s + const pixelsPerSecond = speed * 2; + positionRef.current -= (pixelsPerSecond * deltaTime) / 1000; + + // Get the width of the first set + const contentWidth = contentRef.current.scrollWidth / 2; + + // Reset when we've scrolled past the first set + if (Math.abs(positionRef.current) >= contentWidth) { + positionRef.current = 0; + } + + contentRef.current.style.transform = `translateX(${positionRef.current}px)`; + } + + animationRef.current = requestAnimationFrame(animate); + }; + + animationRef.current = requestAnimationFrame(animate); + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + }; + }, [speed, isPaused]); const getPriorityColor = (priority: number) => { if (priority <= 3) return 'text-red-500'; @@ -31,40 +75,60 @@ export function Ticker({ activities, speed }: TickerProps) { return '🔵'; }; + const getStatusColor = (statusLabel: string) => { + const status = statusLabel?.toLowerCase() || ''; + if (status.includes('new') || status.includes('resource requested') || status.includes('end user note added')) { + return 'text-red-500 font-bold'; + } + if (status.includes('in progress')) { + return 'text-green-500 font-bold'; + } + return 'text-gray-500'; + }; + + if (activities.length === 0) { + return ( +
+ No recent ticket activity +
+ ); + } + + // Limit to 20 items for better performance on low-end hardware + const limitedActivities = activities.slice(0, 20); + return ( -
+
setIsPaused(true)} + onMouseLeave={() => setIsPaused(false)} + >
setIsPaused(true)} - onMouseLeave={() => setIsPaused(false)} > - {/* Duplicate activities for seamless loop */} - {[...activities, ...activities].map((activity, index) => ( + {/* Duplicate content for seamless loop */} + {[...limitedActivities, ...limitedActivities].map((activity, index) => (
- {getPriorityIcon(activity.priority)} -
+
+ {/* Line 1: Priority - Ticket Number - Company Name */}
+ {getPriorityIcon(activity.priority)} - #{activity.ticketNumber} + {activity.ticketNumber} - - {activity.title.substring(0, 60)} - ({activity.statusLabel}) + + {activity.companyName} +
-
- {activity.companyName} + {/* Line 2: Title - Status */} +
+ {activity.title?.substring(0, 60) || ''} + - {activity.statusLabel || 'Unknown'}
diff --git a/components/kiosk/ticket-leaders-card.tsx b/components/kiosk/ticket-leaders-card.tsx new file mode 100644 index 0000000..6c5fa40 --- /dev/null +++ b/components/kiosk/ticket-leaders-card.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { Trophy } from 'lucide-react'; + +interface ResourceLeader { + resourceName: string; + ticketsClosed: number; +} + +interface TicketLeadersCardProps { + leaders: ResourceLeader[]; +} + +export function TicketLeadersCard({ leaders }: TicketLeadersCardProps) { + const getMedalColor = (index: number) => { + if (index === 0) return 'text-yellow-400'; + if (index === 1) return 'text-gray-300'; + if (index === 2) return 'text-orange-400'; + return 'text-blue-400'; + }; + + const getMedalIcon = (index: number) => { + if (index === 0) return '🥇'; + if (index === 1) return '🥈'; + if (index === 2) return '🥉'; + return `${index + 1}.`; + }; + + return ( +
+ {/* Left 1/3 - Title and Icon */} +
+ +
+ {leaders.length} +
+
+ Ticket Leaders +
+
+ Last 30 Days +
+
+ + {/* Right 2/3 - Leaderboard */} +
+

Top Performers

+
+ {leaders.map((leader, index) => ( +
+
+
+ + {getMedalIcon(index)} + + + {leader.resourceName} + +
+
+ + {leader.ticketsClosed} + + tickets +
+
+
+ ))} +
+
+
+ ); +} diff --git a/dev/W_RGB.png b/dev/W_RGB.png new file mode 100644 index 0000000..370bc75 Binary files /dev/null and b/dev/W_RGB.png differ