This repository has been archived on 2026-03-21. You can view files and clone it, but cannot push or open issues or pull requests.
payfrit-biz/portal/portal.js
John Mizerek d1910a7d34 Add staff role system: Staff keeps cash, Manager/Admin collect for restaurant
- Create tt_StaffRoles lookup table (Staff, Manager, Admin)
- Add RoleID column to Employees table (default: Staff)
- Wire portal role dropdown to addTeamMember API
- Return RoleName in team list and RoleID to Android
- Skip worker payout ledger and cash_debit for Manager/Admin roles
  on cash task completion (they collect on behalf of the restaurant)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:19:22 -08:00

3951 lines
168 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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();
// Setup refresh button
this.setupRefreshButton();
// 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) {
// No token - redirect to signup
localStorage.removeItem('payfrit_portal_token');
localStorage.removeItem('payfrit_portal_userid');
localStorage.removeItem('payfrit_portal_business');
window.location.href = BASE_PATH + '/portal/signup.html';
return;
}
// Use saved business ID from localStorage (might be null)
this.config.businessId = savedBusiness ? parseInt(savedBusiness) : null;
this.config.userId = parseInt(userId) || 1;
this.config.token = token;
// Verify user has access to this business (or get their businesses if none selected)
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 && data.BUSINESSES.length > 0) {
if (!this.config.businessId) {
// No business selected
if (data.BUSINESSES.length === 1) {
// Only one business - auto-select it
this.config.businessId = data.BUSINESSES[0].BusinessID;
localStorage.setItem('payfrit_portal_business', this.config.businessId);
} else {
// Multiple businesses - show chooser
this.showBusinessChooser(data.BUSINESSES);
return;
}
} else {
const hasAccess = data.BUSINESSES.some(b => b.BusinessID === this.config.businessId);
if (!hasAccess) {
// User doesn't have access to requested business - show chooser
this.showBusinessChooser(data.BUSINESSES);
return;
}
}
} else {
// User has no businesses - go to setup wizard
window.location.href = BASE_PATH + '/portal/setup-wizard.html';
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.Name || 'Business';
document.getElementById('businessAvatar').textContent = (biz.Name || 'B').charAt(0).toUpperCase();
document.getElementById('userAvatar').textContent = (document.getElementById('businessAvatar').textContent || 'B');
} else {
this.businessData = null;
document.getElementById('businessName').textContent = 'Business #' + this.config.businessId;
document.getElementById('businessAvatar').textContent = 'B';
document.getElementById('userAvatar').textContent = (document.getElementById('businessAvatar').textContent || 'B');
}
} 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 = (document.getElementById('businessAvatar').textContent || 'B');
}
},
// Logout
logout() {
localStorage.removeItem('payfrit_portal_token');
localStorage.removeItem('payfrit_portal_userid');
localStorage.removeItem('payfrit_portal_business');
window.location.href = BASE_PATH + '/portal/signup.html';
},
// Switch to a different business - show chooser modal
async switchBusiness() {
const token = localStorage.getItem('payfrit_portal_token');
const userId = localStorage.getItem('payfrit_portal_userid');
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: userId })
});
const data = await response.json();
if (data.OK && data.BUSINESSES && data.BUSINESSES.length > 0) {
this.showBusinessChooser(data.BUSINESSES);
} else {
window.location.href = BASE_PATH + '/portal/setup-wizard.html';
}
} catch (err) {
console.error('[Portal] Failed to load businesses:', err);
}
},
showBusinessChooser(businesses) {
// Remove existing modal if any
const existing = document.getElementById('businessChooserModal');
if (existing) existing.remove();
const modal = document.createElement('div');
modal.id = 'businessChooserModal';
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999';
const content = document.createElement('div');
content.style.cssText = 'background:white;color:#333;border-radius:12px;padding:24px;max-width:400px;width:90%;max-height:80vh;overflow-y:auto';
content.innerHTML = `
<h2 style="margin:0 0 16px;font-size:18px;color:#333">Select Business</h2>
<div id="businessList"></div>
<button id="closeChooser" style="margin-top:16px;padding:8px 16px;background:#f0f0f0;color:#333;border:none;border-radius:6px;cursor:pointer;width:100%">Cancel</button>
`;
const list = content.querySelector('#businessList');
businesses.forEach(biz => {
const item = document.createElement('div');
item.style.cssText = 'padding:12px;border:1px solid #e0e0e0;border-radius:8px;margin-bottom:8px;cursor:pointer;transition:background 0.2s;color:#333;background:white';
item.innerHTML = `<strong>${biz.BusinessName || biz.Name}</strong>`;
item.onmouseover = () => item.style.background = '#f5f5f5';
item.onmouseout = () => item.style.background = 'white';
item.onclick = () => {
localStorage.setItem('payfrit_portal_business', biz.BusinessID);
window.location.reload();
};
list.appendChild(item);
});
content.querySelector('#closeChooser').onclick = () => modal.remove();
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
modal.appendChild(content);
document.body.appendChild(modal);
},
// Add a new business (go to setup wizard)
addNewBusiness() {
window.location.href = BASE_PATH + '/portal/setup-wizard.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');
});
},
// Setup refresh button
setupRefreshButton() {
const btn = document.getElementById('refreshBtn');
if (btn) {
btn.addEventListener('click', () => {
this.loadPageData(this.currentPage);
});
}
},
// Handle hash change
handleHashChange() {
const hash = window.location.hash.slice(1) || 'dashboard';
this.navigate(hash);
},
// Open HUD in new window (uses localStorage for business ID)
openHUD() {
window.open('/hud/index.html', '_blank');
},
// Open Quick Tasks page (uses localStorage for business ID)
openQuickTasks() {
window.open('/portal/quick-tasks.html', '_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',
services: 'Service Requests',
'service-points': 'Service Points',
'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 'services':
await this.loadServicesPage();
break;
case 'service-points':
await this.loadServicePointsPage();
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 = '<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.FirstName || 'Guest'} - ${order.LineItems?.length || 0} items</div>
</div>
<span class="status-badge ${this.getStatusClass(order.StatusID)}">${this.getStatusText(order.StatusID)}</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.FirstName || 'Guest'} ${order.LastName || ''}</td>
<td>${order.LineItems?.length || 0} items</td>
<td>$${(order.OrderTotal || 0).toFixed(2)}</td>
<td><span class="status-badge ${this.getStatusClass(order.StatusID)}">${this.getStatusText(order.StatusID)}</span></td>
<td>${this.formatTime(order.SubmittedOn)}</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" 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" 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.ParentItemID === 0) {
// This is a top-level item, find its category
const catId = item.CategoryID || 0;
if (!categories[catId]) {
categories[catId] = {
name: item.Name || '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.Name}">` : '🍽️'}
</div>
<div class="item-info">
<div class="item-name">${item.Name}</div>
<div class="item-description">${item.Description || ''}</div>
</div>
<div class="item-price">$${(item.Price || 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="6" 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="6" 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>${member.RoleName || 'Staff'}</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.ID})">Edit</button>
</td>
</tr>
`).join('');
} else {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">Failed to load team</td></tr>';
}
} catch (err) {
console.error('[Portal] Error loading team:', err);
tbody.innerHTML = '<tr><td colspan="6" 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 (Lucee serializes all keys as uppercase)
document.getElementById('settingBusinessName').value = biz.BUSINESSNAME || biz.Name || '';
document.getElementById('settingPhone').value = biz.BUSINESSPHONE || biz.Phone || '';
document.getElementById('settingTaxRate').value = biz.TAXRATEPERCENT || biz.TaxRatePercent || '';
document.getElementById('settingAddressLine1').value = biz.ADDRESSLINE1 || biz.Line1 || '';
document.getElementById('settingCity').value = biz.ADDRESSCITY || biz.City || '';
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.HoursDetail || []);
// Tab settings
const tabsEnabled = biz.SESSIONENABLED || biz.SessionEnabled || 0;
const tabLockMinutes = biz.SESSIONLOCKMINUTES || biz.SessionLockMinutes || 30;
const tabsCheckbox = document.getElementById('tabsEnabled');
const tabDetails = document.getElementById('tabSettingsDetails');
if (tabsCheckbox) {
tabsCheckbox.checked = tabsEnabled == 1;
if (tabDetails) tabDetails.style.display = tabsEnabled == 1 ? 'block' : 'none';
}
const lockInput = document.getElementById('tabLockMinutes');
if (lockInput) lockInput.value = tabLockMinutes;
const minAuthInput = document.getElementById('tabMinAuthAmount');
if (minAuthInput) minAuthInput.value = biz.TABMINAUTHAMOUNT || biz.TabMinAuthAmount || 50;
const defaultAuthInput = document.getElementById('tabDefaultAuthAmount');
if (defaultAuthInput) defaultAuthInput.value = biz.TABDEFAULTAUTHAMOUNT || biz.TabDefaultAuthAmount || 150;
const maxAuthInput = document.getElementById('tabMaxAuthAmount');
if (maxAuthInput) maxAuthInput.value = biz.TABMAXAUTHAMOUNT || biz.TabMaxAuthAmount || 1000;
const maxMembersInput = document.getElementById('tabMaxMembers');
if (maxMembersInput) maxMembersInput.value = biz.TABMAXMEMBERS || biz.TabMaxMembers || 10;
const thresholdInput = document.getElementById('tabAutoIncreaseThreshold');
if (thresholdInput) thresholdInput.value = biz.TABAUTOINCREASETHRESHOLD || biz.TabAutoIncreaseThreshold || 0.80;
const approvalCheckbox = document.getElementById('tabApprovalRequired');
if (approvalCheckbox) approvalCheckbox.checked = (biz.TABAPPROVALREQUIRED || biz.TabApprovalRequired || 1) == 1;
}
} catch (err) {
console.error('[Portal] Error loading business info:', err);
}
},
async saveTabSettings() {
const tabsEnabled = document.getElementById('tabsEnabled').checked ? 1 : 0;
const tabLockMinutes = parseInt(document.getElementById('tabLockMinutes').value) || 30;
const tabDetails = document.getElementById('tabSettingsDetails');
if (tabDetails) tabDetails.style.display = tabsEnabled ? 'block' : 'none';
try {
const response = await fetch(`${this.config.apiBaseUrl}/businesses/updateTabs.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
SessionEnabled: tabsEnabled,
SessionLockMinutes: tabLockMinutes,
SessionPaymentStrategy: 'A',
TabMinAuthAmount: parseFloat(document.getElementById('tabMinAuthAmount').value) || 50,
TabDefaultAuthAmount: parseFloat(document.getElementById('tabDefaultAuthAmount').value) || 150,
TabMaxAuthAmount: parseFloat(document.getElementById('tabMaxAuthAmount').value) || 1000,
TabMaxMembers: parseInt(document.getElementById('tabMaxMembers').value) || 10,
TabAutoIncreaseThreshold: parseFloat(document.getElementById('tabAutoIncreaseThreshold').value) || 0.80,
TabApprovalRequired: document.getElementById('tabApprovalRequired').checked ? 1 : 0
})
});
const data = await response.json();
if (data.OK) { this.showToast('Tab settings saved!', 'success'); }
else { this.showToast(data.ERROR || 'Failed to save tab settings', 'error'); }
} catch (err) {
console.error('[Portal] Error saving tab settings:', err);
this.showToast('Error saving tab settings', 'error');
}
},
// 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,
Name: document.getElementById('settingBusinessName').value,
Phone: document.getElementById('settingPhone').value,
TaxRatePercent: parseFloat(document.getElementById('settingTaxRate').value) || 0,
Line1: 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
// Open customer preview in Payfrit app via deep link
openCustomerPreview() {
const businessId = this.config.businessId;
const businessName = encodeURIComponent(
this.currentBusiness?.BUSINESSNAME || this.currentBusiness?.Name || 'Preview'
);
const deepLink = `payfrit://open?businessId=${businessId}&businessName=${businessName}`;
window.location.href = deepLink;
},
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 = `
<div style="padding: 8px;">
<p style="color: #999; margin-bottom: 16px;">This color is used for accents and fallback backgrounds in your menu.</p>
<div style="display: flex; gap: 16px; align-items: flex-start;">
<input type="color" id="brandColorInput" value="${currentColor}"
style="width: 80px; height: 80px; border: none; cursor: pointer; border-radius: 8px;">
<div style="flex: 1;">
<label style="display: block; margin-bottom: 4px; font-size: 13px; color: #999;">Hex Color</label>
<input type="text" id="brandColorHex" value="${currentColor}"
style="width: 100%; padding: 8px; border: 1px solid #444; border-radius: 4px; background: #222; color: #fff; font-family: monospace;">
</div>
</div>
<div id="brandColorPreview" style="margin-top: 16px; height: 60px; border-radius: 8px; background: linear-gradient(to bottom, ${currentColor}44, ${currentColor}00, ${currentColor}66);"></div>
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
<button type="button" class="btn btn-primary" onclick="Portal.saveBrandColor()">Save Color</button>
</div>
</div>
`;
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)) color = '#' + color;
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 or 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
? `<div class="order-item-modifiers">
${item.Modifiers.map(mod => `
<div class="order-item-modifier">
+ ${mod.Name}
${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.Name}</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 = 'Add Team Member';
document.getElementById('modalBody').innerHTML = `
<form id="inviteForm" class="form">
<div class="form-group">
<label>Phone Number or Email</label>
<input type="text" id="inviteContact" class="form-input" placeholder="(555) 123-4567 or email@example.com" required>
<small style="color: #888; margin-top: 4px; display: block;">Enter the person's phone number or email address</small>
</div>
<div id="userSearchResults" style="margin: 10px 0; display: none;"></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" id="inviteSubmitBtn">Search & Add</button>
</form>
`;
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,
RoleID: { staff: 1, manager: 2, admin: 3 }[document.getElementById('inviteRole').value] || 1
})
});
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 = `
<div style="padding: 12px; background: #e8f5e9; border-radius: 8px; border: 1px solid #4caf50;">
<strong style="color: #2e7d32;">Found:</strong> ${data.USER.Name}<br>
<small style="color: #666;">${data.USER.Phone || data.USER.Email || ''}</small>
</div>
`;
btn.textContent = 'Add Team Member';
} else {
resultsDiv.style.display = 'block';
resultsDiv.innerHTML = `
<div style="padding: 12px; background: #fff3e0; border-radius: 8px; border: 1px solid #ff9800;">
<strong style="color: #e65100;">Not found</strong><br>
<small style="color: #666;">No user with that phone/email. They need to create an account first.</small>
</div>
`;
btn.textContent = 'Search & Add';
}
} catch (err) {
resultsDiv.style.display = 'block';
resultsDiv.innerHTML = `<div style="color: red;">Search failed: ${err.message}</div>`;
}
btn.disabled = false;
});
},
// ========== BEACONS PAGE ==========
beacons: [],
servicePoints: [],
assignments: [],
// Load beacons page data
async loadServicePointsPage() {
await Promise.all([
this.loadServicePoints(),
this.loadBeacons(),
this.loadAssignments()
// COMMENTED OUT FOR LAUNCH - SP Marketing (coming soon)
// this.loadSPSharingPage()
]);
},
// Legacy alias
async loadBeaconsPage() {
await this.loadServicePointsPage();
},
// 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 => {
const minorInfo = b.Minor != null ? ` (Minor: ${b.Minor})` : '';
return `
<div class="list-group-item ${b.IsActive ? '' : 'inactive'}">
<div class="item-info">
<div class="item-name">${this.escapeHtml(b.Name)}${minorInfo}</div>
<div class="item-detail">${b.UUID || 'No UUID'}</div>
</div>
<div class="item-actions">
<button class="btn btn-sm btn-secondary" onclick="Portal.editBeacon(${b.ServicePointID})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="Portal.deleteBeacon(${b.ServicePointID})">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.Name)}</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">UUID: ${a.UUID || ''}</div>
</div>
<div class="item-actions">
<button class="btn btn-sm btn-danger" onclick="Portal.deleteAssignment(${a.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.Name || '')}" 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 = {
Name: 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 (removes BeaconMinor from service point)
async deleteBeacon(servicePointId) {
if (!confirm('Are you sure you want to remove 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({ ServicePointID: servicePointId })
});
const data = await response.json();
if (data.OK) {
this.toast('Beacon removed', 'success');
await this.loadBeacons();
} else {
this.toast(data.ERROR || 'Failed to remove beacon', 'error');
}
} catch (err) {
console.error('[Portal] Error removing beacon:', err);
this.toast('Error removing 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.Name || '')}" 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.Code || ''}" 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 = {
Name: document.getElementById('servicePointName').value,
Code: 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.Name)}</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.Name)}</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({ 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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[m]));
},
// =====================
// Services (Task Types)
// =====================
async loadServicesPage() {
console.log('[Portal] Loading services page...');
await Promise.all([
this.loadServiceTypes(),
this.loadTaskCategories()
]);
},
// Available icons for services (matches Flutter TaskType._iconMap)
serviceIcons: {
// Service & Staff
'room_service': { label: 'Room Service', svg: '<path d="M2 17h20v2H2zm11.84-9.21A2.006 2.006 0 0 0 12 5a2.006 2.006 0 0 0-1.84 2.79C6.25 8.6 3.27 11.93 3 16h18c-.27-4.07-3.25-7.4-7.16-8.21z"/>' },
'support_agent': { label: 'Support Agent', svg: '<path d="M21 12.22C21 6.73 16.74 3 12 3c-4.69 0-9 3.65-9 9.28-.6.34-1 .98-1 1.72v2c0 1.1.9 2 2 2h1v-6.1c0-3.87 3.13-7 7-7s7 3.13 7 7V19h-8v2h8c1.1 0 2-.9 2-2v-1.22c.59-.31 1-.92 1-1.64v-2.3c0-.7-.41-1.31-1-1.62z"/><circle cx="9" cy="13" r="1"/><circle cx="15" cy="13" r="1"/><path d="M18 11.03A6.04 6.04 0 0 0 12.05 6c-3.03 0-6.29 2.51-6.03 6.45a8.075 8.075 0 0 0 4.86-5.89c1.31 2.63 4 4.44 7.12 4.47z"/>' },
'person': { label: 'Person', svg: '<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>' },
'groups': { label: 'Groups', svg: '<path d="M12 12.75c1.63 0 3.07.39 4.24.9 1.08.48 1.76 1.56 1.76 2.73V18H6v-1.61c0-1.18.68-2.26 1.76-2.73 1.17-.52 2.61-.91 4.24-.91zM4 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm1.13 1.1c-.37-.06-.74-.1-1.13-.1-.99 0-1.93.21-2.78.58A2.01 2.01 0 0 0 0 16.43V18h4.5v-1.61c0-.83.23-1.61.63-2.29zM20 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4 3.43c0-.81-.48-1.53-1.22-1.85A6.95 6.95 0 0 0 20 14c-.39 0-.76.04-1.13.1.4.68.63 1.46.63 2.29V18H24v-1.57zM12 6c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3z"/>' },
// Payment & Money
'attach_money': { label: 'Money/Cash', svg: '<path d="M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4z"/>' },
'payments': { label: 'Payments', svg: '<path d="M19 14V6c0-1.1-.9-2-2-2H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zm-9-1c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm13-6v11c0 1.1-.9 2-2 2H4v-2h17V7h2z"/>' },
'receipt': { label: 'Receipt', svg: '<path d="M18 17H6v-2h12v2zm0-4H6v-2h12v2zm0-4H6V7h12v2zM3 22l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5L18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20z"/>' },
'credit_card': { label: 'Credit Card', svg: '<path d="M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z"/>' },
// Communication
'chat': { label: 'Chat', svg: '<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/>' },
'message': { label: 'Message', svg: '<path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/>' },
'call': { label: 'Call', svg: '<path d="M20.01 15.38c-1.23 0-2.42-.2-3.53-.56a.977.977 0 0 0-1.01.24l-1.57 1.97c-2.83-1.35-5.48-3.9-6.89-6.83l1.95-1.66c.27-.28.35-.67.24-1.02-.37-1.11-.56-2.3-.56-3.53 0-.54-.45-.99-.99-.99H4.19C3.65 3 3 3.24 3 3.99 3 13.28 10.73 21 20.01 21c.71 0 .99-.63.99-1.18v-3.45c0-.54-.45-.99-.99-.99z"/>' },
'notifications': { label: 'Notification', svg: '<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>' },
// Food & Drink
'restaurant': { label: 'Restaurant', svg: '<path d="M11 9H9V2H7v7H5V2H3v7c0 2.12 1.66 3.84 3.75 3.97V22h2.5v-9.03C11.34 12.84 13 11.12 13 9V2h-2v7zm5-3v8h2.5v8H21V2c-2.76 0-5 2.24-5 4z"/>' },
'local_bar': { label: 'Bar/Cocktail', svg: '<path d="M21 5V3H3v2l8 9v5H6v2h12v-2h-5v-5l8-9zM7.43 7L5.66 5h12.69l-1.78 2H7.43z"/>' },
'coffee': { label: 'Coffee', svg: '<path d="M20 3H4v10c0 2.21 1.79 4 4 4h6c2.21 0 4-1.79 4-4v-3h2c1.11 0 2-.9 2-2V5c0-1.11-.89-2-2-2zm0 5h-2V5h2v3zM2 21h18v-2H2v2z"/>' },
'icecream': { label: 'Ice Cream', svg: '<path d="M8.79 12.4l3.26 6.22 3.17-6.21c-.11-.01-.21-.01-.32-.01-2.69 0-5.15 1.54-6.11 4z"/><path d="M12 2C9.85 2 7.89 2.86 6.46 4.24l1.33 2.54C8.82 5.66 10.32 5 12 5c1.67 0 3.18.66 4.21 1.78l1.33-2.54C16.11 2.86 14.15 2 12 2z"/><path d="M15.74 13.26C14.96 11.87 13.54 11 12 11c-1.52 0-2.94.86-3.71 2.24L12 20.87l3.74-7.61z"/><path d="M18.16 6.44l-1.33 2.54c.61.91.97 2 .97 3.17 0 .38-.05.76-.12 1.13l1.69-.45c.13-.44.21-.9.21-1.38 0-1.92-.66-3.69-1.75-5.01h.33z"/><path d="M5.2 14.83l1.69.45c-.07-.37-.12-.75-.12-1.13 0-1.17.36-2.26.97-3.17L6.41 8.44C5.32 9.76 4.66 11.53 4.66 13.45c0 .48.08.94.21 1.38h.33z"/>' },
'cake': { label: 'Cake', svg: '<path d="M12 6c1.11 0 2-.9 2-2 0-.38-.1-.73-.29-1.03L12 0l-1.71 2.97c-.19.3-.29.65-.29 1.03 0 1.1.9 2 2 2zm4.6 9.99l-1.07-1.07-1.08 1.07c-1.3 1.3-3.58 1.31-4.89 0l-1.07-1.07-1.09 1.07C6.75 16.64 5.88 17 4.96 17c-.73 0-1.4-.23-1.96-.61V21c0 .55.45 1 1 1h16c.55 0 1-.45 1-1v-4.61c-.56.38-1.23.61-1.96.61-.92 0-1.79-.36-2.44-1.01zM18 9h-5V7h-2v2H6c-1.66 0-3 1.34-3 3v1.54c0 1.08.88 1.96 1.96 1.96.52 0 1.02-.2 1.38-.57l2.14-2.13 2.13 2.13c.74.74 2.03.74 2.77 0l2.14-2.13 2.13 2.13c.37.37.86.57 1.38.57 1.08 0 1.96-.88 1.96-1.96V12C21 10.34 19.66 9 18 9z"/>' },
'local_pizza': { label: 'Pizza', svg: '<path d="M12 2C8.43 2 5.23 3.54 3.01 6L12 22l8.99-16C18.78 3.55 15.57 2 12 2zM7 7c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2zm5 8c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>' },
'lunch_dining': { label: 'Lunch/Burger', svg: '<path d="M2 19h20v2H2v-2zm2.56-8.42c-.78-.09-1.56.24-1.56 1.42h18c0-1.13-.78-1.51-1.56-1.42-.25-1.3-1.07-2.41-2.18-3.04.19-.59.29-1.22.29-1.87C17.55 3.05 14.72.22 11.09.22S4.64 3.05 4.64 6.67c0 .65.1 1.27.29 1.87a3.998 3.998 0 0 0-2.17 3.04zm0 1.4h15.1c-.1 1.41-1.29 2.52-2.71 2.52H7.27c-1.42 0-2.61-1.11-2.71-2.52zM2 16v1h20v-1c0-.55-.45-1-1-1H3c-.55 0-1 .45-1 1z"/>' },
'fastfood': { label: 'Fast Food', svg: '<path d="M21 15c0-4.625-3.507-8.441-8-8.941V4h-2v2.059c-4.493.5-8 4.316-8 8.941v2h18v-2zM5 15c.033-3.576 2.856-6.469 6.378-6.934l.622-.066.622.066C16.144 8.531 18.967 11.424 19 15H5zm-3 4h20v2H2z"/>' },
'ramen_dining': { label: 'Ramen/Noodles', svg: '<path d="M22 3.51V3h-2.01c.16 1.05-.04 2.18-.56 3.22-.53 1.05-1.37 1.96-2.42 2.6l-1 .6V3h-2v6.07l-.78.47c-.41.24-.83.44-1.24.61-.12.06-.25.1-.38.15-.35.13-.71.23-1.07.32l-.53.12V3h-2v7.35c-.58.03-1.15.01-1.71-.06l-.29-.04V3H4v7.67c-.66-.23-1.28-.52-1.84-.87L2 9.69V3H0v17h2v-8.07c.15.07.3.16.45.23.24.12.49.24.74.34.17.07.34.14.51.2.22.08.44.15.66.21.18.05.36.1.54.15.23.06.46.11.69.15.18.03.35.06.53.09.26.04.52.07.79.09.15.01.3.02.45.04.43.03.87.04 1.32.02.12-.01.24 0 .37-.01.35-.02.7-.05 1.06-.1.05-.01.1-.01.16-.02.43-.07.86-.16 1.29-.27.02 0 .04-.01.06-.02.13-.03.26-.08.39-.12.27-.08.53-.16.79-.25.19-.07.37-.14.55-.21.21-.09.43-.17.63-.27.2-.09.39-.19.59-.29.17-.09.34-.18.51-.28.19-.11.38-.22.56-.34.17-.11.34-.22.51-.34.16-.11.32-.22.48-.34.3-.22.59-.46.87-.71.1-.09.2-.18.3-.28.21-.2.42-.4.62-.62.11-.12.23-.24.33-.37.21-.25.41-.51.61-.78l.31-.44c.13-.2.26-.4.38-.62l.3-.55c.09-.18.18-.37.26-.56.08-.18.16-.35.23-.54.08-.21.15-.43.22-.65.05-.16.11-.32.15-.48.07-.26.13-.53.17-.8L22 3.51zM2 21v-2h10v2H2zm18 0h-6v-2h6v2z"/>' },
'bakery_dining': { label: 'Bakery', svg: '<path d="M19.28 16.34c.95-.19 1.68-.94 1.68-1.94 0-.71-.37-1.3-.92-1.62.21-.36.33-.77.33-1.19 0-1.21-.87-2.19-2.04-2.44l-.72-1.44c-.42-.83-1.24-1.35-2.13-1.38a6.32 6.32 0 0 0-1.3-1.97C13.39 3.54 12.48 3 11.5 3c-.98 0-1.89.54-2.68 1.36-.49.51-.89 1.12-1.2 1.75a2.5 2.5 0 0 0-2.5.78l-.76 1.53C3.21 8.67 2.35 9.65 2.35 10.89c0 .42.12.83.33 1.19-.55.32-.92.91-.92 1.62 0 1 .73 1.75 1.68 1.94A2.49 2.49 0 0 0 5.67 19h12.66a2.49 2.49 0 0 0 2.23-3.66h-1.28zM4.75 11.17c.54.06.98.47 1.08 1.01.19-.15.4-.26.63-.33-.11-.48-.09-.98.07-1.46-.51-.17-.88-.63-.88-1.17 0-.23.07-.44.18-.63-.29-.26-.47-.62-.47-1.01 0-.58.35-1.07.84-1.29l.51-1.02c.35-.7 1.08-1.14 1.85-1.14l.59.06.43-.69c.54-.87 1.32-1.37 2.07-1.37.73 0 1.5.48 2.04 1.34l.44.71.6-.06c.78 0 1.5.44 1.86 1.14l.51 1.02c.49.22.84.71.84 1.29 0 .39-.18.75-.47 1.01.11.19.18.4.18.63 0 .54-.37 1-.88 1.17.16.48.18.98.07 1.46.23.07.44.18.63.33.1-.54.54-.95 1.08-1.01l.2-.86c-.4-.36-.63-.87-.63-1.43 0-.36.1-.69.27-.98a2.22 2.22 0 0 1-.27-1.07c0-.58.22-1.1.58-1.5l-.28-.56a3.07 3.07 0 0 0-2.75-1.74h-.24c-.67-1.08-1.68-1.7-2.77-1.7-1.11 0-2.14.65-2.81 1.77-.11-.01-.22-.01-.33-.01a3.07 3.07 0 0 0-2.75 1.74l-.28.56c.36.4.58.92.58 1.5 0 .38-.1.74-.27 1.07.17.29.27.62.27.98 0 .56-.23 1.07-.63 1.43l.2.86z"/>' },
// Drinks & Refills
'water_drop': { label: 'Water/Refill', svg: '<path d="M12 2c-5.33 4.55-8 8.48-8 11.8 0 4.98 3.8 8.2 8 8.2s8-3.22 8-8.2c0-3.32-2.67-7.25-8-11.8zm0 18c-3.35 0-6-2.57-6-6.2 0-2.34 1.95-5.44 6-9.14 4.05 3.7 6 6.79 6 9.14 0 3.63-2.65 6.2-6 6.2z"/>' },
'local_drink': { label: 'Drink/Soda', svg: '<path d="M3 2l2.01 18.23C5.13 21.23 5.97 22 7 22h10c1.03 0 1.87-.77 1.99-1.77L21 2H3zm9 17c-1.66 0-3-1.34-3-3 0-2 3-5.4 3-5.4s3 3.4 3 5.4c0 1.66-1.34 3-3 3zm6.33-11H5.67l-.44-4h13.53l-.43 4z"/>' },
'wine_bar': { label: 'Wine', svg: '<path d="M6 3l1.5 14.029a3.568 3.568 0 0 0 3.562 3.471h1.877a3.569 3.569 0 0 0 3.562-3.471L18 3H6zm5 12.5c-2 0-4-1.5-4-3.5 0-3 4-5.505 4-5.505S15 9 15 12c0 2-2 3.5-4 3.5zM5 21h14v2H5z"/>' },
'sports_bar': { label: 'Beer', svg: '<path d="M19 9h-1V4H8v5H7c-1.66 0-3 1.34-3 3v1c0 1.66 1.34 3 3 3h1v1c0 1.1.9 2 2 2h6c1.1 0 2-.9 2-2V9zm-8 4.5c-.55 0-1-.45-1-1V8h2v4.5c0 .55-.45 1-1 1zm3 0c-.55 0-1-.45-1-1V8h2v4.5c0 .55-.45 1-1 1zM7 14c-.55 0-1-.45-1-1v-1c0-.55.45-1 1-1h1v3H7z"/>' },
'liquor': { label: 'Liquor', svg: '<path d="M3 14c0 1.3.84 2.4 2 2.82V20H3v2h6v-2H7v-3.18C8.16 16.4 9 15.3 9 14V6H3v8zm2-6h2v3H5V8zm15.64 1.35l-.96-.32c-.36-.12-.64-.45-.64-.84V5c0-.55-.45-1-1-1h-3c-.55 0-1 .45-1 1v3.19c0 .39-.28.72-.64.84l-.96.32C11.58 9.65 11 10.46 11 11.37v.13h9v-.13c0-.91-.58-1.72-1.36-2.02zM18 21h-5v-2h5v2zm1-4h-7v-4h7v4z"/>' },
// Hookah & Fire
'local_fire_department': { label: 'Fire/Charcoal', svg: '<path d="M12 12.9l-2.13 2.09c-.56.56-.87 1.29-.87 2.07C9 18.68 10.35 20 12 20s3-1.32 3-2.94c0-.78-.31-1.52-.87-2.07L12 12.9z"/><path d="M16 6l-.44.55C14.38 8.02 12 7.19 12 5.3V2S4 6 4 13c0 2.92 1.56 5.47 3.89 6.86-.56-.79-.89-1.76-.89-2.8 0-1.32.52-2.56 1.47-3.5L12 10.1l3.53 3.47c.95.93 1.47 2.17 1.47 3.5 0 1.02-.31 1.96-.85 2.75 1.89-1.15 3.29-3.06 3.71-5.3.66-3.55-1.07-6.9-3.86-8.52z"/>' },
'whatshot': { label: 'Hot/Trending', svg: '<path d="M13.5.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/>' },
'smoke_free': { label: 'No Smoking', svg: '<path d="M2 6l6.99 7H2v3h9.99l7 7 1.26-1.25-17-17zm18.5 7H22v3h-1.5zM18 13h1.5v3H18zm.85-8.12c.62-.61 1-1.45 1-2.38h-1.5c0 1.02-.83 1.85-1.85 1.85v1.5c2.24 0 4 1.83 4 4.07V12H22V9.92c0-2.23-1.28-4.15-3.15-5.04zM14.5 8.7h1.53c1.05 0 1.97.74 1.97 2.05V12h1.5v-1.59c0-1.8-1.6-3.16-3.47-3.16H14.5c-1.02 0-1.85-.98-1.85-2s.83-1.75 1.85-1.75V2a3.35 3.35 0 0 0-3.35 3.35c0 1.85 1.52 3.35 3.35 3.35z"/><path d="M9.5 16H13v-3z"/>' },
// Cleaning & Maintenance
'cleaning_services': { label: 'Cleaning', svg: '<path d="M16 11h-1V3c0-1.1-.9-2-2-2h-2c-1.1 0-2 .9-2 2v8H8c-2.76 0-5 2.24-5 5v7h18v-7c0-2.76-2.24-5-5-5zm3 10h-2v-3c0-.55-.45-1-1-1s-1 .45-1 1v3h-2v-3c0-.55-.45-1-1-1s-1 .45-1 1v3H9v-3c0-.55-.45-1-1-1s-1 .45-1 1v3H5v-5c0-1.65 1.35-3 3-3h8c1.65 0 3 1.35 3 3v5z"/>' },
'delete_sweep': { label: 'Clear Table', svg: '<path d="M15 16h4v2h-4zm0-8h7v2h-7zm0 4h6v2h-6zM3 18c0 1.1.9 2 2 2h6c1.1 0 2-.9 2-2V8H3v10zM14 5h-3l-1-1H6L5 5H2v2h12z"/>' },
'auto_fix_high': { label: 'Fix/Repair', svg: '<path d="M7.5 5.6L10 7 8.6 4.5 10 2 7.5 3.4 5 2l1.4 2.5L5 7zm12 9.8L17 14l1.4 2.5L17 19l2.5-1.4L22 19l-1.4-2.5L22 14zM22 2l-2.5 1.4L17 2l1.4 2.5L17 7l2.5-1.4L22 7l-1.4-2.5zm-7.63 5.29a.996.996 0 0 0-1.41 0L1.29 18.96a.996.996 0 0 0 0 1.41l2.34 2.34c.39.39 1.02.39 1.41 0L16.7 11.05a.996.996 0 0 0 0-1.41l-2.33-2.35zm-1.03 5.49l-2.12-2.12 2.44-2.44 2.12 2.12-2.44 2.44z"/>' },
// Supplies & Items
'inventory': { label: 'Inventory', svg: '<path d="M20 2H4c-1 0-2 .9-2 2v3.01c0 .72.43 1.34 1 1.69V20c0 1.1 1.1 2 2 2h14c.9 0 2-.9 2-2V8.7c.57-.35 1-.97 1-1.69V4c0-1.1-1-2-2-2zm-5 12H9v-2h6v2zm5-7H4V4h16v3z"/>' },
'shopping_basket': { label: 'Shopping', svg: '<path d="M17.21 9l-4.38-6.56a.993.993 0 0 0-.83-.42c-.32 0-.64.14-.83.43L6.79 9H2c-.55 0-1 .45-1 1 0 .09.01.18.04.27l2.54 9.27c.23.84 1 1.46 1.92 1.46h13c.92 0 1.69-.62 1.93-1.46l2.54-9.27L23 10c0-.55-.45-1-1-1h-4.79zM9 9l3-4.4L15 9H9zm3 8c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>' },
'add_box': { label: 'Add Item', svg: '<path d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"/>' },
'note_add': { label: 'Add Note', svg: '<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 14h-3v3h-2v-3H8v-2h3v-3h2v3h3v2zm-3-7V3.5L18.5 9H13z"/>' },
// Entertainment
'music_note': { label: 'Music', svg: '<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>' },
'tv': { label: 'TV', svg: '<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"/>' },
'sports_esports': { label: 'Gaming', svg: '<path d="M21.58 16.09l-1.09-7.66A3.996 3.996 0 0 0 16.53 5H7.47C5.48 5 3.79 6.46 3.51 8.43l-1.09 7.66C2.2 17.63 3.39 19 4.94 19c.68 0 1.32-.27 1.8-.75L9 16h6l2.25 2.25c.48.48 1.13.75 1.8.75 1.56 0 2.75-1.37 2.53-2.91zM11 11H9v2H8v-2H6v-1h2V8h1v2h2v1zm4-1c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm2 3c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1z"/>' },
'celebration': { label: 'Party', svg: '<path d="M2 22l14-5-9-9zm12.53-9.47l5.59-5.59a1.25 1.25 0 0 1 1.77 0l.59.59 1.06-1.06-.59-.59a2.758 2.758 0 0 0-3.89 0l-5.59 5.59 1.06 1.06zm-4.47-5.65l-.59.59 1.06 1.06.59-.59a2.758 2.758 0 0 0 0-3.89l-.59-.59-1.06 1.07.59.59c.48.48.48 1.28 0 1.76zm7 5l-.59.59 1.06 1.06.59-.59c.48-.48.48-1.28 0-1.76l-.59-.59-1.06 1.06.59.59v-.36zm-6-4l-.59.59 1.06 1.06.59-.59c.48-.48.48-1.28 0-1.76l-.59-.59-1.06 1.06.59.59v-.36z"/>' },
// Comfort & Amenities
'ac_unit': { label: 'A/C', svg: '<path d="M22 11h-4.17l3.24-3.24-1.41-1.42L15 11h-2V9l4.66-4.66-1.42-1.41L13 6.17V2h-2v4.17L7.76 2.93 6.34 4.34 11 9v2H9L4.34 6.34 2.93 7.76 6.17 11H2v2h4.17l-3.24 3.24 1.41 1.42L9 13h2v2l-4.66 4.66 1.42 1.41L11 17.83V22h2v-4.17l3.24 3.24 1.42-1.41L13 15v-2h2l4.66 4.66 1.41-1.42L17.83 13H22z"/>' },
'wb_sunny': { label: 'Sunny/Bright', svg: '<path d="M6.76 4.84l-1.8-1.79-1.41 1.41 1.79 1.79 1.42-1.41zM4 10.5H1v2h3v-2zm9-9.95h-2V3.5h2V.55zm7.45 3.91l-1.41-1.41-1.79 1.79 1.41 1.41 1.79-1.79zm-3.21 13.7l1.79 1.8 1.41-1.41-1.8-1.79-1.4 1.4zM20 10.5v2h3v-2h-3zm-8-5c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm-1 16.95h2V19.5h-2v2.95zm-7.45-3.91l1.41 1.41 1.79-1.8-1.41-1.41-1.79 1.8z"/>' },
'light_mode': { label: 'Light', svg: '<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58a.996.996 0 0 0-1.41 0 .996.996 0 0 0 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37a.996.996 0 0 0-1.41 0 .996.996 0 0 0 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0a.996.996 0 0 0 0-1.41l-1.06-1.06zm1.06-10.96a.996.996 0 0 0 0-1.41.996.996 0 0 0-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36a.996.996 0 0 0 0-1.41.996.996 0 0 0-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/>' },
'volume_up': { label: 'Volume Up', svg: '<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>' },
'volume_down': { label: 'Volume Down', svg: '<path d="M18.5 12A4.5 4.5 0 0 0 16 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/>' },
// Health & Safety
'medical_services': { label: 'Medical', svg: '<path d="M20 6h-4V4c0-1.1-.9-2-2-2h-4c-1.1 0-2 .9-2 2v2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-10-2h4v2h-4V4zm6 11h-3v3h-2v-3H8v-2h3v-3h2v3h3v2z"/>' },
'health_and_safety': { label: 'Health & Safety', svg: '<path d="M10.5 13H8v-3h2.5V7.5h3V10H16v3h-2.5v2.5h-3V13zM12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3z"/>' },
'child_care': { label: 'Child Care', svg: '<circle cx="14.5" cy="10.5" r="1.25"/><circle cx="9.5" cy="10.5" r="1.25"/><path d="M22.94 12.66c.04-.21.06-.43.06-.66s-.02-.45-.06-.66a4.008 4.008 0 0 0-2.81-3.17 9.114 9.114 0 0 0-2.19-2.91C16.36 3.85 14.28 3 12 3s-4.36.85-5.94 2.26c-.92.81-1.67 1.8-2.19 2.91a3.994 3.994 0 0 0-2.81 3.17c-.04.21-.06.43-.06.66s.02.45.06.66a4.008 4.008 0 0 0 2.81 3.17 8.977 8.977 0 0 0 2.17 2.89C7.62 20.14 9.71 21 12 21s4.38-.86 5.97-2.28c.9-.8 1.65-1.79 2.17-2.89a3.998 3.998 0 0 0 2.8-3.17zM19 14c-.1 0-.19-.02-.29-.03-.2.67-.49 1.29-.86 1.86C16.6 17.74 14.45 19 12 19s-4.6-1.26-5.85-3.17c-.37-.57-.66-1.19-.86-1.86-.1.01-.19.03-.29.03-1.1 0-2-.9-2-2s.9-2 2-2c.1 0 .19.02.29.03.2-.67.49-1.29.86-1.86C7.4 6.26 9.55 5 12 5s4.6 1.26 5.85 3.17c.37.57.66 1.19.86 1.86.1-.01.19-.03.29-.03 1.1 0 2 .9 2 2s-.9 2-2 2zM12 17c2.01 0 3.74-1.23 4.5-3h-9c.76 1.77 2.49 3 4.5 3z"/>' },
'accessible': { label: 'Accessible', svg: '<circle cx="12" cy="4" r="2"/><path d="M19 13v-2c-1.54.02-3.09-.75-4.07-1.83l-1.29-1.43c-.17-.19-.38-.34-.61-.45-.01 0-.01-.01-.02-.01H13a.863.863 0 0 0-.31-.06c-.02 0-.03-.01-.05-.01h-.02c-.67 0-1.29.33-1.66.85-.03.04-.05.08-.08.13l-1.5 2.5a1.98 1.98 0 0 0 .68 2.72l3.94 2.35v5h2v-6.5l-2.13-1.28 1.29-2.15C13.28 12.02 14.96 13 17 13h2z"/>' },
// Location & Navigation
'directions': { label: 'Directions', svg: '<path d="M21.71 11.29l-9-9a.996.996 0 0 0-1.41 0l-9 9a.996.996 0 0 0 0 1.41l9 9c.39.39 1.02.39 1.41 0l9-9a.996.996 0 0 0 0-1.41zM14 14.5V12h-4v3H8v-4c0-.55.45-1 1-1h5V7.5l3.5 3.5-3.5 3.5z"/>' },
'meeting_room': { label: 'Meeting Room', svg: '<path d="M14 6v15H3v-2h2V3h9v1h5v15h2v2h-4V6h-3zm-4 5v2h2v-2h-2z"/>' },
'wc': { label: 'Restroom', svg: '<path d="M5.5 22v-7.5H4V9c0-1.1.9-2 2-2h3c1.1 0 2 .9 2 2v5.5H9.5V22h-4zM18 22v-6h3l-2.54-7.63A2.01 2.01 0 0 0 16.56 7h-.12a2 2 0 0 0-1.9 1.37L12 16h3v6h3zM7.5 6c1.11 0 2-.89 2-2s-.89-2-2-2-2 .89-2 2 .89 2 2 2zm9 0c1.11 0 2-.89 2-2s-.89-2-2-2-2 .89-2 2 .89 2 2 2z"/>' },
'local_parking': { label: 'Parking', svg: '<path d="M13 3H6v18h4v-6h3c3.31 0 6-2.69 6-6s-2.69-6-6-6zm.2 8H10V7h3.2c1.1 0 2 .9 2 2s-.9 2-2 2z"/>' },
// General
'help': { label: 'Help', svg: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"/>' },
'info': { label: 'Info', svg: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/>' },
'star': { label: 'Star/VIP', svg: '<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/>' },
'favorite': { label: 'Favorite', svg: '<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>' },
'thumb_up': { label: 'Thumbs Up', svg: '<path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/>' },
'check_circle': { label: 'Check/Done', svg: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>' },
'warning': { label: 'Warning', svg: '<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/>' },
'error': { label: 'Error/Alert', svg: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>' },
'schedule': { label: 'Schedule', svg: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>' },
'event': { label: 'Event', svg: '<path d="M17 12h-5v5h5v-5zM16 1v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-1V1h-2zm3 18H5V8h14v11z"/>' }
},
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 = '<div class="empty-state">Failed to load services</div>';
}
} catch (err) {
console.error('[Portal] Error loading services:', err);
document.getElementById('servicesList').innerHTML = '<div class="empty-state">Error loading services</div>';
}
},
getServiceIconSvg(iconName) {
const icon = this.serviceIcons[iconName] || this.serviceIcons['notifications'];
return `<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">${icon.svg}</svg>`;
},
renderServices(services) {
const container = document.getElementById('servicesList');
if (!services.length) {
container.innerHTML = '<div class="empty-state">No services configured yet. Click "+ Add Service" to create one.</div>';
return;
}
container.innerHTML = services.map((s, index) => `
<div class="service-item" draggable="true" data-id="${s.TaskTypeID}" data-index="${index}" style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-bottom:1px solid #eee;background:#fff;cursor:grab;transition:background 0.2s, transform 0.2s;">
<div style="display:flex;align-items:center;gap:12px;">
<div style="color:#999;cursor:grab;" class="drag-handle">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>
</div>
<div style="width:8px;height:36px;background:${s.TaskTypeColor || '#9C27B0'};border-radius:4px;"></div>
<div style="color:${s.TaskTypeColor || '#6366f1'};">${this.getServiceIconSvg(s.TaskTypeIcon || 'notifications')}</div>
<div>
<strong>${this.escapeHtml(s.TaskTypeName)}</strong>
<div style="color:#666;font-size:12px;">${s.TaskTypeDescription ? this.escapeHtml(s.TaskTypeDescription) : 'No description'}</div>
</div>
</div>
<div style="display:flex;gap:8px;">
<button class="btn btn-sm btn-secondary" onclick="Portal.editService(${s.TaskTypeID})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="Portal.deleteService(${s.TaskTypeID}, '${this.escapeHtml(s.TaskTypeName).replace(/'/g, "\\'")}')">Delete</button>
</div>
</div>
`).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: [
// Material Design colors
'#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5', // Red, Pink, Purple, Deep Purple, Indigo
'#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50', // Blue, Light Blue, Cyan, Teal, Green
'#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800', // Light Green, Lime, Yellow, Amber, Orange
'#FF5722', '#795548', '#9E9E9E', '#607D8B', '#000000', // Deep Orange, Brown, Grey, Blue Grey, Black
'#6366f1', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', // Tailwind colors
],
buildColorPicker(selectedColor = '#9C27B0') {
const normalizedSelected = selectedColor.toUpperCase();
return this.colorPalette.map(color => {
const isSelected = color.toUpperCase() === normalizedSelected;
return `
<label style="display:inline-block;cursor:pointer;margin:3px;">
<input type="radio" name="serviceColor" value="${color}" ${isSelected ? 'checked' : ''} style="display:none;">
<div style="width:28px;height:28px;background:${color};border-radius:4px;border:3px solid ${isSelected ? '#333' : 'transparent'};box-sizing:border-box;"></div>
</label>
`;
}).join('');
},
buildIconPicker(selectedIcon = 'notifications') {
return Object.entries(this.serviceIcons).map(([key, val]) => `
<label style="display:inline-flex;flex-direction:column;align-items:center;padding:8px;cursor:pointer;border:2px solid ${key === selectedIcon ? '#6366f1' : '#e5e7eb'};border-radius:8px;margin:4px;">
<input type="radio" name="serviceIcon" value="${key}" ${key === selectedIcon ? 'checked' : ''} style="display:none;">
<div style="color:${key === selectedIcon ? '#6366f1' : '#666'};">${this.getServiceIconSvg(key)}</div>
<span style="font-size:10px;color:#666;margin-top:4px;">${val.label}</span>
</label>
`).join('');
},
showAddServiceModal() {
// Build category options for dropdown
const categoryOptions = (this._taskCategories || []).map(c =>
`<option value="${c.TaskCategoryID}">${this.escapeHtml(c.Name)}</option>`
).join('');
const html = `
<form id="addServiceForm" onsubmit="Portal.saveNewService(event)">
<div class="form-group">
<label>Service Name</label>
<input type="text" id="newServiceName" class="form-input" placeholder="e.g., Add Charcoal, Request Napkins" required maxlength="45">
<small style="color:#666;">This will appear in the customer's bell menu</small>
</div>
<div class="form-group">
<label>Description (optional)</label>
<input type="text" id="newServiceDescription" class="form-input" placeholder="e.g., Staff will bring fresh charcoal" maxlength="100">
<small style="color:#666;">Subtitle shown under the service name</small>
</div>
<div class="form-group">
<label>Category</label>
<select id="newServiceCategory" class="form-input" required>
<option value="">Select a category...</option>
${categoryOptions}
</select>
<small style="color:#666;">Tasks created from this service will be assigned to this category</small>
</div>
<div class="form-group">
<label>Color</label>
<div style="display:flex;flex-wrap:wrap;gap:2px;" id="colorPicker">
${this.buildColorPicker('#9C27B0')}
</div>
<small style="color:#666;">This color will be shown on the HUD when tasks are created</small>
</div>
<div class="form-group">
<label>Icon</label>
<div style="display:flex;flex-wrap:wrap;gap:4px;" id="iconPicker">
${this.buildIconPicker('notifications')}
</div>
</div>
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Add Service</button>
</div>
</form>
`;
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 categoryId = document.getElementById('newServiceCategory').value;
const color = document.querySelector('input[name="serviceColor"]:checked')?.value || '#9C27B0';
const icon = document.querySelector('input[name="serviceIcon"]:checked')?.value || 'notifications';
if (!name) return;
if (!categoryId) {
this.toast('Please select a category', 'error');
return;
}
const payload = {
BusinessID: this.config.businessId,
TaskTypeName: name,
TaskTypeDescription: description,
TaskTypeColor: color,
TaskTypeIcon: icon,
CategoryID: parseInt(categoryId)
};
try {
const response = await fetch(`${this.config.apiBaseUrl}/tasks/saveType.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
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;
}
// Build category options for dropdown
const categoryOptions = (this._taskCategories || []).map(c =>
`<option value="${c.TaskCategoryID}" ${service.CategoryID == c.TaskCategoryID ? 'selected' : ''}>${this.escapeHtml(c.Name)}</option>`
).join('');
const html = `
<form id="editServiceForm" onsubmit="Portal.updateService(event, ${taskTypeId})">
<div class="form-group">
<label>Service Name</label>
<input type="text" id="editServiceName" class="form-input" value="${this.escapeHtml(service.TaskTypeName)}" required maxlength="45">
</div>
<div class="form-group">
<label>Description (optional)</label>
<input type="text" id="editServiceDescription" class="form-input" value="${this.escapeHtml(service.TaskTypeDescription || '')}" maxlength="100">
<small style="color:#666;">Subtitle shown under the service name</small>
</div>
<div class="form-group">
<label>Category</label>
<select id="editServiceCategory" class="form-input" required>
<option value="">Select a category...</option>
${categoryOptions}
</select>
<small style="color:#666;">Tasks created from this service will be assigned to this category</small>
</div>
<div class="form-group">
<label>Color</label>
<div style="display:flex;flex-wrap:wrap;gap:2px;" id="colorPicker">
${this.buildColorPicker(service.TaskTypeColor || '#9C27B0')}
</div>
<small style="color:#666;">This color will be shown on the HUD when tasks are created</small>
</div>
<div class="form-group">
<label>Icon</label>
<div style="display:flex;flex-wrap:wrap;gap:4px;" id="iconPicker">
${this.buildIconPicker(service.TaskTypeIcon || 'notifications')}
</div>
</div>
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
`;
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 categoryId = document.getElementById('editServiceCategory').value;
const color = document.querySelector('input[name="serviceColor"]:checked')?.value || '#9C27B0';
const icon = document.querySelector('input[name="serviceIcon"]:checked')?.value || 'notifications';
if (!name) return;
if (!categoryId) {
this.toast('Please select a category', 'error');
return;
}
const payload = {
TaskTypeID: taskTypeId,
BusinessID: this.config.businessId,
TaskTypeName: name,
TaskTypeDescription: description,
TaskTypeColor: color,
TaskTypeIcon: icon,
CategoryID: parseInt(categoryId)
};
try {
const response = await fetch(`${this.config.apiBaseUrl}/tasks/saveType.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
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: [],
_taskCategories: [],
async loadAdminTasksPage() {
console.log('[Portal] Loading admin tasks page...');
await Promise.all([
this.loadTaskCategories(),
this.loadQuickTaskTemplates(),
this.loadScheduledTasks(),
this.loadPendingRatings()
]);
},
// Task Categories
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 || [];
this.renderTaskCategories();
}
} catch (err) {
console.error('[Portal] Error loading task categories:', err);
}
},
renderTaskCategories() {
const container = document.getElementById('taskCategoriesGrid');
if (!container) return;
if (!this._taskCategories.length) {
container.innerHTML = `
<div class="empty-state">
No categories yet.
<button class="btn btn-sm btn-primary" onclick="Portal.seedDefaultCategories()" style="margin-left:8px;">Seed Defaults</button>
</div>`;
return;
}
container.innerHTML = `
<div style="display:flex;flex-wrap:wrap;gap:8px;">
${this._taskCategories.map(c => `
<div class="category-chip" style="display:inline-flex;align-items:center;gap:8px;padding:8px 12px;background:#f5f5f5;border-radius:8px;border-left:4px solid ${c.Color};">
<div style="width:20px;height:20px;border-radius:4px;background:${c.Color};"></div>
<span style="font-weight:500;">${this.escapeHtml(c.Name)}</span>
<button class="btn btn-sm" onclick="Portal.editTaskCategory(${c.TaskCategoryID})" style="padding:2px 6px;font-size:11px;">Edit</button>
<button class="btn btn-sm btn-danger" onclick="Portal.deleteTaskCategory(${c.TaskCategoryID})" style="padding:2px 6px;font-size:11px;">×</button>
</div>
`).join('')}
</div>
`;
},
async seedDefaultCategories() {
try {
const response = await fetch(`${this.config.apiBaseUrl}/tasks/seedCategories.cfm?bid=${this.config.businessId}`);
const data = await response.json();
if (data.OK) {
this.toast(data.MESSAGE || 'Categories created!', 'success');
await this.loadTaskCategories();
} else {
this.toast(data.MESSAGE || 'Failed to seed categories', 'error');
}
} catch (err) {
console.error('[Portal] Error seeding categories:', err);
this.toast('Error seeding categories', 'error');
}
},
showAddTaskCategoryModal(categoryId = null) {
const isEdit = categoryId !== null;
const category = isEdit ? this._taskCategories.find(c => c.TaskCategoryID === categoryId) : {};
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Category' : 'Add Task Category';
document.getElementById('modalBody').innerHTML = `
<form id="taskCategoryForm" class="form">
<input type="hidden" id="taskCategoryId" value="${categoryId || ''}">
<div class="form-group">
<label>Category Name</label>
<input type="text" id="taskName" class="form-input" value="${this.escapeHtml(category.Name || '')}" required placeholder="e.g., Kitchen, Cleaning, Delivery">
</div>
<div class="form-group">
<label>Color</label>
<div style="display:flex;flex-wrap:wrap;gap:4px;" id="taskCategoryColorPicker">
${this.buildColorPicker(category.Color || '#6366f1')}
</div>
</div>
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">${isEdit ? 'Save' : 'Create'}</button>
</div>
</form>
`;
this.showModal();
// Color selection highlighting
document.querySelectorAll('#taskCategoryColorPicker label').forEach(label => {
label.addEventListener('click', () => {
document.querySelectorAll('#taskCategoryColorPicker label div').forEach(d => d.style.border = '3px solid transparent');
label.querySelector('div').style.border = '3px solid #333';
});
});
document.getElementById('taskCategoryForm').addEventListener('submit', (e) => {
e.preventDefault();
this.saveTaskCategory();
});
},
editTaskCategory(categoryId) {
this.showAddTaskCategoryModal(categoryId);
},
async saveTaskCategory() {
const id = document.getElementById('taskCategoryId').value;
const name = document.getElementById('taskName').value;
const color = document.querySelector('#taskCategoryColorPicker input[name="serviceColor"]:checked')?.value || '#6366f1';
const payload = {
BusinessID: this.config.businessId,
Name: name,
Color: color
};
if (id) payload.TaskCategoryID = parseInt(id);
try {
const response = await fetch(`${this.config.apiBaseUrl}/tasks/saveCategory.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.OK) {
this.toast('Category saved!', 'success');
this.closeModal();
await this.loadTaskCategories();
} else {
this.toast(data.MESSAGE || 'Failed to save', 'error');
}
} catch (err) {
console.error('[Portal] Error saving category:', err);
this.toast('Error saving category', 'error');
}
},
async deleteTaskCategory(categoryId) {
if (!confirm('Delete this category?')) return;
try {
const response = await fetch(`${this.config.apiBaseUrl}/tasks/deleteCategory.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessID: this.config.businessId,
TaskCategoryID: categoryId
})
});
const data = await response.json();
if (data.OK) {
this.toast(data.MESSAGE || 'Category deleted', 'success');
await this.loadTaskCategories();
} else {
this.toast(data.MESSAGE || 'Failed to delete', 'error');
}
} catch (err) {
console.error('[Portal] Error deleting category:', err);
this.toast('Error deleting category', 'error');
}
},
// 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 = '<div class="empty-state">Failed to load quick task templates</div>';
}
} catch (err) {
console.error('[Portal] Error loading quick task templates:', err);
document.getElementById('quickTasksGrid').innerHTML = '<div class="empty-state">Error loading templates</div>';
}
},
renderQuickTaskTemplates() {
const gridContainer = document.getElementById('quickTasksGrid');
const manageContainer = document.getElementById('quickTasksManageList');
if (!this.quickTaskTemplates.length) {
gridContainer.innerHTML = '<div class="empty-state">No quick tasks yet. Click "+ Add Quick Task" to create one.</div>';
manageContainer.innerHTML = '';
return;
}
// Shortcut buttons grid
gridContainer.innerHTML = this.quickTaskTemplates.map(t => `
<button class="quick-task-btn" onclick="Portal.createQuickTask(${t.QuickTaskTemplateID})"
style="background:${t.Color || '#6366f1'};color:#fff;border:none;padding:16px;border-radius:8px;cursor:pointer;text-align:left;transition:transform 0.1s,box-shadow 0.1s;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
${this.getServiceIconSvg(t.Icon || 'add_box')}
<strong>${this.escapeHtml(t.Name)}</strong>
</div>
<div style="font-size:12px;opacity:0.9;">${this.escapeHtml(t.Title)}</div>
</button>
`).join('');
// Management list
manageContainer.innerHTML = `
<h4 style="margin-bottom:12px;color:#666;">Manage Quick Tasks</h4>
<div class="list-group">
${this.quickTaskTemplates.map(t => `
<div class="list-group-item" style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-bottom:1px solid #eee;">
<div style="display:flex;align-items:center;gap:12px;">
<div style="width:8px;height:36px;background:${t.Color || '#6366f1'};border-radius:4px;"></div>
<div>
<strong>${this.escapeHtml(t.Name)}</strong>
<div style="color:#666;font-size:12px;">${this.escapeHtml(t.Title)}</div>
</div>
</div>
<div style="display:flex;gap:8px;">
<button class="btn btn-sm btn-secondary" onclick="Portal.editQuickTask(${t.QuickTaskTemplateID})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="Portal.deleteQuickTask(${t.QuickTaskTemplateID})">Delete</button>
</div>
</div>
`).join('')}
</div>
`;
},
showAddQuickTaskModal(templateId = null) {
const isEdit = templateId !== null;
const template = isEdit ? this.quickTaskTemplates.find(t => t.QuickTaskTemplateID === templateId) : {};
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Quick Task' : 'Add Quick Task';
document.getElementById('modalBody').innerHTML = `
<form id="quickTaskForm" class="form">
<input type="hidden" id="quickTaskTemplateId" value="${templateId || ''}">
<input type="hidden" id="quickTaskCategory" value="${template.CategoryID || ''}">
<div class="form-group">
<label>Button Name</label>
<input type="text" id="quickTaskName" class="form-input" value="${this.escapeHtml(template.Name || '')}" required placeholder="e.g., Check Trash">
<small style="color:#666;">Name shown on the shortcut button</small>
</div>
<div class="form-group">
<label>Task Title</label>
<input type="text" id="quickTitle" class="form-input" value="${this.escapeHtml(template.Title || '')}" required placeholder="e.g., Check and empty trash bins">
<small style="color:#666;">Title shown on the created task</small>
</div>
<div class="form-group">
<label>Task Details (optional)</label>
<textarea id="quickDetails" class="form-textarea" rows="2" placeholder="Optional instructions">${this.escapeHtml(template.Details || '')}</textarea>
</div>
<div class="form-group">
<label>Category</label>
<div id="quickTaskCategoryGrid" style="display:flex;flex-wrap:wrap;gap:8px;padding:8px 0;">
<div class="empty-state" style="padding:20px;color:#666;">Loading categories...</div>
</div>
<small style="color:#666;">Task will inherit the category's color</small>
</div>
<div class="form-group">
<label>Icon</label>
<div id="quickTaskIconPicker" style="display:flex;flex-wrap:wrap;gap:4px;max-height:150px;overflow-y:auto;padding:8px;border:1px solid #e5e7eb;border-radius:8px;background:#fafafa;">
${this.buildQuickTaskIconPicker(template.Icon || 'add_box', template.Color || '#6366f1')}
</div>
</div>
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">${isEdit ? 'Save Changes' : 'Create'}</button>
</div>
</form>
`;
this.showModal();
// Load categories and render as clickable chips
this.loadTaskCategories().then(() => {
this.renderQuickTaskCategoryGrid(template.CategoryID);
});
document.getElementById('quickTaskForm').addEventListener('submit', (e) => {
e.preventDefault();
this.saveQuickTask();
});
},
renderQuickTaskCategoryGrid(selectedCategoryId) {
const container = document.getElementById('quickTaskCategoryGrid');
if (!container) return;
const categories = this._taskCategories || [];
if (!categories.length) {
container.innerHTML = '<div class="empty-state" style="padding:20px;color:#666;">No categories defined. Add some above first.</div>';
return;
}
container.innerHTML = categories.map(c => {
const isSelected = selectedCategoryId == c.TaskCategoryID;
const color = c.Color || '#6366f1';
return `
<div class="quick-task-category-chip" data-id="${c.TaskCategoryID}" data-color="${color}"
style="display:inline-flex;align-items:center;gap:8px;padding:10px 14px;cursor:pointer;
border:3px solid ${isSelected ? color : '#e5e7eb'};border-radius:8px;
background:${isSelected ? color + '20' : '#fff'};transition:all 0.2s;">
<div style="width:16px;height:16px;border-radius:4px;background:${color};"></div>
<span style="font-size:13px;color:#333;font-weight:${isSelected ? '600' : '400'};">${this.escapeHtml(c.Name)}</span>
</div>
`;
}).join('');
// Add click handlers
container.querySelectorAll('.quick-task-category-chip').forEach(chip => {
chip.addEventListener('click', () => {
// Deselect all
container.querySelectorAll('.quick-task-category-chip').forEach(c => {
c.style.borderColor = '#e5e7eb';
c.style.background = '#fff';
c.querySelector('span').style.fontWeight = '400';
});
// Select this one
const color = chip.dataset.color;
chip.style.borderColor = color;
chip.style.background = color + '20';
chip.querySelector('span').style.fontWeight = '600';
// Update hidden field and icon colors
document.getElementById('quickTaskCategory').value = chip.dataset.id;
this.updateQuickTaskIconColors(color);
});
});
document.getElementById('quickTaskCategory').value = selectedCategoryId || '';
// Set up icon picker click handlers
this.setupQuickTaskIconHandlers();
},
buildQuickTaskIconPicker(selectedIcon = 'add_box', color = '#6366f1') {
return Object.entries(this.serviceIcons).map(([key, val]) => `
<label style="display:inline-flex;flex-direction:column;align-items:center;padding:6px;cursor:pointer;border:2px solid ${key === selectedIcon ? color : '#e5e7eb'};border-radius:8px;min-width:50px;">
<input type="radio" name="quickTaskIconRadio" value="${key}" ${key === selectedIcon ? 'checked' : ''} style="display:none;">
<div style="color:${key === selectedIcon ? color : '#999'};">${this.getServiceIconSvg(key)}</div>
<span style="font-size:9px;color:#666;margin-top:2px;text-align:center;max-width:48px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${val.label}</span>
</label>
`).join('');
},
setupQuickTaskIconHandlers() {
document.querySelectorAll('#quickTaskIconPicker label').forEach(label => {
label.addEventListener('click', () => {
const categoryChip = document.querySelector('.quick-task-category-chip[style*="font-weight: 600"]') ||
document.querySelector('.quick-task-category-chip[style*="font-weight:600"]');
const color = categoryChip?.dataset?.color || '#6366f1';
document.querySelectorAll('#quickTaskIconPicker label').forEach(l => {
l.style.borderColor = '#e5e7eb';
l.querySelector('div').style.color = '#999';
});
label.style.borderColor = color;
label.querySelector('div').style.color = color;
});
});
},
updateQuickTaskIconColors(color) {
const selectedRadio = document.querySelector('input[name="quickTaskIconRadio"]:checked');
document.querySelectorAll('#quickTaskIconPicker label').forEach(label => {
const radio = label.querySelector('input[type="radio"]');
const isSelected = radio === selectedRadio;
label.style.borderColor = isSelected ? color : '#e5e7eb';
label.querySelector('div').style.color = isSelected ? color : '#999';
});
},
editQuickTask(templateId) {
this.showAddQuickTaskModal(templateId);
},
async saveQuickTask() {
const id = document.getElementById('quickTaskTemplateId').value;
const categoryId = document.getElementById('quickTaskCategory')?.value;
// Validate category is selected
if (!categoryId) {
this.toast('Please select a category', 'error');
return;
}
// Get icon from radio button
const selectedIcon = document.querySelector('input[name="quickTaskIconRadio"]:checked')?.value || 'add_box';
// Get color from selected category chip
const selectedChip = document.querySelector('.quick-task-category-chip[data-id="' + categoryId + '"]');
const color = selectedChip?.dataset?.color || '#6366f1';
const payload = {
BusinessID: this.config.businessId,
Name: document.getElementById('quickTaskName').value,
Title: document.getElementById('quickTitle').value,
Details: document.getElementById('quickDetails').value,
CategoryID: categoryId || null,
Icon: selectedIcon,
Color: color
};
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('Quick task 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:', err);
this.toast('Error saving quick task', 'error');
}
},
async deleteQuickTask(templateId) {
if (!confirm('Delete this quick task?')) 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('Quick task deleted', 'success');
await this.loadQuickTaskTemplates();
} else {
this.toast(data.MESSAGE || 'Failed to delete', 'error');
}
} catch (err) {
console.error('[Portal] Error deleting quick task:', err);
this.toast('Error deleting quick task', '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 = '<div class="empty-state">Failed to load scheduled tasks</div>';
}
} catch (err) {
console.error('[Portal] Error loading scheduled tasks:', err);
document.getElementById('scheduledTasksList').innerHTML = '<div class="empty-state">Error loading scheduled tasks</div>';
}
},
renderScheduledTasks() {
const container = document.getElementById('scheduledTasksList');
if (!this.scheduledTasks.length) {
container.innerHTML = '<div class="empty-state">No scheduled tasks configured. Click "+ Add Scheduled Task" to create one.</div>';
return;
}
container.innerHTML = this.scheduledTasks.map(s => `
<div class="list-group-item" style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-bottom:1px solid #eee;">
<div style="flex:1;">
<div style="display:flex;align-items:center;gap:8px;">
<span class="status-badge ${s.IsActive ? 'active' : 'inactive'}">${s.IsActive ? 'Active' : 'Paused'}</span>
<strong>${this.escapeHtml(s.Name)}</strong>
</div>
<div style="color:#666;font-size:12px;margin-top:4px;">
${this.escapeHtml(s.Title)} | Schedule: <code>${this.formatScheduleDisplay(s)}</code>
</div>
${s.NextRunOn ? `<div style="color:#999;font-size:11px;">Next run: ${s.NextRunOn}</div>` : ''}
${s.LastRunOn ? `<div style="color:#999;font-size:11px;">Last run: ${s.LastRunOn}</div>` : ''}
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn btn-sm btn-secondary" onclick="Portal.runScheduledTaskNow(${s.ScheduledTaskID})" title="Run Now">
Run Now
</button>
<button class="btn btn-sm btn-secondary" onclick="Portal.toggleScheduledTask(${s.ScheduledTaskID}, ${!s.IsActive})">
${s.IsActive ? 'Pause' : 'Enable'}
</button>
<button class="btn btn-sm btn-secondary" onclick="Portal.editScheduledTask(${s.ScheduledTaskID})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="Portal.deleteScheduledTask(${s.ScheduledTaskID})">Delete</button>
</div>
</div>
`).join('');
},
// Format schedule for display in list
formatScheduleDisplay(task) {
if (task.ScheduleType === 'interval' && task.IntervalMinutes) {
const mins = parseInt(task.IntervalMinutes);
if (mins >= 60 && mins % 60 === 0) {
const hours = mins / 60;
return `Every ${hours} hour${hours === 1 ? '' : 's'}`;
} else {
return `Every ${mins} minute${mins === 1 ? '' : 's'}`;
}
}
return this.escapeHtml(task.CronExpression);
},
// Parse task schedule into friendly values
parseCronToFriendly(task) {
// Check if this is interval-based scheduling
const isIntervalType = task.ScheduleType === 'interval' || task.ScheduleType === 'interval_after_completion';
if (isIntervalType && task.IntervalMinutes) {
const intervalMins = parseInt(task.IntervalMinutes);
const isAfterCompletion = task.ScheduleType === 'interval_after_completion';
if (intervalMins >= 60 && intervalMins % 60 === 0) {
// Express as hours
return {
hour: 9, minute: 0, frequency: 'interval',
selectedDays: [], monthDay: 1, intervalValue: intervalMins / 60,
intervalUnit: 'hours', intervalMode: isAfterCompletion ? 'after_completion' : 'fixed'
};
} else {
// Express as minutes
return {
hour: 9, minute: 0, frequency: 'interval',
selectedDays: [], monthDay: 1, intervalValue: intervalMins,
intervalUnit: 'minutes', intervalMode: isAfterCompletion ? 'after_completion' : 'fixed'
};
}
}
// Cron-based scheduling
const cron = task.CronExpression || task;
const parts = (typeof cron === 'string' ? cron : '0 9 * * *').split(' ');
const minute = parseInt(parts[0]) || 0;
const hour = parseInt(parts[1]) || 9;
const dayOfMonth = parts[2] || '*';
const month = parts[3] || '*';
const dayOfWeek = parts[4] || '*';
let frequency = 'daily';
let selectedDays = [];
let monthDay = 1;
if (dayOfMonth !== '*' && !isNaN(parseInt(dayOfMonth))) {
frequency = 'monthly';
monthDay = parseInt(dayOfMonth);
} else if (dayOfWeek === '1-5') {
frequency = 'weekdays';
} else if (dayOfWeek !== '*') {
frequency = 'weekly';
// Parse days: could be "1,3,5" or "1-5" etc.
if (dayOfWeek.includes(',')) {
selectedDays = dayOfWeek.split(',').map(d => parseInt(d));
} else if (dayOfWeek.includes('-')) {
const [start, end] = dayOfWeek.split('-').map(d => parseInt(d));
for (let i = start; i <= end; i++) selectedDays.push(i);
} else {
selectedDays = [parseInt(dayOfWeek)];
}
}
return { hour, minute, frequency, selectedDays, monthDay, intervalValue: 15, intervalUnit: 'minutes', intervalMode: 'fixed' };
},
// Build cron expression from friendly values
buildCronFromFriendly() {
const frequency = document.getElementById('scheduleFrequency').value;
const hour = document.getElementById('scheduleHour').value;
const minute = document.getElementById('scheduleMinute').value;
let dayOfMonth = '*';
let dayOfWeek = '*';
if (frequency === 'weekdays') {
dayOfWeek = '1-5';
} else if (frequency === 'weekly') {
const checkedDays = [...document.querySelectorAll('.day-btn.selected')].map(btn => btn.dataset.day);
dayOfWeek = checkedDays.length > 0 ? checkedDays.join(',') : '*';
} else if (frequency === 'monthly') {
dayOfMonth = document.getElementById('scheduleMonthDay').value;
} else if (frequency === 'custom') {
return document.getElementById('scheduledTaskCron').value;
}
return `${minute} ${hour} ${dayOfMonth} * ${dayOfWeek}`;
},
showAddScheduledTaskModal(taskId = null) {
const isEdit = taskId !== null;
const task = isEdit ? this.scheduledTasks.find(t => t.ScheduledTaskID === taskId) : {};
// Parse existing schedule or use defaults
const schedule = this.parseCronToFriendly(task);
// Build category options
const categoryOptions = (this._taskCategories || []).map(c =>
`<option value="${c.TaskCategoryID}" ${task.CategoryID == c.TaskCategoryID ? 'selected' : ''}>${this.escapeHtml(c.Name)}</option>`
).join('');
// Build hour options (12-hour format display, 24-hour value)
const hourOptions = Array.from({length: 24}, (_, i) => {
const displayHour = i === 0 ? 12 : (i > 12 ? i - 12 : i);
const ampm = i < 12 ? 'AM' : 'PM';
return `<option value="${i}" ${schedule.hour === i ? 'selected' : ''}>${displayHour} ${ampm}</option>`;
}).join('');
// Build minute options (every 5 minutes)
const minuteOptions = Array.from({length: 12}, (_, i) => {
const min = i * 5;
return `<option value="${min}" ${schedule.minute === min ? 'selected' : ''}>${min.toString().padStart(2, '0')}</option>`;
}).join('');
// Build day of month options
const monthDayOptions = Array.from({length: 31}, (_, i) => {
const day = i + 1;
const suffix = day === 1 || day === 21 || day === 31 ? 'st' : (day === 2 || day === 22 ? 'nd' : (day === 3 || day === 23 ? 'rd' : 'th'));
return `<option value="${day}" ${schedule.monthDay === day ? 'selected' : ''}>${day}${suffix}</option>`;
}).join('');
// Day buttons for weekly
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const dayButtons = dayNames.map((name, i) => {
const isSelected = schedule.selectedDays.includes(i);
return `<button type="button" class="day-btn ${isSelected ? 'selected' : ''}" data-day="${i}">${name}</button>`;
}).join('');
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Scheduled Task' : 'Add Scheduled Task';
document.getElementById('modalBody').innerHTML = `
<form id="scheduledTaskForm" class="form">
<input type="hidden" id="scheduledTaskId" value="${taskId || ''}">
<input type="hidden" id="scheduledTaskCron" value="${task.CronExpression || '0 9 * * *'}">
<div class="form-group">
<label>Task Title</label>
<input type="text" id="scheduledTitle" class="form-input" value="${this.escapeHtml(task.Title || '')}" required placeholder="e.g., Check all garbage cans">
</div>
<div class="form-group">
<label>Task Details (optional)</label>
<textarea id="scheduledDetails" class="form-textarea" rows="2" placeholder="Optional instructions">${this.escapeHtml(task.Details || '')}</textarea>
</div>
<div class="form-group">
<label>Category</label>
<select id="scheduledTaskCategory" class="form-input" required>
<option value="">Select a category...</option>
${categoryOptions}
</select>
</div>
<div class="form-group">
<label>Repeat</label>
<select id="scheduleFrequency" class="form-input" onchange="Portal.onScheduleFrequencyChange()">
<option value="interval" ${schedule.frequency === 'interval' || schedule.frequency === 'interval_minutes' || schedule.frequency === 'interval_hours' ? 'selected' : ''}>Time Interval</option>
<option value="daily" ${schedule.frequency === 'daily' ? 'selected' : ''}>Every Day</option>
<option value="weekdays" ${schedule.frequency === 'weekdays' ? 'selected' : ''}>Weekdays (Mon-Fri)</option>
<option value="weekly" ${schedule.frequency === 'weekly' ? 'selected' : ''}>Specific Days</option>
<option value="monthly" ${schedule.frequency === 'monthly' ? 'selected' : ''}>Monthly</option>
<option value="custom" ${schedule.frequency === 'custom' ? 'selected' : ''}>Custom (Advanced)</option>
</select>
</div>
<div id="intervalContainer" class="form-group" style="display:${schedule.frequency === 'interval' || schedule.frequency === 'interval_minutes' || schedule.frequency === 'interval_hours' ? 'block' : 'none'};">
<label>Repeat every</label>
<div class="interval-presets" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px;">
<button type="button" class="interval-preset-btn" data-minutes="5">5 min</button>
<button type="button" class="interval-preset-btn" data-minutes="10">10 min</button>
<button type="button" class="interval-preset-btn" data-minutes="15">15 min</button>
<button type="button" class="interval-preset-btn" data-minutes="30">30 min</button>
<button type="button" class="interval-preset-btn" data-minutes="60">1 hour</button>
<button type="button" class="interval-preset-btn" data-minutes="120">2 hours</button>
<button type="button" class="interval-preset-btn" data-minutes="240">4 hours</button>
</div>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:12px;">
<span style="color:#666;">Or custom:</span>
<input type="number" id="scheduleInterval" class="form-input" style="width:80px;" min="1" max="1440" value="${schedule.intervalValue || 15}">
<select id="intervalUnit" class="form-input" style="width:auto;">
<option value="minutes" ${schedule.intervalUnit !== 'hours' ? 'selected' : ''}>minutes</option>
<option value="hours" ${schedule.intervalUnit === 'hours' ? 'selected' : ''}>hours</option>
</select>
</div>
<label style="margin-top:8px;">Interval starts</label>
<select id="intervalMode" class="form-input" onchange="Portal.updateSchedulePreview()">
<option value="fixed" ${schedule.intervalMode !== 'after_completion' ? 'selected' : ''}>From when task is enabled (fixed schedule)</option>
<option value="after_completion" ${schedule.intervalMode === 'after_completion' ? 'selected' : ''}>After previous task is completed</option>
</select>
</div>
<div id="weeklyDaysContainer" class="form-group" style="display:${schedule.frequency === 'weekly' ? 'block' : 'none'};">
<label>On these days</label>
<div class="day-buttons">${dayButtons}</div>
</div>
<div id="monthlyDayContainer" class="form-group" style="display:${schedule.frequency === 'monthly' ? 'block' : 'none'};">
<label>Day of Month</label>
<select id="scheduleMonthDay" class="form-input">${monthDayOptions}</select>
</div>
<div id="customCronContainer" class="form-group" style="display:${schedule.frequency === 'custom' ? 'block' : 'none'};">
<label>Cron Expression</label>
<input type="text" id="customCronInput" class="form-input" value="${task.CronExpression || '0 9 * * *'}" placeholder="0 9 * * *">
<small style="color:#666;">Format: minute hour day month weekday</small>
</div>
<div id="timePickerContainer" class="form-group" style="display:${schedule.frequency === 'interval_minutes' || schedule.frequency === 'interval_hours' || schedule.frequency === 'custom' ? 'none' : 'block'};">
<label>At</label>
<div style="display:flex;gap:8px;align-items:center;">
<select id="scheduleHour" class="form-input" style="width:auto;">${hourOptions}</select>
<span>:</span>
<select id="scheduleMinute" class="form-input" style="width:auto;">${minuteOptions}</select>
</div>
</div>
<div id="schedulePreview" class="schedule-preview" style="background:#f0f4ff;padding:12px;border-radius:8px;margin:16px 0;">
<span style="color:#666;">This task will run:</span>
<strong id="schedulePreviewText" style="display:block;margin-top:4px;color:#4f46e5;"></strong>
</div>
<div class="form-group">
<label>Internal Name (optional)</label>
<input type="text" id="scheduledTaskName" class="form-input" value="${this.escapeHtml(task.Name || '')}" placeholder="e.g., garbage-check-daily">
<small style="color:#666;">Used for tracking; auto-generated if blank</small>
</div>
<div class="form-group">
<label><input type="checkbox" id="scheduledTaskActive" ${task.IsActive !== false ? 'checked' : ''}> Active</label>
</div>
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">${isEdit ? 'Save Changes' : 'Create'}</button>
</div>
</form>
<style>
.day-buttons { display:flex; gap:4px; flex-wrap:wrap; }
.day-btn {
padding:8px 12px; border:2px solid #ddd; background:#fff; border-radius:8px;
cursor:pointer; font-weight:500; transition:all 0.2s;
}
.day-btn:hover { border-color:#4f46e5; }
.day-btn.selected { background:#4f46e5; color:#fff; border-color:#4f46e5; }
.interval-preset-btn {
padding:8px 14px; border:2px solid #ddd; background:#fff; border-radius:8px;
cursor:pointer; font-weight:500; transition:all 0.2s; font-size:14px;
}
.interval-preset-btn:hover { border-color:#4f46e5; background:#f0f4ff; }
.interval-preset-btn.selected { background:#4f46e5; color:#fff; border-color:#4f46e5; }
</style>
`;
this.showModal();
// Set up day button click handlers
document.querySelectorAll('.day-btn').forEach(btn => {
btn.addEventListener('click', () => {
btn.classList.toggle('selected');
this.updateSchedulePreview();
});
});
// Set up interval preset button click handlers
document.querySelectorAll('.interval-preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
const minutes = parseInt(btn.dataset.minutes);
// Clear all preset selections
document.querySelectorAll('.interval-preset-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
// Update the custom input to match
if (minutes >= 60 && minutes % 60 === 0) {
document.getElementById('scheduleInterval').value = minutes / 60;
document.getElementById('intervalUnit').value = 'hours';
} else {
document.getElementById('scheduleInterval').value = minutes;
document.getElementById('intervalUnit').value = 'minutes';
}
this.updateSchedulePreview();
});
});
// Set up change listeners for preview
['scheduleFrequency', 'scheduleHour', 'scheduleMinute', 'scheduleMonthDay', 'scheduleInterval', 'intervalUnit'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('change', () => {
// Clear preset selection when custom value changes
if (id === 'scheduleInterval' || id === 'intervalUnit') {
document.querySelectorAll('.interval-preset-btn').forEach(b => b.classList.remove('selected'));
}
this.updateSchedulePreview();
});
});
// Also listen for input event on the interval number field
const intervalInput = document.getElementById('scheduleInterval');
if (intervalInput) intervalInput.addEventListener('input', () => {
document.querySelectorAll('.interval-preset-btn').forEach(b => b.classList.remove('selected'));
this.updateSchedulePreview();
});
// Initial preview update
this.updateSchedulePreview();
// Load categories if not loaded
if (!this._taskCategories) {
this.loadTaskCategories();
}
document.getElementById('scheduledTaskForm').addEventListener('submit', (e) => {
e.preventDefault();
this.saveScheduledTask();
});
},
onScheduleFrequencyChange() {
const freq = document.getElementById('scheduleFrequency').value;
const isInterval = freq === 'interval';
document.getElementById('intervalContainer').style.display = isInterval ? 'block' : 'none';
document.getElementById('weeklyDaysContainer').style.display = freq === 'weekly' ? 'block' : 'none';
document.getElementById('monthlyDayContainer').style.display = freq === 'monthly' ? 'block' : 'none';
document.getElementById('customCronContainer').style.display = freq === 'custom' ? 'block' : 'none';
document.getElementById('timePickerContainer').style.display = (isInterval || freq === 'custom') ? 'none' : 'block';
document.getElementById('schedulePreview').style.display = freq === 'custom' ? 'none' : 'block';
this.updateSchedulePreview();
},
updateSchedulePreview() {
const freq = document.getElementById('scheduleFrequency').value;
const hour = parseInt(document.getElementById('scheduleHour').value);
const minute = parseInt(document.getElementById('scheduleMinute').value);
const displayHour = hour === 0 ? 12 : (hour > 12 ? hour - 12 : hour);
const ampm = hour < 12 ? 'AM' : 'PM';
const timeStr = `${displayHour}:${minute.toString().padStart(2, '0')} ${ampm}`;
let preview = '';
if (freq === 'interval' || freq === 'interval_minutes' || freq === 'interval_hours') {
const intervalVal = parseInt(document.getElementById('scheduleInterval').value) || 15;
const unit = document.getElementById('intervalUnit').value;
const unitLabel = unit === 'hours' ? (intervalVal === 1 ? 'hour' : 'hours') : (intervalVal === 1 ? 'minute' : 'minutes');
const intervalMode = document.getElementById('intervalMode').value;
if (intervalMode === 'after_completion') {
preview = `${intervalVal} ${unitLabel} after each task is completed`;
} else {
preview = `Every ${intervalVal} ${unitLabel} (continuous)`;
}
} else if (freq === 'daily') {
preview = `Every day at ${timeStr}`;
} else if (freq === 'weekdays') {
preview = `Monday through Friday at ${timeStr}`;
} else if (freq === 'weekly') {
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const selectedDays = [...document.querySelectorAll('.day-btn.selected')].map(btn => dayNames[btn.dataset.day]);
if (selectedDays.length === 0) {
preview = `Select at least one day`;
} else {
preview = `Every ${selectedDays.join(', ')} at ${timeStr}`;
}
} else if (freq === 'monthly') {
const day = document.getElementById('scheduleMonthDay').value;
const suffix = day == 1 || day == 21 || day == 31 ? 'st' : (day == 2 || day == 22 ? 'nd' : (day == 3 || day == 23 ? 'rd' : 'th'));
preview = `Every month on the ${day}${suffix} at ${timeStr}`;
}
document.getElementById('schedulePreviewText').textContent = preview;
},
editScheduledTask(taskId) {
this.showAddScheduledTaskModal(taskId);
},
async saveScheduledTask() {
const id = document.getElementById('scheduledTaskId').value;
const freq = document.getElementById('scheduleFrequency').value;
const isInterval = freq === 'interval' || freq === 'interval_minutes' || freq === 'interval_hours';
// Build cron expression from friendly UI (for non-interval types)
let cronExpression = '* * * * *'; // Placeholder for interval type
let scheduleType = 'cron';
let intervalMinutes = null;
if (isInterval) {
const intervalMode = document.getElementById('intervalMode').value;
scheduleType = intervalMode === 'after_completion' ? 'interval_after_completion' : 'interval';
const intervalVal = parseInt(document.getElementById('scheduleInterval').value) || 1;
const unit = document.getElementById('intervalUnit').value;
if (unit === 'hours') {
intervalMinutes = intervalVal * 60;
} else {
intervalMinutes = intervalVal;
}
// Validate interval
if (intervalMinutes < 1) {
this.toast('Interval must be at least 1 minute', 'error');
return;
}
} else if (freq === 'custom') {
cronExpression = document.getElementById('customCronInput').value;
} else {
cronExpression = this.buildCronFromFriendly();
}
// Validate weekly has at least one day selected
if (freq === 'weekly') {
const selectedDays = document.querySelectorAll('.day-btn.selected');
if (selectedDays.length === 0) {
this.toast('Please select at least one day', 'error');
return;
}
}
// Auto-generate name if not provided
let name = document.getElementById('scheduledTaskName').value.trim();
if (!name) {
const title = document.getElementById('scheduledTitle').value.trim();
name = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 50);
}
const payload = {
BusinessID: this.config.businessId,
Name: name,
Title: document.getElementById('scheduledTitle').value,
Details: document.getElementById('scheduledDetails').value,
CategoryID: document.getElementById('scheduledTaskCategory').value || null,
CronExpression: cronExpression,
ScheduleType: scheduleType,
IntervalMinutes: intervalMinutes,
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');
}
},
// Worker Ratings
pendingRatings: [],
async loadPendingRatings() {
const container = document.getElementById('pendingRatingsList');
if (!container) return;
try {
const response = await fetch(`${this.config.apiBaseUrl}/ratings/listForAdmin.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ BusinessID: this.config.businessId })
});
const data = await response.json();
if (data.OK) {
this.pendingRatings = data.TASKS || [];
this.renderPendingRatings();
} else {
container.innerHTML = '<div class="empty-state">Failed to load ratings</div>';
}
} catch (err) {
console.error('[Portal] Error loading pending ratings:', err);
container.innerHTML = '<div class="empty-state">Error loading ratings</div>';
}
},
renderPendingRatings() {
const container = document.getElementById('pendingRatingsList');
if (!this.pendingRatings.length) {
container.innerHTML = '<div class="empty-state">No completed tasks to rate from the last 7 days.</div>';
return;
}
container.innerHTML = this.pendingRatings.map(t => `
<div class="list-group-item" style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-bottom:1px solid #eee;">
<div style="flex:1;">
<div style="display:flex;align-items:center;gap:8px;">
<strong>${this.escapeHtml(t.Title)}</strong>
</div>
<div style="color:#666;font-size:12px;margin-top:4px;">
Worker: <strong>${this.escapeHtml(t.WorkerName)}</strong>
${t.CustomerName ? ` | Customer: ${this.escapeHtml(t.CustomerName)}` : ''}
${t.Name ? ` | ${this.escapeHtml(t.Name)}` : ''}
</div>
<div style="color:#999;font-size:11px;margin-top:2px;">
Completed: ${t.CompletedOn}
</div>
</div>
<div>
<button class="btn btn-sm btn-primary" onclick="Portal.showRateWorkerModal(${t.ID}, '${this.escapeHtml(t.WorkerName)}', '${this.escapeHtml(t.Title)}')">
Rate Worker
</button>
</div>
</div>
`).join('');
},
showRateWorkerModal(taskId, workerName, taskTitle) {
document.getElementById('modalTitle').textContent = 'Rate Worker Performance';
document.getElementById('modalBody').innerHTML = `
<form id="rateWorkerForm" class="form">
<input type="hidden" id="ratingTaskId" value="${taskId}">
<p style="margin-bottom:16px;">
Rating <strong>${this.escapeHtml(workerName)}</strong> for: <em>${this.escapeHtml(taskTitle)}</em>
</p>
<div class="form-group" style="margin-bottom:16px;">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:8px;border:1px solid #ddd;border-radius:6px;margin-bottom:8px;">
<input type="checkbox" id="ratingOnTime" checked>
<span>Was the worker on time?</span>
</label>
</div>
<div class="form-group" style="margin-bottom:16px;">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:8px;border:1px solid #ddd;border-radius:6px;margin-bottom:8px;">
<input type="checkbox" id="ratingCompletedScope" checked>
<span>Was the scope completed?</span>
</label>
</div>
<div class="form-group" style="margin-bottom:16px;">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:8px;border:1px solid #ddd;border-radius:6px;margin-bottom:8px;">
<input type="checkbox" id="ratingRequiredFollowup">
<span>Was follow-up required?</span>
</label>
</div>
<div class="form-group" style="margin-bottom:16px;">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:8px;border:1px solid #ddd;border-radius:6px;margin-bottom:8px;">
<input type="checkbox" id="ratingContinueAllow" checked>
<span>Continue to allow these tasks?</span>
</label>
</div>
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Submit Rating</button>
</div>
</form>
`;
this.showModal();
document.getElementById('rateWorkerForm').addEventListener('submit', (e) => {
e.preventDefault();
this.submitWorkerRating();
});
},
async submitWorkerRating() {
const taskId = parseInt(document.getElementById('ratingTaskId').value);
const onTime = document.getElementById('ratingOnTime').checked;
const completedScope = document.getElementById('ratingCompletedScope').checked;
const requiredFollowup = document.getElementById('ratingRequiredFollowup').checked;
const continueAllow = document.getElementById('ratingContinueAllow').checked;
try {
const response = await fetch(`${this.config.apiBaseUrl}/ratings/createAdminRating.cfm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
TaskID: taskId,
AdminUserID: this.config.userId,
onTime: onTime,
completedScope: completedScope,
requiredFollowup: requiredFollowup,
continueAllow: continueAllow
})
});
const data = await response.json();
if (data.OK) {
this.toast('Rating submitted successfully!', 'success');
this.closeModal();
await this.loadPendingRatings();
} else {
this.toast(data.MESSAGE || 'Failed to submit rating', 'error');
}
} catch (err) {
console.error('[Portal] Error submitting rating:', err);
this.toast('Error submitting rating', 'error');
}
},
// ========== SP SHARING ==========
_spSharingSelectedBizID: 0,
_spSharingSearchTimer: null,
/* COMMENTED OUT FOR LAUNCH - SP Marketing / Grants (coming soon)
async loadSPSharingPage() { ... },
showInviteBusinessModal() { ... },
closeInviteModal() { ... },
toggleEconValue() { ... },
async searchBusinessForInvite() { ... },
selectInviteBiz(bizID, name) { ... },
async submitGrantInvite() { ... },
async revokeGrant(grantID) { ... },
async acceptGrant(grantID) { ... },
async declineGrant(grantID) { ... }
*/
};
// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
Portal.init();
});