- HUD now displays "Payfrit Tasks - <BusinessName>" by fetching from getBusiness API - Fixed portal Task HUD button to link to /hud/index.html instead of /hud/ Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
384 lines
11 KiB
JavaScript
384 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(new URLSearchParams(window.location.search).get('b')) || 17,
|
|
},
|
|
|
|
// 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);
|
|
|
|
// Fetch business name
|
|
this.fetchBusinessName();
|
|
|
|
// Initial fetch
|
|
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 business name from API
|
|
async fetchBusinessName() {
|
|
try {
|
|
const response = await fetch('/api/setup/getBusiness.cfm', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ BusinessID: this.config.businessId })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.OK && data.BUSINESS && data.BUSINESS.BusinessName) {
|
|
this.businessName = data.BUSINESS.BusinessName;
|
|
document.getElementById('businessName').textContent = ' - ' + this.businessName;
|
|
}
|
|
} catch (err) {
|
|
console.error('[HUD] Error fetching business name:', err);
|
|
}
|
|
},
|
|
|
|
// 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);
|
|
} else {
|
|
console.error('[HUD] API error:', data.ERROR);
|
|
this.setConnected(false);
|
|
}
|
|
} catch (err) {
|
|
console.error('[HUD] Fetch error:', err);
|
|
this.setConnected(false);
|
|
|
|
// Demo mode: generate fake tasks if API unavailable
|
|
if (this.tasks.length === 0) {
|
|
this.loadDemoTasks();
|
|
}
|
|
}
|
|
},
|
|
|
|
// Load demo tasks for testing
|
|
loadDemoTasks() {
|
|
const now = Date.now();
|
|
this.tasks = [
|
|
{ TaskID: 101, TaskCategoryID: 1, TaskTitle: 'Order #42', TaskCreatedOn: new Date(now - 15000).toISOString() },
|
|
{ TaskID: 102, TaskCategoryID: 3, TaskTitle: 'Order #43', TaskCreatedOn: new Date(now - 35000).toISOString() },
|
|
{ TaskID: 103, TaskCategoryID: 2, TaskTitle: 'Pickup #7', TaskCreatedOn: new Date(now - 55000).toISOString() },
|
|
{ TaskID: 104, TaskCategoryID: 5, TaskTitle: 'Prep #12', TaskCreatedOn: new Date(now - 70000).toISOString() },
|
|
];
|
|
this.renderTasks();
|
|
},
|
|
|
|
// 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.TaskID));
|
|
|
|
// 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.getCategoryName(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.TaskCreatedOn);
|
|
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.TaskID === 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 name - use task's category name if available, fall back to hardcoded
|
|
getCategoryName(task) {
|
|
if (task && task.TaskCategoryName) {
|
|
return task.TaskCategoryName;
|
|
}
|
|
return this.categories[task?.TaskCategoryID]?.name || 'Task';
|
|
},
|
|
|
|
// Get category color - use task's category color if available, fall back to hardcoded
|
|
getCategoryColor(task) {
|
|
if (task && task.TaskCategoryColor) {
|
|
return task.TaskCategoryColor;
|
|
}
|
|
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.showTaskDetails(task);
|
|
},
|
|
|
|
// Show task details overlay
|
|
showTaskDetails(task) {
|
|
this.selectedTask = task;
|
|
const overlay = document.getElementById('taskOverlay');
|
|
const detail = document.getElementById('taskDetail');
|
|
|
|
const elapsed = this.getElapsedSeconds(task.TaskCreatedOn);
|
|
const categoryName = this.getCategoryName(task);
|
|
const categoryColor = this.getCategoryColor(task);
|
|
|
|
document.getElementById('detailTitle').textContent = task.TaskTitle || `Task #${task.TaskID}`;
|
|
document.getElementById('detailCategory').textContent = categoryName;
|
|
document.getElementById('detailCreated').textContent = new Date(task.TaskCreatedOn).toLocaleTimeString();
|
|
document.getElementById('detailWaiting').textContent = this.formatElapsed(elapsed);
|
|
document.getElementById('detailInfo').textContent = task.TaskDetails || '-';
|
|
|
|
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.TaskID !== 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);
|
|
// Demo mode: just remove locally
|
|
this.tasks = this.tasks.filter(t => t.TaskID !== task.TaskID);
|
|
this.renderTasks();
|
|
this.showFeedback('Task accepted!', 'success');
|
|
}
|
|
},
|
|
|
|
// 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();
|
|
}
|
|
|
|
// Initialize on load
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
HUD.init();
|
|
});
|