This repository has been archived on 2026-03-21. You can view files and clone it, but cannot push or open issues or pull requests.
payfrit-biz/hud/hud.js
John Mizerek 1210249f54 Normalize database column and table names across entire codebase
Update all SQL queries, query result references, and ColdFusion code to match
the renamed database schema. Tables use plural CamelCase, PKs are all `ID`,
column prefixes stripped (e.g. BusinessName→Name, UserFirstName→FirstName).

Key changes:
- Strip table-name prefixes from all column references (Businesses, Users,
  Addresses, Hours, Menus, Categories, Items, Stations, Orders,
  OrderLineItems, Tasks, TaskCategories, TaskRatings, QuickTaskTemplates,
  ScheduledTaskDefinitions, ChatMessages, Beacons, ServicePoints, Employees,
  VisitorTrackings, ApiPerfLogs, tt_States, tt_Days, tt_AddressTypes,
  tt_OrderTypes, tt_TaskTypes)
- Rename PK references from {TableName}ID to ID in all queries
- Rewrite 7 admin beacon files to use ServicePoints.BeaconID instead of
  dropped lt_Beacon_Businesses_ServicePoints link table
- Rewrite beacon assignment files (list, save, delete) for new schema
- Fix FK references incorrectly changed to ID (OrderLineItems.OrderID,
  Categories.MenuID, Tasks.CategoryID, ServicePoints.BeaconID)
- Update Addresses: AddressLat→Latitude, AddressLng→Longitude
- Update Users: UserPassword→Password, UserIsEmailVerified→IsEmailVerified,
  UserIsActive→IsActive, UserBalance→Balance, etc.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:39:12 -08:00

392 lines
11 KiB
JavaScript

