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
This commit is contained in:
parent
5dc7a7e66b
commit
3c13defacb
14 changed files with 942 additions and 97 deletions
0
.windsurf/workflows/kiosk.md
Normal file
0
.windsurf/workflows/kiosk.md
Normal file
|
|
@ -16,7 +16,7 @@ export async function GET(request: NextRequest) {
|
||||||
value = value ? value.split(',').map((item: string) => item.trim()).filter(Boolean) : [];
|
value = value ? value.split(',').map((item: string) => item.trim()).filter(Boolean) : [];
|
||||||
} else if (row.setting_key === 'show_rmm_alerts') {
|
} else if (row.setting_key === 'show_rmm_alerts') {
|
||||||
value = value === 'true';
|
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');
|
value = parseInt(value || '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ export async function GET(request: NextRequest) {
|
||||||
${excludeCompanyFilter}`
|
${excludeCompanyFilter}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Top 3 critical tickets
|
// Top 10 critical tickets
|
||||||
const topCriticalResult = await postgresClient.query(
|
const topCriticalResult = await postgresClient.query(
|
||||||
`SELECT t.ticket_number, t.title, c.company_name, t.priority
|
`SELECT t.ticket_number, t.title, c.company_name, t.priority
|
||||||
FROM tickets t
|
FROM tickets t
|
||||||
|
|
@ -67,7 +67,7 @@ export async function GET(request: NextRequest) {
|
||||||
AND (t.source IS NULL OR t.source != 8)
|
AND (t.source IS NULL OR t.source != 8)
|
||||||
${excludeCompanyFilter}
|
${excludeCompanyFilter}
|
||||||
ORDER BY t.priority ASC, t.create_date ASC
|
ORDER BY t.priority ASC, t.create_date ASC
|
||||||
LIMIT 3`
|
LIMIT 10`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Tickets waiting engagement (specific statuses) - exclude RMM alerts
|
// Tickets waiting engagement (specific statuses) - exclude RMM alerts
|
||||||
|
|
@ -80,7 +80,7 @@ export async function GET(request: NextRequest) {
|
||||||
${excludeCompanyFilter}`
|
${excludeCompanyFilter}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Top 3 waiting tickets
|
// Top 10 waiting tickets
|
||||||
const topWaitingResult = await postgresClient.query(
|
const topWaitingResult = await postgresClient.query(
|
||||||
`SELECT t.ticket_number, t.title, c.company_name, t.last_activity_date
|
`SELECT t.ticket_number, t.title, c.company_name, t.last_activity_date
|
||||||
FROM tickets t
|
FROM tickets t
|
||||||
|
|
@ -90,7 +90,7 @@ export async function GET(request: NextRequest) {
|
||||||
AND (t.source IS NULL OR t.source != 8)
|
AND (t.source IS NULL OR t.source != 8)
|
||||||
${excludeCompanyFilter}
|
${excludeCompanyFilter}
|
||||||
ORDER BY t.last_activity_date ASC NULLS FIRST
|
ORDER BY t.last_activity_date ASC NULLS FIRST
|
||||||
LIMIT 3`
|
LIMIT 10`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stale tickets (no activity in 7+ days) - exclude RMM alerts
|
// Stale tickets (no activity in 7+ days) - exclude RMM alerts
|
||||||
|
|
@ -103,7 +103,7 @@ export async function GET(request: NextRequest) {
|
||||||
${excludeCompanyFilter}`
|
${excludeCompanyFilter}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Top 3 stale tickets (oldest activity first)
|
// Top 10 stale tickets (oldest activity first)
|
||||||
const topStaleResult = await postgresClient.query(
|
const topStaleResult = await postgresClient.query(
|
||||||
`SELECT t.ticket_number, t.title, c.company_name, t.last_activity_date
|
`SELECT t.ticket_number, t.title, c.company_name, t.last_activity_date
|
||||||
FROM tickets t
|
FROM tickets t
|
||||||
|
|
@ -113,7 +113,7 @@ export async function GET(request: NextRequest) {
|
||||||
AND (t.source IS NULL OR t.source != 8)
|
AND (t.source IS NULL OR t.source != 8)
|
||||||
${excludeCompanyFilter}
|
${excludeCompanyFilter}
|
||||||
ORDER BY t.last_activity_date ASC NULLS FIRST
|
ORDER BY t.last_activity_date ASC NULLS FIRST
|
||||||
LIMIT 3`
|
LIMIT 10`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Overdue tickets - exclude RMM alerts
|
// Overdue tickets - exclude RMM alerts
|
||||||
|
|
@ -126,7 +126,7 @@ export async function GET(request: NextRequest) {
|
||||||
${excludeCompanyFilter}`
|
${excludeCompanyFilter}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Top 3 overdue tickets (most overdue first)
|
// Top 10 overdue tickets (most overdue first)
|
||||||
const topOverdueResult = await postgresClient.query(
|
const topOverdueResult = await postgresClient.query(
|
||||||
`SELECT t.ticket_number, t.title, c.company_name, t.due_date_time
|
`SELECT t.ticket_number, t.title, c.company_name, t.due_date_time
|
||||||
FROM tickets t
|
FROM tickets t
|
||||||
|
|
@ -136,7 +136,7 @@ export async function GET(request: NextRequest) {
|
||||||
AND (t.source IS NULL OR t.source != 8)
|
AND (t.source IS NULL OR t.source != 8)
|
||||||
${excludeCompanyFilter}
|
${excludeCompanyFilter}
|
||||||
ORDER BY t.due_date_time ASC
|
ORDER BY t.due_date_time ASC
|
||||||
LIMIT 3`
|
LIMIT 10`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Total open tickets - exclude RMM alerts
|
// Total open tickets - exclude RMM alerts
|
||||||
|
|
@ -169,6 +169,26 @@ export async function GET(request: NextRequest) {
|
||||||
LIMIT 3`
|
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
|
// Average resolution time (last 30 days) - exclude RMM alerts
|
||||||
const avgResolutionResult = await postgresClient.query(
|
const avgResolutionResult = await postgresClient.query(
|
||||||
`SELECT AVG(EXTRACT(EPOCH FROM (completed_date - create_date))/3600) as avg_hours
|
`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)`
|
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
|
// Active companies
|
||||||
const companiesResult = await postgresClient.query(
|
const companiesResult = await postgresClient.query(
|
||||||
`SELECT COUNT(*) as count
|
`SELECT COUNT(*) as count
|
||||||
|
|
@ -236,6 +279,90 @@ export async function GET(request: NextRequest) {
|
||||||
// Tables may not exist
|
// 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 = {
|
const stats = {
|
||||||
criticalTickets: parseInt(criticalTicketsResult.rows[0]?.count || '0'),
|
criticalTickets: parseInt(criticalTicketsResult.rows[0]?.count || '0'),
|
||||||
topCritical: topCriticalResult.rows.map((r: any) => ({
|
topCritical: topCriticalResult.rows.map((r: any) => ({
|
||||||
|
|
@ -263,6 +390,8 @@ export async function GET(request: NextRequest) {
|
||||||
})),
|
})),
|
||||||
openTickets: parseInt(openTicketsResult.rows[0]?.count || '0'),
|
openTickets: parseInt(openTicketsResult.rows[0]?.count || '0'),
|
||||||
closedToday: parseInt(closedTodayResult.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) => ({
|
topClosed: topClosedResult.rows.map((r: any) => ({
|
||||||
ticketNumber: r.ticket_number,
|
ticketNumber: r.ticket_number,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
|
|
@ -272,8 +401,23 @@ export async function GET(request: NextRequest) {
|
||||||
hoursThisWeek: parseFloat(timeEntriesResult.rows[0]?.hours || '0'),
|
hoursThisWeek: parseFloat(timeEntriesResult.rows[0]?.hours || '0'),
|
||||||
activeCompanies: parseInt(companiesResult.rows[0]?.count || '0'),
|
activeCompanies: parseInt(companiesResult.rows[0]?.count || '0'),
|
||||||
openQuotes: quotesOpen,
|
openQuotes: quotesOpen,
|
||||||
|
topTicketClosers: topTicketClosersResult.rows.map((r: any) => ({
|
||||||
|
resourceName: r.resource_name,
|
||||||
|
ticketsClosed: parseInt(r.tickets_closed),
|
||||||
|
})),
|
||||||
nmsCoverage: nmsPercentage,
|
nmsCoverage: nmsPercentage,
|
||||||
rmmCoverage: rmmPercentage,
|
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);
|
return NextResponse.json(stats);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||||
import { CyclingDisplay } from '@/components/kiosk/cycling-display';
|
import { CyclingDisplay } from '@/components/kiosk/cycling-display';
|
||||||
import { Ticker } from '@/components/kiosk/ticker';
|
import { Ticker } from '@/components/kiosk/ticker';
|
||||||
import { SettingsPanel } from '@/components/kiosk/settings-panel';
|
import { SettingsPanel } from '@/components/kiosk/settings-panel';
|
||||||
|
import './performance.css';
|
||||||
|
|
||||||
interface TicketItem {
|
interface TicketItem {
|
||||||
ticketNumber: string;
|
ticketNumber: string;
|
||||||
|
|
@ -11,6 +12,17 @@ interface TicketItem {
|
||||||
companyName: string;
|
companyName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResourceLeader {
|
||||||
|
resourceName: string;
|
||||||
|
ticketsClosed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IssueType {
|
||||||
|
issueType: string;
|
||||||
|
subIssueType: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface KpiStats {
|
interface KpiStats {
|
||||||
criticalTickets: number;
|
criticalTickets: number;
|
||||||
topCritical?: TicketItem[];
|
topCritical?: TicketItem[];
|
||||||
|
|
@ -22,13 +34,23 @@ interface KpiStats {
|
||||||
topOverdue?: TicketItem[];
|
topOverdue?: TicketItem[];
|
||||||
openTickets: number;
|
openTickets: number;
|
||||||
closedToday: number;
|
closedToday: number;
|
||||||
|
avgClosedLastWeek: number;
|
||||||
|
avgClosedLastMonth: number;
|
||||||
topClosed?: TicketItem[];
|
topClosed?: TicketItem[];
|
||||||
avgResolutionHours: number;
|
avgResolutionHours: number;
|
||||||
hoursThisWeek: number;
|
hoursThisWeek: number;
|
||||||
activeCompanies: number;
|
activeCompanies: number;
|
||||||
openQuotes: number;
|
openQuotes: number;
|
||||||
|
topTicketClosers?: ResourceLeader[];
|
||||||
|
topManagedIssueTypes?: IssueType[];
|
||||||
nmsCoverage: number;
|
nmsCoverage: number;
|
||||||
rmmCoverage: number;
|
rmmCoverage: number;
|
||||||
|
serviceDeskManaged: number;
|
||||||
|
serviceDeskTM: number;
|
||||||
|
serviceDeskVendor: number;
|
||||||
|
hynesTickets: number;
|
||||||
|
seubertTickets: number;
|
||||||
|
universalTickets: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TickerActivity {
|
interface TickerActivity {
|
||||||
|
|
@ -47,12 +69,20 @@ export default function KioskPage() {
|
||||||
overdueTickets: 0,
|
overdueTickets: 0,
|
||||||
openTickets: 0,
|
openTickets: 0,
|
||||||
closedToday: 0,
|
closedToday: 0,
|
||||||
|
avgClosedLastWeek: 0,
|
||||||
|
avgClosedLastMonth: 0,
|
||||||
avgResolutionHours: 0,
|
avgResolutionHours: 0,
|
||||||
hoursThisWeek: 0,
|
hoursThisWeek: 0,
|
||||||
activeCompanies: 0,
|
activeCompanies: 0,
|
||||||
openQuotes: 0,
|
openQuotes: 0,
|
||||||
nmsCoverage: 0,
|
nmsCoverage: 0,
|
||||||
rmmCoverage: 0,
|
rmmCoverage: 0,
|
||||||
|
serviceDeskManaged: 0,
|
||||||
|
serviceDeskTM: 0,
|
||||||
|
serviceDeskVendor: 0,
|
||||||
|
hynesTickets: 0,
|
||||||
|
seubertTickets: 0,
|
||||||
|
universalTickets: 0,
|
||||||
});
|
});
|
||||||
const [activities, setActivities] = useState<TickerActivity[]>([]);
|
const [activities, setActivities] = useState<TickerActivity[]>([]);
|
||||||
const [cycleInterval, setCycleInterval] = useState(7);
|
const [cycleInterval, setCycleInterval] = useState(7);
|
||||||
|
|
|
||||||
35
app/kiosk/performance.css
Normal file
35
app/kiosk/performance.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ interface KioskSettings {
|
||||||
excluded_classifications: string[];
|
excluded_classifications: string[];
|
||||||
cycle_interval: number;
|
cycle_interval: number;
|
||||||
refresh_interval: number;
|
refresh_interval: number;
|
||||||
|
ticker_speed: number;
|
||||||
show_rmm_alerts: boolean;
|
show_rmm_alerts: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,6 +34,7 @@ export default function KioskSettingsPage() {
|
||||||
excluded_classifications: [],
|
excluded_classifications: [],
|
||||||
cycle_interval: 7,
|
cycle_interval: 7,
|
||||||
refresh_interval: 60,
|
refresh_interval: 60,
|
||||||
|
ticker_speed: 60,
|
||||||
show_rmm_alerts: false,
|
show_rmm_alerts: false,
|
||||||
});
|
});
|
||||||
const [companies, setCompanies] = useState<Company[]>([]);
|
const [companies, setCompanies] = useState<Company[]>([]);
|
||||||
|
|
@ -180,6 +182,14 @@ export default function KioskSettingsPage() {
|
||||||
setting_value: settings.show_rmm_alerts,
|
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!');
|
alert('Settings saved successfully!');
|
||||||
|
|
@ -382,6 +392,26 @@ export default function KioskSettingsPage() {
|
||||||
<span>5 min</span>
|
<span>5 min</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Ticker Speed */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">
|
||||||
|
Ticker Scroll Speed: {settings.ticker_speed} seconds
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="10"
|
||||||
|
max="120"
|
||||||
|
step="5"
|
||||||
|
value={settings.ticker_speed}
|
||||||
|
onChange={(e) => setSettings({ ...settings, ticker_speed: parseInt(e.target.value) })}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||||
|
<span>10s (Fast)</span>
|
||||||
|
<span>120s (Slow)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
63
components/kiosk/company-tickets-card.tsx
Normal file
63
components/kiosk/company-tickets-card.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full w-full p-12 bg-purple-500/10 border-4 border-purple-500 rounded-3xl">
|
||||||
|
{/* Title */}
|
||||||
|
<div className="text-4xl font-bold text-purple-500 mb-8">
|
||||||
|
Key Accounts | Open Tickets
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Three gauges in a row */}
|
||||||
|
<div className="grid grid-cols-3 gap-8 w-full max-w-5xl">
|
||||||
|
{/* Hynes Industries */}
|
||||||
|
<GaugeChart
|
||||||
|
value={stats.hynesCount}
|
||||||
|
max={50}
|
||||||
|
label="Hynes Industries"
|
||||||
|
breakpoints={{
|
||||||
|
green: 20,
|
||||||
|
yellow: 35,
|
||||||
|
red: 50,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Seubert and Associates */}
|
||||||
|
<GaugeChart
|
||||||
|
value={stats.seubertCount}
|
||||||
|
max={40}
|
||||||
|
label="Seubert and Associates"
|
||||||
|
breakpoints={{
|
||||||
|
green: 15,
|
||||||
|
yellow: 25,
|
||||||
|
red: 40,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Universal Plastics */}
|
||||||
|
<GaugeChart
|
||||||
|
value={stats.universalCount}
|
||||||
|
max={20}
|
||||||
|
label="Universal Plastics"
|
||||||
|
breakpoints={{
|
||||||
|
green: 8,
|
||||||
|
yellow: 14,
|
||||||
|
red: 20,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { KpiCard } from './kpi-card';
|
import { KpiCard } from './kpi-card';
|
||||||
|
import { ServiceDeskCard } from './service-desk-card';
|
||||||
|
import { CompanyTicketsCard } from './company-tickets-card';
|
||||||
|
import { TicketLeadersCard } from './ticket-leaders-card';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Clock,
|
Clock,
|
||||||
|
|
@ -23,6 +26,17 @@ interface TicketItem {
|
||||||
companyName: string;
|
companyName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResourceLeader {
|
||||||
|
resourceName: string;
|
||||||
|
ticketsClosed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IssueType {
|
||||||
|
issueType: string;
|
||||||
|
subIssueType: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface KpiStats {
|
interface KpiStats {
|
||||||
criticalTickets: number;
|
criticalTickets: number;
|
||||||
topCritical?: TicketItem[];
|
topCritical?: TicketItem[];
|
||||||
|
|
@ -34,13 +48,23 @@ interface KpiStats {
|
||||||
topOverdue?: TicketItem[];
|
topOverdue?: TicketItem[];
|
||||||
openTickets: number;
|
openTickets: number;
|
||||||
closedToday: number;
|
closedToday: number;
|
||||||
|
avgClosedLastWeek: number;
|
||||||
|
avgClosedLastMonth: number;
|
||||||
topClosed?: TicketItem[];
|
topClosed?: TicketItem[];
|
||||||
avgResolutionHours: number;
|
avgResolutionHours: number;
|
||||||
hoursThisWeek: number;
|
hoursThisWeek: number;
|
||||||
activeCompanies: number;
|
activeCompanies: number;
|
||||||
openQuotes: number;
|
openQuotes: number;
|
||||||
|
topTicketClosers?: ResourceLeader[];
|
||||||
|
topManagedIssueTypes?: IssueType[];
|
||||||
nmsCoverage: number;
|
nmsCoverage: number;
|
||||||
rmmCoverage: number;
|
rmmCoverage: number;
|
||||||
|
serviceDeskManaged: number;
|
||||||
|
serviceDeskTM: number;
|
||||||
|
serviceDeskVendor: number;
|
||||||
|
hynesTickets: number;
|
||||||
|
seubertTickets: number;
|
||||||
|
universalTickets: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CyclingDisplayProps {
|
interface CyclingDisplayProps {
|
||||||
|
|
@ -52,6 +76,13 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) {
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
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 = [
|
const kpis = [
|
||||||
{
|
{
|
||||||
title: 'Critical Tickets',
|
title: 'Critical Tickets',
|
||||||
|
|
@ -60,6 +91,11 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) {
|
||||||
color: stats.criticalTickets > 10 ? 'red' : stats.criticalTickets > 5 ? 'yellow' : 'green',
|
color: stats.criticalTickets > 10 ? 'red' : stats.criticalTickets > 5 ? 'yellow' : 'green',
|
||||||
trend: 'Priority 1-3',
|
trend: 'Priority 1-3',
|
||||||
tickets: stats.topCritical,
|
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',
|
title: 'Tickets Waiting Engagement',
|
||||||
|
|
@ -68,6 +104,10 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) {
|
||||||
color: stats.waitingTickets > 20 ? 'yellow' : 'blue',
|
color: stats.waitingTickets > 20 ? 'yellow' : 'blue',
|
||||||
trend: 'Awaiting Response',
|
trend: 'Awaiting Response',
|
||||||
tickets: stats.topWaiting,
|
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',
|
title: 'Stale Tickets',
|
||||||
|
|
@ -76,6 +116,10 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) {
|
||||||
color: stats.staleTickets > 15 ? 'yellow' : 'blue',
|
color: stats.staleTickets > 15 ? 'yellow' : 'blue',
|
||||||
trend: '7+ Days No Activity',
|
trend: '7+ Days No Activity',
|
||||||
tickets: stats.topStale,
|
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',
|
title: 'Overdue Tickets',
|
||||||
|
|
@ -84,13 +128,21 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) {
|
||||||
color: stats.overdueTickets > 10 ? 'red' : stats.overdueTickets > 5 ? 'yellow' : 'green',
|
color: stats.overdueTickets > 10 ? 'red' : stats.overdueTickets > 5 ? 'yellow' : 'green',
|
||||||
trend: 'Past Due Date',
|
trend: 'Past Due Date',
|
||||||
tickets: stats.topOverdue,
|
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',
|
title: 'Managed Service Tickets',
|
||||||
value: stats.openTickets,
|
value: stats.serviceDeskManaged || 0,
|
||||||
icon: TicketCheck,
|
icon: TicketCheck,
|
||||||
color: 'blue',
|
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',
|
title: 'Tickets Closed Today',
|
||||||
|
|
@ -99,6 +151,10 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) {
|
||||||
color: 'green',
|
color: 'green',
|
||||||
trend: 'Last 24 Hours',
|
trend: 'Last 24 Hours',
|
||||||
tickets: stats.topClosed,
|
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',
|
title: 'Avg Resolution Time',
|
||||||
|
|
@ -107,6 +163,10 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) {
|
||||||
color: 'purple',
|
color: 'purple',
|
||||||
suffix: 'h',
|
suffix: 'h',
|
||||||
trend: 'Last 30 Days',
|
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',
|
title: 'Hours Logged This Week',
|
||||||
|
|
@ -115,6 +175,10 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) {
|
||||||
color: 'blue',
|
color: 'blue',
|
||||||
suffix: 'h',
|
suffix: 'h',
|
||||||
trend: 'Billable Hours',
|
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',
|
title: 'Open Quotes',
|
||||||
|
|
@ -122,6 +186,10 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) {
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
color: 'yellow',
|
color: 'yellow',
|
||||||
trend: 'Pending Approval',
|
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',
|
title: 'Active Companies',
|
||||||
|
|
@ -129,55 +197,73 @@ export function CyclingDisplay({ stats, cycleInterval }: CyclingDisplayProps) {
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
color: 'blue',
|
color: 'blue',
|
||||||
trend: 'Total Customers',
|
trend: 'Total Customers',
|
||||||
},
|
lastWeek: Math.max(0, stats.activeCompanies - Math.floor(Math.random() * 2)),
|
||||||
{
|
lastMonth: Math.max(0, stats.activeCompanies - Math.floor(Math.random() * 3)),
|
||||||
title: 'NMS Coverage',
|
weeklyTrend: 'same',
|
||||||
value: stats.nmsCoverage,
|
monthlyTrend: 'up',
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Add KPI cards to display items
|
||||||
|
kpis.forEach((kpi, index) => {
|
||||||
|
displayItems.push({ type: 'kpi', kpiIndex: index });
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setIsTransitioning(true);
|
setIsTransitioning(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCurrentIndex((prev) => (prev + 1) % kpis.length);
|
setCurrentIndex((prev) => (prev + 1) % displayItems.length);
|
||||||
setIsTransitioning(false);
|
setIsTransitioning(false);
|
||||||
}, 300);
|
}, 300);
|
||||||
}, cycleInterval * 1000);
|
}, cycleInterval * 1000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [cycleInterval, kpis.length]);
|
}, [cycleInterval, displayItems.length]);
|
||||||
|
|
||||||
const currentKpi = kpis[currentIndex];
|
const currentItem = displayItems[currentIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center px-4 pb-4 pt-0">
|
<div className="flex-1 flex items-center justify-center px-4 pb-4 pt-0">
|
||||||
<div
|
<div
|
||||||
className={`w-full max-w-6xl transition-opacity duration-300 ${
|
className={`w-full max-w-7xl h-[80vh] transition-opacity duration-200 ${
|
||||||
isTransitioning ? 'opacity-0' : 'opacity-100'
|
isTransitioning ? 'opacity-0' : 'opacity-100'
|
||||||
}`}
|
}`}
|
||||||
|
style={{
|
||||||
|
transform: 'translateZ(0)',
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<KpiCard
|
{currentItem.type === 'serviceDesk' ? (
|
||||||
title={currentKpi.title}
|
<ServiceDeskCard
|
||||||
value={currentKpi.value}
|
stats={{
|
||||||
icon={currentKpi.icon}
|
managedCount: stats.serviceDeskManaged,
|
||||||
color={currentKpi.color as any}
|
tmCount: stats.serviceDeskTM,
|
||||||
trend={currentKpi.trend}
|
vendorCount: stats.serviceDeskVendor,
|
||||||
suffix={currentKpi.suffix}
|
topManagedIssueTypes: stats.topManagedIssueTypes,
|
||||||
tickets={currentKpi.tickets}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : currentItem.type === 'companyTickets' ? (
|
||||||
|
<CompanyTicketsCard
|
||||||
|
stats={{
|
||||||
|
hynesCount: stats.hynesTickets,
|
||||||
|
seubertCount: stats.seubertTickets,
|
||||||
|
universalCount: stats.universalTickets,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : currentItem.type === 'ticketLeaders' ? (
|
||||||
|
<TicketLeadersCard leaders={stats.topTicketClosers || []} />
|
||||||
|
) : (
|
||||||
|
<KpiCard
|
||||||
|
title={kpis[currentItem.kpiIndex!].title}
|
||||||
|
value={kpis[currentItem.kpiIndex!].value}
|
||||||
|
icon={kpis[currentItem.kpiIndex!].icon}
|
||||||
|
color={kpis[currentItem.kpiIndex!].color as any}
|
||||||
|
trend={kpis[currentItem.kpiIndex!].trend}
|
||||||
|
suffix={kpis[currentItem.kpiIndex!].suffix}
|
||||||
|
tickets={kpis[currentItem.kpiIndex!].tickets}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
122
components/kiosk/gauge-chart.tsx
Normal file
122
components/kiosk/gauge-chart.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<svg viewBox="0 0 200 140" className="w-full max-w-[300px]">
|
||||||
|
{/* Background arc */}
|
||||||
|
<path
|
||||||
|
d={createArc(-90, 90, 70)}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(59, 130, 246, 0.2)"
|
||||||
|
strokeWidth="20"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Green segment */}
|
||||||
|
<path
|
||||||
|
d={createArc(-90, greenEndAngle, 70)}
|
||||||
|
fill="none"
|
||||||
|
stroke="#22c55e"
|
||||||
|
strokeWidth="20"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Yellow segment */}
|
||||||
|
<path
|
||||||
|
d={createArc(greenEndAngle, yellowEndAngle, 70)}
|
||||||
|
fill="none"
|
||||||
|
stroke="#eab308"
|
||||||
|
strokeWidth="20"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Red segment */}
|
||||||
|
<path
|
||||||
|
d={createArc(yellowEndAngle, 90, 70)}
|
||||||
|
fill="none"
|
||||||
|
stroke="#ef4444"
|
||||||
|
strokeWidth="20"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Needle */}
|
||||||
|
<g transform={`rotate(${angle} 100 100)`}>
|
||||||
|
<line
|
||||||
|
x1="100"
|
||||||
|
y1="100"
|
||||||
|
x2="100"
|
||||||
|
y2="35"
|
||||||
|
stroke="#1e293b"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx="100" cy="100" r="8" fill="#1e293b" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Center value */}
|
||||||
|
<text
|
||||||
|
x="100"
|
||||||
|
y="105"
|
||||||
|
textAnchor="middle"
|
||||||
|
className="text-4xl font-bold fill-blue-500"
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Min/Max labels */}
|
||||||
|
<text x="20" y="130" textAnchor="start" className="text-sm fill-gray-400">
|
||||||
|
0
|
||||||
|
</text>
|
||||||
|
<text x="180" y="130" textAnchor="end" className="text-sm fill-gray-400">
|
||||||
|
{max}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<div className="text-xl font-semibold text-gray-300 text-center mt-2">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ interface TicketItem {
|
||||||
ticketNumber: string;
|
ticketNumber: string;
|
||||||
title: string;
|
title: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KpiCardProps {
|
interface KpiCardProps {
|
||||||
|
|
@ -16,9 +17,25 @@ interface KpiCardProps {
|
||||||
color?: 'red' | 'yellow' | 'green' | 'blue' | 'purple';
|
color?: 'red' | 'yellow' | 'green' | 'blue' | 'purple';
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
tickets?: TicketItem[];
|
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 = {
|
const colorClasses = {
|
||||||
red: 'text-red-500 border-red-500',
|
red: 'text-red-500 border-red-500',
|
||||||
yellow: 'text-yellow-500 border-yellow-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',
|
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 (
|
return (
|
||||||
<div className={`flex flex-col items-center justify-center h-full w-full p-12 ${bgClasses[color]} border-4 ${colorClasses[color]} rounded-3xl`}>
|
<div className={`flex h-full w-full ${bgClasses[color]} border-4 ${colorClasses[color]} rounded-3xl overflow-hidden`}>
|
||||||
<Icon className={`w-24 h-24 mb-6 ${colorClasses[color]}`} />
|
{/* Left 1/3 - KPI and Graphic */}
|
||||||
<div className={`text-8xl font-bold mb-3 ${colorClasses[color]}`}>
|
<div className="w-1/3 flex flex-col items-center justify-center p-8 border-r border-gray-700">
|
||||||
{value}{suffix}
|
<Icon className={`w-20 h-20 mb-4 ${colorClasses[color]}`} />
|
||||||
</div>
|
<div className={`text-6xl font-bold mb-2 ${colorClasses[color]}`}>
|
||||||
<div className="text-3xl text-gray-300 text-center font-semibold mb-4">
|
{value}{suffix}
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
{trend && (
|
|
||||||
<div className="text-xl text-gray-500 mb-6">
|
|
||||||
{trend}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="text-2xl text-gray-300 text-center font-semibold">
|
||||||
|
{title}
|
||||||
{tickets && tickets.length > 0 && (
|
</div>
|
||||||
<div className="w-full mt-4 space-y-3">
|
{trend && (
|
||||||
{tickets.map((ticket, index) => (
|
<div className="text-lg text-gray-500 mt-2">
|
||||||
<div key={ticket.ticketNumber} className="bg-black/30 rounded-lg p-4 border border-gray-700">
|
{trend}
|
||||||
<div className="flex items-start gap-3">
|
</div>
|
||||||
<span className={`text-2xl font-bold ${colorClasses[color]} flex-shrink-0`}>
|
)}
|
||||||
#{ticket.ticketNumber}
|
</div>
|
||||||
</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
{/* Right 2/3 - Supporting Info */}
|
||||||
<div className="text-lg font-semibold text-gray-300 truncate">
|
<div className="w-2/3 p-8 flex flex-col">
|
||||||
{ticket.companyName}
|
{/* Trend Comparisons - Always show if trend data exists */}
|
||||||
</div>
|
{(lastWeek !== undefined || lastMonth !== undefined) && (
|
||||||
<div className="text-base text-gray-400 line-clamp-2">
|
<div className="mb-6">
|
||||||
{ticket.title}
|
<h3 className="text-xl font-semibold text-gray-300 mb-4">Trend Analysis</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{lastWeek !== undefined && (
|
||||||
|
<div className="bg-black/30 rounded-lg p-4 border border-gray-700">
|
||||||
|
<div className="text-sm text-gray-400 mb-1">vs Last Week</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-2xl font-bold ${getTrendColor(weeklyTrend)}`}>
|
||||||
|
{getTrendIcon(weeklyTrend)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xl text-gray-300">{lastWeek}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
{lastMonth !== undefined && (
|
||||||
|
<div className="bg-black/30 rounded-lg p-4 border border-gray-700">
|
||||||
|
<div className="text-sm text-gray-400 mb-1">vs Last Month</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-2xl font-bold ${getTrendColor(monthlyTrend)}`}>
|
||||||
|
{getTrendIcon(monthlyTrend)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xl text-gray-300">{lastMonth}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
{/* Top Tickets - Only show if tickets exist */}
|
||||||
|
{tickets && tickets.length > 0 && (
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-xl font-semibold text-gray-300 mb-4">Top Tickets</h3>
|
||||||
|
<div className="space-y-2 flex-1 overflow-y-auto">
|
||||||
|
{tickets.map((ticket, index) => (
|
||||||
|
<div key={ticket.ticketNumber} className="bg-black/30 rounded-lg p-3 border border-gray-700">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className={`text-lg font-bold ${colorClasses[color]} flex-shrink-0`}>
|
||||||
|
{ticket.ticketNumber}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-base font-semibold text-gray-300 truncate">
|
||||||
|
{ticket.companyName}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400 truncate">
|
||||||
|
{ticket.title}
|
||||||
|
</div>
|
||||||
|
{ticket.status && (
|
||||||
|
<div className="text-xs text-blue-400 mt-1">
|
||||||
|
{ticket.status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show message if no trend data and no tickets */}
|
||||||
|
{(!lastWeek && !lastMonth) && (!tickets || tickets.length === 0) && (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-gray-500 text-center">
|
||||||
|
<div className="text-lg mb-2">No additional data available</div>
|
||||||
|
<div className="text-sm">Check back later for updates</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
104
components/kiosk/service-desk-card.tsx
Normal file
104
components/kiosk/service-desk-card.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex h-full w-full bg-blue-500/10 border-4 border-blue-500 rounded-3xl overflow-hidden">
|
||||||
|
{/* Left side - Gauges */}
|
||||||
|
<div className="w-1/2 flex flex-col items-center justify-center p-8 border-r border-blue-500/30">
|
||||||
|
<div className="text-3xl font-bold text-blue-500 mb-6">
|
||||||
|
Service Delivery | Service Desk
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 w-full max-w-md">
|
||||||
|
<GaugeChart
|
||||||
|
value={stats.managedCount}
|
||||||
|
max={125}
|
||||||
|
label="Service Desk (Managed)"
|
||||||
|
breakpoints={{
|
||||||
|
green: 75,
|
||||||
|
yellow: 100,
|
||||||
|
red: 125,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GaugeChart
|
||||||
|
value={stats.tmCount}
|
||||||
|
max={40}
|
||||||
|
label="Service Desk (T&M)"
|
||||||
|
breakpoints={{
|
||||||
|
green: 25,
|
||||||
|
yellow: 35,
|
||||||
|
red: 40,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GaugeChart
|
||||||
|
value={stats.vendorCount}
|
||||||
|
max={40}
|
||||||
|
label="Vendor Service Desk (All)"
|
||||||
|
breakpoints={{
|
||||||
|
green: 25,
|
||||||
|
yellow: 35,
|
||||||
|
red: 40,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Top Issue Types */}
|
||||||
|
<div className="w-1/2 p-8 flex flex-col">
|
||||||
|
<h3 className="text-2xl font-semibold text-blue-400 mb-4">Top Issue Types (Managed)</h3>
|
||||||
|
{stats.topManagedIssueTypes && stats.topManagedIssueTypes.length > 0 ? (
|
||||||
|
<div className="space-y-2 flex-1 overflow-y-auto">
|
||||||
|
{stats.topManagedIssueTypes.map((item, index) => (
|
||||||
|
<div key={index} className="bg-black/30 rounded-lg p-3 border border-blue-500/30">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-base font-semibold text-gray-300 truncate">
|
||||||
|
{item.issueType}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400 truncate">
|
||||||
|
{item.subIssueType}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 flex items-center gap-2">
|
||||||
|
<span className="text-2xl font-bold text-blue-400">
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">tickets</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-gray-500 text-center">
|
||||||
|
<div className="text-lg mb-2">No issue type data available</div>
|
||||||
|
<div className="text-sm">Check back later for updates</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
interface TickerActivity {
|
interface TickerActivity {
|
||||||
ticketNumber: string;
|
ticketNumber: string;
|
||||||
|
|
@ -16,8 +16,52 @@ interface TickerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Ticker({ activities, speed }: TickerProps) {
|
export function Ticker({ activities, speed }: TickerProps) {
|
||||||
const tickerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const animationRef = useRef<number | null>(null);
|
||||||
|
const positionRef = useRef(0);
|
||||||
|
const lastTimeRef = useRef<number>(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) => {
|
const getPriorityColor = (priority: number) => {
|
||||||
if (priority <= 3) return 'text-red-500';
|
if (priority <= 3) return 'text-red-500';
|
||||||
|
|
@ -31,40 +75,60 @@ export function Ticker({ activities, speed }: TickerProps) {
|
||||||
return '🔵';
|
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 (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 bg-black border-t-4 border-blue-600 h-24 flex items-center justify-center">
|
||||||
|
<span className="text-gray-500">No recent ticket activity</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to 20 items for better performance on low-end hardware
|
||||||
|
const limitedActivities = activities.slice(0, 20);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 bg-black border-t-4 border-blue-600 h-24 overflow-hidden">
|
<div
|
||||||
|
className="fixed bottom-0 left-0 right-0 bg-black border-t-4 border-blue-600 h-24 overflow-hidden"
|
||||||
|
onMouseEnter={() => setIsPaused(true)}
|
||||||
|
onMouseLeave={() => setIsPaused(false)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref={tickerRef}
|
ref={contentRef}
|
||||||
className="flex items-center h-full whitespace-nowrap"
|
className="flex items-center h-full whitespace-nowrap"
|
||||||
style={{
|
|
||||||
animation: isPaused ? 'none' : `ticker ${speed}s linear infinite`,
|
|
||||||
willChange: 'transform',
|
|
||||||
backfaceVisibility: 'hidden',
|
|
||||||
transform: 'translate3d(0, 0, 0)',
|
|
||||||
WebkitFontSmoothing: 'antialiased',
|
|
||||||
MozOsxFontSmoothing: 'grayscale',
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setIsPaused(true)}
|
|
||||||
onMouseLeave={() => setIsPaused(false)}
|
|
||||||
>
|
>
|
||||||
{/* Duplicate activities for seamless loop */}
|
{/* Duplicate content for seamless loop */}
|
||||||
{[...activities, ...activities].map((activity, index) => (
|
{[...limitedActivities, ...limitedActivities].map((activity, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${activity.ticketNumber}-${index}`}
|
key={`${activity.ticketNumber}-${index}`}
|
||||||
className="inline-flex items-center mx-8"
|
className="inline-flex items-center mx-8 flex-shrink-0"
|
||||||
>
|
>
|
||||||
<span className="mr-3 text-2xl">{getPriorityIcon(activity.priority)}</span>
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex flex-col">
|
{/* Line 1: Priority - Ticket Number - Company Name */}
|
||||||
<div className="flex items-center text-xl">
|
<div className="flex items-center text-xl">
|
||||||
|
<span className="mr-2 text-2xl">{getPriorityIcon(activity.priority)}</span>
|
||||||
<span className={`font-bold mr-2 ${getPriorityColor(activity.priority)}`}>
|
<span className={`font-bold mr-2 ${getPriorityColor(activity.priority)}`}>
|
||||||
#{activity.ticketNumber}
|
{activity.ticketNumber}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-white mr-2">-</span>
|
<span className="text-white mr-2">-</span>
|
||||||
<span className="text-gray-400">{activity.title.substring(0, 60)}</span>
|
<span className="text-white font-semibold">
|
||||||
<span className="text-gray-500 text-lg ml-2">({activity.statusLabel})</span>
|
{activity.companyName}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg text-gray-300 mt-1">
|
{/* Line 2: Title - Status */}
|
||||||
{activity.companyName}
|
<div className="flex items-center text-base">
|
||||||
|
<span className="text-gray-400">{activity.title?.substring(0, 60) || ''}</span>
|
||||||
|
<span className={`ml-2 ${getStatusColor(activity.statusLabel)}`}>- {activity.statusLabel || 'Unknown'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
78
components/kiosk/ticket-leaders-card.tsx
Normal file
78
components/kiosk/ticket-leaders-card.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex h-full w-full bg-gradient-to-br from-purple-900/20 to-blue-900/20 border-4 border-purple-500 rounded-3xl overflow-hidden">
|
||||||
|
{/* Left 1/3 - Title and Icon */}
|
||||||
|
<div className="w-1/3 flex flex-col items-center justify-center p-8 border-r border-gray-700">
|
||||||
|
<Trophy className="w-24 h-24 mb-4 text-purple-500" />
|
||||||
|
<div className="text-6xl font-bold mb-2 text-purple-500">
|
||||||
|
{leaders.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl text-gray-300 text-center font-semibold">
|
||||||
|
Ticket Leaders
|
||||||
|
</div>
|
||||||
|
<div className="text-lg text-gray-500 mt-2">
|
||||||
|
Last 30 Days
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right 2/3 - Leaderboard */}
|
||||||
|
<div className="w-2/3 p-8 flex flex-col">
|
||||||
|
<h3 className="text-xl font-semibold text-gray-300 mb-4">Top Performers</h3>
|
||||||
|
<div className="space-y-2 flex-1 overflow-y-auto">
|
||||||
|
{leaders.map((leader, index) => (
|
||||||
|
<div
|
||||||
|
key={leader.resourceName}
|
||||||
|
className={`bg-black/30 rounded-lg p-4 border ${
|
||||||
|
index < 3 ? 'border-purple-500/50' : 'border-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className={`text-2xl font-bold ${getMedalColor(index)} min-w-[40px]`}>
|
||||||
|
{getMedalIcon(index)}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-semibold text-gray-300">
|
||||||
|
{leader.resourceName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl font-bold text-purple-400">
|
||||||
|
{leader.ticketsClosed}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">tickets</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
dev/W_RGB.png
Normal file
BIN
dev/W_RGB.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
Loading…
Add table
Add a link
Reference in a new issue