payfrit-works/portal/portal.js
John Mizerek 1f4d06edba Add Payfrit Works (WDS) support and task completion flow
Task System:
- Tasks auto-created when KDS marks order Ready (status 3)
- Duplicate task prevention via TaskOrderID check
- Task completion now marks associated order as Completed (status 4)
- Fixed isNull() check for TaskCompletedOn (use len() instead)
- Added TaskOrderID to task queries for order linking

Worker APIs:
- api/workers/myBusinesses.cfm with GROUP BY to prevent duplicates
- api/tasks/listMine.cfm for worker's claimed tasks with filters
- api/tasks/complete.cfm updates both task and order status
- api/tasks/accept.cfm for claiming tasks

KDS/Portal:
- KDS only shows orders with status < 4
- Portal dashboard improvements

Admin/Debug:
- Debug endpoints for tasks and businesses
- Test data reset endpoint

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 14:52:04 -08:00

758 lines
24 KiB
JavaScript

/**
* Payfrit Business Portal
* Modern admin interface for business management
*/
const Portal = {
// Configuration
config: {
apiBaseUrl: '/api',
businessId: null,
userId: null,
},
// State
currentPage: 'dashboard',
businessData: null,
menuData: null,
// Initialize
async init() {
console.log('[Portal] Initializing...');
// Check auth
await this.checkAuth();
// Setup navigation
this.setupNavigation();
// Setup sidebar toggle
this.setupSidebarToggle();
// Load initial data
await this.loadDashboard();
// Handle hash navigation
this.handleHashChange();
window.addEventListener('hashchange', () => this.handleHashChange());
console.log('[Portal] Ready');
},
// Check authentication
async checkAuth() {
// Check URL parameter for businessId, otherwise default to 17
const urlParams = new URLSearchParams(window.location.search);
this.config.businessId = parseInt(urlParams.get('bid')) || 17;
this.config.userId = 1;
// Fetch actual business info
try {
const response = await fetch(`${this.config.apiBaseUrl}/businesses/get.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) {
const biz = data.BUSINESS;
document.getElementById('businessName').textContent = biz.BusinessName || 'Business';
document.getElementById('businessAvatar').textContent = (biz.BusinessName || 'B').charAt(0).toUpperCase();
document.getElementById('userAvatar').textContent = 'U';
} else {
document.getElementById('businessName').textContent = 'Business #' + this.config.businessId;
document.getElementById('businessAvatar').textContent = 'B';
document.getElementById('userAvatar').textContent = 'U';
}
} catch (err) {
console.error('[Portal] Auth error:', err);
document.getElementById('businessName').textContent = 'Business #' + this.config.businessId;
document.getElementById('businessAvatar').textContent = 'B';
document.getElementById('userAvatar').textContent = 'U';
}
},
// Setup navigation
setupNavigation() {
document.querySelectorAll('.nav-item[data-page]').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const page = item.dataset.page;
if (page === 'logout') {
this.logout();
} else {
this.navigate(page);
}
});
});
},
// Setup sidebar toggle
setupSidebarToggle() {
const toggle = document.getElementById('sidebarToggle');
const sidebar = document.getElementById('sidebar');
toggle.addEventListener('click', () => {
sidebar.classList.toggle('collapsed');
});
},
// Handle hash change
handleHashChange() {
const hash = window.location.hash.slice(1) || 'dashboard';
this.navigate(hash);
},
// Navigate to page
navigate(page) {
console.log('[Portal] Navigating to:', page);
// Update active nav item
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.remove('active');
if (item.dataset.page === page) {
item.classList.add('active');
}
});
// Show page
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
const pageEl = document.getElementById(`page-${page}`);
if (pageEl) {
pageEl.classList.add('active');
}
// Update title
const titles = {
dashboard: 'Dashboard',
orders: 'Orders',
menu: 'Menu Management',
reports: 'Reports',
team: 'Team',
settings: 'Settings'
};
document.getElementById('pageTitle').textContent = titles[page] || page;
// Update URL
window.location.hash = page;
// Load page data
this.loadPageData(page);
this.currentPage = page;
},
// Load page data
async loadPageData(page) {
switch (page) {
case 'dashboard':
await this.loadDashboard();
break;
case 'orders':
await this.loadOrders();
break;
case 'menu':
await this.loadMenu();
break;
case 'reports':
await this.loadReports();
break;
case 'team':
await this.loadTeam();
break;
case 'settings':
await this.loadSettings();
break;
}
},
// Load dashboard
async loadDashboard() {
console.log('[Portal] Loading dashboard...');
try {
// Load stats
const stats = await this.fetchStats();
document.getElementById('statOrdersToday').textContent = stats.ordersToday || 0;
document.getElementById('statRevenueToday').textContent = '$' + (stats.revenueToday || 0).toFixed(2);
document.getElementById('statPendingOrders').textContent = stats.pendingOrders || 0;
document.getElementById('statMenuItems').textContent = stats.menuItems || 0;
// Load recent orders
const orders = await this.fetchRecentOrders();
this.renderRecentOrders(orders);
} catch (err) {
console.error('[Portal] Dashboard error:', err);
// Show demo data
document.getElementById('statOrdersToday').textContent = '12';
document.getElementById('statRevenueToday').textContent = '$342.50';
document.getElementById('statPendingOrders').textContent = '3';
document.getElementById('statMenuItems').textContent = '24';
}
},
// Fetch stats
async fetchStats() {
const response = await fetch(`${this.config.apiBaseUrl}/portal/stats.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId })
});
const data = await response.json();
if (data.OK) return data.STATS;
throw new Error(data.ERROR);
},
// Fetch recent orders
async fetchRecentOrders() {
const response = await fetch(`${this.config.apiBaseUrl}/orders/listForKDS.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId })
});
const data = await response.json();
if (data.OK) return data.ORDERS || [];
throw new Error(data.ERROR);
},
// Render recent orders
renderRecentOrders(orders) {
const container = document.getElementById('recentOrdersList');
if (!orders || orders.length === 0) {
container.innerHTML = '<div class="empty-state">No recent orders</div>';
return;
}
container.innerHTML = orders.slice(0, 5).map(order => `
<div class="menu-item">
<div class="item-info">
<div class="item-name">Order #${order.OrderID}</div>
<div class="item-description">${order.UserFirstName || 'Guest'} - ${order.LineItems?.length || 0} items</div>
</div>
<span class="status-badge ${this.getStatusClass(order.OrderStatusID)}">${this.getStatusText(order.OrderStatusID)}</span>
</div>
`).join('');
},
// Get status class
getStatusClass(statusId) {
const classes = {
1: 'submitted',
2: 'preparing',
3: 'ready',
4: 'completed'
};
return classes[statusId] || 'submitted';
},
// Get status text
getStatusText(statusId) {
const texts = {
1: 'Submitted',
2: 'Preparing',
3: 'Ready',
4: 'Completed'
};
return texts[statusId] || 'Unknown';
},
// Load orders
async loadOrders() {
console.log('[Portal] Loading orders...');
try {
const response = await fetch(`${this.config.apiBaseUrl}/orders/listForKDS.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId })
});
const data = await response.json();
if (data.OK) {
this.renderOrdersTable(data.ORDERS || []);
}
} catch (err) {
console.error('[Portal] Orders error:', err);
}
},
// Render orders table
renderOrdersTable(orders) {
const tbody = document.getElementById('ordersTableBody');
if (!orders || orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">No orders found</td></tr>';
return;
}
tbody.innerHTML = orders.map(order => `
<tr>
<td><strong>#${order.OrderID}</strong></td>
<td>${order.UserFirstName || 'Guest'} ${order.UserLastName || ''}</td>
<td>${order.LineItems?.length || 0} items</td>
<td>$${(order.OrderTotal || 0).toFixed(2)}</td>
<td><span class="status-badge ${this.getStatusClass(order.OrderStatusID)}">${this.getStatusText(order.OrderStatusID)}</span></td>
<td>${this.formatTime(order.OrderSubmittedOn)}</td>
<td>
<button class="btn btn-secondary" onclick="Portal.viewOrder(${order.OrderID})">View</button>
</td>
</tr>
`).join('');
},
// Format time
formatTime(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
},
// Load menu - show menu builder options
async loadMenu() {
console.log('[Portal] Loading menu...');
// Show links to menu tools
const container = document.getElementById('menuGrid');
container.innerHTML = `
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px;">
<div class="menu-editor-redirect">
<div class="redirect-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</svg>
</div>
<h3>Visual Menu Builder</h3>
<p>Drag-and-drop interface to build menus. Clone items, add modifiers, create photo tasks.</p>
<a href="/portal/menu-builder.html?bid=${this.config.businessId}" class="btn btn-primary btn-lg">
Open Builder
</a>
</div>
<div class="menu-editor-redirect">
<div class="redirect-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</div>
<h3>Classic Menu Editor</h3>
<p>Traditional form-based editor for detailed menu management.</p>
<a href="/index.cfm?mode=viewmenu" class="btn btn-secondary btn-lg">
Open Editor
</a>
</div>
</div>
`;
},
// Render menu
renderMenu() {
const container = document.getElementById('menuGrid');
if (!this.menuData || this.menuData.length === 0) {
container.innerHTML = '<div class="empty-state">No menu items. Click "Add Category" to get started.</div>';
return;
}
// Group by category
const categories = {};
this.menuData.forEach(item => {
if (item.ItemParentItemID === 0) {
// This is a top-level item, find its category
const catId = item.ItemCategoryID || 0;
if (!categories[catId]) {
categories[catId] = {
name: item.CategoryName || 'Uncategorized',
items: []
};
}
categories[catId].items.push(item);
}
});
container.innerHTML = Object.entries(categories).map(([catId, cat]) => `
<div class="menu-category">
<div class="category-header">
<h3>${cat.name}</h3>
<button class="btn btn-secondary" onclick="Portal.editCategory(${catId})">Edit</button>
</div>
<div class="menu-items">
${cat.items.map(item => `
<div class="menu-item">
<div class="item-image">
${item.ItemImageURL ? `<img src="${item.ItemImageURL}" alt="${item.ItemName}">` : '🍽️'}
</div>
<div class="item-info">
<div class="item-name">${item.ItemName}</div>
<div class="item-description">${item.ItemDescription || ''}</div>
</div>
<div class="item-price">$${(item.ItemPrice || 0).toFixed(2)}</div>
<div class="item-actions">
<button onclick="Portal.editItem(${item.ItemID})" title="Edit">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button onclick="Portal.deleteItem(${item.ItemID})" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
</svg>
</button>
</div>
</div>
`).join('')}
</div>
</div>
`).join('');
},
// Load reports
async loadReports() {
console.log('[Portal] Loading reports...');
// TODO: Implement reports loading
},
// Load team
async loadTeam() {
console.log('[Portal] Loading team...');
// TODO: Implement team loading
},
// Load settings
async loadSettings() {
console.log('[Portal] Loading settings...');
// Load business info
await this.loadBusinessInfo();
// Check Stripe status
await this.checkStripeStatus();
},
// Load business info for settings
async loadBusinessInfo() {
try {
const response = await fetch(`${this.config.apiBaseUrl}/businesses/get.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) {
const biz = data.BUSINESS;
document.getElementById('settingBusinessName').value = biz.BusinessName || '';
document.getElementById('settingDescription').value = biz.BusinessDescription || '';
document.getElementById('settingAddress').value = biz.BusinessAddress || '';
document.getElementById('settingPhone').value = biz.BusinessPhone || '';
document.getElementById('settingEmail').value = biz.BusinessEmail || '';
}
} catch (err) {
console.error('[Portal] Error loading business info:', err);
}
},
// Check Stripe Connect status
async checkStripeStatus() {
const statusContainer = document.getElementById('stripeStatus');
try {
const response = await fetch(`${this.config.apiBaseUrl}/stripe/status.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId })
});
const data = await response.json();
if (data.OK) {
if (data.CONNECTED) {
// Stripe is connected and active
statusContainer.innerHTML = `
<div class="status-icon connected">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div class="status-text">
<strong>Stripe Connected</strong>
<p>Your account is active and ready to accept payments</p>
</div>
<a href="https://dashboard.stripe.com" target="_blank" class="btn btn-secondary">
View Dashboard
</a>
`;
} else if (data.ACCOUNT_STATUS === 'pending_verification') {
// Waiting for Stripe verification
statusContainer.innerHTML = `
<div class="status-icon pending">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
</div>
<div class="status-text">
<strong>Verification Pending</strong>
<p>Stripe is reviewing your account. This usually takes 1-2 business days.</p>
</div>
<button class="btn btn-secondary" onclick="Portal.refreshStripeStatus()">
Check Status
</button>
`;
} else if (data.ACCOUNT_STATUS === 'incomplete') {
// Started but not finished onboarding
statusContainer.innerHTML = `
<div class="status-icon warning">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</div>
<div class="status-text">
<strong>Setup Incomplete</strong>
<p>Please complete your Stripe account setup to accept payments</p>
</div>
<button class="btn btn-primary" onclick="Portal.connectStripe()">
Continue Setup
</button>
`;
} else {
// Not started
this.renderStripeNotConnected();
}
} else {
this.renderStripeNotConnected();
}
} catch (err) {
console.error('[Portal] Stripe status error:', err);
this.renderStripeNotConnected();
}
},
// Render Stripe not connected state
renderStripeNotConnected() {
const statusContainer = document.getElementById('stripeStatus');
statusContainer.innerHTML = `
<div class="status-icon disconnected">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M15 9l-6 6M9 9l6 6"/>
</svg>
</div>
<div class="status-text">
<strong>Stripe Not Connected</strong>
<p>Connect your Stripe account to accept payments</p>
</div>
<button class="btn btn-primary" onclick="Portal.connectStripe()">
Connect Stripe
</button>
`;
},
// Refresh Stripe status
async refreshStripeStatus() {
this.toast('Checking status...', 'info');
await this.checkStripeStatus();
},
// Show add category modal
showAddCategoryModal() {
document.getElementById('modalTitle').textContent = 'Add Category';
document.getElementById('modalBody').innerHTML = `
<form id="addCategoryForm" class="form">
<div class="form-group">
<label>Category Name</label>
<input type="text" id="categoryName" class="form-input" required>
</div>
<div class="form-group">
<label>Description (optional)</label>
<textarea id="categoryDescription" class="form-textarea" rows="2"></textarea>
</div>
<div class="form-group">
<label>Display Order</label>
<input type="number" id="categoryOrder" class="form-input" value="0">
</div>
<button type="submit" class="btn btn-primary">Add Category</button>
</form>
`;
this.showModal();
document.getElementById('addCategoryForm').addEventListener('submit', (e) => {
e.preventDefault();
this.addCategory();
});
},
// Show add item modal
showAddItemModal() {
document.getElementById('modalTitle').textContent = 'Add Menu Item';
document.getElementById('modalBody').innerHTML = `
<form id="addItemForm" class="form">
<div class="form-group">
<label>Item Name</label>
<input type="text" id="itemName" class="form-input" required>
</div>
<div class="form-group">
<label>Description</label>
<textarea id="itemDescription" class="form-textarea" rows="2"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Price</label>
<input type="number" id="itemPrice" class="form-input" step="0.01" required>
</div>
<div class="form-group">
<label>Category</label>
<select id="itemCategory" class="form-select">
<option value="">Select Category</option>
</select>
</div>
</div>
<button type="submit" class="btn btn-primary">Add Item</button>
</form>
`;
this.showModal();
document.getElementById('addItemForm').addEventListener('submit', (e) => {
e.preventDefault();
this.addItem();
});
},
// Show modal
showModal() {
document.getElementById('modalOverlay').classList.add('visible');
},
// Close modal
closeModal() {
document.getElementById('modalOverlay').classList.remove('visible');
},
// Show toast
toast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
},
// Connect Stripe - initiate onboarding
async connectStripe() {
this.toast('Starting Stripe setup...', 'info');
try {
const response = await fetch(`${this.config.apiBaseUrl}/stripe/onboard.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId })
});
const data = await response.json();
if (data.OK && data.ONBOARDING_URL) {
// Redirect to Stripe onboarding
window.location.href = data.ONBOARDING_URL;
} else {
this.toast(data.ERROR || 'Failed to start Stripe setup', 'error');
}
} catch (err) {
console.error('[Portal] Stripe connect error:', err);
this.toast('Error connecting to Stripe', 'error');
}
},
// Logout
logout() {
if (confirm('Are you sure you want to logout?')) {
window.location.href = '/index.cfm?mode=logout';
}
},
// View order
viewOrder(orderId) {
this.toast(`Viewing order #${orderId}`, 'info');
// TODO: Implement order detail view
},
// Edit item
editItem(itemId) {
this.toast(`Editing item #${itemId}`, 'info');
// TODO: Implement item editing
},
// Delete item
deleteItem(itemId) {
if (confirm('Are you sure you want to delete this item?')) {
this.toast(`Deleting item #${itemId}`, 'info');
// TODO: Implement item deletion
}
},
// Edit category
editCategory(catId) {
this.toast(`Editing category #${catId}`, 'info');
// TODO: Implement category editing
},
// Add category
async addCategory() {
const name = document.getElementById('categoryName').value;
// TODO: Implement API call
this.toast(`Category "${name}" added!`, 'success');
this.closeModal();
this.loadMenu();
},
// Add item
async addItem() {
const name = document.getElementById('itemName').value;
// TODO: Implement API call
this.toast(`Item "${name}" added!`, 'success');
this.closeModal();
this.loadMenu();
},
// Show invite modal
showInviteModal() {
document.getElementById('modalTitle').textContent = 'Invite Team Member';
document.getElementById('modalBody').innerHTML = `
<form id="inviteForm" class="form">
<div class="form-group">
<label>Email Address</label>
<input type="email" id="inviteEmail" class="form-input" required>
</div>
<div class="form-group">
<label>Role</label>
<select id="inviteRole" class="form-select">
<option value="staff">Staff</option>
<option value="manager">Manager</option>
<option value="admin">Admin</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Send Invitation</button>
</form>
`;
this.showModal();
document.getElementById('inviteForm').addEventListener('submit', (e) => {
e.preventDefault();
const email = document.getElementById('inviteEmail').value;
this.toast(`Invitation sent to ${email}`, 'success');
this.closeModal();
});
}
};
// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
Portal.init();
});