From 3c13defacbee3a882558d9288378fce293fd2bd8 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 19 Feb 2026 15:31:23 -0500 Subject: [PATCH] feat: kiosk UI updates - new cards, gauge chart, performance improvements - Updated kiosk stats API with expanded metrics - New components: company-tickets-card, gauge-chart, service-desk-card, ticket-leaders-card - Updated cycling-display, kpi-card, and ticker components - Added performance.css for kiosk optimizations - Added Wulf logo asset --- .windsurf/workflows/kiosk.md | 0 app/api/kiosk/settings/route.ts | 2 +- app/api/kiosk/stats/route.ts | 160 ++++++++++++++++++++-- app/kiosk/page.tsx | 30 ++++ app/kiosk/performance.css | 35 +++++ app/kiosk/settings/page.tsx | 30 ++++ components/kiosk/company-tickets-card.tsx | 63 +++++++++ components/kiosk/cycling-display.tsx | 150 +++++++++++++++----- components/kiosk/gauge-chart.tsx | 122 +++++++++++++++++ components/kiosk/kpi-card.tsx | 153 ++++++++++++++++----- components/kiosk/service-desk-card.tsx | 104 ++++++++++++++ components/kiosk/ticker.tsx | 112 +++++++++++---- components/kiosk/ticket-leaders-card.tsx | 78 +++++++++++ dev/W_RGB.png | Bin 0 -> 9646 bytes 14 files changed, 942 insertions(+), 97 deletions(-) create mode 100644 .windsurf/workflows/kiosk.md create mode 100644 app/kiosk/performance.css create mode 100644 components/kiosk/company-tickets-card.tsx create mode 100644 components/kiosk/gauge-chart.tsx create mode 100644 components/kiosk/service-desk-card.tsx create mode 100644 components/kiosk/ticket-leaders-card.tsx create mode 100644 dev/W_RGB.png 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 0000000000000000000000000000000000000000..370bc7536c7125ed2ccdc4ba469b7044c0821c00 GIT binary patch literal 9646 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 literal 0 HcmV?d00001