/**
* Payfrit HUD - Heads Up Display for Task Management
*
* Displays tasks as vertical bars that grow over 60 seconds.
* Bars flash when they exceed the target time.
* Workers can tap to view details or long-press to accept.
*/
const HUD = {
// Configuration
config: {
pollInterval: 1000, // Poll every second for smooth animation
targetSeconds: 60, // Target time to accept a task
apiBaseUrl: '/api/tasks', // API endpoint
businessId: parseInt(localStorage.getItem('payfrit_portal_business')) || 0,
},
// State
tasks: [],
selectedTask: null,
longPressTimer: null,
isConnected: true,
businessName: '',
// Category names (will be loaded from API)
categories: {
1: { name: 'Orders', color: '#ef4444' },
2: { name: 'Pickup', color: '#f59e0b' },
3: { name: 'Delivery', color: '#22c55e' },
4: { name: 'Support', color: '#3b82f6' },
5: { name: 'Kitchen', color: '#8b5cf6' },
6: { name: 'Other', color: '#ec4899' },
},
// Initialize
init() {
this.updateClock();
setInterval(() => this.updateClock(), 1000);
setInterval(() => this.updateBars(), 1000);
setInterval(() => this.fetchTasks(), 3000);
// Initial fetch (also gets business name)
this.fetchTasks();
// Close overlay on background tap
document.getElementById('taskOverlay').addEventListener('click', (e) => {
if (e.target.id === 'taskOverlay') {
this.closeOverlay();
}
});
console.log('[HUD] Initialized');
},
// Update the clock display
updateClock() {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
document.getElementById('clock').textContent = `${hours}:${minutes}:${seconds}`;
},
// Fetch tasks from API
async fetchTasks() {
try {
const response = await fetch(`${this.config.apiBaseUrl}/listPending.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId })
});
const data = await response.json();
if (data.OK) {
this.tasks = data.TASKS || [];
this.renderTasks();
this.setConnected(true);
// Update business name from response
if (data.BUSINESS_NAME && !this.businessName) {
this.businessName = data.BUSINESS_NAME;
document.getElementById('businessName').textContent = ' - ' + this.businessName;
}
} else {
console.error('[HUD] API error:', data.ERROR);
this.setConnected(false);
}
} catch (err) {
console.error('[HUD] Fetch error:', err);
this.setConnected(false);
}
},
// Render task bars
renderTasks() {
const container = document.getElementById('taskContainer');
const emptyState = document.getElementById('emptyState');
// Show/hide empty state
if (this.tasks.length === 0) {
emptyState.style.display = 'block';
} else {
emptyState.style.display = 'none';
}
// Get existing bar elements
const existingBars = container.querySelectorAll('.task-bar');
const existingIds = new Set();
existingBars.forEach(bar => existingIds.add(parseInt(bar.dataset.taskId)));
// Get current task IDs
const currentIds = new Set(this.tasks.map(t => t.ID));
// Remove bars for tasks that no longer exist
existingBars.forEach(bar => {
const id = parseInt(bar.dataset.taskId);
if (!currentIds.has(id)) {
bar.remove();
}
});
// Add or update bars
this.tasks.forEach(task => {
let bar = container.querySelector(`.task-bar[data-task-id="${task.TaskID}"]`);
if (!bar) {
bar = this.createTaskBar(task);
container.appendChild(bar);
}
this.updateTaskBar(bar, task);
});
},
// Create a new task bar element
createTaskBar(task) {
const bar = document.createElement('div');
bar.className = 'task-bar';
bar.dataset.taskId = task.TaskID;
bar.dataset.category = task.TaskCategoryID || 1;
// Apply dynamic category color
const categoryColor = this.getCategoryColor(task);
bar.style.setProperty('--bar-color', categoryColor);
bar.innerHTML = `
<span class="task-time"></span>
<span class="task-id">#${task.TaskID}</span>
<span class="task-label">${this.getName(task)}</span>
`;
// Touch/click handlers
bar.addEventListener('mousedown', (e) => this.onBarPress(task, e));
bar.addEventListener('mouseup', () => this.onBarRelease());
bar.addEventListener('mouseleave', () => this.onBarRelease());
bar.addEventListener('touchstart', (e) => this.onBarPress(task, e));
bar.addEventListener('touchend', () => this.onBarRelease());
bar.addEventListener('click', () => this.onBarTap(task));
return bar;
},
// Update task bar height and state
updateTaskBar(bar, task) {
const elapsed = this.getElapsedSeconds(task.CreatedOn);
const percentage = Math.min(elapsed / this.config.targetSeconds * 100, 100);
bar.style.height = `${percentage}%`;
// Update time display
const timeEl = bar.querySelector('.task-time');
if (timeEl) {
timeEl.textContent = this.formatElapsed(elapsed);
}
// Flash if overdue
if (elapsed >= this.config.targetSeconds) {
bar.classList.add('flashing');
} else {
bar.classList.remove('flashing');
}
},
// Update all bars (called every second)
updateBars() {
const bars = document.querySelectorAll('.task-bar');
bars.forEach(bar => {
const taskId = parseInt(bar.dataset.taskId);
const task = this.tasks.find(t => t.ID === taskId);
if (task) {
this.updateTaskBar(bar, task);
}
});
},
// Get elapsed seconds since task creation
getElapsedSeconds(createdOn) {
const created = new Date(createdOn);
const now = new Date();
return Math.floor((now - created) / 1000);
},
// Format elapsed time as mm:ss
formatElapsed(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${String(secs).padStart(2, '0')}`;
},
// Get category/task type name - prefer task type for service bell tasks
getName(task) {
if (task && task.TaskTypeName && task.TaskTypeName.length > 0) {
return task.TaskTypeName;
}
if (task && task.Name) {
return task.Name;
}
return this.categories[task?.TaskCategoryID]?.name || 'Task';
},
// Get category/task type color - prefer task type color for service bell tasks
getCategoryColor(task) {
if (task && task.TaskTypeColor && task.TaskTypeColor.length > 0 && task.TaskTypeColor !== '#9C27B0') {
return task.TaskTypeColor;
}
if (task && task.Color && task.Color !== '#888888') {
return task.Color;
}
return this.categories[task?.TaskCategoryID]?.color || '#888888';
},
// Handle bar press (start long press timer)
onBarPress(task, e) {
e.preventDefault();
this.longPressTimer = setTimeout(() => {
this.acceptTaskDirect(task);
}, 800); // 800ms for long press
},
// Handle bar release (cancel long press)
onBarRelease() {
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
},
// Handle bar tap (show details)
onBarTap(task) {
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
this.showDetails(task);
},
// Show task details overlay
showDetails(task) {
this.selectedTask = task;
const overlay = document.getElementById('taskOverlay');
const detail = document.getElementById('taskDetail');
const elapsed = this.getElapsedSeconds(task.CreatedOn);
const categoryName = this.getName(task);
const categoryColor = this.getCategoryColor(task);
document.getElementById('detailTitle').textContent = task.Title || `Task #${task.TaskID}`;
document.getElementById('detailCategory').textContent = categoryName;
document.getElementById('detailCreated').textContent = new Date(task.CreatedOn).toLocaleTimeString();
document.getElementById('detailWaiting').textContent = this.formatElapsed(elapsed);
document.getElementById('detailInfo').textContent = task.Details || '-';
detail.style.setProperty('--detail-color', categoryColor);
overlay.classList.add('visible');
},
// Close overlay
closeOverlay() {
document.getElementById('taskOverlay').classList.remove('visible');
this.selectedTask = null;
},
// Accept task from overlay
acceptTask() {
if (this.selectedTask) {
this.acceptTaskDirect(this.selectedTask);
this.closeOverlay();
}
},
// Accept task directly (long press or button)
async acceptTaskDirect(task) {
console.log('[HUD] Accepting task:', task.TaskID);
try {
const response = await fetch(`${this.config.apiBaseUrl}/accept.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
TaskID: task.TaskID,
BusinessID: this.config.businessId
})
});
const data = await response.json();
if (data.OK) {
// Remove from local list immediately
this.tasks = this.tasks.filter(t => t.ID !== task.TaskID);
this.renderTasks();
this.showFeedback('Task accepted!', 'success');
} else {
this.showFeedback(data.ERROR || 'Failed to accept', 'error');
}
} catch (err) {
console.error('[HUD] Accept error:', err);
this.showFeedback('Failed to accept task', 'error');
}
},
// Show feedback message
showFeedback(message, type) {
// Simple console feedback for now
console.log(`[HUD] ${type}: ${message}`);
// TODO: Add toast notification
},
// Set connection status
setConnected(connected) {
this.isConnected = connected;
const indicator = document.getElementById('statusIndicator');
if (connected) {
indicator.classList.remove('disconnected');
} else {
indicator.classList.add('disconnected');
}
}
};
// Global functions for onclick handlers
function closeOverlay() {
HUD.closeOverlay();
}
function acceptTask() {
HUD.acceptTask();
}
function toggleFullscreen() {
const body = document.body;
const isMaximized = body.classList.contains('maximized');
if (isMaximized) {
// Restore normal view
body.classList.remove('maximized');
// Also exit native fullscreen if active
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
} else if (document.webkitFullscreenElement) {
document.webkitExitFullscreen();
}
} else {
// Maximize - hide header for more space
body.classList.add('maximized');
// Also try native fullscreen on desktop
const elem = document.documentElement;
if (elem.requestFullscreen) {
elem.requestFullscreen().catch(() => {});
} else if (elem.webkitRequestFullscreen) {
elem.webkitRequestFullscreen();
}
}
}
// Exit maximized mode when exiting native fullscreen
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement) {
document.body.classList.remove('maximized');
}
});
document.addEventListener('webkitfullscreenchange', () => {
if (!document.webkitFullscreenElement) {
document.body.classList.remove('maximized');
}
});
// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
HUD.init();
});