/** * Payfrit Business Portal * Modern admin interface for business management */ // Detect base path for API calls (handles local dev at /biz.payfrit.com/) const BASE_PATH = (() => { const path = window.location.pathname; const portalIndex = path.indexOf('/portal/'); if (portalIndex > 0) { return path.substring(0, portalIndex); } return ''; })(); const Portal = { // Configuration config: { apiBaseUrl: BASE_PATH + '/api', businessId: null, userId: null, }, // State currentPage: 'dashboard', businessData: null, menuData: null, // Initialize async init() { console.log('[Portal] Initializing...'); // Check auth await this.checkAuth(); // Setup navigation this.setupNavigation(); // Setup sidebar toggle this.setupSidebarToggle(); // Load initial data await this.loadDashboard(); // Handle hash navigation this.handleHashChange(); window.addEventListener('hashchange', () => this.handleHashChange()); console.log('[Portal] Ready'); }, // Check authentication async checkAuth() { // Check if user is logged in const token = localStorage.getItem('payfrit_portal_token'); const savedBusiness = localStorage.getItem('payfrit_portal_business'); const userId = localStorage.getItem('payfrit_portal_userid'); if (!token || !savedBusiness) { // Not logged in - redirect to login localStorage.removeItem('payfrit_portal_token'); localStorage.removeItem('payfrit_portal_userid'); localStorage.removeItem('payfrit_portal_business'); window.location.href = BASE_PATH + '/portal/login.html'; return; } // Use saved business ID from localStorage this.config.businessId = parseInt(savedBusiness) || null; this.config.userId = parseInt(userId) || 1; this.config.token = token; // Verify user has access to this business try { const response = await fetch(`${this.config.apiBaseUrl}/portal/myBusinesses.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-Token': token }, body: JSON.stringify({ UserID: this.config.userId }) }); const data = await response.json(); if (data.OK && data.BUSINESSES) { const hasAccess = data.BUSINESSES.some(b => b.BusinessID === this.config.businessId); if (!hasAccess && data.BUSINESSES.length > 0) { // User doesn't have access to requested business, use their first business this.config.businessId = data.BUSINESSES[0].BusinessID; localStorage.setItem('payfrit_portal_business', this.config.businessId); } else if (!hasAccess) { // User has no businesses this.toast('No businesses associated with your account', 'error'); this.logout(); return; } } } catch (err) { console.error('[Portal] Auth verification error:', err); } // Fetch actual business info try { const response = await fetch(`${this.config.apiBaseUrl}/businesses/get.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK && data.BUSINESS) { const biz = data.BUSINESS; this.businessData = biz; // Store for later use document.getElementById('businessName').textContent = biz.Name || 'Business'; document.getElementById('businessAvatar').textContent = (biz.Name || 'B').charAt(0).toUpperCase(); document.getElementById('userAvatar').textContent = 'U'; } else { this.businessData = null; document.getElementById('businessName').textContent = 'Business #' + this.config.businessId; document.getElementById('businessAvatar').textContent = 'B'; document.getElementById('userAvatar').textContent = 'U'; } } catch (err) { console.error('[Portal] Business info error:', err); document.getElementById('businessName').textContent = 'Business #' + this.config.businessId; document.getElementById('businessAvatar').textContent = 'B'; document.getElementById('userAvatar').textContent = 'U'; } }, // Toggle user dropdown menu toggleUserMenu() { const dd = document.getElementById('userDropdown'); if (!dd) return; const showing = dd.style.display !== 'none'; dd.style.display = showing ? 'none' : 'block'; if (!showing) { // Close on outside click const close = (e) => { if (!dd.contains(e.target) && e.target.id !== 'userBtn' && !e.target.closest('#userBtn')) { dd.style.display = 'none'; document.removeEventListener('click', close); } }; setTimeout(() => document.addEventListener('click', close), 0); } }, // Logout logout() { localStorage.removeItem('payfrit_portal_token'); localStorage.removeItem('payfrit_portal_userid'); localStorage.removeItem('payfrit_portal_business'); window.location.href = BASE_PATH + '/portal/login.html'; }, // Switch to a different business (go back to login to select) switchBusiness() { // Clear current business selection but keep token localStorage.removeItem('payfrit_portal_business'); window.location.href = BASE_PATH + '/portal/login.html'; }, // 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'); }); }, // 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', beacons: 'Beacons', services: 'Service Requests', 'admin-tasks': 'Task Admin', settings: 'Settings' }; document.getElementById('pageTitle').textContent = titles[page] || page; // Update URL window.location.hash = page; // Load page data this.loadPageData(page); this.currentPage = page; }, // Load page data async loadPageData(page) { switch (page) { case 'dashboard': await this.loadDashboard(); break; case 'orders': await this.loadOrders(); break; case 'menu': await this.loadMenu(); break; case 'reports': await this.loadReports(); break; case 'team': await this.loadTeam(); break; case 'beacons': await this.loadBeaconsPage(); break; case 'services': await this.loadServicesPage(); break; case 'admin-tasks': await this.loadAdminTasksPage(); break; case 'settings': await this.loadSettings(); break; } }, // Load dashboard async loadDashboard() { console.log('[Portal] Loading dashboard...'); try { // Load stats const stats = await this.fetchStats(); document.getElementById('statOrdersToday').textContent = stats.ordersToday || 0; document.getElementById('statRevenueToday').textContent = '$' + (stats.revenueToday || 0).toFixed(2); document.getElementById('statPendingOrders').textContent = stats.pendingOrders || 0; document.getElementById('statMenuItems').textContent = stats.menuItems || 0; // Load recent orders const orders = await this.fetchRecentOrders(); this.renderRecentOrders(orders); } catch (err) { console.error('[Portal] Dashboard error:', err); // Show demo data document.getElementById('statOrdersToday').textContent = '12'; document.getElementById('statRevenueToday').textContent = '$342.50'; document.getElementById('statPendingOrders').textContent = '3'; document.getElementById('statMenuItems').textContent = '24'; } }, // Fetch stats async fetchStats() { const response = await fetch(`${this.config.apiBaseUrl}/portal/stats.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK) return data.STATS; throw new Error(data.ERROR); }, // Fetch recent orders async fetchRecentOrders() { const response = await fetch(`${this.config.apiBaseUrl}/orders/listForKDS.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK) return data.ORDERS || []; throw new Error(data.ERROR); }, // Render recent orders renderRecentOrders(orders) { const container = document.getElementById('recentOrdersList'); if (!orders || orders.length === 0) { container.innerHTML = '
No recent orders
'; return; } container.innerHTML = orders.slice(0, 5).map(order => ` `).join(''); }, // Get status class for orders getStatusClass(statusId) { const classes = { 0: 'pending', // Employee: Pending 1: 'submitted', // Order: Submitted, Employee: Invited 2: 'active', // Order: Preparing, Employee: Active 3: 'suspended', // Order: Ready, Employee: Suspended 4: 'completed' // Order: Completed }; return classes[statusId] || 'pending'; }, // Get status text getStatusText(statusId) { const texts = { 1: 'Submitted', 2: 'Preparing', 3: 'Ready', 4: 'Completed' }; return texts[statusId] || 'Unknown'; }, // Load orders async loadOrders() { console.log('[Portal] Loading orders...'); try { const response = await fetch(`${this.config.apiBaseUrl}/orders/listForKDS.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK) { this.renderOrdersTable(data.ORDERS || []); } } catch (err) { console.error('[Portal] Orders error:', err); } }, // Render orders table renderOrdersTable(orders) { const tbody = document.getElementById('ordersTableBody'); if (!orders || orders.length === 0) { tbody.innerHTML = 'No orders found'; return; } tbody.innerHTML = orders.map(order => ` #${order.OrderID} ${order.FirstName || 'Guest'} ${order.LastName || ''} ${order.LineItems?.length || 0} items $${(order.OrderTotal || 0).toFixed(2)} ${this.getStatusText(order.StatusID)} ${this.formatTime(order.SubmittedOn)} `).join(''); }, // Format time formatTime(dateStr) { if (!dateStr) return '-'; const date = new Date(dateStr); return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }, // Format phone number formatPhone(phone) { if (!phone) return '-'; // Remove all non-digits const digits = phone.replace(/\D/g, ''); if (digits.length === 10) { return `(${digits.slice(0,3)}) ${digits.slice(3,6)}-${digits.slice(6)}`; } if (digits.length === 11 && digits[0] === '1') { return `(${digits.slice(1,4)}) ${digits.slice(4,7)}-${digits.slice(7)}`; } return phone; }, // Load menu - show menu builder options async loadMenu() { console.log('[Portal] Loading menu...'); // Show links to menu tools const container = document.getElementById('menuGrid'); container.innerHTML = `
`; }, // Render menu renderMenu() { const container = document.getElementById('menuGrid'); if (!this.menuData || this.menuData.length === 0) { container.innerHTML = '
No menu items. Click "Add Category" to get started.
'; return; } // Group by category const categories = {}; this.menuData.forEach(item => { if (item.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]) => ` `).join(''); }, // Load reports async loadReports() { console.log('[Portal] Loading reports...'); // TODO: Implement reports loading }, // Load team async loadTeam() { console.log('[Portal] Loading team for business:', this.config.businessId); const tbody = document.getElementById('teamTableBody'); tbody.innerHTML = 'Loading team...'; // Load hiring toggle state from business data this.loadHiringToggle(); try { const url = `${this.config.apiBaseUrl}/portal/team.cfm`; console.log('[Portal] Fetching team from:', url); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); console.log('[Portal] Team response:', data); if (data.OK && data.TEAM) { if (data.TEAM.length === 0) { tbody.innerHTML = 'No team members yet'; return; } tbody.innerHTML = data.TEAM.map(member => `
${(member.FirstName || '?').charAt(0)}${(member.LastName || '').charAt(0)} ${member.Name || 'Unknown'}
${member.Email || '-'} ${this.formatPhone(member.Phone)} ${member.StatusName || 'Unknown'} `).join(''); } else { tbody.innerHTML = 'Failed to load team'; } } catch (err) { console.error('[Portal] Error loading team:', err); tbody.innerHTML = 'Error loading team'; } }, // Load hiring toggle from business data loadHiringToggle() { const toggle = document.getElementById('hiringToggle'); if (!toggle) return; // Remove existing listener if any toggle.onchange = null; // Set initial state from business data if (this.businessData && typeof this.businessData.IsHiring !== 'undefined') { toggle.checked = this.businessData.IsHiring; console.log('[Portal] Hiring toggle set to:', this.businessData.IsHiring); } else { console.log('[Portal] No business data for hiring toggle, fetching...'); // Fetch fresh business data if not available this.refreshBusinessData(); } // Wire up the toggle toggle.onchange = () => this.toggleHiring(toggle.checked); }, // Refresh business data async refreshBusinessData() { try { const response = await fetch(`${this.config.apiBaseUrl}/businesses/get.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK && data.BUSINESS) { this.businessData = data.BUSINESS; const toggle = document.getElementById('hiringToggle'); if (toggle) { toggle.checked = this.businessData.IsHiring; console.log('[Portal] Hiring toggle refreshed to:', this.businessData.IsHiring); } } } catch (err) { console.error('[Portal] Error refreshing business data:', err); } }, // Toggle hiring status async toggleHiring(isHiring) { console.log('[Portal] Setting hiring to:', isHiring); try { const response = await fetch(`${this.config.apiBaseUrl}/businesses/setHiring.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId, IsHiring: isHiring }) }); const data = await response.json(); console.log('[Portal] setHiring response:', data); if (data.OK) { // Update local state if (this.businessData) { this.businessData.IsHiring = data.IsHiring; } this.toast(isHiring ? 'Now accepting applications' : 'Applications disabled', 'success'); } else { // Revert toggle const toggle = document.getElementById('hiringToggle'); if (toggle) toggle.checked = !isHiring; this.toast(data.ERROR || 'Failed to update hiring status', 'error'); } } catch (err) { console.error('[Portal] Error toggling hiring:', err); // Revert toggle const toggle = document.getElementById('hiringToggle'); if (toggle) toggle.checked = !isHiring; this.toast('Error updating hiring status', 'error'); } }, // Edit team member (placeholder) editTeamMember(employeeId) { this.showToast('Team member editing coming soon', 'info'); }, // Load settings async loadSettings() { console.log('[Portal] Loading settings...'); // Load states dropdown first await this.loadStatesDropdown(); // Load business info await this.loadBusinessInfo(); // Check Stripe status await this.checkStripeStatus(); }, // Load states for dropdown async loadStatesDropdown() { try { const response = await fetch(`${this.config.apiBaseUrl}/addresses/states.cfm`); const data = await response.json(); if (data.OK && data.STATES) { const select = document.getElementById('settingState'); select.innerHTML = ''; data.STATES.forEach(state => { select.innerHTML += ``; }); } } catch (err) { console.error('[Portal] Error loading states:', err); } }, // Load business info for settings async loadBusinessInfo() { try { const response = await fetch(`${this.config.apiBaseUrl}/businesses/get.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK && data.BUSINESS) { // Normalize all keys to uppercase for consistent access // (Lucee serializeJSON casing varies by server config) const raw = data.BUSINESS; const biz = {}; Object.keys(raw).forEach(k => { biz[k.toUpperCase()] = raw[k]; }); this.currentBusiness = biz; // Populate form fields document.getElementById('settingBusinessName').value = biz.NAME || biz.BUSINESSNAME || ''; document.getElementById('settingPhone').value = biz.PHONE || biz.BUSINESSPHONE || ''; document.getElementById('settingTaxRate').value = biz.TAXRATEPERCENT || ''; document.getElementById('settingAddressLine1').value = biz.LINE1 || biz.ADDRESSLINE1 || ''; document.getElementById('settingCity').value = biz.CITY || biz.ADDRESSCITY || ''; document.getElementById('settingState').value = biz.ADDRESSSTATE || ''; document.getElementById('settingZip').value = biz.ADDRESSZIP || ''; // Load brand color if set const brandColor = biz.BRANDCOLOR || ''; if (brandColor) { this.brandColor = brandColor; const swatch = document.getElementById('brandColorSwatch'); if (swatch) swatch.style.backgroundColor = brandColor; } // Load header preview const headerPreview = document.getElementById('headerPreview'); const headerWrapper = document.getElementById('headerPreviewWrapper'); const headerUrl = biz.HEADERIMAGEURL || ''; if (headerPreview && headerUrl) { headerPreview.onload = function() { if (headerWrapper) headerWrapper.style.display = 'block'; }; headerPreview.src = `${headerUrl}?t=${Date.now()}`; } // Render hours editor this.renderHoursEditor(biz.HOURSDETAIL || biz.BUSINESSHOURSDETAIL || []); } } catch (err) { console.error('[Portal] Error loading business info:', err); } }, // Render hours editor renderHoursEditor(hours) { const container = document.getElementById('hoursEditor'); const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; const dayIds = [1, 2, 3, 4, 5, 6, 7]; // Monday=1 through Sunday=7 // Create a map of existing hours by day ID const hoursMap = {}; hours.forEach(h => { hoursMap[h.dayId] = h; }); let html = '
'; 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 += `
`; }); html += '
'; container.innerHTML = html; }, // Helper to convert "7:00 AM" format to "07:00" for time input formatTimeFor24Input(timeStr) { if (!timeStr) return '09:00'; // If already in 24h format (HH:MM), return as-is if (/^\d{2}:\d{2}$/.test(timeStr)) return timeStr; // Parse "7:00 AM" or "7:30 PM" format const match = timeStr.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i); if (match) { let hours = parseInt(match[1]); const minutes = match[2]; const ampm = match[3].toUpperCase(); if (ampm === 'PM' && hours < 12) hours += 12; if (ampm === 'AM' && hours === 12) hours = 0; return `${String(hours).padStart(2, '0')}:${minutes}`; } return timeStr; }, // Toggle hours day closed/open toggleHoursDay(dayId) { const isClosed = document.getElementById(`hours_closed_${dayId}`).checked; document.getElementById(`hours_open_${dayId}`).disabled = isClosed; document.getElementById(`hours_close_${dayId}`).disabled = isClosed; }, // Save business info async saveBusinessInfo(event) { event.preventDefault(); const btn = document.getElementById('saveBusinessBtn'); const originalText = btn.textContent; btn.textContent = 'Saving...'; btn.disabled = true; try { const response = await fetch(`${this.config.apiBaseUrl}/businesses/update.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId, 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 = `
Stripe Connected

Your account is active and ready to accept payments

View Dashboard `; } else if (data.ACCOUNT_STATUS === 'pending_verification') { // Waiting for Stripe verification statusContainer.innerHTML = `
Verification Pending

Stripe is reviewing your account. This usually takes 1-2 business days.

`; } else if (data.ACCOUNT_STATUS === 'incomplete') { // Started but not finished onboarding statusContainer.innerHTML = `
Setup Incomplete

Please complete your Stripe account setup to accept payments

`; } else { // Not started this.renderStripeNotConnected(); } } else { this.renderStripeNotConnected(); } } catch (err) { console.error('[Portal] Stripe status error:', err); this.renderStripeNotConnected(); } }, // Render Stripe not connected state renderStripeNotConnected() { const statusContainer = document.getElementById('stripeStatus'); statusContainer.innerHTML = `
Stripe Not Connected

Connect your Stripe account to accept payments

`; }, // Refresh Stripe status async refreshStripeStatus() { this.toast('Checking status...', 'info'); await this.checkStripeStatus(); }, // Show add category modal showAddCategoryModal() { document.getElementById('modalTitle').textContent = 'Add Category'; document.getElementById('modalBody').innerHTML = `
`; this.showModal(); document.getElementById('addCategoryForm').addEventListener('submit', (e) => { e.preventDefault(); this.addCategory(); }); }, // Show add item modal showAddItemModal() { document.getElementById('modalTitle').textContent = 'Add Menu Item'; document.getElementById('modalBody').innerHTML = `
`; this.showModal(); document.getElementById('addItemForm').addEventListener('submit', (e) => { e.preventDefault(); this.addItem(); }); }, // Show modal showModal() { document.getElementById('modalOverlay').classList.add('visible'); }, // Close modal closeModal() { document.getElementById('modalOverlay').classList.remove('visible'); }, // Show toast toast(message, type = 'info') { const container = document.getElementById('toastContainer'); const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.textContent = message; container.appendChild(toast); setTimeout(() => { toast.remove(); }, 3000); }, // Connect Stripe - initiate onboarding // Open customer preview in Payfrit app via deep link openCustomerPreview() { const businessId = this.config.businessId; const businessName = encodeURIComponent( this.currentBusiness?.NAME || this.currentBusiness?.BUSINESSNAME || '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'); const wrapper = document.getElementById('headerPreviewWrapper'); if (preview && data.HEADERURL) { preview.onload = function() { if (wrapper) wrapper.style.display = 'block'; }; preview.src = `${BASE_PATH}${data.HEADERURL}?t=${Date.now()}`; } } else { this.toast(data.MESSAGE || 'Failed to upload header', 'error'); } } catch (err) { console.error('[Portal] Header upload error:', err); this.toast('Failed to upload header', 'error'); } }; input.click(); }, // Show brand color picker showBrandColorPicker() { const currentColor = this.brandColor || '#1B4D3E'; document.getElementById('modalTitle').textContent = 'Brand Color'; document.getElementById('modalBody').innerHTML = `

This color is used for accents and fallback backgrounds in your menu.

`; this.showModal(); // Sync inputs const colorInput = document.getElementById('brandColorInput'); const hexInput = document.getElementById('brandColorHex'); const preview = document.getElementById('brandColorPreview'); colorInput.addEventListener('input', () => { const color = colorInput.value.toUpperCase(); hexInput.value = color; preview.style.background = `linear-gradient(to bottom, ${color}44, ${color}00, ${color}66)`; }); hexInput.addEventListener('input', () => { let color = hexInput.value; if (/^[0-9A-Fa-f]{6}$/.test(color)) 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.backgroundColor = color; this.closeModal(); this.toast('Brand color saved!', 'success'); } else { this.toast(data.MESSAGE || 'Failed to save color', 'error'); } } catch (err) { console.error('[Portal] Save brand color error:', err); this.toast('Failed to save brand color', 'error'); } }, // View order async viewOrder(orderId) { this.toast('Loading order...', 'info'); try { const response = await fetch(`${this.config.apiBaseUrl}/orders/getDetail.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ OrderID: orderId }) }); const data = await response.json(); if (data.OK && data.ORDER) { this.showOrderDetailModal(data.ORDER); } else { this.toast(data.ERROR || 'Failed to load order', 'error'); } } catch (err) { console.error('[Portal] Error loading order:', err); this.toast('Error loading order details', 'error'); } }, // Show order detail modal showOrderDetailModal(order) { document.getElementById('modalTitle').textContent = `Order #${order.OrderID}`; const customerName = [order.Customer.FirstName, order.Customer.LastName].filter(Boolean).join(' ') || 'Guest'; const servicePoint = order.ServicePoint.Name || 'Not assigned'; // Build line items HTML const lineItemsHtml = order.LineItems.map(item => { const modifiersHtml = item.Modifiers && item.Modifiers.length > 0 ? `
${item.Modifiers.map(mod => `
+ ${mod.Name} ${mod.UnitPrice > 0 ? `+$${mod.UnitPrice.toFixed(2)}` : ''}
`).join('')}
` : ''; const remarksHtml = item.Remarks ? `
"${item.Remarks}"
` : ''; return `
${item.Quantity}x ${item.Name} $${(item.UnitPrice * item.Quantity).toFixed(2)}
${modifiersHtml} ${remarksHtml}
`; }).join(''); document.getElementById('modalBody').innerHTML = `
Status
${order.StatusText}
Customer
${customerName}
${order.Customer.Phone ? `
${order.Customer.Phone}
` : ''} ${order.Customer.Email ? `
${order.Customer.Email}
` : ''}
Service Point
${servicePoint}
${order.ServicePoint.Type ? `
${order.ServicePoint.Type}
` : ''}
Items
${lineItemsHtml || '
No items
'}
${order.Notes ? `
Notes
${order.Notes}
` : ''}
Subtotal $${order.Subtotal.toFixed(2)}
Tax $${order.Tax.toFixed(2)}
${order.Tip > 0 ? `
Tip $${order.Tip.toFixed(2)}
` : ''}
Total $${order.Total.toFixed(2)}
`; this.showModal(); }, // Format date time formatDateTime(dateStr) { if (!dateStr) return '-'; const date = new Date(dateStr); return date.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); }, // Edit item editItem(itemId) { this.toast(`Editing item #${itemId}`, 'info'); // TODO: Implement item editing }, // Delete item deleteItem(itemId) { if (confirm('Are you sure you want to delete this item?')) { this.toast(`Deleting item #${itemId}`, 'info'); // TODO: Implement item deletion } }, // Edit category editCategory(catId) { this.toast(`Editing category #${catId}`, 'info'); // TODO: Implement category editing }, // Add category async addCategory() { const name = document.getElementById('categoryName').value; // TODO: Implement API call this.toast(`Category "${name}" added!`, 'success'); this.closeModal(); this.loadMenu(); }, // Add item async addItem() { const name = document.getElementById('itemName').value; // TODO: Implement API call this.toast(`Item "${name}" added!`, 'success'); this.closeModal(); this.loadMenu(); }, // Show invite modal showInviteModal() { document.getElementById('modalTitle').textContent = 'Add Team Member'; document.getElementById('modalBody').innerHTML = `
Enter the person's phone number or email address
`; this.showModal(); let foundUserId = null; document.getElementById('inviteForm').addEventListener('submit', async (e) => { e.preventDefault(); const contact = document.getElementById('inviteContact').value.trim(); const btn = document.getElementById('inviteSubmitBtn'); const resultsDiv = document.getElementById('userSearchResults'); if (foundUserId) { // Actually add the team member btn.disabled = true; btn.textContent = 'Adding...'; try { const response = await fetch(`${this.config.apiBaseUrl}/portal/addTeamMember.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId, UserID: foundUserId }) }); const data = await response.json(); if (data.OK) { this.toast('Team member added!', 'success'); this.closeModal(); this.loadTeam(); } else { this.toast(data.MESSAGE || 'Failed to add', 'error'); } } catch (err) { this.toast('Error adding team member', 'error'); } btn.disabled = false; btn.textContent = 'Add Team Member'; return; } // Search for user btn.disabled = true; btn.textContent = 'Searching...'; try { const response = await fetch(`${this.config.apiBaseUrl}/portal/searchUser.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ Query: contact, BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK && data.USER) { foundUserId = data.USER.UserID; resultsDiv.style.display = 'block'; resultsDiv.innerHTML = `
Found: ${data.USER.Name}
${data.USER.Phone || data.USER.Email || ''}
`; btn.textContent = 'Add Team Member'; } else { resultsDiv.style.display = 'block'; resultsDiv.innerHTML = `
Not found
No user with that phone/email. They need to create an account first.
`; btn.textContent = 'Search & Add'; } } catch (err) { resultsDiv.style.display = 'block'; resultsDiv.innerHTML = `
Search failed: ${err.message}
`; } btn.disabled = false; }); }, // ========== BEACONS PAGE ========== beacons: [], servicePoints: [], assignments: [], // Load beacons page data async loadBeaconsPage() { await Promise.all([ this.loadBeacons(), this.loadServicePoints(), this.loadAssignments() ]); }, // Load beacons list async loadBeacons() { const container = document.getElementById('beaconsList'); try { const response = await fetch(`${this.config.apiBaseUrl}/beacons/list.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-Token': this.config.token, 'X-Business-ID': this.config.businessId }, body: JSON.stringify({ onlyActive: true }) }); const data = await response.json(); if (data.OK) { this.beacons = data.BEACONS || []; this.renderBeaconsList(); } else { console.error('[Portal] Beacons API error:', data.ERROR); container.innerHTML = `
Error: ${data.ERROR || 'Unknown error'}
`; } } catch (err) { console.error('[Portal] Error loading beacons:', err); container.innerHTML = `
Failed to load beacons
`; } }, // Render beacons list renderBeaconsList() { const container = document.getElementById('beaconsList'); if (this.beacons.length === 0) { container.innerHTML = '
No beacons yet. Add your first beacon!
'; return; } container.innerHTML = this.beacons.map(b => `
${this.escapeHtml(b.Name)}
${b.UUID || b.NamespaceId || 'No UUID'}
`).join(''); }, // Load service points list async loadServicePoints() { try { const response = await fetch(`${this.config.apiBaseUrl}/servicepoints/list.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-Token': this.config.token, 'X-Business-ID': this.config.businessId }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK) { this.servicePoints = data.SERVICEPOINTS || data.ServicePoints || []; this.renderServicePointsList(); } } catch (err) { console.error('[Portal] Error loading service points:', err); } }, // Render service points list renderServicePointsList() { const container = document.getElementById('servicePointsList'); if (this.servicePoints.length === 0) { container.innerHTML = '
No service points yet. Add tables, counters, etc.
'; return; } container.innerHTML = this.servicePoints.map(sp => `
${this.escapeHtml(sp.Name)}
`).join(''); }, // Load assignments list async loadAssignments() { const container = document.getElementById('assignmentsList'); try { const response = await fetch(`${this.config.apiBaseUrl}/assignments/list.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-Token': this.config.token, 'X-Business-ID': this.config.businessId }, body: JSON.stringify({}) }); const data = await response.json(); if (data.OK) { this.assignments = data.ASSIGNMENTS || []; this.renderAssignmentsList(); } else { console.error('[Portal] Assignments API error:', data.ERROR); container.innerHTML = `
Error: ${data.ERROR || 'Unknown error'}
`; } } catch (err) { console.error('[Portal] Error loading assignments:', err); container.innerHTML = `
Failed to load assignments
`; } }, // Render assignments list renderAssignmentsList() { const container = document.getElementById('assignmentsList'); if (this.assignments.length === 0) { container.innerHTML = '
No assignments yet. Link beacons to service points.
'; return; } container.innerHTML = this.assignments.map(a => `
${this.escapeHtml(a.Name)} → ${this.escapeHtml(a.Name)}
${a.lt_Beacon_Businesses_ServicePointNotes || ''}
`).join(''); }, // Show beacon modal (add/edit) showBeaconModal(beaconId = null) { const isEdit = beaconId !== null; const beacon = isEdit ? this.beacons.find(b => b.BeaconID === beaconId) : {}; document.getElementById('modalTitle').textContent = isEdit ? 'Edit Beacon' : 'Add Beacon'; document.getElementById('modalBody').innerHTML = `
`; this.showModal(); document.getElementById('beaconForm').addEventListener('submit', (e) => { e.preventDefault(); this.saveBeacon(); }); }, // Save beacon async saveBeacon() { const beaconId = document.getElementById('beaconId').value; const payload = { 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 async deleteBeacon(beaconId) { if (!confirm('Are you sure you want to deactivate this beacon?')) return; try { const response = await fetch(`${this.config.apiBaseUrl}/beacons/delete.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-Token': this.config.token, 'X-Business-ID': this.config.businessId }, body: JSON.stringify({ BeaconID: beaconId }) }); const data = await response.json(); if (data.OK) { this.toast('Beacon deactivated', 'success'); await this.loadBeacons(); } else { this.toast(data.ERROR || 'Failed to delete beacon', 'error'); } } catch (err) { console.error('[Portal] Error deleting beacon:', err); this.toast('Error deleting beacon', 'error'); } }, // Show service point modal (add/edit) showServicePointModal(servicePointId = null) { const isEdit = servicePointId !== null; const sp = isEdit ? this.servicePoints.find(s => s.ServicePointID === servicePointId) : {}; document.getElementById('modalTitle').textContent = isEdit ? 'Edit Service Point' : 'Add Service Point'; document.getElementById('modalBody').innerHTML = `
`; this.showModal(); document.getElementById('servicePointForm').addEventListener('submit', (e) => { e.preventDefault(); this.saveServicePoint(); }); }, // Save service point async saveServicePoint() { const spId = document.getElementById('servicePointId').value; const payload = { 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.ID)); if (availableBeacons.length === 0) { this.toast('No unassigned beacons available', 'warning'); return; } if (availableSPs.length === 0) { this.toast('No unassigned service points available', 'warning'); return; } document.getElementById('modalTitle').textContent = 'Assign Beacon to Service Point'; document.getElementById('modalBody').innerHTML = `
`; this.showModal(); document.getElementById('assignmentForm').addEventListener('submit', (e) => { e.preventDefault(); this.saveAssignment(); }); }, // Save assignment async saveAssignment() { const payload = { BeaconID: parseInt(document.getElementById('assignBeaconId').value), ServicePointID: parseInt(document.getElementById('assignServicePointId').value), Notes: document.getElementById('assignNotes').value }; try { const response = await fetch(`${this.config.apiBaseUrl}/assignments/save.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-Token': this.config.token, 'X-Business-ID': this.config.businessId }, body: JSON.stringify(payload) }); const data = await response.json(); if (data.OK) { this.toast('Assignment created!', 'success'); this.closeModal(); await this.loadAssignments(); } else { this.toast(data.ERROR || 'Failed to create assignment', 'error'); } } catch (err) { console.error('[Portal] Error saving assignment:', err); this.toast('Error creating assignment', 'error'); } }, // Delete assignment async deleteAssignment(assignmentId) { if (!confirm('Are you sure you want to remove this assignment?')) return; try { const response = await fetch(`${this.config.apiBaseUrl}/assignments/delete.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-Token': this.config.token, 'X-Business-ID': this.config.businessId }, body: JSON.stringify({ lt_Beacon_Businesses_ServicePointID: assignmentId }) }); const data = await response.json(); if (data.OK) { this.toast('Assignment removed', 'success'); await this.loadAssignments(); } else { this.toast(data.ERROR || 'Failed to remove assignment', 'error'); } } catch (err) { console.error('[Portal] Error deleting assignment:', err); this.toast('Error removing assignment', 'error'); } }, // Escape HTML helper escapeHtml(str) { if (!str) return ''; return str.replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m])); }, // ===================== // Services (Task Types) // ===================== async loadServicesPage() { console.log('[Portal] Loading services page...'); await Promise.all([ this.loadServiceTypes(), this.loadTaskCategories() ]); }, // Available icons for services (matches Flutter TaskType._iconMap) serviceIcons: { // Service & Staff 'room_service': { label: 'Room Service', svg: '' }, 'support_agent': { label: 'Support Agent', svg: '' }, 'person': { label: 'Person', svg: '' }, 'groups': { label: 'Groups', svg: '' }, // Payment & Money 'attach_money': { label: 'Money/Cash', svg: '' }, 'payments': { label: 'Payments', svg: '' }, 'receipt': { label: 'Receipt', svg: '' }, 'credit_card': { label: 'Credit Card', svg: '' }, // Communication 'chat': { label: 'Chat', svg: '' }, 'message': { label: 'Message', svg: '' }, 'call': { label: 'Call', svg: '' }, 'notifications': { label: 'Notification', svg: '' }, // Food & Drink 'restaurant': { label: 'Restaurant', svg: '' }, 'local_bar': { label: 'Bar/Cocktail', svg: '' }, 'coffee': { label: 'Coffee', svg: '' }, 'icecream': { label: 'Ice Cream', svg: '' }, 'cake': { label: 'Cake', svg: '' }, 'local_pizza': { label: 'Pizza', svg: '' }, 'lunch_dining': { label: 'Lunch/Burger', svg: '' }, 'fastfood': { label: 'Fast Food', svg: '' }, 'ramen_dining': { label: 'Ramen/Noodles', svg: '' }, 'bakery_dining': { label: 'Bakery', svg: '' }, // Drinks & Refills 'water_drop': { label: 'Water/Refill', svg: '' }, 'local_drink': { label: 'Drink/Soda', svg: '' }, 'wine_bar': { label: 'Wine', svg: '' }, 'sports_bar': { label: 'Beer', svg: '' }, 'liquor': { label: 'Liquor', svg: '' }, // Hookah & Fire 'local_fire_department': { label: 'Fire/Charcoal', svg: '' }, 'whatshot': { label: 'Hot/Trending', svg: '' }, 'smoke_free': { label: 'No Smoking', svg: '' }, // Cleaning & Maintenance 'cleaning_services': { label: 'Cleaning', svg: '' }, 'delete_sweep': { label: 'Clear Table', svg: '' }, 'auto_fix_high': { label: 'Fix/Repair', svg: '' }, // Supplies & Items 'inventory': { label: 'Inventory', svg: '' }, 'shopping_basket': { label: 'Shopping', svg: '' }, 'add_box': { label: 'Add Item', svg: '' }, 'note_add': { label: 'Add Note', svg: '' }, // Entertainment 'music_note': { label: 'Music', svg: '' }, 'tv': { label: 'TV', svg: '' }, 'sports_esports': { label: 'Gaming', svg: '' }, 'celebration': { label: 'Party', svg: '' }, // Comfort & Amenities 'ac_unit': { label: 'A/C', svg: '' }, 'wb_sunny': { label: 'Sunny/Bright', svg: '' }, 'light_mode': { label: 'Light', svg: '' }, 'volume_up': { label: 'Volume Up', svg: '' }, 'volume_down': { label: 'Volume Down', svg: '' }, // Health & Safety 'medical_services': { label: 'Medical', svg: '' }, 'health_and_safety': { label: 'Health & Safety', svg: '' }, 'child_care': { label: 'Child Care', svg: '' }, 'accessible': { label: 'Accessible', svg: '' }, // Location & Navigation 'directions': { label: 'Directions', svg: '' }, 'meeting_room': { label: 'Meeting Room', svg: '' }, 'wc': { label: 'Restroom', svg: '' }, 'local_parking': { label: 'Parking', svg: '' }, // General 'help': { label: 'Help', svg: '' }, 'info': { label: 'Info', svg: '' }, 'star': { label: 'Star/VIP', svg: '' }, 'favorite': { label: 'Favorite', svg: '' }, 'thumb_up': { label: 'Thumbs Up', svg: '' }, 'check_circle': { label: 'Check/Done', svg: '' }, 'warning': { label: 'Warning', svg: '' }, 'error': { label: 'Error/Alert', svg: '' }, 'schedule': { label: 'Schedule', svg: '' }, 'event': { label: 'Event', svg: '' } }, async loadServiceTypes() { try { const response = await fetch(`${this.config.apiBaseUrl}/tasks/listAllTypes.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK) { this.renderServices(data.TASK_TYPES || []); } else { document.getElementById('servicesList').innerHTML = '
Failed to load services
'; } } catch (err) { console.error('[Portal] Error loading services:', err); document.getElementById('servicesList').innerHTML = '
Error loading services
'; } }, getServiceIconSvg(iconName) { const icon = this.serviceIcons[iconName] || this.serviceIcons['notifications']; return `${icon.svg}`; }, renderServices(services) { const container = document.getElementById('servicesList'); if (!services.length) { container.innerHTML = '
No services configured yet. Click "+ Add Service" to create one.
'; return; } container.innerHTML = services.map((s, index) => `
${this.getServiceIconSvg(s.TaskTypeIcon || 'notifications')}
${this.escapeHtml(s.TaskTypeName)}
${s.TaskTypeDescription ? this.escapeHtml(s.TaskTypeDescription) : 'No description'}
`).join(''); // Store services for editing this._services = services; // Initialize drag and drop this.initDragAndDrop(); }, initDragAndDrop() { const container = document.getElementById('servicesList'); const items = container.querySelectorAll('.service-item'); let draggedItem = null; let draggedOverItem = null; items.forEach(item => { item.addEventListener('dragstart', (e) => { draggedItem = item; item.style.opacity = '0.5'; e.dataTransfer.effectAllowed = 'move'; }); item.addEventListener('dragend', () => { draggedItem.style.opacity = '1'; items.forEach(i => { i.style.background = '#fff'; i.style.transform = ''; }); draggedItem = null; draggedOverItem = null; }); item.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (item !== draggedItem) { item.style.background = '#f0f0ff'; } }); item.addEventListener('dragleave', () => { item.style.background = '#fff'; }); item.addEventListener('drop', async (e) => { e.preventDefault(); if (item !== draggedItem) { // Get all items and their IDs const allItems = [...container.querySelectorAll('.service-item')]; const draggedIndex = allItems.indexOf(draggedItem); const dropIndex = allItems.indexOf(item); // Move the dragged item if (draggedIndex < dropIndex) { item.parentNode.insertBefore(draggedItem, item.nextSibling); } else { item.parentNode.insertBefore(draggedItem, item); } // Save new order await this.saveServiceOrder(); } item.style.background = '#fff'; }); }); }, async saveServiceOrder() { const container = document.getElementById('servicesList'); const items = container.querySelectorAll('.service-item'); const order = [...items].map(item => parseInt(item.dataset.id)); try { const response = await fetch(`${this.config.apiBaseUrl}/tasks/reorderTypes.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId, Order: order }) }); const data = await response.json(); if (data.OK) { this.toast('Order saved', 'success'); } else { this.toast(data.MESSAGE || 'Failed to save order', 'error'); await this.loadServiceTypes(); // Reload to reset order } } catch (err) { console.error('[Portal] Error saving order:', err); this.toast('Error saving order', 'error'); await this.loadServiceTypes(); // Reload to reset order } }, // Preset color palette (GIF-safe colors) colorPalette: [ // 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 ` `; }).join(''); }, buildIconPicker(selectedIcon = 'notifications') { return Object.entries(this.serviceIcons).map(([key, val]) => ` `).join(''); }, showAddServiceModal() { // Build category options for dropdown const categoryOptions = (this._taskCategories || []).map(c => `` ).join(''); const html = `
This will appear in the customer's bell menu
Subtitle shown under the service name
Tasks created from this service will be assigned to this category
${this.buildColorPicker('#9C27B0')}
This color will be shown on the HUD when tasks are created
${this.buildIconPicker('notifications')}
`; 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 => `` ).join(''); const html = `
Subtitle shown under the service name
Tasks created from this service will be assigned to this category
${this.buildColorPicker(service.TaskTypeColor || '#9C27B0')}
This color will be shown on the HUD when tasks are created
${this.buildIconPicker(service.TaskTypeIcon || 'notifications')}
`; 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 = `
No categories yet.
`; return; } container.innerHTML = `
${this._taskCategories.map(c => `
${this.escapeHtml(c.Name)}
`).join('')}
`; }, 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 = `
${this.buildColorPicker(category.Color || '#6366f1')}
`; 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 = '
Failed to load quick task templates
'; } } catch (err) { console.error('[Portal] Error loading quick task templates:', err); document.getElementById('quickTasksGrid').innerHTML = '
Error loading templates
'; } }, renderQuickTaskTemplates() { const gridContainer = document.getElementById('quickTasksGrid'); const manageContainer = document.getElementById('quickTasksManageList'); if (!this.quickTaskTemplates.length) { gridContainer.innerHTML = '
No quick tasks yet. Click "+ Add Quick Task" to create one.
'; manageContainer.innerHTML = ''; return; } // Shortcut buttons grid gridContainer.innerHTML = this.quickTaskTemplates.map(t => ` `).join(''); // Management list manageContainer.innerHTML = `

Manage Quick Tasks

${this.quickTaskTemplates.map(t => `
${this.escapeHtml(t.Name)}
${this.escapeHtml(t.Title)}
`).join('')}
`; }, showAddQuickTaskModal(templateId = null) { const isEdit = templateId !== null; const template = isEdit ? this.quickTaskTemplates.find(t => t.QuickTaskTemplateID === templateId) : {}; document.getElementById('modalTitle').textContent = isEdit ? 'Edit Quick Task' : 'Add Quick Task'; document.getElementById('modalBody').innerHTML = `
Name shown on the shortcut button
Title shown on the created task
Loading categories...
Task will inherit the category's color
${this.buildQuickTaskIconPicker(template.Icon || 'add_box', template.Color || '#6366f1')}
`; 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 = '
No categories defined. Add some above first.
'; return; } container.innerHTML = categories.map(c => { const isSelected = selectedCategoryId == c.TaskCategoryID; const color = c.Color || '#6366f1'; return `
${this.escapeHtml(c.Name)}
`; }).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]) => ` `).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 = '
Failed to load scheduled tasks
'; } } catch (err) { console.error('[Portal] Error loading scheduled tasks:', err); document.getElementById('scheduledTasksList').innerHTML = '
Error loading scheduled tasks
'; } }, renderScheduledTasks() { const container = document.getElementById('scheduledTasksList'); if (!this.scheduledTasks.length) { container.innerHTML = '
No scheduled tasks configured. Click "+ Add Scheduled Task" to create one.
'; return; } container.innerHTML = this.scheduledTasks.map(s => `
${s.IsActive ? 'Active' : 'Paused'} ${this.escapeHtml(s.Name)}
${this.escapeHtml(s.Title)} | Schedule: ${this.formatScheduleDisplay(s)}
${s.NextRunOn ? `
Next run: ${s.NextRunOn}
` : ''} ${s.LastRunOn ? `
Last run: ${s.LastRunOn}
` : ''}
`).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 => `` ).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 ``; }).join(''); // Build minute options (every 5 minutes) const minuteOptions = Array.from({length: 12}, (_, i) => { const min = i * 5; return ``; }).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 ``; }).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 ``; }).join(''); document.getElementById('modalTitle').textContent = isEdit ? 'Edit Scheduled Task' : 'Add Scheduled Task'; document.getElementById('modalBody').innerHTML = `
Or custom:
${dayButtons}
Format: minute hour day month weekday
:
This task will run:
Used for tracking; auto-generated if blank
`; 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 = '
Failed to load ratings
'; } } catch (err) { console.error('[Portal] Error loading pending ratings:', err); container.innerHTML = '
Error loading ratings
'; } }, renderPendingRatings() { const container = document.getElementById('pendingRatingsList'); if (!this.pendingRatings.length) { container.innerHTML = '
No completed tasks to rate from the last 7 days.
'; return; } container.innerHTML = this.pendingRatings.map(t => `
${this.escapeHtml(t.Title)}
Worker: ${this.escapeHtml(t.WorkerName)} ${t.CustomerName ? ` | Customer: ${this.escapeHtml(t.CustomerName)}` : ''} ${t.Name ? ` | ${this.escapeHtml(t.Name)}` : ''}
Completed: ${t.CompletedOn}
`).join(''); }, showRateWorkerModal(taskId, workerName, taskTitle) { document.getElementById('modalTitle').textContent = 'Rate Worker Performance'; document.getElementById('modalBody').innerHTML = `

Rating ${this.escapeHtml(workerName)} for: ${this.escapeHtml(taskTitle)}

`; 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'); } } }; // Initialize on load document.addEventListener('DOMContentLoaded', () => { Portal.init(); });