The inverted group header item isn't always an order line item itself, so RemovedDefaults was never computed. Now detects inverted groups via children's ParentIsInvertedGroup flag and attaches RemovedDefaults to the first child as a proxy. KDS JS handles both patterns. Also skips showing default modifiers from inverted groups since those are represented by "NO removed-item" instead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
594 lines
19 KiB
JavaScript
594 lines
19 KiB
JavaScript
// Configuration
|
|
let config = {
|
|
apiBaseUrl: '/api',
|
|
businessId: parseInt(localStorage.getItem('payfrit_portal_business')) || 0,
|
|
stationId: null,
|
|
stationName: null,
|
|
stationColor: null,
|
|
refreshInterval: 5000,
|
|
};
|
|
|
|
// State
|
|
let orders = [];
|
|
let stations = [];
|
|
let refreshTimer = null;
|
|
let expandedOrders = new Set();
|
|
|
|
// Status ID mapping
|
|
const STATUS = { NEW: 1, PREPARING: 2, READY: 3, COMPLETED: 4 };
|
|
const STATUS_NAMES = { 1: 'New', 2: 'Preparing', 3: 'Ready', 4: 'Completed' };
|
|
|
|
// Initialize
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadConfig();
|
|
checkStationSelection();
|
|
updateClock();
|
|
setInterval(updateClock, 1000);
|
|
|
|
// Monitor online/offline status
|
|
window.addEventListener('online', () => {
|
|
console.log('[KDS] Back online');
|
|
updateStatus(true);
|
|
loadOrders(); // Refresh immediately when back online
|
|
});
|
|
|
|
window.addEventListener('offline', () => {
|
|
console.log('[KDS] Went offline');
|
|
updateStatus(false);
|
|
});
|
|
|
|
// Initial connection check
|
|
if (!navigator.onLine) {
|
|
updateStatus(false);
|
|
}
|
|
});
|
|
|
|
// Update clock display
|
|
function 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}`;
|
|
}
|
|
|
|
// Load config from localStorage
|
|
function loadConfig() {
|
|
// Load KDS-specific settings (station, refresh interval)
|
|
const saved = localStorage.getItem('kds_config');
|
|
if (saved) {
|
|
try {
|
|
const parsed = JSON.parse(saved);
|
|
// Reset station selection if business changed since last KDS use
|
|
if (parsed.businessId && parsed.businessId !== config.businessId) {
|
|
config.stationId = null;
|
|
config.stationName = null;
|
|
config.stationColor = null;
|
|
saveConfigToStorage();
|
|
} else {
|
|
config.stationId = parsed.stationId !== undefined ? parsed.stationId : null;
|
|
config.stationName = parsed.stationName || null;
|
|
config.stationColor = parsed.stationColor || null;
|
|
}
|
|
config.refreshInterval = (parsed.refreshInterval || 5) * 1000;
|
|
} catch (e) {
|
|
console.error('[KDS] Failed to load config:', e);
|
|
}
|
|
}
|
|
|
|
console.log('[KDS] Config loaded:', config);
|
|
}
|
|
|
|
function saveConfigToStorage() {
|
|
localStorage.setItem('kds_config', JSON.stringify({
|
|
businessId: config.businessId,
|
|
stationId: config.stationId,
|
|
stationName: config.stationName,
|
|
stationColor: config.stationColor,
|
|
refreshInterval: config.refreshInterval / 1000
|
|
}));
|
|
}
|
|
|
|
// Check if station selection is needed
|
|
async function checkStationSelection() {
|
|
if (!config.businessId) {
|
|
console.log('[KDS] No businessId - please log in via Portal first');
|
|
updateStatus(false, 'No business selected - log in via Portal');
|
|
return;
|
|
}
|
|
|
|
console.log('[KDS] Starting with businessId:', config.businessId, 'stationId:', config.stationId);
|
|
|
|
// Load business name
|
|
await loadName();
|
|
|
|
// If station already selected (including 0 for "all"), start KDS
|
|
if (config.stationId !== null && config.stationId !== undefined) {
|
|
updateStationBadge();
|
|
startAutoRefresh();
|
|
return;
|
|
}
|
|
|
|
// Load stations to see if we need to show picker
|
|
await loadStations();
|
|
if (stations.length > 0) {
|
|
showStationSelection();
|
|
} else {
|
|
// No stations configured, default to all orders
|
|
config.stationId = 0;
|
|
updateStationBadge();
|
|
startAutoRefresh();
|
|
}
|
|
}
|
|
|
|
// Load business name from API
|
|
async function loadName() {
|
|
if (!config.businessId) return;
|
|
|
|
try {
|
|
const response = await fetch(`${config.apiBaseUrl}/businesses/get.cfm`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ BusinessID: config.businessId })
|
|
});
|
|
const data = await response.json();
|
|
if (data.OK && data.BUSINESS && data.BUSINESS.Name) {
|
|
document.getElementById('businessName').textContent = ' - ' + data.BUSINESS.Name;
|
|
}
|
|
} catch (e) {
|
|
console.error('[KDS] Failed to load business name:', e);
|
|
}
|
|
}
|
|
|
|
// Load stations from API
|
|
async function loadStations() {
|
|
if (!config.businessId) return;
|
|
|
|
try {
|
|
const response = await fetch(`${config.apiBaseUrl}/stations/list.cfm`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ BusinessID: config.businessId })
|
|
});
|
|
const data = await response.json();
|
|
if (data.OK) {
|
|
stations = data.STATIONS || [];
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load stations:', e);
|
|
}
|
|
}
|
|
|
|
// Show station selection overlay
|
|
async function showStationSelection() {
|
|
if (stations.length === 0) {
|
|
await loadStations();
|
|
}
|
|
|
|
const overlay = document.getElementById('stationOverlay');
|
|
const buttons = document.getElementById('stationButtons');
|
|
|
|
let html = `
|
|
<button class="station-btn all-stations" onclick="selectStation(0, 'All Stations', null)">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
|
</svg>
|
|
All Stations
|
|
</button>
|
|
`;
|
|
|
|
stations.forEach(s => {
|
|
const color = s.Color || '#666';
|
|
html += `
|
|
<button class="station-btn" style="background: ${color}; color: #fff;" onclick="selectStation(${s.StationID}, '${escapeHtml(s.Name)}', '${color}')">
|
|
${escapeHtml(s.Name)}
|
|
</button>
|
|
`;
|
|
});
|
|
|
|
buttons.innerHTML = html;
|
|
overlay.classList.remove('hidden');
|
|
}
|
|
|
|
// Select a station
|
|
function selectStation(stationId, stationName, stationColor) {
|
|
config.stationId = stationId;
|
|
config.stationName = stationName;
|
|
config.stationColor = stationColor;
|
|
|
|
// Save to localStorage
|
|
saveConfigToStorage();
|
|
|
|
// Hide overlay and start
|
|
document.getElementById('stationOverlay').classList.add('hidden');
|
|
updateStationBadge();
|
|
startAutoRefresh();
|
|
}
|
|
|
|
// Update station badge in header
|
|
function updateStationBadge() {
|
|
const badge = document.getElementById('stationBadge');
|
|
const name = config.stationId && config.stationName ? config.stationName : 'All';
|
|
badge.innerHTML = `<span class="station-name-display">/ ${escapeHtml(name)}</span>`;
|
|
}
|
|
|
|
// Start auto-refresh
|
|
function startAutoRefresh() {
|
|
if (!config.businessId) return;
|
|
loadOrders();
|
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
refreshTimer = setInterval(loadOrders, config.refreshInterval);
|
|
}
|
|
|
|
// Load orders from API
|
|
async function loadOrders() {
|
|
if (!config.businessId) return;
|
|
|
|
// Check if online before attempting fetch
|
|
if (!navigator.onLine) {
|
|
updateStatus(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const url = `${config.apiBaseUrl}/orders/listForKDS.cfm`;
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
BusinessID: config.businessId,
|
|
StationID: config.stationId || 0
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.OK) {
|
|
orders = data.ORDERS || [];
|
|
renderOrders();
|
|
updateStatus(true, `${orders.length} active orders`);
|
|
} else {
|
|
updateStatus(false, `Error: ${data.MESSAGE || data.ERROR}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load orders:', error);
|
|
updateStatus(false, 'Connection error');
|
|
}
|
|
}
|
|
|
|
// Update status indicator
|
|
function updateStatus(isConnected, message) {
|
|
const indicator = document.getElementById('statusIndicator');
|
|
if (isConnected) {
|
|
indicator.classList.remove('disconnected');
|
|
} else {
|
|
indicator.classList.add('disconnected');
|
|
}
|
|
}
|
|
|
|
// Render orders to DOM
|
|
function renderOrders() {
|
|
const grid = document.getElementById('ordersGrid');
|
|
console.log('renderOrders called, orders count:', orders.length);
|
|
if (orders.length === 0) {
|
|
grid.innerHTML = `
|
|
<div class="empty-state">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
|
</svg>
|
|
<h2>No Active Orders</h2>
|
|
<p>New orders will appear here automatically</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
orders.forEach((order, i) => {
|
|
console.log(`Order ${i}: ID=${order.OrderID}, LineItems count=${order.LineItems?.length || 0}`);
|
|
if (order.LineItems) {
|
|
order.LineItems.forEach(li => {
|
|
console.log(` LineItem: ${li.Name} (ID=${li.OrderLineItemID}, ParentID=${li.ParentOrderLineItemID})`);
|
|
});
|
|
}
|
|
});
|
|
grid.innerHTML = orders.map(order => renderOrder(order)).join('');
|
|
}
|
|
|
|
// Check if a root item belongs to the current station
|
|
function isStationItem(item) {
|
|
if (!config.stationId || config.stationId === 0) return true;
|
|
const sid = parseInt(item.StationID) || 0;
|
|
return sid === config.stationId || sid === 0;
|
|
}
|
|
|
|
// Toggle expand/collapse for an order
|
|
function toggleExpand(orderId) {
|
|
if (expandedOrders.has(orderId)) {
|
|
expandedOrders.delete(orderId);
|
|
} else {
|
|
expandedOrders.add(orderId);
|
|
}
|
|
renderOrders();
|
|
}
|
|
|
|
// Render single order card
|
|
function renderOrder(order) {
|
|
const statusClass = getStatusClass(order.StatusID);
|
|
const elapsedTime = getElapsedTime(order.SubmittedOn);
|
|
const timeClass = getTimeClass(elapsedTime);
|
|
const allRootItems = order.LineItems.filter(item => item.ParentOrderLineItemID === 0);
|
|
const isFiltering = config.stationId && config.stationId > 0;
|
|
const isExpanded = expandedOrders.has(order.OrderID);
|
|
|
|
// Split into station items and other-station items
|
|
const stationItems = isFiltering ? allRootItems.filter(i => isStationItem(i)) : allRootItems;
|
|
const otherItems = isFiltering ? allRootItems.filter(i => !isStationItem(i)) : [];
|
|
const hasOtherItems = otherItems.length > 0;
|
|
|
|
// Build expand toggle button
|
|
let expandToggle = '';
|
|
if (isFiltering && hasOtherItems) {
|
|
if (isExpanded) {
|
|
expandToggle = `<button class="expand-toggle" onclick="toggleExpand(${order.OrderID})">Hide ${otherItems.length} other item${otherItems.length > 1 ? 's' : ''}</button>`;
|
|
} else {
|
|
expandToggle = `<button class="expand-toggle" onclick="toggleExpand(${order.OrderID})">Show ${otherItems.length} other item${otherItems.length > 1 ? 's' : ''}</button>`;
|
|
}
|
|
}
|
|
|
|
// Render line items: station items always, other items only when expanded
|
|
let lineItemsHtml = stationItems.map(item => renderLineItem(item, order.LineItems, false)).join('');
|
|
if (isExpanded && hasOtherItems) {
|
|
lineItemsHtml += otherItems.map(item => renderLineItem(item, order.LineItems, true)).join('');
|
|
}
|
|
|
|
return `
|
|
<div class="order-card ${statusClass}">
|
|
<div class="order-header">
|
|
<div class="order-number">#${order.OrderID}${order.OrderTypeID === 1 ? ' <span class="badge-dinein">DINE-IN</span>' : ''}${order.OrderTypeID === 2 ? ' <span class="badge-pickup">PICKUP</span>' : ''}${order.OrderTypeID === 3 ? ' <span class="badge-delivery">DELIVERY</span>' : ''}</div>
|
|
<div class="order-time">
|
|
<div class="elapsed-time ${timeClass}">${formatElapsedTime(elapsedTime)}</div>
|
|
<div class="submit-time">${formatSubmitTime(order.SubmittedOn)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="order-info">
|
|
<div><strong>${getLocationLabel(order)}:</strong> ${getLocationValue(order)}</div>
|
|
<div><strong>Customer:</strong> ${order.FirstName || ''} ${order.LastName || ''}</div>
|
|
<div><strong>Status:</strong> ${STATUS_NAMES[order.StatusID] || 'Unknown'}</div>
|
|
</div>
|
|
|
|
${order.Remarks ? `<div class="order-remarks">Note: ${escapeHtml(order.Remarks)}</div>` : ''}
|
|
|
|
<div class="line-items">
|
|
${lineItemsHtml}
|
|
</div>
|
|
${expandToggle}
|
|
|
|
<div class="action-buttons">
|
|
${renderActionButtons(order)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Render line item with modifiers
|
|
function renderLineItem(item, allItems, isOtherStation = false) {
|
|
const modifiers = allItems.filter(mod => mod.ParentOrderLineItemID === item.OrderLineItemID);
|
|
console.log(`Item: ${item.Name} (ID: ${item.OrderLineItemID}) has ${modifiers.length} direct modifiers:`, modifiers.map(m => m.Name));
|
|
const otherClass = isOtherStation ? ' other-station' : '';
|
|
const doneClass = (item.StatusID === 1) ? ' line-item-done' : '';
|
|
return `
|
|
<div class="line-item${otherClass}${doneClass}">
|
|
<div class="line-item-main">
|
|
<div class="item-name">${escapeHtml(item.Name)}</div>
|
|
<div class="item-qty">x${item.Quantity}</div>
|
|
</div>
|
|
${modifiers.length > 0 ? renderAllModifiers(modifiers, allItems) : ''}
|
|
${item.Remark ? `<div class="item-remark">Note: ${escapeHtml(item.Remark)}</div>` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Render all modifiers
|
|
function renderAllModifiers(modifiers, allItems) {
|
|
let html = '<div class="modifiers">';
|
|
|
|
function getModifierPath(mod) {
|
|
const path = [];
|
|
let current = mod;
|
|
while (current) {
|
|
path.unshift(current.Name);
|
|
const parentId = current.ParentOrderLineItemID;
|
|
if (parentId === 0) break;
|
|
current = allItems.find(item => item.OrderLineItemID === parentId);
|
|
}
|
|
return path;
|
|
}
|
|
|
|
const leafModifiers = [];
|
|
function collectLeafModifiers(mods, depth = 0) {
|
|
mods.forEach(mod => {
|
|
// Inverted groups: show removed defaults with "NO" prefix instead of listing all selected defaults
|
|
// Check both the item itself (if group header is in order) and proxy (first child carries the data)
|
|
const isInverted = mod.IsInvertedGroup || mod.ISINVERTEDGROUP || mod.IsInvertedGroupProxy || mod.ISINVERTEDGROUPPROXY;
|
|
if (isInverted) {
|
|
const removed = mod.RemovedDefaults || mod.REMOVEDDEFAULTS || [];
|
|
if (removed.length > 0) {
|
|
const groupName = mod.ItemParentName || mod.Name;
|
|
removed.forEach(name => {
|
|
leafModifiers.push({ mod: { Name: 'NO ' + name, ItemParentName: groupName }, path: [] });
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Skip default modifiers inside inverted groups — handled above with "NO" prefix
|
|
if (mod.IsCheckedByDefault && (mod.ParentIsInvertedGroup || mod.PARENTISINVERTEDGROUP)) return;
|
|
|
|
const children = allItems.filter(item => item.ParentOrderLineItemID === mod.OrderLineItemID);
|
|
if (children.length === 0) {
|
|
const path = getModifierPath(mod);
|
|
leafModifiers.push({ mod, path });
|
|
} else {
|
|
collectLeafModifiers(children, depth + 1);
|
|
}
|
|
});
|
|
}
|
|
collectLeafModifiers(modifiers);
|
|
|
|
leafModifiers.forEach(({ mod }) => {
|
|
const displayText = mod.ItemParentName
|
|
? `${mod.ItemParentName}: ${mod.Name}`
|
|
: mod.Name;
|
|
html += `<div class="modifier">+ ${escapeHtml(displayText)}</div>`;
|
|
});
|
|
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
// Check if this station's items are all done for an order
|
|
function isStationDoneForOrder(order) {
|
|
if (!config.stationId || config.stationId === 0) return false;
|
|
const stationRootItems = order.LineItems.filter(li =>
|
|
li.ParentOrderLineItemID === 0 && (parseInt(li.StationID) || 0) === config.stationId
|
|
);
|
|
if (stationRootItems.length === 0) return true; // no items for this station
|
|
return stationRootItems.every(li => li.StatusID === 1);
|
|
}
|
|
|
|
// Render action buttons based on order status
|
|
function renderActionButtons(order) {
|
|
const isFiltering = config.stationId && config.stationId > 0;
|
|
|
|
if (isFiltering) {
|
|
// Station worker view: acknowledge new orders, then mark station done
|
|
if (order.StatusID === STATUS.NEW) {
|
|
return `<button class="btn btn-start" onclick="updateOrderStatus(${order.OrderID}, ${STATUS.PREPARING})">Start Preparing</button>`;
|
|
}
|
|
if (order.StatusID === STATUS.PREPARING) {
|
|
if (isStationDoneForOrder(order)) {
|
|
return `<button class="btn btn-station-done" disabled>Station Done</button>`;
|
|
} else {
|
|
return `<button class="btn btn-station-done" onclick="markStationDone(${order.OrderID})">Mark Station Done</button>`;
|
|
}
|
|
}
|
|
if (order.StatusID === STATUS.READY) {
|
|
return `<button class="btn btn-complete" onclick="updateOrderStatus(${order.OrderID}, ${STATUS.COMPLETED})">Complete</button>`;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// Manager view (no station selected): whole-order buttons
|
|
switch (order.StatusID) {
|
|
case STATUS.NEW:
|
|
return `<button class="btn btn-start" onclick="updateOrderStatus(${order.OrderID}, ${STATUS.PREPARING})">Start Preparing</button>`;
|
|
case STATUS.PREPARING:
|
|
return `<button class="btn btn-ready" onclick="updateOrderStatus(${order.OrderID}, ${STATUS.READY})">Mark as Ready</button>`;
|
|
case STATUS.READY:
|
|
return `<button class="btn btn-complete" onclick="updateOrderStatus(${order.OrderID}, ${STATUS.COMPLETED})">Complete</button>`;
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// Update order status
|
|
async function updateOrderStatus(orderId, newStatusId) {
|
|
try {
|
|
const response = await fetch(`${config.apiBaseUrl}/orders/updateStatus.cfm`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ OrderID: orderId, StatusID: newStatusId })
|
|
});
|
|
const data = await response.json();
|
|
if (data.OK) {
|
|
await loadOrders();
|
|
} else {
|
|
alert(`Failed to update order: ${data.MESSAGE || data.ERROR}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to update order status:', error);
|
|
alert('Failed to update order status. Please try again.');
|
|
}
|
|
}
|
|
|
|
// Mark station's items as done for an order
|
|
async function markStationDone(orderId) {
|
|
try {
|
|
const response = await fetch(`${config.apiBaseUrl}/orders/markStationDone.cfm`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ OrderID: orderId, StationID: config.stationId })
|
|
});
|
|
const data = await response.json();
|
|
if (data.OK) {
|
|
await loadOrders();
|
|
} else {
|
|
alert(`Failed to mark station done: ${data.MESSAGE || data.ERROR}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to mark station done:', error);
|
|
alert('Failed to mark station done. Please try again.');
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
// Get location label based on order type (Table vs Type)
|
|
function getLocationLabel(order) {
|
|
// OrderTypeID: 1=Dine-In, 2=Takeaway, 3=Delivery
|
|
if (order.OrderTypeID === 2 || order.OrderTypeID === 3) {
|
|
return 'Type';
|
|
}
|
|
return 'Table';
|
|
}
|
|
|
|
// Get location value based on order type
|
|
function getLocationValue(order) {
|
|
// OrderTypeID: 1=Dine-In, 2=Takeaway, 3=Delivery
|
|
if (order.OrderTypeID === 2) {
|
|
return 'Takeaway';
|
|
}
|
|
if (order.OrderTypeID === 3) {
|
|
return 'Delivery';
|
|
}
|
|
// Dine-in: show service point name
|
|
return order.Name || 'N/A';
|
|
}
|
|
|
|
function getStatusClass(statusId) {
|
|
switch (statusId) {
|
|
case STATUS.NEW: return 'new';
|
|
case STATUS.PREPARING: return 'preparing';
|
|
case STATUS.READY: return 'ready';
|
|
default: return '';
|
|
}
|
|
}
|
|
|
|
function getElapsedTime(submittedOn) {
|
|
if (!submittedOn) return 0;
|
|
return Math.floor((new Date() - new Date(submittedOn)) / 1000);
|
|
}
|
|
|
|
function getTimeClass(seconds) {
|
|
if (seconds > 900) return 'critical';
|
|
if (seconds > 600) return 'warning';
|
|
return '';
|
|
}
|
|
|
|
function formatElapsedTime(seconds) {
|
|
const minutes = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
function formatSubmitTime(dateString) {
|
|
if (!dateString) return '';
|
|
return new Date(dateString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|