/** * Payfrit Business Portal * Modern admin interface for business management */ // Detect base path for API calls (handles local dev at /biz.payfrit.com/) const BASE_PATH = (() => { const path = window.location.pathname; const portalIndex = path.indexOf('/portal/'); if (portalIndex > 0) { return path.substring(0, portalIndex); } return ''; })(); const Portal = { // Configuration config: { apiBaseUrl: BASE_PATH + '/api', businessId: null, userId: null, }, // State currentPage: 'dashboard', businessData: null, menuData: null, // Initialize async init() { console.log('[Portal] Initializing...'); // Check auth await this.checkAuth(); // Setup navigation this.setupNavigation(); // Setup sidebar toggle this.setupSidebarToggle(); // Load initial data await this.loadDashboard(); // Handle hash navigation this.handleHashChange(); window.addEventListener('hashchange', () => this.handleHashChange()); console.log('[Portal] Ready'); }, // Check authentication async checkAuth() { // Check if user is logged in const token = localStorage.getItem('payfrit_portal_token'); const savedBusiness = localStorage.getItem('payfrit_portal_business'); const userId = localStorage.getItem('payfrit_portal_userid'); if (!token || !savedBusiness) { // Not logged in - redirect to login localStorage.removeItem('payfrit_portal_token'); localStorage.removeItem('payfrit_portal_userid'); localStorage.removeItem('payfrit_portal_business'); window.location.href = BASE_PATH + '/portal/login.html'; return; } // Use saved business ID from localStorage this.config.businessId = parseInt(savedBusiness) || null; this.config.userId = parseInt(userId) || 1; this.config.token = token; // Verify user has access to this business try { const response = await fetch(`${this.config.apiBaseUrl}/portal/myBusinesses.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-Token': token }, body: JSON.stringify({ UserID: this.config.userId }) }); const data = await response.json(); if (data.OK && data.BUSINESSES) { const hasAccess = data.BUSINESSES.some(b => b.BusinessID === this.config.businessId); if (!hasAccess && data.BUSINESSES.length > 0) { // User doesn't have access to requested business, use their first business this.config.businessId = data.BUSINESSES[0].BusinessID; localStorage.setItem('payfrit_portal_business', this.config.businessId); } else if (!hasAccess) { // User has no businesses this.toast('No businesses associated with your account', 'error'); this.logout(); return; } } } catch (err) { console.error('[Portal] Auth verification error:', err); } // Fetch actual business info try { const response = await fetch(`${this.config.apiBaseUrl}/businesses/get.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK && data.BUSINESS) { const biz = data.BUSINESS; this.businessData = biz; // Store for later use document.getElementById('businessName').textContent = biz.BusinessName || 'Business'; document.getElementById('businessAvatar').textContent = (biz.BusinessName || 'B').charAt(0).toUpperCase(); document.getElementById('userAvatar').textContent = 'U'; } else { this.businessData = null; document.getElementById('businessName').textContent = 'Business #' + this.config.businessId; document.getElementById('businessAvatar').textContent = 'B'; document.getElementById('userAvatar').textContent = 'U'; } } catch (err) { console.error('[Portal] Business info error:', err); document.getElementById('businessName').textContent = 'Business #' + this.config.businessId; document.getElementById('businessAvatar').textContent = 'B'; document.getElementById('userAvatar').textContent = 'U'; } }, // Logout logout() { localStorage.removeItem('payfrit_portal_token'); localStorage.removeItem('payfrit_portal_userid'); localStorage.removeItem('payfrit_portal_business'); window.location.href = BASE_PATH + '/portal/login.html'; }, // Setup navigation setupNavigation() { document.querySelectorAll('.nav-item[data-page]').forEach(item => { item.addEventListener('click', (e) => { e.preventDefault(); const page = item.dataset.page; if (page === 'logout') { this.logout(); } else { this.navigate(page); } }); }); }, // Setup sidebar toggle setupSidebarToggle() { const toggle = document.getElementById('sidebarToggle'); const sidebar = document.getElementById('sidebar'); toggle.addEventListener('click', () => { sidebar.classList.toggle('collapsed'); }); }, // Handle hash change handleHashChange() { const hash = window.location.hash.slice(1) || 'dashboard'; this.navigate(hash); }, // Open HUD in new window with business ID openHUD() { window.open('/hud/index.html?b=' + this.config.businessId, '_blank'); }, // Navigate to page navigate(page) { console.log('[Portal] Navigating to:', page); // Update active nav item document.querySelectorAll('.nav-item').forEach(item => { item.classList.remove('active'); if (item.dataset.page === page) { item.classList.add('active'); } }); // Show page document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); const pageEl = document.getElementById(`page-${page}`); if (pageEl) { pageEl.classList.add('active'); } // Update title const titles = { dashboard: 'Dashboard', orders: 'Orders', menu: 'Menu Management', reports: 'Reports', team: 'Team', beacons: 'Beacons', services: 'Service Requests', 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 'settings': await this.loadSettings(); break; } }, // Load dashboard async loadDashboard() { console.log('[Portal] Loading dashboard...'); try { // Load stats const stats = await this.fetchStats(); document.getElementById('statOrdersToday').textContent = stats.ordersToday || 0; document.getElementById('statRevenueToday').textContent = '$' + (stats.revenueToday || 0).toFixed(2); document.getElementById('statPendingOrders').textContent = stats.pendingOrders || 0; document.getElementById('statMenuItems').textContent = stats.menuItems || 0; // Load recent orders const orders = await this.fetchRecentOrders(); this.renderRecentOrders(orders); } catch (err) { console.error('[Portal] Dashboard error:', err); // Show demo data document.getElementById('statOrdersToday').textContent = '12'; document.getElementById('statRevenueToday').textContent = '$342.50'; document.getElementById('statPendingOrders').textContent = '3'; document.getElementById('statMenuItems').textContent = '24'; } }, // Fetch stats async fetchStats() { const response = await fetch(`${this.config.apiBaseUrl}/portal/stats.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK) return data.STATS; throw new Error(data.ERROR); }, // Fetch recent orders async fetchRecentOrders() { const response = await fetch(`${this.config.apiBaseUrl}/orders/listForKDS.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK) return data.ORDERS || []; throw new Error(data.ERROR); }, // Render recent orders renderRecentOrders(orders) { const container = document.getElementById('recentOrdersList'); if (!orders || orders.length === 0) { container.innerHTML = '
No recent orders
'; return; } container.innerHTML = orders.slice(0, 5).map(order => ` `).join(''); }, // Get status class for orders getStatusClass(statusId) { const classes = { 0: 'pending', // Employee: Pending 1: 'submitted', // Order: Submitted, Employee: Invited 2: 'active', // Order: Preparing, Employee: Active 3: 'suspended', // Order: Ready, Employee: Suspended 4: 'completed' // Order: Completed }; return classes[statusId] || 'pending'; }, // Get status text getStatusText(statusId) { const texts = { 1: 'Submitted', 2: 'Preparing', 3: 'Ready', 4: 'Completed' }; return texts[statusId] || 'Unknown'; }, // Load orders async loadOrders() { console.log('[Portal] Loading orders...'); try { const response = await fetch(`${this.config.apiBaseUrl}/orders/listForKDS.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK) { this.renderOrdersTable(data.ORDERS || []); } } catch (err) { console.error('[Portal] Orders error:', err); } }, // Render orders table renderOrdersTable(orders) { const tbody = document.getElementById('ordersTableBody'); if (!orders || orders.length === 0) { tbody.innerHTML = 'No orders found'; return; } tbody.innerHTML = orders.map(order => ` #${order.OrderID} ${order.UserFirstName || 'Guest'} ${order.UserLastName || ''} ${order.LineItems?.length || 0} items $${(order.OrderTotal || 0).toFixed(2)} ${this.getStatusText(order.OrderStatusID)} ${this.formatTime(order.OrderSubmittedOn)} `).join(''); }, // Format time formatTime(dateStr) { if (!dateStr) return '-'; const date = new Date(dateStr); return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }, // Format phone number formatPhone(phone) { if (!phone) return '-'; // Remove all non-digits const digits = phone.replace(/\D/g, ''); if (digits.length === 10) { return `(${digits.slice(0,3)}) ${digits.slice(3,6)}-${digits.slice(6)}`; } if (digits.length === 11 && digits[0] === '1') { return `(${digits.slice(1,4)}) ${digits.slice(4,7)}-${digits.slice(7)}`; } return phone; }, // Load menu - show menu builder options async loadMenu() { console.log('[Portal] Loading menu...'); // Show links to menu tools const container = document.getElementById('menuGrid'); container.innerHTML = `
`; }, // Render menu renderMenu() { const container = document.getElementById('menuGrid'); if (!this.menuData || this.menuData.length === 0) { container.innerHTML = '
No menu items. Click "Add Category" to get started.
'; return; } // Group by category const categories = {}; this.menuData.forEach(item => { if (item.ItemParentItemID === 0) { // This is a top-level item, find its category const catId = item.ItemCategoryID || 0; if (!categories[catId]) { categories[catId] = { name: item.CategoryName || 'Uncategorized', items: [] }; } categories[catId].items.push(item); } }); container.innerHTML = Object.entries(categories).map(([catId, cat]) => ` `).join(''); }, // Load reports async loadReports() { console.log('[Portal] Loading reports...'); // TODO: Implement reports loading }, // Load team async loadTeam() { console.log('[Portal] Loading team for business:', this.config.businessId); const tbody = document.getElementById('teamTableBody'); tbody.innerHTML = 'Loading team...'; // Load hiring toggle state from business data this.loadHiringToggle(); try { const url = `${this.config.apiBaseUrl}/portal/team.cfm`; console.log('[Portal] Fetching team from:', url); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); console.log('[Portal] Team response:', data); if (data.OK && data.TEAM) { if (data.TEAM.length === 0) { tbody.innerHTML = 'No team members yet'; return; } tbody.innerHTML = data.TEAM.map(member => `
${(member.FirstName || '?').charAt(0)}${(member.LastName || '').charAt(0)} ${member.Name || 'Unknown'}
${member.Email || '-'} ${this.formatPhone(member.Phone)} ${member.StatusName || 'Unknown'} `).join(''); } else { tbody.innerHTML = 'Failed to load team'; } } catch (err) { console.error('[Portal] Error loading team:', err); tbody.innerHTML = 'Error loading team'; } }, // Load hiring toggle from business data loadHiringToggle() { const toggle = document.getElementById('hiringToggle'); if (!toggle) return; // Remove existing listener if any toggle.onchange = null; // Set initial state from business data if (this.businessData && typeof this.businessData.IsHiring !== 'undefined') { toggle.checked = this.businessData.IsHiring; console.log('[Portal] Hiring toggle set to:', this.businessData.IsHiring); } else { console.log('[Portal] No business data for hiring toggle, fetching...'); // Fetch fresh business data if not available this.refreshBusinessData(); } // Wire up the toggle toggle.onchange = () => this.toggleHiring(toggle.checked); }, // Refresh business data async refreshBusinessData() { try { const response = await fetch(`${this.config.apiBaseUrl}/businesses/get.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK && data.BUSINESS) { this.businessData = data.BUSINESS; const toggle = document.getElementById('hiringToggle'); if (toggle) { toggle.checked = this.businessData.IsHiring; console.log('[Portal] Hiring toggle refreshed to:', this.businessData.IsHiring); } } } catch (err) { console.error('[Portal] Error refreshing business data:', err); } }, // Toggle hiring status async toggleHiring(isHiring) { console.log('[Portal] Setting hiring to:', isHiring); try { const response = await fetch(`${this.config.apiBaseUrl}/businesses/setHiring.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId, IsHiring: isHiring }) }); const data = await response.json(); console.log('[Portal] setHiring response:', data); if (data.OK) { // Update local state if (this.businessData) { this.businessData.IsHiring = data.IsHiring; } this.toast(isHiring ? 'Now accepting applications' : 'Applications disabled', 'success'); } else { // Revert toggle const toggle = document.getElementById('hiringToggle'); if (toggle) toggle.checked = !isHiring; this.toast(data.ERROR || 'Failed to update hiring status', 'error'); } } catch (err) { console.error('[Portal] Error toggling hiring:', err); // Revert toggle const toggle = document.getElementById('hiringToggle'); if (toggle) toggle.checked = !isHiring; this.toast('Error updating hiring status', 'error'); } }, // Edit team member (placeholder) editTeamMember(employeeId) { this.showToast('Team member editing coming soon', 'info'); }, // Load settings async loadSettings() { console.log('[Portal] Loading settings...'); // Load states dropdown first await this.loadStatesDropdown(); // Load business info await this.loadBusinessInfo(); // Check Stripe status await this.checkStripeStatus(); }, // Load states for dropdown async loadStatesDropdown() { try { const response = await fetch(`${this.config.apiBaseUrl}/addresses/states.cfm`); const data = await response.json(); if (data.OK && data.STATES) { const select = document.getElementById('settingState'); select.innerHTML = ''; data.STATES.forEach(state => { select.innerHTML += ``; }); } } catch (err) { console.error('[Portal] Error loading states:', err); } }, // Load business info for settings async loadBusinessInfo() { try { const response = await fetch(`${this.config.apiBaseUrl}/businesses/get.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK && data.BUSINESS) { const biz = data.BUSINESS; this.currentBusiness = biz; // Populate form fields (Lucee serializes all keys as uppercase) document.getElementById('settingBusinessName').value = biz.BUSINESSNAME || biz.BusinessName || ''; document.getElementById('settingPhone').value = biz.BUSINESSPHONE || biz.BusinessPhone || ''; document.getElementById('settingTaxRate').value = biz.TAXRATEPERCENT || biz.TaxRatePercent || ''; document.getElementById('settingAddressLine1').value = biz.ADDRESSLINE1 || biz.AddressLine1 || ''; document.getElementById('settingCity').value = biz.ADDRESSCITY || biz.AddressCity || ''; document.getElementById('settingState').value = biz.ADDRESSSTATE || biz.AddressState || ''; document.getElementById('settingZip').value = biz.ADDRESSZIP || biz.AddressZip || ''; // Load brand color if set const brandColor = biz.BRANDCOLOR || biz.BrandColor; if (brandColor) { this.brandColor = brandColor; const swatch = document.getElementById('brandColorSwatch'); if (swatch) swatch.style.background = brandColor; } // Load header preview const headerPreview = document.getElementById('headerPreview'); const headerUrl = biz.HEADERIMAGEURL || biz.HeaderImageURL; if (headerPreview && headerUrl) { headerPreview.style.backgroundImage = `url(${headerUrl}?t=${Date.now()})`; } // Render hours editor this.renderHoursEditor(biz.BUSINESSHOURSDETAIL || biz.BusinessHoursDetail || []); } } catch (err) { console.error('[Portal] Error loading business info:', err); } }, // Render hours editor renderHoursEditor(hours) { const container = document.getElementById('hoursEditor'); const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; const dayIds = [1, 2, 3, 4, 5, 6, 7]; // Monday=1 through Sunday=7 // Create a map of existing hours by day ID const hoursMap = {}; hours.forEach(h => { hoursMap[h.dayId] = h; }); let html = '
'; 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, BusinessName: document.getElementById('settingBusinessName').value, BusinessPhone: document.getElementById('settingPhone').value, TaxRatePercent: parseFloat(document.getElementById('settingTaxRate').value) || 0, AddressLine1: document.getElementById('settingAddressLine1').value, City: document.getElementById('settingCity').value, State: document.getElementById('settingState').value, Zip: document.getElementById('settingZip').value }) }); const data = await response.json(); if (data.OK) { this.showToast('Business info saved!', 'success'); // Reload to refresh sidebar etc await this.loadBusinessInfo(); } else { this.showToast(data.ERROR || 'Failed to save', 'error'); } } catch (err) { console.error('[Portal] Error saving business info:', err); this.showToast('Error saving business info', 'error'); } finally { btn.textContent = originalText; btn.disabled = false; } }, // Save hours async saveHours() { const dayIds = [1, 2, 3, 4, 5, 6, 7]; const hours = []; dayIds.forEach(dayId => { const isClosed = document.getElementById(`hours_closed_${dayId}`).checked; if (!isClosed) { hours.push({ dayId: dayId, open: document.getElementById(`hours_open_${dayId}`).value, close: document.getElementById(`hours_close_${dayId}`).value }); } }); try { const response = await fetch(`${this.config.apiBaseUrl}/businesses/updateHours.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId, Hours: hours }) }); const data = await response.json(); if (data.OK) { this.showToast('Hours saved!', 'success'); } else { this.showToast(data.ERROR || 'Failed to save hours', 'error'); } } catch (err) { console.error('[Portal] Error saving hours:', err); this.showToast('Error saving hours', 'error'); } }, // Check Stripe Connect status async checkStripeStatus() { const statusContainer = document.getElementById('stripeStatus'); try { const response = await fetch(`${this.config.apiBaseUrl}/stripe/status.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK) { if (data.CONNECTED) { // Stripe is connected and active statusContainer.innerHTML = `
Stripe Connected

