- Remove Task Categories section from Task Admin page (deprecated) - Simplify HUD getCategoryColor() to use TaskTypeColor directly Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
617 lines
18 KiB
JavaScript
617 lines
18 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: '',
|
|
|
|
// Chat state
|
|
chatTaskId: null,
|
|
chatMessages: [],
|
|
chatPollInterval: null,
|
|
lastMessageId: 0,
|
|
|
|
// 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();
|
|
}
|
|
});
|
|
|
|
// Monitor online/offline status
|
|
window.addEventListener('online', () => {
|
|
console.log('[HUD] Back online');
|
|
this.setConnected(true);
|
|
this.fetchTasks();
|
|
});
|
|
|
|
window.addEventListener('offline', () => {
|
|
console.log('[HUD] Went offline');
|
|
this.setConnected(false);
|
|
});
|
|
|
|
// Initial connection check
|
|
if (!navigator.onLine) {
|
|
this.setConnected(false);
|
|
}
|
|
|
|
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() {
|
|
// Check if online before attempting fetch
|
|
if (!navigator.onLine) {
|
|
this.setConnected(false);
|
|
return;
|
|
}
|
|
|
|
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.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.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.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/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 task type color from tt_TaskTypes
|
|
getCategoryColor(task) {
|
|
if (task && task.TaskTypeColor && task.TaskTypeColor.length > 0) {
|
|
return task.TaskTypeColor;
|
|
}
|
|
return '#9C27B0'; // Default purple
|
|
},
|
|
|
|
// 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 || '-';
|
|
|
|
// Show location if available
|
|
const locationRow = document.getElementById('detailLocationRow');
|
|
const locationValue = document.getElementById('detailLocation');
|
|
if (task.ServicePointName && task.ServicePointName.length > 0) {
|
|
locationValue.textContent = task.ServicePointName;
|
|
locationRow.style.display = 'flex';
|
|
} else {
|
|
locationRow.style.display = 'none';
|
|
}
|
|
|
|
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');
|
|
|
|
// If it's a chat task, open the chat modal
|
|
if (this.isChatTask(task)) {
|
|
this.openChat(task);
|
|
}
|
|
} 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');
|
|
}
|
|
},
|
|
|
|
// Check if task is a chat task
|
|
isChatTask(task) {
|
|
if (!task) return false;
|
|
const name = (task.TaskTypeName || task.Title || '').toLowerCase();
|
|
return name.includes('chat');
|
|
},
|
|
|
|
// Open chat modal for a task
|
|
openChat(task) {
|
|
this.chatTaskId = task.TaskID;
|
|
this.chatMessages = [];
|
|
this.lastMessageId = 0;
|
|
|
|
document.getElementById('chatTitle').textContent = task.Title || 'Chat';
|
|
document.getElementById('chatMessages').innerHTML = '<div class="chat-loading">Loading messages...</div>';
|
|
document.getElementById('chatOverlay').classList.add('visible');
|
|
document.getElementById('chatInput').focus();
|
|
|
|
// Load messages immediately and start polling
|
|
this.loadMessages();
|
|
this.chatPollInterval = setInterval(() => this.loadMessages(), 2000);
|
|
},
|
|
|
|
// Close chat modal
|
|
closeChat() {
|
|
document.getElementById('chatOverlay').classList.remove('visible');
|
|
if (this.chatPollInterval) {
|
|
clearInterval(this.chatPollInterval);
|
|
this.chatPollInterval = null;
|
|
}
|
|
this.chatTaskId = null;
|
|
this.chatMessages = [];
|
|
this.lastMessageId = 0;
|
|
},
|
|
|
|
// Load chat messages
|
|
async loadMessages() {
|
|
if (!this.chatTaskId) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/chat/getMessages.cfm', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
TaskID: this.chatTaskId,
|
|
AfterMessageID: this.lastMessageId
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.OK) {
|
|
const messages = data.MESSAGES || [];
|
|
|
|
// If first load, replace all; otherwise append new messages
|
|
if (this.lastMessageId === 0) {
|
|
this.chatMessages = messages;
|
|
} else if (messages.length > 0) {
|
|
this.chatMessages = [...this.chatMessages, ...messages];
|
|
}
|
|
|
|
// Update last message ID
|
|
if (messages.length > 0) {
|
|
this.lastMessageId = messages[messages.length - 1].MessageID;
|
|
}
|
|
|
|
this.renderMessages();
|
|
|
|
// Check if chat was closed
|
|
if (data.CHAT_CLOSED) {
|
|
this.showFeedback('Chat has ended', 'info');
|
|
this.closeChat();
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('[HUD] Load messages error:', err);
|
|
}
|
|
},
|
|
|
|
// Render chat messages
|
|
renderMessages() {
|
|
const container = document.getElementById('chatMessages');
|
|
|
|
if (this.chatMessages.length === 0) {
|
|
container.innerHTML = '<div class="chat-loading">No messages yet. Start the conversation!</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = this.chatMessages.map(msg => {
|
|
const isStaff = msg.SenderType === 'staff' || msg.SenderType === 'worker';
|
|
const time = new Date(msg.CreatedOn).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
|
|
return `
|
|
<div class="chat-message ${isStaff ? 'staff' : 'customer'}">
|
|
<div class="sender">${msg.SenderName || (isStaff ? 'Staff' : 'Customer')}</div>
|
|
<div class="text">${this.escapeHtml(msg.MessageBody)}</div>
|
|
<div class="time">${time}</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
// Scroll to bottom
|
|
container.scrollTop = container.scrollHeight;
|
|
},
|
|
|
|
// Send a chat message
|
|
async sendMessage() {
|
|
const input = document.getElementById('chatInput');
|
|
const message = input.value.trim();
|
|
|
|
if (!message || !this.chatTaskId) return;
|
|
|
|
input.value = '';
|
|
input.disabled = true;
|
|
|
|
try {
|
|
const response = await fetch('/api/chat/sendMessage.cfm', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
TaskID: this.chatTaskId,
|
|
Message: message,
|
|
SenderType: 'staff'
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.OK) {
|
|
// Add message locally for immediate feedback
|
|
this.chatMessages.push({
|
|
MessageID: data.MessageID || Date.now(),
|
|
MessageBody: message,
|
|
SenderType: 'staff',
|
|
SenderName: 'Staff',
|
|
CreatedOn: new Date().toISOString()
|
|
});
|
|
this.renderMessages();
|
|
} else {
|
|
this.showFeedback('Failed to send message', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('[HUD] Send message error:', err);
|
|
this.showFeedback('Failed to send message', 'error');
|
|
}
|
|
|
|
input.disabled = false;
|
|
input.focus();
|
|
},
|
|
|
|
// End the chat (complete the task)
|
|
async endChat() {
|
|
if (!this.chatTaskId) return;
|
|
|
|
if (!confirm('End this chat?')) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/tasks/completeChat.cfm', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ TaskID: this.chatTaskId })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.OK) {
|
|
this.showFeedback('Chat ended', 'success');
|
|
this.closeChat();
|
|
} else {
|
|
this.showFeedback(data.ERROR || 'Failed to end chat', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('[HUD] End chat error:', err);
|
|
this.showFeedback('Failed to end chat', 'error');
|
|
}
|
|
},
|
|
|
|
// Escape HTML for safe rendering
|
|
escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
};
|
|
|
|
// 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();
|
|
});
|