- Add uploadHeader.cfm API for 1200px header images - Add saveBrandColor.cfm API for hex color storage - Add Branding section to menu builder sidebar - Fix header upload path and permissions - Various beacon and service point API improvements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1764 lines
60 KiB
JavaScript
1764 lines
60 KiB
JavaScript
/**
|
|
* Payfrit Business Portal
|
|
* Modern admin interface for business management
|
|
*/
|
|
|
|
// Detect base path for API calls (handles local dev at /biz.payfrit.com/)
|
|
const BASE_PATH = (() => {
|
|
const path = window.location.pathname;
|
|
const portalIndex = path.indexOf('/portal/');
|
|
if (portalIndex > 0) {
|
|
return path.substring(0, portalIndex);
|
|
}
|
|
return '';
|
|
})();
|
|
|
|
const Portal = {
|
|
// Configuration
|
|
config: {
|
|
apiBaseUrl: BASE_PATH + '/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 if user is logged in
|
|
const token = localStorage.getItem('payfrit_portal_token');
|
|
const savedBusiness = localStorage.getItem('payfrit_portal_business');
|
|
const userId = localStorage.getItem('payfrit_portal_userid');
|
|
|
|
if (!token || !savedBusiness) {
|
|
// Not logged in - redirect to login
|
|
localStorage.removeItem('payfrit_portal_token');
|
|
localStorage.removeItem('payfrit_portal_userid');
|
|
localStorage.removeItem('payfrit_portal_business');
|
|
window.location.href = BASE_PATH + '/portal/login.html';
|
|
return;
|
|
}
|
|
|
|
// Use saved business ID, but allow URL override
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
this.config.businessId = parseInt(urlParams.get('bid')) || parseInt(savedBusiness);
|
|
this.config.userId = parseInt(userId) || 1;
|
|
this.config.token = token;
|
|
|
|
// Verify user has access to this business
|
|
try {
|
|
const response = await fetch(`${this.config.apiBaseUrl}/portal/myBusinesses.cfm`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-User-Token': token
|
|
},
|
|
body: JSON.stringify({ UserID: this.config.userId })
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.OK && data.BUSINESSES) {
|
|
const hasAccess = data.BUSINESSES.some(b => b.BusinessID === this.config.businessId);
|
|
if (!hasAccess && data.BUSINESSES.length > 0) {
|
|
// User doesn't have access to requested business, use their first business
|
|
this.config.businessId = data.BUSINESSES[0].BusinessID;
|
|
localStorage.setItem('payfrit_portal_business', this.config.businessId);
|
|
} else if (!hasAccess) {
|
|
// User has no businesses
|
|
this.toast('No businesses associated with your account', 'error');
|
|
this.logout();
|
|
return;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Auth verification error:', err);
|
|
}
|
|
|
|
// 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;
|
|
this.businessData = biz; // Store for later use
|
|
document.getElementById('businessName').textContent = biz.BusinessName || 'Business';
|
|
document.getElementById('businessAvatar').textContent = (biz.BusinessName || 'B').charAt(0).toUpperCase();
|
|
document.getElementById('userAvatar').textContent = 'U';
|
|
} else {
|
|
this.businessData = null;
|
|
document.getElementById('businessName').textContent = 'Business #' + this.config.businessId;
|
|
document.getElementById('businessAvatar').textContent = 'B';
|
|
document.getElementById('userAvatar').textContent = 'U';
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Business info error:', err);
|
|
document.getElementById('businessName').textContent = 'Business #' + this.config.businessId;
|
|
document.getElementById('businessAvatar').textContent = 'B';
|
|
document.getElementById('userAvatar').textContent = 'U';
|
|
}
|
|
},
|
|
|
|
// Logout
|
|
logout() {
|
|
localStorage.removeItem('payfrit_portal_token');
|
|
localStorage.removeItem('payfrit_portal_userid');
|
|
localStorage.removeItem('payfrit_portal_business');
|
|
window.location.href = BASE_PATH + '/portal/login.html';
|
|
},
|
|
|
|
// 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 'beacons':
|
|
await this.loadBeaconsPage();
|
|
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 for orders
|
|
getStatusClass(statusId) {
|
|
const classes = {
|
|
0: 'pending', // Employee: Pending
|
|
1: 'submitted', // Order: Submitted, Employee: Invited
|
|
2: 'active', // Order: Preparing, Employee: Active
|
|
3: 'suspended', // Order: Ready, Employee: Suspended
|
|
4: 'completed' // Order: Completed
|
|
};
|
|
return classes[statusId] || 'pending';
|
|
},
|
|
|
|
// 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' });
|
|
},
|
|
|
|
// Format phone number
|
|
formatPhone(phone) {
|
|
if (!phone) return '-';
|
|
// Remove all non-digits
|
|
const digits = phone.replace(/\D/g, '');
|
|
if (digits.length === 10) {
|
|
return `(${digits.slice(0,3)}) ${digits.slice(3,6)}-${digits.slice(6)}`;
|
|
}
|
|
if (digits.length === 11 && digits[0] === '1') {
|
|
return `(${digits.slice(1,4)}) ${digits.slice(4,7)}-${digits.slice(7)}`;
|
|
}
|
|
return phone;
|
|
},
|
|
|
|
// 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="${BASE_PATH}/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">
|
|
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
|
<path d="M8 21h8M12 17v4"/>
|
|
</svg>
|
|
</div>
|
|
<h3>Station Assignment</h3>
|
|
<p>Drag items to stations (Grill, Fry, Drinks, etc.) for KDS routing.</p>
|
|
<a href="/portal/station-assignment.html?bid=${this.config.businessId}" class="btn btn-secondary btn-lg">
|
|
Assign Stations
|
|
</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 for business:', this.config.businessId);
|
|
const tbody = document.getElementById('teamTableBody');
|
|
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">Loading team...</td></tr>';
|
|
|
|
// Load hiring toggle state from business data
|
|
this.loadHiringToggle();
|
|
|
|
try {
|
|
const url = `${this.config.apiBaseUrl}/portal/team.cfm`;
|
|
console.log('[Portal] Fetching team from:', url);
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ BusinessID: this.config.businessId })
|
|
});
|
|
const data = await response.json();
|
|
console.log('[Portal] Team response:', data);
|
|
|
|
if (data.OK && data.TEAM) {
|
|
if (data.TEAM.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">No team members yet</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = data.TEAM.map(member => `
|
|
<tr>
|
|
<td>
|
|
<div class="member-info">
|
|
<span class="member-avatar">${(member.FirstName || '?').charAt(0)}${(member.LastName || '').charAt(0)}</span>
|
|
<span>${member.Name || 'Unknown'}</span>
|
|
</div>
|
|
</td>
|
|
<td>${member.Email || '-'}</td>
|
|
<td>${this.formatPhone(member.Phone)}</td>
|
|
<td>
|
|
<span class="status-badge ${this.getStatusClass(member.StatusID)}">
|
|
${member.StatusName || 'Unknown'}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-secondary" onclick="Portal.editTeamMember(${member.EmployeeID})">Edit</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
} else {
|
|
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">Failed to load team</td></tr>';
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Error loading team:', err);
|
|
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">Error loading team</td></tr>';
|
|
}
|
|
},
|
|
|
|
// Load hiring toggle from business data
|
|
loadHiringToggle() {
|
|
const toggle = document.getElementById('hiringToggle');
|
|
if (!toggle) return;
|
|
|
|
// Remove existing listener if any
|
|
toggle.onchange = null;
|
|
|
|
// Set initial state from business data
|
|
if (this.businessData && typeof this.businessData.IsHiring !== 'undefined') {
|
|
toggle.checked = this.businessData.IsHiring;
|
|
console.log('[Portal] Hiring toggle set to:', this.businessData.IsHiring);
|
|
} else {
|
|
console.log('[Portal] No business data for hiring toggle, fetching...');
|
|
// Fetch fresh business data if not available
|
|
this.refreshBusinessData();
|
|
}
|
|
|
|
// Wire up the toggle
|
|
toggle.onchange = () => this.toggleHiring(toggle.checked);
|
|
},
|
|
|
|
// Refresh business data
|
|
async refreshBusinessData() {
|
|
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) {
|
|
this.businessData = data.BUSINESS;
|
|
const toggle = document.getElementById('hiringToggle');
|
|
if (toggle) {
|
|
toggle.checked = this.businessData.IsHiring;
|
|
console.log('[Portal] Hiring toggle refreshed to:', this.businessData.IsHiring);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Error refreshing business data:', err);
|
|
}
|
|
},
|
|
|
|
// Toggle hiring status
|
|
async toggleHiring(isHiring) {
|
|
console.log('[Portal] Setting hiring to:', isHiring);
|
|
try {
|
|
const response = await fetch(`${this.config.apiBaseUrl}/businesses/setHiring.cfm`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
BusinessID: this.config.businessId,
|
|
IsHiring: isHiring
|
|
})
|
|
});
|
|
const data = await response.json();
|
|
console.log('[Portal] setHiring response:', data);
|
|
|
|
if (data.OK) {
|
|
// Update local state
|
|
if (this.businessData) {
|
|
this.businessData.IsHiring = data.IsHiring;
|
|
}
|
|
this.toast(isHiring ? 'Now accepting applications' : 'Applications disabled', 'success');
|
|
} else {
|
|
// Revert toggle
|
|
const toggle = document.getElementById('hiringToggle');
|
|
if (toggle) toggle.checked = !isHiring;
|
|
this.toast(data.ERROR || 'Failed to update hiring status', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Error toggling hiring:', err);
|
|
// Revert toggle
|
|
const toggle = document.getElementById('hiringToggle');
|
|
if (toggle) toggle.checked = !isHiring;
|
|
this.toast('Error updating hiring status', 'error');
|
|
}
|
|
},
|
|
|
|
// Edit team member (placeholder)
|
|
editTeamMember(employeeId) {
|
|
this.showToast('Team member editing coming soon', 'info');
|
|
},
|
|
|
|
// Load settings
|
|
async loadSettings() {
|
|
console.log('[Portal] Loading settings...');
|
|
|
|
// Load states dropdown first
|
|
await this.loadStatesDropdown();
|
|
|
|
// Load business info
|
|
await this.loadBusinessInfo();
|
|
|
|
// Check Stripe status
|
|
await this.checkStripeStatus();
|
|
},
|
|
|
|
// Load states for dropdown
|
|
async loadStatesDropdown() {
|
|
try {
|
|
const response = await fetch(`${this.config.apiBaseUrl}/addresses/states.cfm`);
|
|
const data = await response.json();
|
|
|
|
if (data.OK && data.STATES) {
|
|
const select = document.getElementById('settingState');
|
|
select.innerHTML = '<option value="">--</option>';
|
|
data.STATES.forEach(state => {
|
|
select.innerHTML += `<option value="${state.Abbr}">${state.Abbr}</option>`;
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Error loading states:', err);
|
|
}
|
|
},
|
|
|
|
// 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;
|
|
this.currentBusiness = biz;
|
|
|
|
// Populate form fields
|
|
document.getElementById('settingBusinessName').value = biz.BusinessName || '';
|
|
document.getElementById('settingPhone').value = biz.BusinessPhone || '';
|
|
document.getElementById('settingAddressLine1').value = biz.AddressLine1 || '';
|
|
document.getElementById('settingCity').value = biz.AddressCity || '';
|
|
document.getElementById('settingState').value = biz.AddressState || '';
|
|
document.getElementById('settingZip').value = biz.AddressZip || '';
|
|
|
|
// Render hours editor
|
|
this.renderHoursEditor(biz.BusinessHoursDetail || []);
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Error loading business info:', err);
|
|
}
|
|
},
|
|
|
|
// Render hours editor
|
|
renderHoursEditor(hours) {
|
|
const container = document.getElementById('hoursEditor');
|
|
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
const dayIds = [1, 2, 3, 4, 5, 6, 7]; // Monday=1 through Sunday=7
|
|
|
|
// Create a map of existing hours by day ID
|
|
const hoursMap = {};
|
|
hours.forEach(h => {
|
|
hoursMap[h.dayId] = h;
|
|
});
|
|
|
|
let html = '<div class="hours-grid" style="display:flex;flex-direction:column;gap:12px;">';
|
|
|
|
dayIds.forEach((dayId, idx) => {
|
|
const dayName = dayNames[idx];
|
|
const existing = hoursMap[dayId];
|
|
const isClosed = !existing;
|
|
const openTime = existing ? this.formatTimeFor24Input(existing.open) : '09:00';
|
|
const closeTime = existing ? this.formatTimeFor24Input(existing.close) : '17:00';
|
|
|
|
html += `
|
|
<div class="hours-row" style="display:grid;grid-template-columns:100px 1fr 1fr auto;gap:12px;align-items:center;">
|
|
<label style="font-weight:500;">${dayName}</label>
|
|
<input type="time" id="hours_open_${dayId}" value="${openTime}" class="form-input" ${isClosed ? 'disabled' : ''} style="padding:8px;">
|
|
<input type="time" id="hours_close_${dayId}" value="${closeTime}" class="form-input" ${isClosed ? 'disabled' : ''} style="padding:8px;">
|
|
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
|
<input type="checkbox" id="hours_closed_${dayId}" ${isClosed ? 'checked' : ''} onchange="Portal.toggleHoursDay(${dayId})">
|
|
Closed
|
|
</label>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += '</div>';
|
|
container.innerHTML = html;
|
|
},
|
|
|
|
// Helper to convert "7:00 AM" format to "07:00" for time input
|
|
formatTimeFor24Input(timeStr) {
|
|
if (!timeStr) return '09:00';
|
|
// If already in 24h format (HH:MM), return as-is
|
|
if (/^\d{2}:\d{2}$/.test(timeStr)) return timeStr;
|
|
|
|
// Parse "7:00 AM" or "7:30 PM" format
|
|
const match = timeStr.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i);
|
|
if (match) {
|
|
let hours = parseInt(match[1]);
|
|
const minutes = match[2];
|
|
const ampm = match[3].toUpperCase();
|
|
|
|
if (ampm === 'PM' && hours < 12) hours += 12;
|
|
if (ampm === 'AM' && hours === 12) hours = 0;
|
|
|
|
return `${String(hours).padStart(2, '0')}:${minutes}`;
|
|
}
|
|
|
|
return timeStr;
|
|
},
|
|
|
|
// Toggle hours day closed/open
|
|
toggleHoursDay(dayId) {
|
|
const isClosed = document.getElementById(`hours_closed_${dayId}`).checked;
|
|
document.getElementById(`hours_open_${dayId}`).disabled = isClosed;
|
|
document.getElementById(`hours_close_${dayId}`).disabled = isClosed;
|
|
},
|
|
|
|
// Save business info
|
|
async saveBusinessInfo(event) {
|
|
event.preventDefault();
|
|
|
|
const btn = document.getElementById('saveBusinessBtn');
|
|
const originalText = btn.textContent;
|
|
btn.textContent = 'Saving...';
|
|
btn.disabled = true;
|
|
|
|
try {
|
|
const response = await fetch(`${this.config.apiBaseUrl}/businesses/update.cfm`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
BusinessID: this.config.businessId,
|
|
BusinessName: document.getElementById('settingBusinessName').value,
|
|
BusinessPhone: document.getElementById('settingPhone').value,
|
|
AddressLine1: document.getElementById('settingAddressLine1').value,
|
|
City: document.getElementById('settingCity').value,
|
|
State: document.getElementById('settingState').value,
|
|
Zip: document.getElementById('settingZip').value
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.OK) {
|
|
this.showToast('Business info saved!', 'success');
|
|
// Reload to refresh sidebar etc
|
|
await this.loadBusinessInfo();
|
|
} else {
|
|
this.showToast(data.ERROR || 'Failed to save', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Error saving business info:', err);
|
|
this.showToast('Error saving business info', 'error');
|
|
} finally {
|
|
btn.textContent = originalText;
|
|
btn.disabled = false;
|
|
}
|
|
},
|
|
|
|
// Save hours
|
|
async saveHours() {
|
|
const dayIds = [1, 2, 3, 4, 5, 6, 7];
|
|
const hours = [];
|
|
|
|
dayIds.forEach(dayId => {
|
|
const isClosed = document.getElementById(`hours_closed_${dayId}`).checked;
|
|
if (!isClosed) {
|
|
hours.push({
|
|
dayId: dayId,
|
|
open: document.getElementById(`hours_open_${dayId}`).value,
|
|
close: document.getElementById(`hours_close_${dayId}`).value
|
|
});
|
|
}
|
|
});
|
|
|
|
try {
|
|
const response = await fetch(`${this.config.apiBaseUrl}/businesses/updateHours.cfm`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
BusinessID: this.config.businessId,
|
|
Hours: hours
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.OK) {
|
|
this.showToast('Hours saved!', 'success');
|
|
} else {
|
|
this.showToast(data.ERROR || 'Failed to save hours', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Error saving hours:', err);
|
|
this.showToast('Error saving hours', 'error');
|
|
}
|
|
},
|
|
|
|
// 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');
|
|
}
|
|
},
|
|
|
|
// View order
|
|
async viewOrder(orderId) {
|
|
this.toast('Loading order...', 'info');
|
|
|
|
try {
|
|
const response = await fetch(`${this.config.apiBaseUrl}/orders/getDetail.cfm`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ OrderID: orderId })
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.OK && data.ORDER) {
|
|
this.showOrderDetailModal(data.ORDER);
|
|
} else {
|
|
this.toast(data.ERROR || 'Failed to load order', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Error loading order:', err);
|
|
this.toast('Error loading order details', 'error');
|
|
}
|
|
},
|
|
|
|
// Show order detail modal
|
|
showOrderDetailModal(order) {
|
|
document.getElementById('modalTitle').textContent = `Order #${order.OrderID}`;
|
|
|
|
const customerName = [order.Customer.FirstName, order.Customer.LastName].filter(Boolean).join(' ') || 'Guest';
|
|
const servicePoint = order.ServicePoint.Name || 'Not assigned';
|
|
|
|
// Build line items HTML
|
|
const lineItemsHtml = order.LineItems.map(item => {
|
|
const modifiersHtml = item.Modifiers && item.Modifiers.length > 0
|
|
? `<div class="order-item-modifiers">
|
|
${item.Modifiers.map(mod => `
|
|
<div class="order-item-modifier">
|
|
+ ${mod.ItemName}
|
|
${mod.UnitPrice > 0 ? `<span class="modifier-price">+$${mod.UnitPrice.toFixed(2)}</span>` : ''}
|
|
</div>
|
|
`).join('')}
|
|
</div>`
|
|
: '';
|
|
|
|
const remarksHtml = item.Remarks
|
|
? `<div class="order-item-remarks">"${item.Remarks}"</div>`
|
|
: '';
|
|
|
|
return `
|
|
<div class="order-detail-item">
|
|
<div class="order-item-header">
|
|
<span class="order-item-qty">${item.Quantity}x</span>
|
|
<span class="order-item-name">${item.ItemName}</span>
|
|
<span class="order-item-price">$${(item.UnitPrice * item.Quantity).toFixed(2)}</span>
|
|
</div>
|
|
${modifiersHtml}
|
|
${remarksHtml}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
document.getElementById('modalBody').innerHTML = `
|
|
<div class="order-detail">
|
|
<div class="order-detail-section">
|
|
<div class="order-detail-label">Status</div>
|
|
<span class="status-badge ${this.getStatusClass(order.Status)}">${order.StatusText}</span>
|
|
</div>
|
|
|
|
<div class="order-detail-section">
|
|
<div class="order-detail-label">Customer</div>
|
|
<div class="order-detail-value">${customerName}</div>
|
|
${order.Customer.Phone ? `<div class="order-detail-sub">${order.Customer.Phone}</div>` : ''}
|
|
${order.Customer.Email ? `<div class="order-detail-sub">${order.Customer.Email}</div>` : ''}
|
|
</div>
|
|
|
|
<div class="order-detail-section">
|
|
<div class="order-detail-label">Service Point</div>
|
|
<div class="order-detail-value">${servicePoint}</div>
|
|
${order.ServicePoint.Type ? `<div class="order-detail-sub">${order.ServicePoint.Type}</div>` : ''}
|
|
</div>
|
|
|
|
<div class="order-detail-section">
|
|
<div class="order-detail-label">Items</div>
|
|
<div class="order-items-list">
|
|
${lineItemsHtml || '<div class="empty-state">No items</div>'}
|
|
</div>
|
|
</div>
|
|
|
|
${order.Notes ? `
|
|
<div class="order-detail-section">
|
|
<div class="order-detail-label">Notes</div>
|
|
<div class="order-detail-notes">${order.Notes}</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="order-detail-totals">
|
|
<div class="order-total-row">
|
|
<span>Subtotal</span>
|
|
<span>$${order.Subtotal.toFixed(2)}</span>
|
|
</div>
|
|
<div class="order-total-row">
|
|
<span>Tax</span>
|
|
<span>$${order.Tax.toFixed(2)}</span>
|
|
</div>
|
|
${order.Tip > 0 ? `
|
|
<div class="order-total-row">
|
|
<span>Tip</span>
|
|
<span>$${order.Tip.toFixed(2)}</span>
|
|
</div>
|
|
` : ''}
|
|
<div class="order-total-row total">
|
|
<span>Total</span>
|
|
<span>$${order.Total.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="order-detail-footer">
|
|
<div class="order-detail-time">Placed: ${this.formatDateTime(order.CreatedOn)}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
this.showModal();
|
|
},
|
|
|
|
// Format date time
|
|
formatDateTime(dateStr) {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleString([], {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
},
|
|
|
|
// 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();
|
|
});
|
|
},
|
|
|
|
// ========== BEACONS PAGE ==========
|
|
|
|
beacons: [],
|
|
servicePoints: [],
|
|
assignments: [],
|
|
|
|
// Load beacons page data
|
|
async loadBeaconsPage() {
|
|
await Promise.all([
|
|
this.loadBeacons(),
|
|
this.loadServicePoints(),
|
|
this.loadAssignments()
|
|
]);
|
|
},
|
|
|
|
// Load beacons list
|
|
async loadBeacons() {
|
|
const container = document.getElementById('beaconsList');
|
|
try {
|
|
const response = await fetch(`${this.config.apiBaseUrl}/beacons/list.cfm`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-User-Token': this.config.token,
|
|
'X-Business-ID': this.config.businessId
|
|
},
|
|
body: JSON.stringify({ onlyActive: true })
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.OK) {
|
|
this.beacons = data.BEACONS || [];
|
|
this.renderBeaconsList();
|
|
} else {
|
|
console.error('[Portal] Beacons API error:', data.ERROR);
|
|
container.innerHTML = `<div class="empty-state">Error: ${data.ERROR || 'Unknown error'}</div>`;
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Error loading beacons:', err);
|
|
container.innerHTML = `<div class="empty-state">Failed to load beacons</div>`;
|
|
}
|
|
},
|
|
|
|
// Render beacons list
|
|
renderBeaconsList() {
|
|
const container = document.getElementById('beaconsList');
|
|
if (this.beacons.length === 0) {
|
|
container.innerHTML = '<div class="empty-state">No beacons yet. Add your first beacon!</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = this.beacons.map(b => `
|
|
<div class="list-group-item ${b.IsActive ? '' : 'inactive'}">
|
|
<div class="item-info">
|
|
<div class="item-name">${this.escapeHtml(b.BeaconName)}</div>
|
|
<div class="item-detail">${b.UUID || b.NamespaceId || 'No UUID'}</div>
|
|
</div>
|
|
<div class="item-actions">
|
|
<button class="btn btn-sm btn-secondary" onclick="Portal.editBeacon(${b.BeaconID})">Edit</button>
|
|
<button class="btn btn-sm btn-danger" onclick="Portal.deleteBeacon(${b.BeaconID})">Delete</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
},
|
|
|
|
// Load service points list
|
|
async loadServicePoints() {
|
|
try {
|
|
const response = await fetch(`${this.config.apiBaseUrl}/servicepoints/list.cfm`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-User-Token': this.config.token,
|
|
'X-Business-ID': this.config.businessId
|
|
},
|
|
body: JSON.stringify({ BusinessID: this.config.businessId })
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.OK) {
|
|
this.servicePoints = data.SERVICEPOINTS || data.ServicePoints || [];
|
|
this.renderServicePointsList();
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Error loading service points:', err);
|
|
}
|
|
},
|
|
|
|
// Render service points list
|
|
renderServicePointsList() {
|
|
const container = document.getElementById('servicePointsList');
|
|
if (this.servicePoints.length === 0) {
|
|
container.innerHTML = '<div class="empty-state">No service points yet. Add tables, counters, etc.</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = this.servicePoints.map(sp => `
|
|
<div class="list-group-item">
|
|
<div class="item-info">
|
|
<div class="item-name">${this.escapeHtml(sp.ServicePointName)}</div>
|
|
</div>
|
|
<div class="item-actions">
|
|
<button class="btn btn-sm btn-secondary" onclick="Portal.editServicePoint(${sp.ServicePointID})">Edit</button>
|
|
<button class="btn btn-sm btn-danger" onclick="Portal.deleteServicePoint(${sp.ServicePointID})">Delete</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
},
|
|
|
|
// Load assignments list
|
|
async loadAssignments() {
|
|
const container = document.getElementById('assignmentsList');
|
|
try {
|
|
const response = await fetch(`${this.config.apiBaseUrl}/assignments/list.cfm`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-User-Token': this.config.token,
|
|
'X-Business-ID': this.config.businessId
|
|
},
|
|
body: JSON.stringify({})
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.OK) {
|
|
this.assignments = data.ASSIGNMENTS || [];
|
|
this.renderAssignmentsList();
|
|
} else {
|
|
console.error('[Portal] Assignments API error:', data.ERROR);
|
|
container.innerHTML = `<div class="empty-state">Error: ${data.ERROR || 'Unknown error'}</div>`;
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Error loading assignments:', err);
|
|
container.innerHTML = `<div class="empty-state">Failed to load assignments</div>`;
|
|
}
|
|
},
|
|
|
|
// Render assignments list
|
|
renderAssignmentsList() {
|
|
const container = document.getElementById('assignmentsList');
|
|
if (this.assignments.length === 0) {
|
|
container.innerHTML = '<div class="empty-state">No assignments yet. Link beacons to service points.</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = this.assignments.map(a => `
|
|
<div class="list-group-item">
|
|
<div class="item-info">
|
|
<div class="item-name">${this.escapeHtml(a.BeaconName)} → ${this.escapeHtml(a.ServicePointName)}</div>
|
|
<div class="item-detail">${a.lt_Beacon_Businesses_ServicePointNotes || ''}</div>
|
|
</div>
|
|
<div class="item-actions">
|
|
<button class="btn btn-sm btn-danger" onclick="Portal.deleteAssignment(${a.lt_Beacon_Businesses_ServicePointID})">Remove</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
},
|
|
|
|
// Show beacon modal (add/edit)
|
|
showBeaconModal(beaconId = null) {
|
|
const isEdit = beaconId !== null;
|
|
const beacon = isEdit ? this.beacons.find(b => b.BeaconID === beaconId) : {};
|
|
|
|
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Beacon' : 'Add Beacon';
|
|
document.getElementById('modalBody').innerHTML = `
|
|
<form id="beaconForm" class="form">
|
|
<input type="hidden" id="beaconId" value="${beaconId || ''}">
|
|
<div class="form-group">
|
|
<label>Beacon Name</label>
|
|
<input type="text" id="beaconName" class="form-input" value="${this.escapeHtml(beacon.BeaconName || '')}" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>UUID</label>
|
|
<input type="text" id="beaconUUID" class="form-input" value="${beacon.UUID || ''}" placeholder="e.g., FDA50693-A4E2-4FB1-AFCF-C6EB07647825">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="beaconIsActive" ${beacon.IsActive !== 0 ? 'checked' : ''}> Active
|
|
</label>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary">${isEdit ? 'Save Changes' : 'Add Beacon'}</button>
|
|
</form>
|
|
`;
|
|
this.showModal();
|
|
|
|
document.getElementById('beaconForm').addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
this.saveBeacon();
|
|
});
|
|
},
|
|
|
|
// Save beacon
|
|
async saveBeacon() {
|
|
const beaconId = document.getElementById('beaconId').value;
|
|
const payload = {
|
|
BeaconName: document.getElementById('beaconName').value,
|
|
UUID: document.getElementById('beaconUUID').value,
|
|
IsActive: document.getElementById('beaconIsActive').checked
|
|
};
|
|
|
|
if (beaconId) {
|
|
payload.BeaconID = parseInt(beaconId);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${this.config.apiBaseUrl}/beacons/save.cfm`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-User-Token': this.config.token,
|
|
'X-Business-ID': this.config.businessId
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.OK) {
|
|
this.toast('Beacon saved!', 'success');
|
|
this.closeModal();
|
|
await this.loadBeacons();
|
|
} else {
|
|
this.toast(data.ERROR || 'Failed to save beacon', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Error saving beacon:', err);
|
|
this.toast('Error saving beacon', 'error');
|
|
}
|
|
},
|
|
|
|
// Edit beacon
|
|
editBeacon(beaconId) {
|
|
this.showBeaconModal(beaconId);
|
|
},
|
|
|
|
// Delete beacon
|
|
async deleteBeacon(beaconId) {
|
|
if (!confirm('Are you sure you want to deactivate this beacon?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`${this.config.apiBaseUrl}/beacons/delete.cfm`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-User-Token': this.config.token,
|
|
'X-Business-ID': this.config.businessId
|
|
},
|
|
body: JSON.stringify({ BeaconID: beaconId })
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.OK) {
|
|
this.toast('Beacon deactivated', 'success');
|
|
await this.loadBeacons();
|
|
} else {
|
|
this.toast(data.ERROR || 'Failed to delete beacon', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Error deleting beacon:', err);
|
|
this.toast('Error deleting beacon', 'error');
|
|
}
|
|
},
|
|
|
|
// Show service point modal (add/edit)
|
|
showServicePointModal(servicePointId = null) {
|
|
const isEdit = servicePointId !== null;
|
|
const sp = isEdit ? this.servicePoints.find(s => s.ServicePointID === servicePointId) : {};
|
|
|
|
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Service Point' : 'Add Service Point';
|
|
document.getElementById('modalBody').innerHTML = `
|
|
<form id="servicePointForm" class="form">
|
|
<input type="hidden" id="servicePointId" value="${servicePointId || ''}">
|
|
<div class="form-group">
|
|
<label>Name</label>
|
|
<input type="text" id="servicePointName" class="form-input" value="${this.escapeHtml(sp.ServicePointName || '')}" required placeholder="e.g., Table 1, Counter A">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Code (optional)</label>
|
|
<input type="text" id="servicePointCode" class="form-input" value="${sp.ServicePointCode || ''}" placeholder="Short code for display">
|
|
</div>
|
|
<button type="submit" class="btn btn-primary">${isEdit ? 'Save Changes' : 'Add Service Point'}</button>
|
|
</form>
|
|
`;
|
|
this.showModal();
|
|
|
|
document.getElementById('servicePointForm').addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
this.saveServicePoint();
|
|
});
|
|
},
|
|
|
|
// Save service point
|
|
async saveServicePoint() {
|
|
const spId = document.getElementById('servicePointId').value;
|
|
const payload = {
|
|
ServicePointName: document.getElementById('servicePointName').value,
|
|
ServicePointCode: document.getElementById('servicePointCode').value
|
|
};
|
|
|
|
if (spId) {
|
|
payload.ServicePointID = parseInt(spId);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${this.config.apiBaseUrl}/servicepoints/save.cfm`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-User-Token': this.config.token,
|
|
'X-Business-ID': this.config.businessId
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.OK) {
|
|
this.toast('Service point saved!', 'success');
|
|
this.closeModal();
|
|
await this.loadServicePoints();
|
|
} else {
|
|
this.toast(data.ERROR || 'Failed to save service point', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Error saving service point:', err);
|
|
this.toast('Error saving service point', 'error');
|
|
}
|
|
},
|
|
|
|
// Edit service point
|
|
editServicePoint(servicePointId) {
|
|
this.showServicePointModal(servicePointId);
|
|
},
|
|
|
|
// Delete service point
|
|
async deleteServicePoint(servicePointId) {
|
|
if (!confirm('Are you sure you want to deactivate this service point?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`${this.config.apiBaseUrl}/servicepoints/delete.cfm`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-User-Token': this.config.token,
|
|
'X-Business-ID': this.config.businessId
|
|
},
|
|
body: JSON.stringify({ ServicePointID: servicePointId })
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.OK) {
|
|
this.toast('Service point deactivated', 'success');
|
|
await this.loadServicePoints();
|
|
} else {
|
|
this.toast(data.ERROR || 'Failed to delete service point', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Error deleting service point:', err);
|
|
this.toast('Error deleting service point', 'error');
|
|
}
|
|
},
|
|
|
|
// Show assignment modal
|
|
showAssignmentModal() {
|
|
// Filter out beacons and service points that are already assigned
|
|
const assignedBeaconIds = new Set(this.assignments.map(a => a.BeaconID));
|
|
const assignedSPIds = new Set(this.assignments.map(a => a.ServicePointID));
|
|
|
|
const availableBeacons = this.beacons.filter(b => b.IsActive && !assignedBeaconIds.has(b.BeaconID));
|
|
const availableSPs = this.servicePoints.filter(sp => !assignedSPIds.has(sp.ServicePointID));
|
|
|
|
if (availableBeacons.length === 0) {
|
|
this.toast('No unassigned beacons available', 'warning');
|
|
return;
|
|
}
|
|
if (availableSPs.length === 0) {
|
|
this.toast('No unassigned service points available', 'warning');
|
|
return;
|
|
}
|
|
|
|
document.getElementById('modalTitle').textContent = 'Assign Beacon to Service Point';
|
|
document.getElementById('modalBody').innerHTML = `
|
|
<form id="assignmentForm" class="form">
|
|
<div class="form-group">
|
|
<label>Beacon</label>
|
|
<select id="assignBeaconId" class="form-input" required>
|
|
<option value="">Select a beacon...</option>
|
|
${availableBeacons.map(b => `<option value="${b.BeaconID}">${this.escapeHtml(b.BeaconName)}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Service Point</label>
|
|
<select id="assignServicePointId" class="form-input" required>
|
|
<option value="">Select a service point...</option>
|
|
${availableSPs.map(sp => `<option value="${sp.ServicePointID}">${this.escapeHtml(sp.ServicePointName)}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Notes (optional)</label>
|
|
<input type="text" id="assignNotes" class="form-input" maxlength="255" placeholder="Any notes about this assignment">
|
|
</div>
|
|
<button type="submit" class="btn btn-primary">Create Assignment</button>
|
|
</form>
|
|
`;
|
|
this.showModal();
|
|
|
|
document.getElementById('assignmentForm').addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
this.saveAssignment();
|
|
});
|
|
},
|
|
|
|
// Save assignment
|
|
async saveAssignment() {
|
|
const payload = {
|
|
BeaconID: parseInt(document.getElementById('assignBeaconId').value),
|
|
ServicePointID: parseInt(document.getElementById('assignServicePointId').value),
|
|
Notes: document.getElementById('assignNotes').value
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`${this.config.apiBaseUrl}/assignments/save.cfm`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-User-Token': this.config.token,
|
|
'X-Business-ID': this.config.businessId
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.OK) {
|
|
this.toast('Assignment created!', 'success');
|
|
this.closeModal();
|
|
await this.loadAssignments();
|
|
} else {
|
|
this.toast(data.ERROR || 'Failed to create assignment', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Error saving assignment:', err);
|
|
this.toast('Error creating assignment', 'error');
|
|
}
|
|
},
|
|
|
|
// Delete assignment
|
|
async deleteAssignment(assignmentId) {
|
|
if (!confirm('Are you sure you want to remove this assignment?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`${this.config.apiBaseUrl}/assignments/delete.cfm`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-User-Token': this.config.token,
|
|
'X-Business-ID': this.config.businessId
|
|
},
|
|
body: JSON.stringify({ lt_Beacon_Businesses_ServicePointID: assignmentId })
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.OK) {
|
|
this.toast('Assignment removed', 'success');
|
|
await this.loadAssignments();
|
|
} else {
|
|
this.toast(data.ERROR || 'Failed to remove assignment', 'error');
|
|
}
|
|
} catch (err) {
|
|
console.error('[Portal] Error deleting assignment:', err);
|
|
this.toast('Error removing assignment', 'error');
|
|
}
|
|
},
|
|
|
|
// Escape HTML helper
|
|
escapeHtml(str) {
|
|
if (!str) return '';
|
|
return str.replace(/[&<>"']/g, m => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
}[m]));
|
|
}
|
|
};
|
|
|
|
// Initialize on load
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
Portal.init();
|
|
});
|