Your account is active and ready to accept payments

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

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

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

Please complete your Stripe account setup to accept payments

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

Connect your Stripe account to accept payments

`; }, // Refresh Stripe status async refreshStripeStatus() { this.toast('Checking status...', 'info'); await this.checkStripeStatus(); }, // Show add category modal showAddCategoryModal() { document.getElementById('modalTitle').textContent = 'Add Category'; document.getElementById('modalBody').innerHTML = `
`; this.showModal(); document.getElementById('addCategoryForm').addEventListener('submit', (e) => { e.preventDefault(); this.addCategory(); }); }, // Show add item modal showAddItemModal() { document.getElementById('modalTitle').textContent = 'Add Menu Item'; document.getElementById('modalBody').innerHTML = `
`; this.showModal(); document.getElementById('addItemForm').addEventListener('submit', (e) => { e.preventDefault(); this.addItem(); }); }, // Show modal showModal() { document.getElementById('modalOverlay').classList.add('visible'); }, // Close modal closeModal() { document.getElementById('modalOverlay').classList.remove('visible'); }, // Show toast toast(message, type = 'info') { const container = document.getElementById('toastContainer'); const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.textContent = message; container.appendChild(toast); setTimeout(() => { toast.remove(); }, 3000); }, // Connect Stripe - initiate onboarding async connectStripe() { this.toast('Starting Stripe setup...', 'info'); try { const response = await fetch(`${this.config.apiBaseUrl}/stripe/onboard.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK && data.ONBOARDING_URL) { // Redirect to Stripe onboarding window.location.href = data.ONBOARDING_URL; } else { this.toast(data.ERROR || 'Failed to start Stripe setup', 'error'); } } catch (err) { console.error('[Portal] Stripe connect error:', err); this.toast('Error connecting to Stripe', 'error'); } }, // Upload header image uploadHeader() { const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/png,image/jpeg,image/jpg'; input.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; if (file.size > 5 * 1024 * 1024) { this.toast('Header image must be under 5MB', 'error'); return; } this.toast('Uploading header...', 'info'); const formData = new FormData(); formData.append('BusinessID', this.config.businessId); formData.append('header', file); try { const response = await fetch(`${this.config.apiBaseUrl}/menu/uploadHeader.cfm`, { method: 'POST', body: formData }); const data = await response.json(); if (data.OK) { this.toast('Header uploaded successfully!', 'success'); // Update preview using the URL from the response const preview = document.getElementById('headerPreview'); if (preview && data.HEADERURL) { preview.style.backgroundImage = `url(${BASE_PATH}${data.HEADERURL}?t=${Date.now()})`; } } else { this.toast(data.MESSAGE || 'Failed to upload header', 'error'); } } catch (err) { console.error('[Portal] Header upload error:', err); this.toast('Failed to upload header', 'error'); } }; input.click(); }, // Show brand color picker showBrandColorPicker() { const currentColor = this.brandColor || '#1B4D3E'; document.getElementById('modalTitle').textContent = 'Brand Color'; document.getElementById('modalBody').innerHTML = `

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

`; this.openModal(); // Sync inputs const colorInput = document.getElementById('brandColorInput'); const hexInput = document.getElementById('brandColorHex'); const preview = document.getElementById('brandColorPreview'); colorInput.addEventListener('input', () => { const color = colorInput.value.toUpperCase(); hexInput.value = color; preview.style.background = `linear-gradient(to bottom, ${color}44, ${color}00, ${color}66)`; }); hexInput.addEventListener('input', () => { let color = hexInput.value; if (/^#[0-9A-Fa-f]{6}$/.test(color)) { colorInput.value = color; preview.style.background = `linear-gradient(to bottom, ${color}44, ${color}00, ${color}66)`; } }); }, // Save brand color async saveBrandColor() { const color = document.getElementById('brandColorHex').value.toUpperCase(); if (!/^#[0-9A-Fa-f]{6}$/.test(color)) { this.toast('Please enter a valid hex color (e.g., #1B4D3E)', 'error'); return; } try { const response = await fetch(`${this.config.apiBaseUrl}/businesses/saveBrandColor.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId, BrandColor: color }) }); const data = await response.json(); if (data.OK) { this.brandColor = color; const swatch = document.getElementById('brandColorSwatch'); if (swatch) swatch.style.background = color; this.closeModal(); this.toast('Brand color saved!', 'success'); } else { this.toast(data.MESSAGE || 'Failed to save color', 'error'); } } catch (err) { console.error('[Portal] Save brand color error:', err); this.toast('Failed to save brand color', 'error'); } }, // View order async viewOrder(orderId) { this.toast('Loading order...', 'info'); try { const response = await fetch(`${this.config.apiBaseUrl}/orders/getDetail.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ OrderID: orderId }) }); const data = await response.json(); if (data.OK && data.ORDER) { this.showOrderDetailModal(data.ORDER); } else { this.toast(data.ERROR || 'Failed to load order', 'error'); } } catch (err) { console.error('[Portal] Error loading order:', err); this.toast('Error loading order details', 'error'); } }, // Show order detail modal showOrderDetailModal(order) { document.getElementById('modalTitle').textContent = `Order #${order.OrderID}`; const customerName = [order.Customer.FirstName, order.Customer.LastName].filter(Boolean).join(' ') || 'Guest'; const servicePoint = order.ServicePoint.Name || 'Not assigned'; // Build line items HTML const lineItemsHtml = order.LineItems.map(item => { const modifiersHtml = item.Modifiers && item.Modifiers.length > 0 ? `
${item.Modifiers.map(mod => `
+ ${mod.ItemName} ${mod.UnitPrice > 0 ? `+$${mod.UnitPrice.toFixed(2)}` : ''}
`).join('')}
` : ''; const remarksHtml = item.Remarks ? `
"${item.Remarks}"
` : ''; return `
${item.Quantity}x ${item.ItemName} $${(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.BeaconName)}
${b.UUID || b.NamespaceId || 'No UUID'}
`).join(''); }, // Load service points list async loadServicePoints() { try { const response = await fetch(`${this.config.apiBaseUrl}/servicepoints/list.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-Token': this.config.token, 'X-Business-ID': this.config.businessId }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK) { this.servicePoints = data.SERVICEPOINTS || data.ServicePoints || []; this.renderServicePointsList(); } } catch (err) { console.error('[Portal] Error loading service points:', err); } }, // Render service points list renderServicePointsList() { const container = document.getElementById('servicePointsList'); if (this.servicePoints.length === 0) { container.innerHTML = '
No service points yet. Add tables, counters, etc.
'; return; } container.innerHTML = this.servicePoints.map(sp => `
${this.escapeHtml(sp.ServicePointName)}
`).join(''); }, // Load assignments list async loadAssignments() { const container = document.getElementById('assignmentsList'); try { const response = await fetch(`${this.config.apiBaseUrl}/assignments/list.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-Token': this.config.token, 'X-Business-ID': this.config.businessId }, body: JSON.stringify({}) }); const data = await response.json(); if (data.OK) { this.assignments = data.ASSIGNMENTS || []; this.renderAssignmentsList(); } else { console.error('[Portal] Assignments API error:', data.ERROR); container.innerHTML = `
Error: ${data.ERROR || 'Unknown error'}
`; } } catch (err) { console.error('[Portal] Error loading assignments:', err); container.innerHTML = `
Failed to load assignments
`; } }, // Render assignments list renderAssignmentsList() { const container = document.getElementById('assignmentsList'); if (this.assignments.length === 0) { container.innerHTML = '
No assignments yet. Link beacons to service points.
'; return; } container.innerHTML = this.assignments.map(a => `
${this.escapeHtml(a.BeaconName)} → ${this.escapeHtml(a.ServicePointName)}
${a.lt_Beacon_Businesses_ServicePointNotes || ''}
`).join(''); }, // Show beacon modal (add/edit) showBeaconModal(beaconId = null) { const isEdit = beaconId !== null; const beacon = isEdit ? this.beacons.find(b => b.BeaconID === beaconId) : {}; document.getElementById('modalTitle').textContent = isEdit ? 'Edit Beacon' : 'Add Beacon'; document.getElementById('modalBody').innerHTML = `
`; this.showModal(); document.getElementById('beaconForm').addEventListener('submit', (e) => { e.preventDefault(); this.saveBeacon(); }); }, // Save beacon async saveBeacon() { const beaconId = document.getElementById('beaconId').value; const payload = { BeaconName: document.getElementById('beaconName').value, UUID: document.getElementById('beaconUUID').value, IsActive: document.getElementById('beaconIsActive').checked }; if (beaconId) { payload.BeaconID = parseInt(beaconId); } try { const response = await fetch(`${this.config.apiBaseUrl}/beacons/save.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-Token': this.config.token, 'X-Business-ID': this.config.businessId }, body: JSON.stringify(payload) }); const data = await response.json(); if (data.OK) { this.toast('Beacon saved!', 'success'); this.closeModal(); await this.loadBeacons(); } else { this.toast(data.ERROR || 'Failed to save beacon', 'error'); } } catch (err) { console.error('[Portal] Error saving beacon:', err); this.toast('Error saving beacon', 'error'); } }, // Edit beacon editBeacon(beaconId) { this.showBeaconModal(beaconId); }, // Delete beacon async deleteBeacon(beaconId) { if (!confirm('Are you sure you want to deactivate this beacon?')) return; try { const response = await fetch(`${this.config.apiBaseUrl}/beacons/delete.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-Token': this.config.token, 'X-Business-ID': this.config.businessId }, body: JSON.stringify({ BeaconID: beaconId }) }); const data = await response.json(); if (data.OK) { this.toast('Beacon deactivated', 'success'); await this.loadBeacons(); } else { this.toast(data.ERROR || 'Failed to delete beacon', 'error'); } } catch (err) { console.error('[Portal] Error deleting beacon:', err); this.toast('Error deleting beacon', 'error'); } }, // Show service point modal (add/edit) showServicePointModal(servicePointId = null) { const isEdit = servicePointId !== null; const sp = isEdit ? this.servicePoints.find(s => s.ServicePointID === servicePointId) : {}; document.getElementById('modalTitle').textContent = isEdit ? 'Edit Service Point' : 'Add Service Point'; document.getElementById('modalBody').innerHTML = `
`; this.showModal(); document.getElementById('servicePointForm').addEventListener('submit', (e) => { e.preventDefault(); this.saveServicePoint(); }); }, // Save service point async saveServicePoint() { const spId = document.getElementById('servicePointId').value; const payload = { ServicePointName: document.getElementById('servicePointName').value, ServicePointCode: document.getElementById('servicePointCode').value }; if (spId) { payload.ServicePointID = parseInt(spId); } try { const response = await fetch(`${this.config.apiBaseUrl}/servicepoints/save.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-Token': this.config.token, 'X-Business-ID': this.config.businessId }, body: JSON.stringify(payload) }); const data = await response.json(); if (data.OK) { this.toast('Service point saved!', 'success'); this.closeModal(); await this.loadServicePoints(); } else { this.toast(data.ERROR || 'Failed to save service point', 'error'); } } catch (err) { console.error('[Portal] Error saving service point:', err); this.toast('Error saving service point', 'error'); } }, // Edit service point editServicePoint(servicePointId) { this.showServicePointModal(servicePointId); }, // Delete service point async deleteServicePoint(servicePointId) { if (!confirm('Are you sure you want to deactivate this service point?')) return; try { const response = await fetch(`${this.config.apiBaseUrl}/servicepoints/delete.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-Token': this.config.token, 'X-Business-ID': this.config.businessId }, body: JSON.stringify({ ServicePointID: servicePointId }) }); const data = await response.json(); if (data.OK) { this.toast('Service point deactivated', 'success'); await this.loadServicePoints(); } else { this.toast(data.ERROR || 'Failed to delete service point', 'error'); } } catch (err) { console.error('[Portal] Error deleting service point:', err); this.toast('Error deleting service point', 'error'); } }, // Show assignment modal showAssignmentModal() { // Filter out beacons and service points that are already assigned const assignedBeaconIds = new Set(this.assignments.map(a => a.BeaconID)); const assignedSPIds = new Set(this.assignments.map(a => a.ServicePointID)); const availableBeacons = this.beacons.filter(b => b.IsActive && !assignedBeaconIds.has(b.BeaconID)); const availableSPs = this.servicePoints.filter(sp => !assignedSPIds.has(sp.ServicePointID)); if (availableBeacons.length === 0) { this.toast('No unassigned beacons available', 'warning'); return; } if (availableSPs.length === 0) { this.toast('No unassigned service points available', 'warning'); return; } document.getElementById('modalTitle').textContent = 'Assign Beacon to Service Point'; document.getElementById('modalBody').innerHTML = `
`; this.showModal(); document.getElementById('assignmentForm').addEventListener('submit', (e) => { e.preventDefault(); this.saveAssignment(); }); }, // Save assignment async saveAssignment() { const payload = { BeaconID: parseInt(document.getElementById('assignBeaconId').value), ServicePointID: parseInt(document.getElementById('assignServicePointId').value), Notes: document.getElementById('assignNotes').value }; try { const response = await fetch(`${this.config.apiBaseUrl}/assignments/save.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-Token': this.config.token, 'X-Business-ID': this.config.businessId }, body: JSON.stringify(payload) }); const data = await response.json(); if (data.OK) { this.toast('Assignment created!', 'success'); this.closeModal(); await this.loadAssignments(); } else { this.toast(data.ERROR || 'Failed to create assignment', 'error'); } } catch (err) { console.error('[Portal] Error saving assignment:', err); this.toast('Error creating assignment', 'error'); } }, // Delete assignment async deleteAssignment(assignmentId) { if (!confirm('Are you sure you want to remove this assignment?')) return; try { const response = await fetch(`${this.config.apiBaseUrl}/assignments/delete.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-Token': this.config.token, 'X-Business-ID': this.config.businessId }, body: JSON.stringify({ lt_Beacon_Businesses_ServicePointID: assignmentId }) }); const data = await response.json(); if (data.OK) { this.toast('Assignment removed', 'success'); await this.loadAssignments(); } else { this.toast(data.ERROR || 'Failed to remove assignment', 'error'); } } catch (err) { console.error('[Portal] Error deleting assignment:', err); this.toast('Error removing assignment', 'error'); } }, // Escape HTML helper escapeHtml(str) { if (!str) return ''; return str.replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m])); }, // ===================== // Services (Task Types) // ===================== async loadServicesPage() { console.log('[Portal] Loading services page...'); await this.loadServiceTypes(); }, // Available icons for services (matches Flutter TaskType._iconMap) serviceIcons: { // Service & Staff 'room_service': { label: 'Room Service', svg: '' }, 'support_agent': { label: 'Support Agent', svg: '' }, 'person': { label: 'Person', svg: '' }, 'groups': { label: 'Groups', svg: '' }, // Payment & Money 'attach_money': { label: 'Money/Cash', svg: '' }, 'payments': { label: 'Payments', svg: '' }, 'receipt': { label: 'Receipt', svg: '' }, 'credit_card': { label: 'Credit Card', svg: '' }, // Communication 'chat': { label: 'Chat', svg: '' }, 'message': { label: 'Message', svg: '' }, 'call': { label: 'Call', svg: '' }, 'notifications': { label: 'Notification', svg: '' }, // Food & Drink 'restaurant': { label: 'Restaurant', svg: '' }, 'local_bar': { label: 'Bar/Cocktail', svg: '' }, 'coffee': { label: 'Coffee', svg: '' }, 'icecream': { label: 'Ice Cream', svg: '' }, 'cake': { label: 'Cake', svg: '' }, 'local_pizza': { label: 'Pizza', svg: '' }, 'lunch_dining': { label: 'Lunch/Burger', svg: '' }, 'fastfood': { label: 'Fast Food', svg: '' }, 'ramen_dining': { label: 'Ramen/Noodles', svg: '' }, 'bakery_dining': { label: 'Bakery', svg: '' }, // Drinks & Refills 'water_drop': { label: 'Water/Refill', svg: '' }, 'local_drink': { label: 'Drink/Soda', svg: '' }, 'wine_bar': { label: 'Wine', svg: '' }, 'sports_bar': { label: 'Beer', svg: '' }, 'liquor': { label: 'Liquor', svg: '' }, // Hookah & Fire 'local_fire_department': { label: 'Fire/Charcoal', svg: '' }, 'whatshot': { label: 'Hot/Trending', svg: '' }, 'smoke_free': { label: 'No Smoking', svg: '' }, // Cleaning & Maintenance 'cleaning_services': { label: 'Cleaning', svg: '' }, 'delete_sweep': { label: 'Clear Table', svg: '' }, 'auto_fix_high': { label: 'Fix/Repair', svg: '' }, // Supplies & Items 'inventory': { label: 'Inventory', svg: '' }, 'shopping_basket': { label: 'Shopping', svg: '' }, 'add_box': { label: 'Add Item', svg: '' }, 'note_add': { label: 'Add Note', svg: '' }, // Entertainment 'music_note': { label: 'Music', svg: '' }, 'tv': { label: 'TV', svg: '' }, 'sports_esports': { label: 'Gaming', svg: '' }, 'celebration': { label: 'Party', svg: '' }, // Comfort & Amenities 'ac_unit': { label: 'A/C', svg: '' }, 'wb_sunny': { label: 'Sunny/Bright', svg: '' }, 'light_mode': { label: 'Light', svg: '' }, 'volume_up': { label: 'Volume Up', svg: '' }, 'volume_down': { label: 'Volume Down', svg: '' }, // Health & Safety 'medical_services': { label: 'Medical', svg: '' }, 'health_and_safety': { label: 'Health & Safety', svg: '' }, 'child_care': { label: 'Child Care', svg: '' }, 'accessible': { label: 'Accessible', svg: '' }, // Location & Navigation 'directions': { label: 'Directions', svg: '' }, 'meeting_room': { label: 'Meeting Room', svg: '' }, 'wc': { label: 'Restroom', svg: '' }, 'local_parking': { label: 'Parking', svg: '' }, // General 'help': { label: 'Help', svg: '' }, 'info': { label: 'Info', svg: '' }, 'star': { label: 'Star/VIP', svg: '' }, 'favorite': { label: 'Favorite', svg: '' }, 'thumb_up': { label: 'Thumbs Up', svg: '' }, 'check_circle': { label: 'Check/Done', svg: '' }, 'warning': { label: 'Warning', svg: '' }, 'error': { label: 'Error/Alert', svg: '' }, 'schedule': { label: 'Schedule', svg: '' }, 'event': { label: 'Event', svg: '' } }, async loadServiceTypes() { try { const response = await fetch(`${this.config.apiBaseUrl}/tasks/listAllTypes.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK) { this.renderServices(data.TASK_TYPES || []); } else { document.getElementById('servicesList').innerHTML = '
Failed to load services
'; } } catch (err) { console.error('[Portal] Error loading services:', err); document.getElementById('servicesList').innerHTML = '
Error loading services
'; } }, getServiceIconSvg(iconName) { const icon = this.serviceIcons[iconName] || this.serviceIcons['notifications']; return `${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: [ '#FF0000', '#FF4500', '#FF6600', '#FF9900', '#FFCC00', // Reds to Yellows '#FFFF00', '#CCFF00', '#99FF00', '#66FF00', '#33FF00', // Yellows to Greens '#00FF00', '#00FF66', '#00FF99', '#00FFCC', '#00FFFF', // Greens to Cyans '#00CCFF', '#0099FF', '#0066FF', '#0033FF', '#0000FF', // Cyans to Blues '#6600FF', '#9900FF', '#CC00FF', '#FF00FF', '#FF0099', // Blues to Magentas ], buildColorPicker(selectedColor = '#9C27B0') { const normalizedSelected = selectedColor.toUpperCase(); return this.colorPalette.map(color => { const isSelected = color.toUpperCase() === normalizedSelected; return ` `; }).join(''); }, buildIconPicker(selectedIcon = 'notifications') { return Object.entries(this.serviceIcons).map(([key, val]) => ` `).join(''); }, showAddServiceModal() { const html = `
This will appear in the customer's bell menu
Subtitle shown under the service name
${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 color = document.querySelector('input[name="serviceColor"]:checked')?.value || '#9C27B0'; const icon = document.querySelector('input[name="serviceIcon"]:checked')?.value || 'notifications'; if (!name) return; try { const response = await fetch(`${this.config.apiBaseUrl}/tasks/saveType.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ BusinessID: this.config.businessId, TaskTypeName: name, TaskTypeDescription: description, TaskTypeColor: color, TaskTypeIcon: icon }) }); const data = await response.json(); if (data.OK) { this.toast('Service added', 'success'); this.closeModal(); await this.loadServiceTypes(); } else { this.toast(data.MESSAGE || 'Failed to add service', 'error'); } } catch (err) { console.error('[Portal] Error adding service:', err); this.toast('Error adding service', 'error'); } }, editService(taskTypeId) { const service = this._services?.find(s => s.TaskTypeID === taskTypeId); if (!service) { this.toast('Service not found', 'error'); return; } const html = `
Subtitle shown under the service name
${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 color = document.querySelector('input[name="serviceColor"]:checked')?.value || '#9C27B0'; const icon = document.querySelector('input[name="serviceIcon"]:checked')?.value || 'notifications'; if (!name) return; try { const response = await fetch(`${this.config.apiBaseUrl}/tasks/saveType.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ TaskTypeID: taskTypeId, BusinessID: this.config.businessId, TaskTypeName: name, TaskTypeDescription: description, TaskTypeColor: color, TaskTypeIcon: icon }) }); const data = await response.json(); if (data.OK) { this.toast('Service updated', 'success'); this.closeModal(); await this.loadServiceTypes(); } else { this.toast(data.MESSAGE || 'Failed to update service', 'error'); } } catch (err) { console.error('[Portal] Error updating service:', err); this.toast('Error updating service', 'error'); } }, async deleteService(taskTypeId, serviceName) { if (!confirm(`Delete "${serviceName}"? This cannot be undone.`)) return; try { const response = await fetch(`${this.config.apiBaseUrl}/tasks/deleteType.cfm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ TaskTypeID: taskTypeId, BusinessID: this.config.businessId }) }); const data = await response.json(); if (data.OK) { this.toast('Service deleted', 'success'); await this.loadServiceTypes(); } else { this.toast(data.ERROR || 'Failed to delete service', 'error'); } } catch (err) { console.error('[Portal] Error deleting service:', err); this.toast('Error deleting service', 'error'); } } }; // Initialize on load document.addEventListener('DOMContentLoaded', () => { Portal.init(); });