/**
* 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 from localStorage
this.config.businessId = parseInt(savedBusiness) || null;
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);
},
// Open HUD in new window with business ID
openHUD() {
window.open('/hud/index.html?b=' + this.config.businessId, '_blank');
},
// 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',
beacons: 'Beacons',
services: 'Service Requests',
'admin-tasks': 'Task Admin',
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 'services':
await this.loadServicesPage();
break;
case 'admin-tasks':
await this.loadAdminTasksPage();
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 = '
No recent orders
';
return;
}
container.innerHTML = orders.slice(0, 5).map(order => `
`).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 = '| No orders found |
';
return;
}
tbody.innerHTML = orders.map(order => `
| #${order.OrderID} |
${order.UserFirstName || 'Guest'} ${order.UserLastName || ''} |
${order.LineItems?.length || 0} items |
$${(order.OrderTotal || 0).toFixed(2)} |
${this.getStatusText(order.OrderStatusID)} |
${this.formatTime(order.OrderSubmittedOn)} |
|
`).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 = `
`;
},
// Render menu
renderMenu() {
const container = document.getElementById('menuGrid');
if (!this.menuData || this.menuData.length === 0) {
container.innerHTML = 'No menu items. Click "Add Category" to get started.
';
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]) => `
`).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 = '| Loading team... |
';
// 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 = '| No team members yet |
';
return;
}
tbody.innerHTML = data.TEAM.map(member => `
|
${(member.FirstName || '?').charAt(0)}${(member.LastName || '').charAt(0)}
${member.Name || 'Unknown'}
|
${member.Email || '-'} |
${this.formatPhone(member.Phone)} |
${member.StatusName || 'Unknown'}
|
|
`).join('');
} else {
tbody.innerHTML = '| Failed to load team |
';
}
} catch (err) {
console.error('[Portal] Error loading team:', err);
tbody.innerHTML = '| Error loading team |
';
}
},
// 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 = '';
data.STATES.forEach(state => {
select.innerHTML += ``;
});
}
} 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 (Lucee serializes all keys as uppercase)
document.getElementById('settingBusinessName').value = biz.BUSINESSNAME || biz.BusinessName || '';
document.getElementById('settingPhone').value = biz.BUSINESSPHONE || biz.BusinessPhone || '';
document.getElementById('settingTaxRate').value = biz.TAXRATEPERCENT || biz.TaxRatePercent || '';
document.getElementById('settingAddressLine1').value = biz.ADDRESSLINE1 || biz.AddressLine1 || '';
document.getElementById('settingCity').value = biz.ADDRESSCITY || biz.AddressCity || '';
document.getElementById('settingState').value = biz.ADDRESSSTATE || biz.AddressState || '';
document.getElementById('settingZip').value = biz.ADDRESSZIP || biz.AddressZip || '';
// Load brand color if set
const brandColor = biz.BRANDCOLOR || biz.BrandColor;
if (brandColor) {
this.brandColor = brandColor;
const swatch = document.getElementById('brandColorSwatch');
if (swatch) swatch.style.background = brandColor;
}
// Load header preview
const headerPreview = document.getElementById('headerPreview');
const headerUrl = biz.HEADERIMAGEURL || biz.HeaderImageURL;
if (headerPreview && headerUrl) {
headerPreview.style.backgroundImage = `url(${headerUrl}?t=${Date.now()})`;
}
// Render hours editor
this.renderHoursEditor(biz.BUSINESSHOURSDETAIL || 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 = '';
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,
TaxRatePercent: parseFloat(document.getElementById('settingTaxRate').value) || 0,
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 = `
Stripe Connected
Your account is active and ready to accept payments
View Dashboard
`;
} else if (data.ACCOUNT_STATUS === 'pending_verification') {
// Waiting for Stripe verification
statusContainer.innerHTML = `
Verification Pending
Stripe is reviewing your account. This usually takes 1-2 business days.
`;
} else if (data.ACCOUNT_STATUS === 'incomplete') {
// Started but not finished onboarding
statusContainer.innerHTML = `
Setup Incomplete
Please complete your Stripe account setup to accept payments
`;
} 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 = `
Stripe Not Connected
Connect your Stripe account to accept payments
`;
},
// 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 = `
`;
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 = `
`;
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');
}
},
// Upload header image
uploadHeader() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/png,image/jpeg,image/jpg';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
this.toast('Header image must be under 5MB', 'error');
return;
}
this.toast('Uploading header...', 'info');
const formData = new FormData();
formData.append('BusinessID', this.config.businessId);
formData.append('header', file);
try {
const response = await fetch(`${this.config.apiBaseUrl}/menu/uploadHeader.cfm`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.OK) {
this.toast('Header uploaded successfully!', 'success');
// Update preview using the URL from the response
const preview = document.getElementById('headerPreview');
if (preview && data.HEADERURL) {
preview.style.backgroundImage = `url(${BASE_PATH}${data.HEADERURL}?t=${Date.now()})`;
}
} else {
this.toast(data.MESSAGE || 'Failed to upload header', 'error');
}
} catch (err) {
console.error('[Portal] Header upload error:', err);
this.toast('Failed to upload header', 'error');
}
};
input.click();
},
// Show brand color picker
showBrandColorPicker() {
const currentColor = this.brandColor || '#1B4D3E';
document.getElementById('modalTitle').textContent = 'Brand Color';
document.getElementById('modalBody').innerHTML = `
This color is used for accents and fallback backgrounds in your menu.
`;
this.showModal();
// Sync inputs
const colorInput = document.getElementById('brandColorInput');
const hexInput = document.getElementById('brandColorHex');
const preview = document.getElementById('brandColorPreview');
colorInput.addEventListener('input', () => {
const color = colorInput.value.toUpperCase();
hexInput.value = color;
preview.style.background = `linear-gradient(to bottom, ${color}44, ${color}00, ${color}66)`;
});
hexInput.addEventListener('input', () => {
let color = hexInput.value;
if (/^#[0-9A-Fa-f]{6}$/.test(color)) {
colorInput.value = color;
preview.style.background = `linear-gradient(to bottom, ${color}44, ${color}00, ${color}66)`;
}
});
},
// Save brand color
async saveBrandColor() {
const color = document.getElementById('brandColorHex').value.toUpperCase();
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
this.toast('Please enter a valid hex color (e.g., #1B4D3E)', 'error');
return;
}
try {
const response = await fetch(`${this.config.apiBaseUrl}/businesses/saveBrandColor.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId, BrandColor: color })
});
const data = await response.json();
if (data.OK) {
this.brandColor = color;
const swatch = document.getElementById('brandColorSwatch');
if (swatch) swatch.style.background = color;
this.closeModal();
this.toast('Brand color saved!', 'success');
} else {
this.toast(data.MESSAGE || 'Failed to save color', 'error');
}
} catch (err) {
console.error('[Portal] Save brand color error:', err);
this.toast('Failed to save brand color', '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
? `
${item.Modifiers.map(mod => `
+ ${mod.ItemName}
${mod.UnitPrice > 0 ? `+$${mod.UnitPrice.toFixed(2)}` : ''}
`).join('')}
`
: '';
const remarksHtml = item.Remarks
? ``
: '';
return `
${modifiersHtml}
${remarksHtml}
`;
}).join('');
document.getElementById('modalBody').innerHTML = `
Status
${order.StatusText}
Customer
${customerName}
${order.Customer.Phone ? `
${order.Customer.Phone}
` : ''}
${order.Customer.Email ? `
${order.Customer.Email}
` : ''}
Service Point
${servicePoint}
${order.ServicePoint.Type ? `
${order.ServicePoint.Type}
` : ''}
Items
${lineItemsHtml || '
No items
'}
${order.Notes ? `
` : ''}
Subtotal
$${order.Subtotal.toFixed(2)}
Tax
$${order.Tax.toFixed(2)}
${order.Tip > 0 ? `
Tip
$${order.Tip.toFixed(2)}
` : ''}
Total
$${order.Total.toFixed(2)}
`;
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 = 'Add Team Member';
document.getElementById('modalBody').innerHTML = `
`;
this.showModal();
let foundUserId = null;
document.getElementById('inviteForm').addEventListener('submit', async (e) => {
e.preventDefault();
const contact = document.getElementById('inviteContact').value.trim();
const btn = document.getElementById('inviteSubmitBtn');
const resultsDiv = document.getElementById('userSearchResults');
if (foundUserId) {
// Actually add the team member
btn.disabled = true;
btn.textContent = 'Adding...';
try {
const response = await fetch(`${this.config.apiBaseUrl}/portal/addTeamMember.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
UserID: foundUserId
})
});
const data = await response.json();
if (data.OK) {
this.toast('Team member added!', 'success');
this.closeModal();
this.loadTeam();
} else {
this.toast(data.MESSAGE || 'Failed to add', 'error');
}
} catch (err) {
this.toast('Error adding team member', 'error');
}
btn.disabled = false;
btn.textContent = 'Add Team Member';
return;
}
// Search for user
btn.disabled = true;
btn.textContent = 'Searching...';
try {
const response = await fetch(`${this.config.apiBaseUrl}/portal/searchUser.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Query: contact, BusinessID: this.config.businessId })
});
const data = await response.json();
if (data.OK && data.USER) {
foundUserId = data.USER.UserID;
resultsDiv.style.display = 'block';
resultsDiv.innerHTML = `
Found: ${data.USER.Name}
${data.USER.Phone || data.USER.Email || ''}
`;
btn.textContent = 'Add Team Member';
} else {
resultsDiv.style.display = 'block';
resultsDiv.innerHTML = `
Not found
No user with that phone/email. They need to create an account first.
`;
btn.textContent = 'Search & Add';
}
} catch (err) {
resultsDiv.style.display = 'block';
resultsDiv.innerHTML = `Search failed: ${err.message}
`;
}
btn.disabled = false;
});
},
// ========== 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 = `Error: ${data.ERROR || 'Unknown error'}
`;
}
} catch (err) {
console.error('[Portal] Error loading beacons:', err);
container.innerHTML = `Failed to load beacons
`;
}
},
// Render beacons list
renderBeaconsList() {
const container = document.getElementById('beaconsList');
if (this.beacons.length === 0) {
container.innerHTML = 'No beacons yet. Add your first beacon!
';
return;
}
container.innerHTML = this.beacons.map(b => `
${this.escapeHtml(b.BeaconName)}
${b.UUID || b.NamespaceId || 'No UUID'}
`).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 = 'No service points yet. Add tables, counters, etc.
';
return;
}
container.innerHTML = this.servicePoints.map(sp => `
${this.escapeHtml(sp.ServicePointName)}
`).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 = `Error: ${data.ERROR || 'Unknown error'}
`;
}
} catch (err) {
console.error('[Portal] Error loading assignments:', err);
container.innerHTML = `Failed to load assignments
`;
}
},
// Render assignments list
renderAssignmentsList() {
const container = document.getElementById('assignmentsList');
if (this.assignments.length === 0) {
container.innerHTML = 'No assignments yet. Link beacons to service points.
';
return;
}
container.innerHTML = this.assignments.map(a => `
${this.escapeHtml(a.BeaconName)} → ${this.escapeHtml(a.ServicePointName)}
${a.lt_Beacon_Businesses_ServicePointNotes || ''}
`).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 = `
`;
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 = `
`;
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 = `
`;
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]));
},
// =====================
// Services (Task Types)
// =====================
async loadServicesPage() {
console.log('[Portal] Loading services page...');
await this.loadServiceTypes();
},
// Available icons for services (matches Flutter TaskType._iconMap)
serviceIcons: {
// Service & Staff
'room_service': { label: 'Room Service', svg: '' },
'support_agent': { label: 'Support Agent', svg: '' },
'person': { label: 'Person', svg: '' },
'groups': { label: 'Groups', svg: '' },
// Payment & Money
'attach_money': { label: 'Money/Cash', svg: '' },
'payments': { label: 'Payments', svg: '' },
'receipt': { label: 'Receipt', svg: '' },
'credit_card': { label: 'Credit Card', svg: '' },
// Communication
'chat': { label: 'Chat', svg: '' },
'message': { label: 'Message', svg: '' },
'call': { label: 'Call', svg: '' },
'notifications': { label: 'Notification', svg: '' },
// Food & Drink
'restaurant': { label: 'Restaurant', svg: '' },
'local_bar': { label: 'Bar/Cocktail', svg: '' },
'coffee': { label: 'Coffee', svg: '' },
'icecream': { label: 'Ice Cream', svg: '' },
'cake': { label: 'Cake', svg: '' },
'local_pizza': { label: 'Pizza', svg: '' },
'lunch_dining': { label: 'Lunch/Burger', svg: '' },
'fastfood': { label: 'Fast Food', svg: '' },
'ramen_dining': { label: 'Ramen/Noodles', svg: '' },
'bakery_dining': { label: 'Bakery', svg: '' },
// Drinks & Refills
'water_drop': { label: 'Water/Refill', svg: '' },
'local_drink': { label: 'Drink/Soda', svg: '' },
'wine_bar': { label: 'Wine', svg: '' },
'sports_bar': { label: 'Beer', svg: '' },
'liquor': { label: 'Liquor', svg: '' },
// Hookah & Fire
'local_fire_department': { label: 'Fire/Charcoal', svg: '' },
'whatshot': { label: 'Hot/Trending', svg: '' },
'smoke_free': { label: 'No Smoking', svg: '' },
// Cleaning & Maintenance
'cleaning_services': { label: 'Cleaning', svg: '' },
'delete_sweep': { label: 'Clear Table', svg: '' },
'auto_fix_high': { label: 'Fix/Repair', svg: '' },
// Supplies & Items
'inventory': { label: 'Inventory', svg: '' },
'shopping_basket': { label: 'Shopping', svg: '' },
'add_box': { label: 'Add Item', svg: '' },
'note_add': { label: 'Add Note', svg: '' },
// Entertainment
'music_note': { label: 'Music', svg: '' },
'tv': { label: 'TV', svg: '' },
'sports_esports': { label: 'Gaming', svg: '' },
'celebration': { label: 'Party', svg: '' },
// Comfort & Amenities
'ac_unit': { label: 'A/C', svg: '' },
'wb_sunny': { label: 'Sunny/Bright', svg: '' },
'light_mode': { label: 'Light', svg: '' },
'volume_up': { label: 'Volume Up', svg: '' },
'volume_down': { label: 'Volume Down', svg: '' },
// Health & Safety
'medical_services': { label: 'Medical', svg: '' },
'health_and_safety': { label: 'Health & Safety', svg: '' },
'child_care': { label: 'Child Care', svg: '' },
'accessible': { label: 'Accessible', svg: '' },
// Location & Navigation
'directions': { label: 'Directions', svg: '' },
'meeting_room': { label: 'Meeting Room', svg: '' },
'wc': { label: 'Restroom', svg: '' },
'local_parking': { label: 'Parking', svg: '' },
// General
'help': { label: 'Help', svg: '' },
'info': { label: 'Info', svg: '' },
'star': { label: 'Star/VIP', svg: '' },
'favorite': { label: 'Favorite', svg: '' },
'thumb_up': { label: 'Thumbs Up', svg: '' },
'check_circle': { label: 'Check/Done', svg: '' },
'warning': { label: 'Warning', svg: '' },
'error': { label: 'Error/Alert', svg: '' },
'schedule': { label: 'Schedule', svg: '' },
'event': { label: 'Event', svg: '' }
},
async loadServiceTypes() {
try {
const response = await fetch(`${this.config.apiBaseUrl}/tasks/listAllTypes.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId })
});
const data = await response.json();
if (data.OK) {
this.renderServices(data.TASK_TYPES || []);
} else {
document.getElementById('servicesList').innerHTML = 'Failed to load services
';
}
} catch (err) {
console.error('[Portal] Error loading services:', err);
document.getElementById('servicesList').innerHTML = 'Error loading services
';
}
},
getServiceIconSvg(iconName) {
const icon = this.serviceIcons[iconName] || this.serviceIcons['notifications'];
return ``;
},
renderServices(services) {
const container = document.getElementById('servicesList');
if (!services.length) {
container.innerHTML = 'No services configured yet. Click "+ Add Service" to create one.
';
return;
}
container.innerHTML = services.map((s, index) => `
${this.getServiceIconSvg(s.TaskTypeIcon || 'notifications')}
${this.escapeHtml(s.TaskTypeName)}
${s.TaskTypeDescription ? this.escapeHtml(s.TaskTypeDescription) : 'No description'}
`).join('');
// Store services for editing
this._services = services;
// Initialize drag and drop
this.initDragAndDrop();
},
initDragAndDrop() {
const container = document.getElementById('servicesList');
const items = container.querySelectorAll('.service-item');
let draggedItem = null;
let draggedOverItem = null;
items.forEach(item => {
item.addEventListener('dragstart', (e) => {
draggedItem = item;
item.style.opacity = '0.5';
e.dataTransfer.effectAllowed = 'move';
});
item.addEventListener('dragend', () => {
draggedItem.style.opacity = '1';
items.forEach(i => {
i.style.background = '#fff';
i.style.transform = '';
});
draggedItem = null;
draggedOverItem = null;
});
item.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (item !== draggedItem) {
item.style.background = '#f0f0ff';
}
});
item.addEventListener('dragleave', () => {
item.style.background = '#fff';
});
item.addEventListener('drop', async (e) => {
e.preventDefault();
if (item !== draggedItem) {
// Get all items and their IDs
const allItems = [...container.querySelectorAll('.service-item')];
const draggedIndex = allItems.indexOf(draggedItem);
const dropIndex = allItems.indexOf(item);
// Move the dragged item
if (draggedIndex < dropIndex) {
item.parentNode.insertBefore(draggedItem, item.nextSibling);
} else {
item.parentNode.insertBefore(draggedItem, item);
}
// Save new order
await this.saveServiceOrder();
}
item.style.background = '#fff';
});
});
},
async saveServiceOrder() {
const container = document.getElementById('servicesList');
const items = container.querySelectorAll('.service-item');
const order = [...items].map(item => parseInt(item.dataset.id));
try {
const response = await fetch(`${this.config.apiBaseUrl}/tasks/reorderTypes.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
Order: order
})
});
const data = await response.json();
if (data.OK) {
this.toast('Order saved', 'success');
} else {
this.toast(data.MESSAGE || 'Failed to save order', 'error');
await this.loadServiceTypes(); // Reload to reset order
}
} catch (err) {
console.error('[Portal] Error saving order:', err);
this.toast('Error saving order', 'error');
await this.loadServiceTypes(); // Reload to reset order
}
},
// Preset color palette (GIF-safe colors)
colorPalette: [
'#FF0000', '#FF4500', '#FF6600', '#FF9900', '#FFCC00', // Reds to Yellows
'#FFFF00', '#CCFF00', '#99FF00', '#66FF00', '#33FF00', // Yellows to Greens
'#00FF00', '#00FF66', '#00FF99', '#00FFCC', '#00FFFF', // Greens to Cyans
'#00CCFF', '#0099FF', '#0066FF', '#0033FF', '#0000FF', // Cyans to Blues
'#6600FF', '#9900FF', '#CC00FF', '#FF00FF', '#FF0099', // Blues to Magentas
],
buildColorPicker(selectedColor = '#9C27B0') {
const normalizedSelected = selectedColor.toUpperCase();
return this.colorPalette.map(color => {
const isSelected = color.toUpperCase() === normalizedSelected;
return `
`;
}).join('');
},
buildIconPicker(selectedIcon = 'notifications') {
return Object.entries(this.serviceIcons).map(([key, val]) => `
`).join('');
},
showAddServiceModal() {
const html = `
`;
document.getElementById('modalTitle').textContent = 'Add Service';
document.getElementById('modalBody').innerHTML = html;
this.showModal();
document.getElementById('newServiceName').focus();
// Color selection highlighting
document.querySelectorAll('#colorPicker label').forEach(label => {
label.addEventListener('click', () => {
document.querySelectorAll('#colorPicker label div').forEach(d => d.style.border = '3px solid transparent');
label.querySelector('div').style.border = '3px solid #333';
});
});
// Icon selection highlighting
document.querySelectorAll('#iconPicker label').forEach(label => {
label.addEventListener('click', () => {
document.querySelectorAll('#iconPicker label').forEach(l => {
l.style.borderColor = '#e5e7eb';
l.querySelector('div').style.color = '#666';
});
label.style.borderColor = '#6366f1';
label.querySelector('div').style.color = '#6366f1';
});
});
},
async saveNewService(event) {
event.preventDefault();
const name = document.getElementById('newServiceName').value.trim();
const description = document.getElementById('newServiceDescription').value.trim();
const color = document.querySelector('input[name="serviceColor"]:checked')?.value || '#9C27B0';
const icon = document.querySelector('input[name="serviceIcon"]:checked')?.value || 'notifications';
if (!name) return;
try {
const response = await fetch(`${this.config.apiBaseUrl}/tasks/saveType.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
TaskTypeName: name,
TaskTypeDescription: description,
TaskTypeColor: color,
TaskTypeIcon: icon
})
});
const data = await response.json();
if (data.OK) {
this.toast('Service added', 'success');
this.closeModal();
await this.loadServiceTypes();
} else {
this.toast(data.MESSAGE || 'Failed to add service', 'error');
}
} catch (err) {
console.error('[Portal] Error adding service:', err);
this.toast('Error adding service', 'error');
}
},
editService(taskTypeId) {
const service = this._services?.find(s => s.TaskTypeID === taskTypeId);
if (!service) {
this.toast('Service not found', 'error');
return;
}
const html = `
`;
document.getElementById('modalTitle').textContent = 'Edit Service';
document.getElementById('modalBody').innerHTML = html;
this.showModal();
document.getElementById('editServiceName').focus();
// Color selection highlighting
document.querySelectorAll('#colorPicker label').forEach(label => {
label.addEventListener('click', () => {
document.querySelectorAll('#colorPicker label div').forEach(d => d.style.border = '3px solid transparent');
label.querySelector('div').style.border = '3px solid #333';
});
});
// Icon selection highlighting
document.querySelectorAll('#iconPicker label').forEach(label => {
label.addEventListener('click', () => {
document.querySelectorAll('#iconPicker label').forEach(l => {
l.style.borderColor = '#e5e7eb';
l.querySelector('div').style.color = '#666';
});
label.style.borderColor = '#6366f1';
label.querySelector('div').style.color = '#6366f1';
});
});
},
async updateService(event, taskTypeId) {
event.preventDefault();
const name = document.getElementById('editServiceName').value.trim();
const description = document.getElementById('editServiceDescription').value.trim();
const color = document.querySelector('input[name="serviceColor"]:checked')?.value || '#9C27B0';
const icon = document.querySelector('input[name="serviceIcon"]:checked')?.value || 'notifications';
if (!name) return;
try {
const response = await fetch(`${this.config.apiBaseUrl}/tasks/saveType.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
TaskTypeID: taskTypeId,
BusinessID: this.config.businessId,
TaskTypeName: name,
TaskTypeDescription: description,
TaskTypeColor: color,
TaskTypeIcon: icon
})
});
const data = await response.json();
if (data.OK) {
this.toast('Service updated', 'success');
this.closeModal();
await this.loadServiceTypes();
} else {
this.toast(data.MESSAGE || 'Failed to update service', 'error');
}
} catch (err) {
console.error('[Portal] Error updating service:', err);
this.toast('Error updating service', 'error');
}
},
async deleteService(taskTypeId, serviceName) {
if (!confirm(`Delete "${serviceName}"? This cannot be undone.`)) return;
try {
const response = await fetch(`${this.config.apiBaseUrl}/tasks/deleteType.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
TaskTypeID: taskTypeId,
BusinessID: this.config.businessId
})
});
const data = await response.json();
if (data.OK) {
this.toast('Service deleted', 'success');
await this.loadServiceTypes();
} else {
this.toast(data.ERROR || 'Failed to delete service', 'error');
}
} catch (err) {
console.error('[Portal] Error deleting service:', err);
this.toast('Error deleting service', 'error');
}
},
// =====================
// Admin Tasks Management
// =====================
quickTaskTemplates: [],
scheduledTasks: [],
async loadAdminTasksPage() {
console.log('[Portal] Loading admin tasks page...');
await Promise.all([
this.loadQuickTaskTemplates(),
this.loadScheduledTasks()
]);
},
// Quick Task Templates
async loadQuickTaskTemplates() {
try {
const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/list.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId })
});
const data = await response.json();
if (data.OK) {
this.quickTaskTemplates = data.TEMPLATES || [];
this.renderQuickTaskTemplates();
} else {
document.getElementById('quickTasksGrid').innerHTML = 'Failed to load quick task templates
';
}
} catch (err) {
console.error('[Portal] Error loading quick task templates:', err);
document.getElementById('quickTasksGrid').innerHTML = 'Error loading templates
';
}
},
renderQuickTaskTemplates() {
const gridContainer = document.getElementById('quickTasksGrid');
const manageContainer = document.getElementById('quickTasksManageList');
if (!this.quickTaskTemplates.length) {
gridContainer.innerHTML = 'No quick task templates. Click "+ Add Template" to create one.
';
manageContainer.innerHTML = '';
return;
}
// Shortcut buttons grid
gridContainer.innerHTML = this.quickTaskTemplates.map(t => `
`).join('');
// Management list
manageContainer.innerHTML = `
Manage Templates
${this.quickTaskTemplates.map(t => `
${this.escapeHtml(t.Name)}
${this.escapeHtml(t.Title)}
`).join('')}
`;
},
showAddQuickTaskModal(templateId = null) {
const isEdit = templateId !== null;
const template = isEdit ? this.quickTaskTemplates.find(t => t.QuickTaskTemplateID === templateId) : {};
// Build category options
const categoryOptions = (this._taskCategories || []).map(c =>
``
).join('');
// Build icon options
const iconOptions = Object.keys(this.serviceIcons).map(key =>
``
).join('');
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Quick Task Template' : 'Add Quick Task Template';
document.getElementById('modalBody').innerHTML = `
`;
this.showModal();
// Load categories if not loaded
if (!this._taskCategories) {
this.loadTaskCategories();
}
document.getElementById('quickTaskForm').addEventListener('submit', (e) => {
e.preventDefault();
this.saveQuickTask();
});
},
editQuickTask(templateId) {
this.showAddQuickTaskModal(templateId);
},
async loadTaskCategories() {
try {
const response = await fetch(`${this.config.apiBaseUrl}/tasks/listCategories.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId })
});
const data = await response.json();
if (data.OK) {
this._taskCategories = data.CATEGORIES || [];
}
} catch (err) {
console.error('[Portal] Error loading task categories:', err);
}
},
async saveQuickTask() {
const id = document.getElementById('quickTaskTemplateId').value;
const payload = {
BusinessID: this.config.businessId,
Name: document.getElementById('quickTaskName').value,
Title: document.getElementById('quickTaskTitle').value,
Details: document.getElementById('quickTaskDetails').value,
CategoryID: document.getElementById('quickTaskCategory').value || null,
Icon: document.getElementById('quickTaskIcon').value,
Color: document.getElementById('quickTaskColor').value
};
if (id) payload.QuickTaskTemplateID = parseInt(id);
try {
const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/save.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.OK) {
this.toast('Template saved!', 'success');
this.closeModal();
await this.loadQuickTaskTemplates();
} else {
this.toast(data.MESSAGE || 'Failed to save', 'error');
}
} catch (err) {
console.error('[Portal] Error saving quick task template:', err);
this.toast('Error saving template', 'error');
}
},
async deleteQuickTask(templateId) {
if (!confirm('Delete this quick task template?')) return;
try {
const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/delete.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
QuickTaskTemplateID: templateId
})
});
const data = await response.json();
if (data.OK) {
this.toast('Template deleted', 'success');
await this.loadQuickTaskTemplates();
} else {
this.toast(data.MESSAGE || 'Failed to delete', 'error');
}
} catch (err) {
console.error('[Portal] Error deleting quick task template:', err);
this.toast('Error deleting template', 'error');
}
},
async createQuickTask(templateId) {
try {
const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/create.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
QuickTaskTemplateID: templateId
})
});
const data = await response.json();
if (data.OK) {
this.toast('Task created!', 'success');
} else {
this.toast(data.MESSAGE || 'Failed to create task', 'error');
}
} catch (err) {
console.error('[Portal] Error creating quick task:', err);
this.toast('Error creating task', 'error');
}
},
// Scheduled Tasks
async loadScheduledTasks() {
try {
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/list.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId })
});
const data = await response.json();
if (data.OK) {
this.scheduledTasks = data.SCHEDULED_TASKS || [];
this.renderScheduledTasks();
} else {
document.getElementById('scheduledTasksList').innerHTML = 'Failed to load scheduled tasks
';
}
} catch (err) {
console.error('[Portal] Error loading scheduled tasks:', err);
document.getElementById('scheduledTasksList').innerHTML = 'Error loading scheduled tasks
';
}
},
renderScheduledTasks() {
const container = document.getElementById('scheduledTasksList');
if (!this.scheduledTasks.length) {
container.innerHTML = 'No scheduled tasks configured. Click "+ Add Scheduled Task" to create one.
';
return;
}
container.innerHTML = this.scheduledTasks.map(s => `
${s.IsActive ? 'Active' : 'Paused'}
${this.escapeHtml(s.Name)}
${this.escapeHtml(s.Title)} | Schedule: ${this.escapeHtml(s.CronExpression)}
${s.NextRunOn ? `
Next run: ${s.NextRunOn}
` : ''}
${s.LastRunOn ? `
Last run: ${s.LastRunOn}
` : ''}
`).join('');
},
showAddScheduledTaskModal(taskId = null) {
const isEdit = taskId !== null;
const task = isEdit ? this.scheduledTasks.find(t => t.ScheduledTaskID === taskId) : {};
// Build category options
const categoryOptions = (this._taskCategories || []).map(c =>
``
).join('');
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Scheduled Task' : 'Add Scheduled Task';
document.getElementById('modalBody').innerHTML = `
`;
this.showModal();
// Load categories if not loaded
if (!this._taskCategories) {
this.loadTaskCategories();
}
document.getElementById('scheduledTaskForm').addEventListener('submit', (e) => {
e.preventDefault();
this.saveScheduledTask();
});
},
editScheduledTask(taskId) {
this.showAddScheduledTaskModal(taskId);
},
async saveScheduledTask() {
const id = document.getElementById('scheduledTaskId').value;
const payload = {
BusinessID: this.config.businessId,
Name: document.getElementById('scheduledTaskName').value,
Title: document.getElementById('scheduledTaskTitle').value,
Details: document.getElementById('scheduledTaskDetails').value,
CategoryID: document.getElementById('scheduledTaskCategory').value || null,
CronExpression: document.getElementById('scheduledTaskCron').value,
IsActive: document.getElementById('scheduledTaskActive').checked
};
if (id) payload.ScheduledTaskID = parseInt(id);
try {
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/save.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.OK) {
this.toast(`Scheduled task saved! Next run: ${data.NEXT_RUN}`, 'success');
this.closeModal();
await this.loadScheduledTasks();
} else {
this.toast(data.MESSAGE || 'Failed to save', 'error');
}
} catch (err) {
console.error('[Portal] Error saving scheduled task:', err);
this.toast('Error saving scheduled task', 'error');
}
},
async deleteScheduledTask(taskId) {
if (!confirm('Delete this scheduled task?')) return;
try {
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/delete.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
ScheduledTaskID: taskId
})
});
const data = await response.json();
if (data.OK) {
this.toast('Scheduled task deleted', 'success');
await this.loadScheduledTasks();
} else {
this.toast(data.MESSAGE || 'Failed to delete', 'error');
}
} catch (err) {
console.error('[Portal] Error deleting scheduled task:', err);
this.toast('Error deleting scheduled task', 'error');
}
},
async toggleScheduledTask(taskId, isActive) {
try {
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/toggle.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
ScheduledTaskID: taskId,
IsActive: isActive
})
});
const data = await response.json();
if (data.OK) {
this.toast(isActive ? 'Scheduled task enabled' : 'Scheduled task paused', 'success');
await this.loadScheduledTasks();
} else {
this.toast(data.MESSAGE || 'Failed to toggle', 'error');
}
} catch (err) {
console.error('[Portal] Error toggling scheduled task:', err);
this.toast('Error toggling scheduled task', 'error');
}
},
async runScheduledTaskNow(taskId) {
try {
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/run.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
ScheduledTaskID: taskId
})
});
const data = await response.json();
if (data.OK) {
this.toast(`Task #${data.TASK_ID} created!`, 'success');
} else {
this.toast(data.MESSAGE || 'Failed to run task', 'error');
}
} catch (err) {
console.error('[Portal] Error running scheduled task:', err);
this.toast('Error running scheduled task', 'error');
}
}
};
// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
Portal.init();
});