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
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<TickerActivity[]>([]);
|
||||
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[];
|
||||
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<Company[]>([]);
|
||||
|
|
@ -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() {
|
|||
<span>5 min</span>
|
||||
</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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue