The API returns ServicePointID but portal.js was using sp.ID which was undefined. Changed all sp.ID references to sp.ServicePointID. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
3853 lines
163 KiB
JavaScript
3853 lines
163 KiB
JavaScript
/**
|
||
* Payfrit Business Portal
|
||
* Modern admin interface for business management
|
||
*/
|
||
|
||
// Detect base path for API calls (handles local dev at /biz.payfrit.com/)
|
||
const BASE_PATH = (() => {
|
||
const path = window.location.pathname;
|
||
const portalIndex = path.indexOf('/portal/');
|
||
if (portalIndex > 0) {
|
||
return path.substring(0, portalIndex);
|
||
}
|
||
return '';
|
||
})();
|
||
|
||
const Portal = {
|
||
// Configuration
|
||
config: {
|
||
apiBaseUrl: BASE_PATH + '/api',
|
||
businessId: null,
|
||
userId: null,
|
||
},
|
||
|
||
// State
|
||
currentPage: 'dashboard',
|
||
businessData: null,
|
||
menuData: null,
|
||
|
||
// Initialize
|
||
async init() {
|
||
console.log('[Portal] Initializing...');
|
||
|
||
// Check auth
|
||
await this.checkAuth();
|
||
|
||
// Setup navigation
|
||
this.setupNavigation();
|
||
|
||
// Setup sidebar toggle
|
||
this.setupSidebarToggle();
|
||
|
||
// Load initial data
|
||
await this.loadDashboard();
|
||
|
||
// Handle hash navigation
|
||
this.handleHashChange();
|
||
window.addEventListener('hashchange', () => this.handleHashChange());
|
||
|
||
console.log('[Portal] Ready');
|
||
},
|
||
|
||
// Check authentication
|
||
async checkAuth() {
|
||
// Check if user is logged in
|
||
const token = localStorage.getItem('payfrit_portal_token');
|
||
const savedBusiness = localStorage.getItem('payfrit_portal_business');
|
||
const userId = localStorage.getItem('payfrit_portal_userid');
|
||
|
||
if (!token || !savedBusiness) {
|
||
// Not logged in - redirect to login
|
||
localStorage.removeItem('payfrit_portal_token');
|
||
localStorage.removeItem('payfrit_portal_userid');
|
||
localStorage.removeItem('payfrit_portal_business');
|
||
window.location.href = BASE_PATH + '/portal/login.html';
|
||
return;
|
||
}
|
||
|
||
// Use saved business ID 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 = (document.getElementById('businessAvatar').textContent || 'B');
|
||
} else {
|
||
this.businessData = null;
|
||
document.getElementById('businessName').textContent = 'Business #' + this.config.businessId;
|
||
document.getElementById('businessAvatar').textContent = 'B';
|
||
document.getElementById('userAvatar').textContent = (document.getElementById('businessAvatar').textContent || 'B');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Business info error:', err);
|
||
document.getElementById('businessName').textContent = 'Business #' + this.config.businessId;
|
||
document.getElementById('businessAvatar').textContent = 'B';
|
||
document.getElementById('userAvatar').textContent = (document.getElementById('businessAvatar').textContent || 'B');
|
||
}
|
||
},
|
||
|
||
// Logout
|
||
logout() {
|
||
localStorage.removeItem('payfrit_portal_token');
|
||
localStorage.removeItem('payfrit_portal_userid');
|
||
localStorage.removeItem('payfrit_portal_business');
|
||
window.location.href = BASE_PATH + '/portal/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',
|
||
services: 'Service Requests',
|
||
'service-points': 'Service Points',
|
||
'admin-tasks': 'Task Admin',
|
||
settings: 'Settings'
|
||
};
|
||
document.getElementById('pageTitle').textContent = titles[page] || page;
|
||
|
||
// Update URL
|
||
window.location.hash = page;
|
||
|
||
// Load page data
|
||
this.loadPageData(page);
|
||
|
||
this.currentPage = page;
|
||
},
|
||
|
||
// Load page data
|
||
async loadPageData(page) {
|
||
switch (page) {
|
||
case 'dashboard':
|
||
await this.loadDashboard();
|
||
break;
|
||
case 'orders':
|
||
await this.loadOrders();
|
||
break;
|
||
case 'menu':
|
||
await this.loadMenu();
|
||
break;
|
||
case 'reports':
|
||
await this.loadReports();
|
||
break;
|
||
case 'team':
|
||
await this.loadTeam();
|
||
break;
|
||
case 'services':
|
||
await this.loadServicesPage();
|
||
break;
|
||
case 'service-points':
|
||
await this.loadServicePointsPage();
|
||
break;
|
||
case 'admin-tasks':
|
||
await this.loadAdminTasksPage();
|
||
break;
|
||
case 'settings':
|
||
await this.loadSettings();
|
||
break;
|
||
}
|
||
},
|
||
|
||
// Load dashboard
|
||
async loadDashboard() {
|
||
console.log('[Portal] Loading dashboard...');
|
||
|
||
try {
|
||
// Load stats
|
||
const stats = await this.fetchStats();
|
||
document.getElementById('statOrdersToday').textContent = stats.ordersToday || 0;
|
||
document.getElementById('statRevenueToday').textContent = '$' + (stats.revenueToday || 0).toFixed(2);
|
||
document.getElementById('statPendingOrders').textContent = stats.pendingOrders || 0;
|
||
document.getElementById('statMenuItems').textContent = stats.menuItems || 0;
|
||
|
||
// Load recent orders
|
||
const orders = await this.fetchRecentOrders();
|
||
this.renderRecentOrders(orders);
|
||
|
||
} catch (err) {
|
||
console.error('[Portal] Dashboard error:', err);
|
||
// Show demo data
|
||
document.getElementById('statOrdersToday').textContent = '12';
|
||
document.getElementById('statRevenueToday').textContent = '$342.50';
|
||
document.getElementById('statPendingOrders').textContent = '3';
|
||
document.getElementById('statMenuItems').textContent = '24';
|
||
}
|
||
},
|
||
|
||
// Fetch stats
|
||
async fetchStats() {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/portal/stats.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||
});
|
||
const data = await response.json();
|
||
if (data.OK) return data.STATS;
|
||
throw new Error(data.ERROR);
|
||
},
|
||
|
||
// Fetch recent orders
|
||
async fetchRecentOrders() {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/orders/listForKDS.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||
});
|
||
const data = await response.json();
|
||
if (data.OK) return data.ORDERS || [];
|
||
throw new Error(data.ERROR);
|
||
},
|
||
|
||
// Render recent orders
|
||
renderRecentOrders(orders) {
|
||
const container = document.getElementById('recentOrdersList');
|
||
|
||
if (!orders || orders.length === 0) {
|
||
container.innerHTML = '<div class="empty-state">No recent orders</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = orders.slice(0, 5).map(order => `
|
||
<div class="menu-item">
|
||
<div class="item-info">
|
||
<div class="item-name">Order #${order.OrderID}</div>
|
||
<div class="item-description">${order.FirstName || 'Guest'} - ${order.LineItems?.length || 0} items</div>
|
||
</div>
|
||
<span class="status-badge ${this.getStatusClass(order.StatusID)}">${this.getStatusText(order.StatusID)}</span>
|
||
</div>
|
||
`).join('');
|
||
},
|
||
|
||
// Get status class for orders
|
||
getStatusClass(statusId) {
|
||
const classes = {
|
||
0: 'pending', // Employee: Pending
|
||
1: 'submitted', // Order: Submitted, Employee: Invited
|
||
2: 'active', // Order: Preparing, Employee: Active
|
||
3: 'suspended', // Order: Ready, Employee: Suspended
|
||
4: 'completed' // Order: Completed
|
||
};
|
||
return classes[statusId] || 'pending';
|
||
},
|
||
|
||
// Get status text
|
||
getStatusText(statusId) {
|
||
const texts = {
|
||
1: 'Submitted',
|
||
2: 'Preparing',
|
||
3: 'Ready',
|
||
4: 'Completed'
|
||
};
|
||
return texts[statusId] || 'Unknown';
|
||
},
|
||
|
||
// Load orders
|
||
async loadOrders() {
|
||
console.log('[Portal] Loading orders...');
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/orders/listForKDS.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.renderOrdersTable(data.ORDERS || []);
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Orders error:', err);
|
||
}
|
||
},
|
||
|
||
// Render orders table
|
||
renderOrdersTable(orders) {
|
||
const tbody = document.getElementById('ordersTableBody');
|
||
|
||
if (!orders || orders.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">No orders found</td></tr>';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = orders.map(order => `
|
||
<tr>
|
||
<td><strong>#${order.OrderID}</strong></td>
|
||
<td>${order.FirstName || 'Guest'} ${order.LastName || ''}</td>
|
||
<td>${order.LineItems?.length || 0} items</td>
|
||
<td>$${(order.OrderTotal || 0).toFixed(2)}</td>
|
||
<td><span class="status-badge ${this.getStatusClass(order.StatusID)}">${this.getStatusText(order.StatusID)}</span></td>
|
||
<td>${this.formatTime(order.SubmittedOn)}</td>
|
||
<td>
|
||
<button class="btn btn-secondary" onclick="Portal.viewOrder(${order.OrderID})">View</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
},
|
||
|
||
// Format time
|
||
formatTime(dateStr) {
|
||
if (!dateStr) return '-';
|
||
const date = new Date(dateStr);
|
||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
},
|
||
|
||
// Format phone number
|
||
formatPhone(phone) {
|
||
if (!phone) return '-';
|
||
// Remove all non-digits
|
||
const digits = phone.replace(/\D/g, '');
|
||
if (digits.length === 10) {
|
||
return `(${digits.slice(0,3)}) ${digits.slice(3,6)}-${digits.slice(6)}`;
|
||
}
|
||
if (digits.length === 11 && digits[0] === '1') {
|
||
return `(${digits.slice(1,4)}) ${digits.slice(4,7)}-${digits.slice(7)}`;
|
||
}
|
||
return phone;
|
||
},
|
||
|
||
// Load menu - show menu builder options
|
||
async loadMenu() {
|
||
console.log('[Portal] Loading menu...');
|
||
|
||
// Show links to menu tools
|
||
const container = document.getElementById('menuGrid');
|
||
container.innerHTML = `
|
||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px;">
|
||
<div class="menu-editor-redirect">
|
||
<div class="redirect-icon">
|
||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
||
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
|
||
</svg>
|
||
</div>
|
||
<h3>Visual Menu Builder</h3>
|
||
<p>Drag-and-drop interface to build menus. Clone items, add modifiers, create photo tasks.</p>
|
||
<a href="${BASE_PATH}/portal/menu-builder.html" class="btn btn-primary btn-lg">
|
||
Open Builder
|
||
</a>
|
||
</div>
|
||
<div class="menu-editor-redirect">
|
||
<div class="redirect-icon">
|
||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
||
<path d="M8 21h8M12 17v4"/>
|
||
</svg>
|
||
</div>
|
||
<h3>Station Assignment</h3>
|
||
<p>Drag items to stations (Grill, Fry, Drinks, etc.) for KDS routing.</p>
|
||
<a href="/portal/station-assignment.html" class="btn btn-secondary btn-lg">
|
||
Assign Stations
|
||
</a>
|
||
</div>
|
||
<div class="menu-editor-redirect">
|
||
<div class="redirect-icon">
|
||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
|
||
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||
</svg>
|
||
</div>
|
||
<h3>Classic Menu Editor</h3>
|
||
<p>Traditional form-based editor for detailed menu management.</p>
|
||
<a href="/index.cfm?mode=viewmenu" class="btn btn-secondary btn-lg">
|
||
Open Editor
|
||
</a>
|
||
</div>
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
// Render menu
|
||
renderMenu() {
|
||
const container = document.getElementById('menuGrid');
|
||
|
||
if (!this.menuData || this.menuData.length === 0) {
|
||
container.innerHTML = '<div class="empty-state">No menu items. Click "Add Category" to get started.</div>';
|
||
return;
|
||
}
|
||
|
||
// Group by category
|
||
const categories = {};
|
||
this.menuData.forEach(item => {
|
||
if (item.ParentItemID === 0) {
|
||
// This is a top-level item, find its category
|
||
const catId = item.CategoryID || 0;
|
||
if (!categories[catId]) {
|
||
categories[catId] = {
|
||
name: item.Name || 'Uncategorized',
|
||
items: []
|
||
};
|
||
}
|
||
categories[catId].items.push(item);
|
||
}
|
||
});
|
||
|
||
container.innerHTML = Object.entries(categories).map(([catId, cat]) => `
|
||
<div class="menu-category">
|
||
<div class="category-header">
|
||
<h3>${cat.name}</h3>
|
||
<button class="btn btn-secondary" onclick="Portal.editCategory(${catId})">Edit</button>
|
||
</div>
|
||
<div class="menu-items">
|
||
${cat.items.map(item => `
|
||
<div class="menu-item">
|
||
<div class="item-image">
|
||
${item.ItemImageURL ? `<img src="${item.ItemImageURL}" alt="${item.Name}">` : '🍽️'}
|
||
</div>
|
||
<div class="item-info">
|
||
<div class="item-name">${item.Name}</div>
|
||
<div class="item-description">${item.Description || ''}</div>
|
||
</div>
|
||
<div class="item-price">$${(item.Price || 0).toFixed(2)}</div>
|
||
<div class="item-actions">
|
||
<button onclick="Portal.editItem(${item.ItemID})" title="Edit">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
|
||
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||
</svg>
|
||
</button>
|
||
<button onclick="Portal.deleteItem(${item.ItemID})" title="Delete">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
},
|
||
|
||
// Load reports
|
||
async loadReports() {
|
||
console.log('[Portal] Loading reports...');
|
||
// TODO: Implement reports loading
|
||
},
|
||
|
||
// Load team
|
||
async loadTeam() {
|
||
console.log('[Portal] Loading team for business:', this.config.businessId);
|
||
const tbody = document.getElementById('teamTableBody');
|
||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">Loading team...</td></tr>';
|
||
|
||
// Load hiring toggle state from business data
|
||
this.loadHiringToggle();
|
||
|
||
try {
|
||
const url = `${this.config.apiBaseUrl}/portal/team.cfm`;
|
||
console.log('[Portal] Fetching team from:', url);
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||
});
|
||
const data = await response.json();
|
||
console.log('[Portal] Team response:', data);
|
||
|
||
if (data.OK && data.TEAM) {
|
||
if (data.TEAM.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">No team members yet</td></tr>';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = data.TEAM.map(member => `
|
||
<tr>
|
||
<td>
|
||
<div class="member-info">
|
||
<span class="member-avatar">${(member.FirstName || '?').charAt(0)}${(member.LastName || '').charAt(0)}</span>
|
||
<span>${member.Name || 'Unknown'}</span>
|
||
</div>
|
||
</td>
|
||
<td>${member.Email || '-'}</td>
|
||
<td>${this.formatPhone(member.Phone)}</td>
|
||
<td>
|
||
<span class="status-badge ${this.getStatusClass(member.StatusID)}">
|
||
${member.StatusName || 'Unknown'}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<button class="btn btn-sm btn-secondary" onclick="Portal.editTeamMember(${member.ID})">Edit</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
} else {
|
||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">Failed to load team</td></tr>';
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error loading team:', err);
|
||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state">Error loading team</td></tr>';
|
||
}
|
||
},
|
||
|
||
// Load hiring toggle from business data
|
||
loadHiringToggle() {
|
||
const toggle = document.getElementById('hiringToggle');
|
||
if (!toggle) return;
|
||
|
||
// Remove existing listener if any
|
||
toggle.onchange = null;
|
||
|
||
// Set initial state from business data
|
||
if (this.businessData && typeof this.businessData.IsHiring !== 'undefined') {
|
||
toggle.checked = this.businessData.IsHiring;
|
||
console.log('[Portal] Hiring toggle set to:', this.businessData.IsHiring);
|
||
} else {
|
||
console.log('[Portal] No business data for hiring toggle, fetching...');
|
||
// Fetch fresh business data if not available
|
||
this.refreshBusinessData();
|
||
}
|
||
|
||
// Wire up the toggle
|
||
toggle.onchange = () => this.toggleHiring(toggle.checked);
|
||
},
|
||
|
||
// Refresh business data
|
||
async refreshBusinessData() {
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/businesses/get.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||
});
|
||
const data = await response.json();
|
||
if (data.OK && data.BUSINESS) {
|
||
this.businessData = data.BUSINESS;
|
||
const toggle = document.getElementById('hiringToggle');
|
||
if (toggle) {
|
||
toggle.checked = this.businessData.IsHiring;
|
||
console.log('[Portal] Hiring toggle refreshed to:', this.businessData.IsHiring);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error refreshing business data:', err);
|
||
}
|
||
},
|
||
|
||
// Toggle hiring status
|
||
async toggleHiring(isHiring) {
|
||
console.log('[Portal] Setting hiring to:', isHiring);
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/businesses/setHiring.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
BusinessID: this.config.businessId,
|
||
IsHiring: isHiring
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
console.log('[Portal] setHiring response:', data);
|
||
|
||
if (data.OK) {
|
||
// Update local state
|
||
if (this.businessData) {
|
||
this.businessData.IsHiring = data.IsHiring;
|
||
}
|
||
this.toast(isHiring ? 'Now accepting applications' : 'Applications disabled', 'success');
|
||
} else {
|
||
// Revert toggle
|
||
const toggle = document.getElementById('hiringToggle');
|
||
if (toggle) toggle.checked = !isHiring;
|
||
this.toast(data.ERROR || 'Failed to update hiring status', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error toggling hiring:', err);
|
||
// Revert toggle
|
||
const toggle = document.getElementById('hiringToggle');
|
||
if (toggle) toggle.checked = !isHiring;
|
||
this.toast('Error updating hiring status', 'error');
|
||
}
|
||
},
|
||
|
||
// Edit team member (placeholder)
|
||
editTeamMember(employeeId) {
|
||
this.showToast('Team member editing coming soon', 'info');
|
||
},
|
||
|
||
// Load settings
|
||
async loadSettings() {
|
||
console.log('[Portal] Loading settings...');
|
||
|
||
// Load states dropdown first
|
||
await this.loadStatesDropdown();
|
||
|
||
// Load business info
|
||
await this.loadBusinessInfo();
|
||
|
||
// Check Stripe status
|
||
await this.checkStripeStatus();
|
||
},
|
||
|
||
// Load states for dropdown
|
||
async loadStatesDropdown() {
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/addresses/states.cfm`);
|
||
const data = await response.json();
|
||
|
||
if (data.OK && data.STATES) {
|
||
const select = document.getElementById('settingState');
|
||
select.innerHTML = '<option value="">--</option>';
|
||
data.STATES.forEach(state => {
|
||
select.innerHTML += `<option value="${state.Abbr}">${state.Abbr}</option>`;
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error loading states:', err);
|
||
}
|
||
},
|
||
|
||
// Load business info for settings
|
||
async loadBusinessInfo() {
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/businesses/get.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK && data.BUSINESS) {
|
||
const biz = data.BUSINESS;
|
||
this.currentBusiness = biz;
|
||
|
||
// Populate form fields (Lucee serializes all keys as uppercase)
|
||
document.getElementById('settingBusinessName').value = biz.BUSINESSNAME || biz.Name || '';
|
||
document.getElementById('settingPhone').value = biz.BUSINESSPHONE || biz.Phone || '';
|
||
document.getElementById('settingTaxRate').value = biz.TAXRATEPERCENT || biz.TaxRatePercent || '';
|
||
document.getElementById('settingAddressLine1').value = biz.ADDRESSLINE1 || biz.Line1 || '';
|
||
document.getElementById('settingCity').value = biz.ADDRESSCITY || biz.City || '';
|
||
document.getElementById('settingState').value = biz.ADDRESSSTATE || biz.AddressState || '';
|
||
document.getElementById('settingZip').value = biz.ADDRESSZIP || biz.AddressZip || '';
|
||
|
||
// Load brand color if set
|
||
const brandColor = biz.BRANDCOLOR || biz.BrandColor;
|
||
if (brandColor) {
|
||
this.brandColor = brandColor;
|
||
const swatch = document.getElementById('brandColorSwatch');
|
||
if (swatch) swatch.style.background = brandColor;
|
||
}
|
||
|
||
// Load header preview
|
||
const headerPreview = document.getElementById('headerPreview');
|
||
const headerUrl = biz.HEADERIMAGEURL || biz.HeaderImageURL;
|
||
if (headerPreview && headerUrl) {
|
||
headerPreview.style.backgroundImage = `url(${headerUrl}?t=${Date.now()})`;
|
||
}
|
||
|
||
// Render hours editor
|
||
this.renderHoursEditor(biz.BUSINESSHOURSDETAIL || biz.HoursDetail || []);
|
||
|
||
// COMMENTED OUT FOR LAUNCH - Tabs/Running Checks (coming soon)
|
||
// const tabsEnabled = biz.SESSIONENABLED || biz.SessionEnabled || 0;
|
||
// const tabLockMinutes = biz.SESSIONLOCKMINUTES || biz.SessionLockMinutes || 30;
|
||
// const tabPaymentStrategy = biz.SESSIONPAYMENTSTRATEGY || biz.SessionPaymentStrategy || 'A';
|
||
// const tabsCheckbox = document.getElementById('tabsEnabled');
|
||
// const tabDetails = document.getElementById('tabSettingsDetails');
|
||
// if (tabsCheckbox) {
|
||
// tabsCheckbox.checked = tabsEnabled == 1;
|
||
// if (tabDetails) tabDetails.style.display = tabsEnabled == 1 ? 'block' : 'none';
|
||
// }
|
||
// const lockInput = document.getElementById('tabLockMinutes');
|
||
// if (lockInput) lockInput.value = tabLockMinutes;
|
||
// const strategySelect = document.getElementById('tabPaymentStrategy');
|
||
// if (strategySelect) strategySelect.value = tabPaymentStrategy;
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error loading business info:', err);
|
||
}
|
||
},
|
||
|
||
// COMMENTED OUT FOR LAUNCH - Tabs/Running Checks (coming soon)
|
||
// async saveTabSettings() {
|
||
// const tabsEnabled = document.getElementById('tabsEnabled').checked ? 1 : 0;
|
||
// const tabLockMinutes = parseInt(document.getElementById('tabLockMinutes').value) || 30;
|
||
// const tabPaymentStrategy = document.getElementById('tabPaymentStrategy').value || 'A';
|
||
// const tabDetails = document.getElementById('tabSettingsDetails');
|
||
// if (tabDetails) tabDetails.style.display = tabsEnabled ? 'block' : 'none';
|
||
// try {
|
||
// const response = await fetch(`${this.config.apiBaseUrl}/businesses/updateTabs.cfm`, {
|
||
// method: 'POST',
|
||
// headers: { 'Content-Type': 'application/json' },
|
||
// body: JSON.stringify({
|
||
// BusinessID: this.config.businessId,
|
||
// SessionEnabled: tabsEnabled,
|
||
// SessionLockMinutes: tabLockMinutes,
|
||
// SessionPaymentStrategy: tabPaymentStrategy
|
||
// })
|
||
// });
|
||
// const data = await response.json();
|
||
// if (data.OK) { this.showToast('Tab settings saved!', 'success'); }
|
||
// else { this.showToast(data.ERROR || 'Failed to save tab settings', 'error'); }
|
||
// } catch (err) {
|
||
// console.error('[Portal] Error saving tab settings:', err);
|
||
// this.showToast('Error saving tab settings', 'error');
|
||
// }
|
||
// },
|
||
|
||
// Render hours editor
|
||
renderHoursEditor(hours) {
|
||
const container = document.getElementById('hoursEditor');
|
||
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||
const dayIds = [1, 2, 3, 4, 5, 6, 7]; // Monday=1 through Sunday=7
|
||
|
||
// Create a map of existing hours by day ID
|
||
const hoursMap = {};
|
||
hours.forEach(h => {
|
||
hoursMap[h.dayId] = h;
|
||
});
|
||
|
||
let html = '<div class="hours-grid" style="display:flex;flex-direction:column;gap:12px;">';
|
||
|
||
dayIds.forEach((dayId, idx) => {
|
||
const dayName = dayNames[idx];
|
||
const existing = hoursMap[dayId];
|
||
const isClosed = !existing;
|
||
const openTime = existing ? this.formatTimeFor24Input(existing.open) : '09:00';
|
||
const closeTime = existing ? this.formatTimeFor24Input(existing.close) : '17:00';
|
||
|
||
html += `
|
||
<div class="hours-row" style="display:grid;grid-template-columns:100px 1fr 1fr auto;gap:12px;align-items:center;">
|
||
<label style="font-weight:500;">${dayName}</label>
|
||
<input type="time" id="hours_open_${dayId}" value="${openTime}" class="form-input" ${isClosed ? 'disabled' : ''} style="padding:8px;">
|
||
<input type="time" id="hours_close_${dayId}" value="${closeTime}" class="form-input" ${isClosed ? 'disabled' : ''} style="padding:8px;">
|
||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||
<input type="checkbox" id="hours_closed_${dayId}" ${isClosed ? 'checked' : ''} onchange="Portal.toggleHoursDay(${dayId})">
|
||
Closed
|
||
</label>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += '</div>';
|
||
container.innerHTML = html;
|
||
},
|
||
|
||
// Helper to convert "7:00 AM" format to "07:00" for time input
|
||
formatTimeFor24Input(timeStr) {
|
||
if (!timeStr) return '09:00';
|
||
// If already in 24h format (HH:MM), return as-is
|
||
if (/^\d{2}:\d{2}$/.test(timeStr)) return timeStr;
|
||
|
||
// Parse "7:00 AM" or "7:30 PM" format
|
||
const match = timeStr.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i);
|
||
if (match) {
|
||
let hours = parseInt(match[1]);
|
||
const minutes = match[2];
|
||
const ampm = match[3].toUpperCase();
|
||
|
||
if (ampm === 'PM' && hours < 12) hours += 12;
|
||
if (ampm === 'AM' && hours === 12) hours = 0;
|
||
|
||
return `${String(hours).padStart(2, '0')}:${minutes}`;
|
||
}
|
||
|
||
return timeStr;
|
||
},
|
||
|
||
// Toggle hours day closed/open
|
||
toggleHoursDay(dayId) {
|
||
const isClosed = document.getElementById(`hours_closed_${dayId}`).checked;
|
||
document.getElementById(`hours_open_${dayId}`).disabled = isClosed;
|
||
document.getElementById(`hours_close_${dayId}`).disabled = isClosed;
|
||
},
|
||
|
||
// Save business info
|
||
async saveBusinessInfo(event) {
|
||
event.preventDefault();
|
||
|
||
const btn = document.getElementById('saveBusinessBtn');
|
||
const originalText = btn.textContent;
|
||
btn.textContent = 'Saving...';
|
||
btn.disabled = true;
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/businesses/update.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
BusinessID: this.config.businessId,
|
||
Name: document.getElementById('settingBusinessName').value,
|
||
Phone: document.getElementById('settingPhone').value,
|
||
TaxRatePercent: parseFloat(document.getElementById('settingTaxRate').value) || 0,
|
||
Line1: document.getElementById('settingAddressLine1').value,
|
||
City: document.getElementById('settingCity').value,
|
||
State: document.getElementById('settingState').value,
|
||
Zip: document.getElementById('settingZip').value
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.showToast('Business info saved!', 'success');
|
||
// Reload to refresh sidebar etc
|
||
await this.loadBusinessInfo();
|
||
} else {
|
||
this.showToast(data.ERROR || 'Failed to save', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error saving business info:', err);
|
||
this.showToast('Error saving business info', 'error');
|
||
} finally {
|
||
btn.textContent = originalText;
|
||
btn.disabled = false;
|
||
}
|
||
},
|
||
|
||
// Save hours
|
||
async saveHours() {
|
||
const dayIds = [1, 2, 3, 4, 5, 6, 7];
|
||
const hours = [];
|
||
|
||
dayIds.forEach(dayId => {
|
||
const isClosed = document.getElementById(`hours_closed_${dayId}`).checked;
|
||
if (!isClosed) {
|
||
hours.push({
|
||
dayId: dayId,
|
||
open: document.getElementById(`hours_open_${dayId}`).value,
|
||
close: document.getElementById(`hours_close_${dayId}`).value
|
||
});
|
||
}
|
||
});
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/businesses/updateHours.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
BusinessID: this.config.businessId,
|
||
Hours: hours
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.showToast('Hours saved!', 'success');
|
||
} else {
|
||
this.showToast(data.ERROR || 'Failed to save hours', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error saving hours:', err);
|
||
this.showToast('Error saving hours', 'error');
|
||
}
|
||
},
|
||
|
||
// Check Stripe Connect status
|
||
async checkStripeStatus() {
|
||
const statusContainer = document.getElementById('stripeStatus');
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/stripe/status.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
if (data.CONNECTED) {
|
||
// Stripe is connected and active
|
||
statusContainer.innerHTML = `
|
||
<div class="status-icon connected">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M22 11.08V12a10 10 0 11-5.93-9.14"/>
|
||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||
</svg>
|
||
</div>
|
||
<div class="status-text">
|
||
<strong>Stripe Connected</strong>
|
||
<p>Your account is active and ready to accept payments</p>
|
||
</div>
|
||
<a href="https://dashboard.stripe.com" target="_blank" class="btn btn-secondary">
|
||
View Dashboard
|
||
</a>
|
||
`;
|
||
} else if (data.ACCOUNT_STATUS === 'pending_verification') {
|
||
// Waiting for Stripe verification
|
||
statusContainer.innerHTML = `
|
||
<div class="status-icon pending">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="10"/>
|
||
<path d="M12 6v6l4 2"/>
|
||
</svg>
|
||
</div>
|
||
<div class="status-text">
|
||
<strong>Verification Pending</strong>
|
||
<p>Stripe is reviewing your account. This usually takes 1-2 business days.</p>
|
||
</div>
|
||
<button class="btn btn-secondary" onclick="Portal.refreshStripeStatus()">
|
||
Check Status
|
||
</button>
|
||
`;
|
||
} else if (data.ACCOUNT_STATUS === 'incomplete') {
|
||
// Started but not finished onboarding
|
||
statusContainer.innerHTML = `
|
||
<div class="status-icon warning">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
|
||
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||
</svg>
|
||
</div>
|
||
<div class="status-text">
|
||
<strong>Setup Incomplete</strong>
|
||
<p>Please complete your Stripe account setup to accept payments</p>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="Portal.connectStripe()">
|
||
Continue Setup
|
||
</button>
|
||
`;
|
||
} else {
|
||
// Not started
|
||
this.renderStripeNotConnected();
|
||
}
|
||
} else {
|
||
this.renderStripeNotConnected();
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Stripe status error:', err);
|
||
this.renderStripeNotConnected();
|
||
}
|
||
},
|
||
|
||
// Render Stripe not connected state
|
||
renderStripeNotConnected() {
|
||
const statusContainer = document.getElementById('stripeStatus');
|
||
statusContainer.innerHTML = `
|
||
<div class="status-icon disconnected">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="10"/>
|
||
<path d="M15 9l-6 6M9 9l6 6"/>
|
||
</svg>
|
||
</div>
|
||
<div class="status-text">
|
||
<strong>Stripe Not Connected</strong>
|
||
<p>Connect your Stripe account to accept payments</p>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="Portal.connectStripe()">
|
||
Connect Stripe
|
||
</button>
|
||
`;
|
||
},
|
||
|
||
// Refresh Stripe status
|
||
async refreshStripeStatus() {
|
||
this.toast('Checking status...', 'info');
|
||
await this.checkStripeStatus();
|
||
},
|
||
|
||
// Show add category modal
|
||
showAddCategoryModal() {
|
||
document.getElementById('modalTitle').textContent = 'Add Category';
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<form id="addCategoryForm" class="form">
|
||
<div class="form-group">
|
||
<label>Category Name</label>
|
||
<input type="text" id="categoryName" class="form-input" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Description (optional)</label>
|
||
<textarea id="categoryDescription" class="form-textarea" rows="2"></textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Display Order</label>
|
||
<input type="number" id="categoryOrder" class="form-input" value="0">
|
||
</div>
|
||
<button type="submit" class="btn btn-primary">Add Category</button>
|
||
</form>
|
||
`;
|
||
this.showModal();
|
||
|
||
document.getElementById('addCategoryForm').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
this.addCategory();
|
||
});
|
||
},
|
||
|
||
// Show add item modal
|
||
showAddItemModal() {
|
||
document.getElementById('modalTitle').textContent = 'Add Menu Item';
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<form id="addItemForm" class="form">
|
||
<div class="form-group">
|
||
<label>Item Name</label>
|
||
<input type="text" id="itemName" class="form-input" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Description</label>
|
||
<textarea id="itemDescription" class="form-textarea" rows="2"></textarea>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Price</label>
|
||
<input type="number" id="itemPrice" class="form-input" step="0.01" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Category</label>
|
||
<select id="itemCategory" class="form-select">
|
||
<option value="">Select Category</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="btn btn-primary">Add Item</button>
|
||
</form>
|
||
`;
|
||
this.showModal();
|
||
|
||
document.getElementById('addItemForm').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
this.addItem();
|
||
});
|
||
},
|
||
|
||
// Show modal
|
||
showModal() {
|
||
document.getElementById('modalOverlay').classList.add('visible');
|
||
},
|
||
|
||
// Close modal
|
||
closeModal() {
|
||
document.getElementById('modalOverlay').classList.remove('visible');
|
||
},
|
||
|
||
// Show toast
|
||
toast(message, type = 'info') {
|
||
const container = document.getElementById('toastContainer');
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
toast.textContent = message;
|
||
container.appendChild(toast);
|
||
|
||
setTimeout(() => {
|
||
toast.remove();
|
||
}, 3000);
|
||
},
|
||
|
||
// Connect Stripe - initiate onboarding
|
||
// Open customer preview in Payfrit app via deep link
|
||
openCustomerPreview() {
|
||
const businessId = this.config.businessId;
|
||
const businessName = encodeURIComponent(
|
||
this.currentBusiness?.BUSINESSNAME || this.currentBusiness?.Name || 'Preview'
|
||
);
|
||
const deepLink = `payfrit://open?businessId=${businessId}&businessName=${businessName}`;
|
||
window.location.href = deepLink;
|
||
},
|
||
|
||
async connectStripe() {
|
||
this.toast('Starting Stripe setup...', 'info');
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/stripe/onboard.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK && data.ONBOARDING_URL) {
|
||
// Redirect to Stripe onboarding
|
||
window.location.href = data.ONBOARDING_URL;
|
||
} else {
|
||
this.toast(data.ERROR || 'Failed to start Stripe setup', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Stripe connect error:', err);
|
||
this.toast('Error connecting to Stripe', 'error');
|
||
}
|
||
},
|
||
|
||
// Upload header image
|
||
uploadHeader() {
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = 'image/png,image/jpeg,image/jpg';
|
||
input.onchange = async (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
this.toast('Header image must be under 5MB', 'error');
|
||
return;
|
||
}
|
||
|
||
this.toast('Uploading header...', 'info');
|
||
|
||
const formData = new FormData();
|
||
formData.append('BusinessID', this.config.businessId);
|
||
formData.append('header', file);
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/menu/uploadHeader.cfm`, {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast('Header uploaded successfully!', 'success');
|
||
// Update preview using the URL from the response
|
||
const preview = document.getElementById('headerPreview');
|
||
if (preview && data.HEADERURL) {
|
||
preview.style.backgroundImage = `url(${BASE_PATH}${data.HEADERURL}?t=${Date.now()})`;
|
||
}
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to upload header', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Header upload error:', err);
|
||
this.toast('Failed to upload header', 'error');
|
||
}
|
||
};
|
||
input.click();
|
||
},
|
||
|
||
// Show brand color picker
|
||
showBrandColorPicker() {
|
||
const currentColor = this.brandColor || '#1B4D3E';
|
||
document.getElementById('modalTitle').textContent = 'Brand Color';
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<div style="padding: 8px;">
|
||
<p style="color: #999; margin-bottom: 16px;">This color is used for accents and fallback backgrounds in your menu.</p>
|
||
<div style="display: flex; gap: 16px; align-items: flex-start;">
|
||
<input type="color" id="brandColorInput" value="${currentColor}"
|
||
style="width: 80px; height: 80px; border: none; cursor: pointer; border-radius: 8px;">
|
||
<div style="flex: 1;">
|
||
<label style="display: block; margin-bottom: 4px; font-size: 13px; color: #999;">Hex Color</label>
|
||
<input type="text" id="brandColorHex" value="${currentColor}"
|
||
style="width: 100%; padding: 8px; border: 1px solid #444; border-radius: 4px; background: #222; color: #fff; font-family: monospace;">
|
||
</div>
|
||
</div>
|
||
<div id="brandColorPreview" style="margin-top: 16px; height: 60px; border-radius: 8px; background: linear-gradient(to bottom, ${currentColor}44, ${currentColor}00, ${currentColor}66);"></div>
|
||
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
|
||
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
|
||
<button type="button" class="btn btn-primary" onclick="Portal.saveBrandColor()">Save Color</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
this.showModal();
|
||
|
||
// Sync inputs
|
||
const colorInput = document.getElementById('brandColorInput');
|
||
const hexInput = document.getElementById('brandColorHex');
|
||
const preview = document.getElementById('brandColorPreview');
|
||
|
||
colorInput.addEventListener('input', () => {
|
||
const color = colorInput.value.toUpperCase();
|
||
hexInput.value = color;
|
||
preview.style.background = `linear-gradient(to bottom, ${color}44, ${color}00, ${color}66)`;
|
||
});
|
||
|
||
hexInput.addEventListener('input', () => {
|
||
let color = hexInput.value;
|
||
if (/^[0-9A-Fa-f]{6}$/.test(color)) color = '#' + color;
|
||
if (/^#[0-9A-Fa-f]{6}$/.test(color)) {
|
||
colorInput.value = color;
|
||
preview.style.background = `linear-gradient(to bottom, ${color}44, ${color}00, ${color}66)`;
|
||
}
|
||
});
|
||
},
|
||
|
||
// Save brand color
|
||
async saveBrandColor() {
|
||
const color = document.getElementById('brandColorHex').value.toUpperCase();
|
||
if (!/^#?[0-9A-Fa-f]{6}$/.test(color)) {
|
||
this.toast('Please enter a valid hex color (e.g., #1B4D3E or 1B4D3E)', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/businesses/saveBrandColor.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ BusinessID: this.config.businessId, BrandColor: color })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.brandColor = color;
|
||
const swatch = document.getElementById('brandColorSwatch');
|
||
if (swatch) swatch.style.background = color;
|
||
this.closeModal();
|
||
this.toast('Brand color saved!', 'success');
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to save color', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Save brand color error:', err);
|
||
this.toast('Failed to save brand color', 'error');
|
||
}
|
||
},
|
||
|
||
// View order
|
||
async viewOrder(orderId) {
|
||
this.toast('Loading order...', 'info');
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/orders/getDetail.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ OrderID: orderId })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK && data.ORDER) {
|
||
this.showOrderDetailModal(data.ORDER);
|
||
} else {
|
||
this.toast(data.ERROR || 'Failed to load order', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error loading order:', err);
|
||
this.toast('Error loading order details', 'error');
|
||
}
|
||
},
|
||
|
||
// Show order detail modal
|
||
showOrderDetailModal(order) {
|
||
document.getElementById('modalTitle').textContent = `Order #${order.OrderID}`;
|
||
|
||
const customerName = [order.Customer.FirstName, order.Customer.LastName].filter(Boolean).join(' ') || 'Guest';
|
||
const servicePoint = order.ServicePoint.Name || 'Not assigned';
|
||
|
||
// Build line items HTML
|
||
const lineItemsHtml = order.LineItems.map(item => {
|
||
const modifiersHtml = item.Modifiers && item.Modifiers.length > 0
|
||
? `<div class="order-item-modifiers">
|
||
${item.Modifiers.map(mod => `
|
||
<div class="order-item-modifier">
|
||
+ ${mod.Name}
|
||
${mod.UnitPrice > 0 ? `<span class="modifier-price">+$${mod.UnitPrice.toFixed(2)}</span>` : ''}
|
||
</div>
|
||
`).join('')}
|
||
</div>`
|
||
: '';
|
||
|
||
const remarksHtml = item.Remarks
|
||
? `<div class="order-item-remarks">"${item.Remarks}"</div>`
|
||
: '';
|
||
|
||
return `
|
||
<div class="order-detail-item">
|
||
<div class="order-item-header">
|
||
<span class="order-item-qty">${item.Quantity}x</span>
|
||
<span class="order-item-name">${item.Name}</span>
|
||
<span class="order-item-price">$${(item.UnitPrice * item.Quantity).toFixed(2)}</span>
|
||
</div>
|
||
${modifiersHtml}
|
||
${remarksHtml}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<div class="order-detail">
|
||
<div class="order-detail-section">
|
||
<div class="order-detail-label">Status</div>
|
||
<span class="status-badge ${this.getStatusClass(order.Status)}">${order.StatusText}</span>
|
||
</div>
|
||
|
||
<div class="order-detail-section">
|
||
<div class="order-detail-label">Customer</div>
|
||
<div class="order-detail-value">${customerName}</div>
|
||
${order.Customer.Phone ? `<div class="order-detail-sub">${order.Customer.Phone}</div>` : ''}
|
||
${order.Customer.Email ? `<div class="order-detail-sub">${order.Customer.Email}</div>` : ''}
|
||
</div>
|
||
|
||
<div class="order-detail-section">
|
||
<div class="order-detail-label">Service Point</div>
|
||
<div class="order-detail-value">${servicePoint}</div>
|
||
${order.ServicePoint.Type ? `<div class="order-detail-sub">${order.ServicePoint.Type}</div>` : ''}
|
||
</div>
|
||
|
||
<div class="order-detail-section">
|
||
<div class="order-detail-label">Items</div>
|
||
<div class="order-items-list">
|
||
${lineItemsHtml || '<div class="empty-state">No items</div>'}
|
||
</div>
|
||
</div>
|
||
|
||
${order.Notes ? `
|
||
<div class="order-detail-section">
|
||
<div class="order-detail-label">Notes</div>
|
||
<div class="order-detail-notes">${order.Notes}</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
<div class="order-detail-totals">
|
||
<div class="order-total-row">
|
||
<span>Subtotal</span>
|
||
<span>$${order.Subtotal.toFixed(2)}</span>
|
||
</div>
|
||
<div class="order-total-row">
|
||
<span>Tax</span>
|
||
<span>$${order.Tax.toFixed(2)}</span>
|
||
</div>
|
||
${order.Tip > 0 ? `
|
||
<div class="order-total-row">
|
||
<span>Tip</span>
|
||
<span>$${order.Tip.toFixed(2)}</span>
|
||
</div>
|
||
` : ''}
|
||
<div class="order-total-row total">
|
||
<span>Total</span>
|
||
<span>$${order.Total.toFixed(2)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="order-detail-footer">
|
||
<div class="order-detail-time">Placed: ${this.formatDateTime(order.CreatedOn)}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
this.showModal();
|
||
},
|
||
|
||
// Format date time
|
||
formatDateTime(dateStr) {
|
||
if (!dateStr) return '-';
|
||
const date = new Date(dateStr);
|
||
return date.toLocaleString([], {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
},
|
||
|
||
// Edit item
|
||
editItem(itemId) {
|
||
this.toast(`Editing item #${itemId}`, 'info');
|
||
// TODO: Implement item editing
|
||
},
|
||
|
||
// Delete item
|
||
deleteItem(itemId) {
|
||
if (confirm('Are you sure you want to delete this item?')) {
|
||
this.toast(`Deleting item #${itemId}`, 'info');
|
||
// TODO: Implement item deletion
|
||
}
|
||
},
|
||
|
||
// Edit category
|
||
editCategory(catId) {
|
||
this.toast(`Editing category #${catId}`, 'info');
|
||
// TODO: Implement category editing
|
||
},
|
||
|
||
// Add category
|
||
async addCategory() {
|
||
const name = document.getElementById('categoryName').value;
|
||
// TODO: Implement API call
|
||
this.toast(`Category "${name}" added!`, 'success');
|
||
this.closeModal();
|
||
this.loadMenu();
|
||
},
|
||
|
||
// Add item
|
||
async addItem() {
|
||
const name = document.getElementById('itemName').value;
|
||
// TODO: Implement API call
|
||
this.toast(`Item "${name}" added!`, 'success');
|
||
this.closeModal();
|
||
this.loadMenu();
|
||
},
|
||
|
||
// Show invite modal
|
||
showInviteModal() {
|
||
document.getElementById('modalTitle').textContent = 'Add Team Member';
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<form id="inviteForm" class="form">
|
||
<div class="form-group">
|
||
<label>Phone Number or Email</label>
|
||
<input type="text" id="inviteContact" class="form-input" placeholder="(555) 123-4567 or email@example.com" required>
|
||
<small style="color: #888; margin-top: 4px; display: block;">Enter the person's phone number or email address</small>
|
||
</div>
|
||
<div id="userSearchResults" style="margin: 10px 0; display: none;"></div>
|
||
<div class="form-group">
|
||
<label>Role</label>
|
||
<select id="inviteRole" class="form-select">
|
||
<option value="staff">Staff</option>
|
||
<option value="manager">Manager</option>
|
||
<option value="admin">Admin</option>
|
||
</select>
|
||
</div>
|
||
<button type="submit" class="btn btn-primary" id="inviteSubmitBtn">Search & Add</button>
|
||
</form>
|
||
`;
|
||
this.showModal();
|
||
|
||
let foundUserId = null;
|
||
|
||
document.getElementById('inviteForm').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const contact = document.getElementById('inviteContact').value.trim();
|
||
const btn = document.getElementById('inviteSubmitBtn');
|
||
const resultsDiv = document.getElementById('userSearchResults');
|
||
|
||
if (foundUserId) {
|
||
// Actually add the team member
|
||
btn.disabled = true;
|
||
btn.textContent = 'Adding...';
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/portal/addTeamMember.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
BusinessID: this.config.businessId,
|
||
UserID: foundUserId
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
if (data.OK) {
|
||
this.toast('Team member added!', 'success');
|
||
this.closeModal();
|
||
this.loadTeam();
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to add', 'error');
|
||
}
|
||
} catch (err) {
|
||
this.toast('Error adding team member', 'error');
|
||
}
|
||
btn.disabled = false;
|
||
btn.textContent = 'Add Team Member';
|
||
return;
|
||
}
|
||
|
||
// Search for user
|
||
btn.disabled = true;
|
||
btn.textContent = 'Searching...';
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/portal/searchUser.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ Query: contact, BusinessID: this.config.businessId })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK && data.USER) {
|
||
foundUserId = data.USER.UserID;
|
||
resultsDiv.style.display = 'block';
|
||
resultsDiv.innerHTML = `
|
||
<div style="padding: 12px; background: #e8f5e9; border-radius: 8px; border: 1px solid #4caf50;">
|
||
<strong style="color: #2e7d32;">Found:</strong> ${data.USER.Name}<br>
|
||
<small style="color: #666;">${data.USER.Phone || data.USER.Email || ''}</small>
|
||
</div>
|
||
`;
|
||
btn.textContent = 'Add Team Member';
|
||
} else {
|
||
resultsDiv.style.display = 'block';
|
||
resultsDiv.innerHTML = `
|
||
<div style="padding: 12px; background: #fff3e0; border-radius: 8px; border: 1px solid #ff9800;">
|
||
<strong style="color: #e65100;">Not found</strong><br>
|
||
<small style="color: #666;">No user with that phone/email. They need to create an account first.</small>
|
||
</div>
|
||
`;
|
||
btn.textContent = 'Search & Add';
|
||
}
|
||
} catch (err) {
|
||
resultsDiv.style.display = 'block';
|
||
resultsDiv.innerHTML = `<div style="color: red;">Search failed: ${err.message}</div>`;
|
||
}
|
||
|
||
btn.disabled = false;
|
||
});
|
||
},
|
||
|
||
// ========== BEACONS PAGE ==========
|
||
|
||
beacons: [],
|
||
servicePoints: [],
|
||
assignments: [],
|
||
|
||
// Load beacons page data
|
||
async loadServicePointsPage() {
|
||
await Promise.all([
|
||
this.loadServicePoints(),
|
||
this.loadBeacons(),
|
||
this.loadAssignments()
|
||
// COMMENTED OUT FOR LAUNCH - SP Marketing (coming soon)
|
||
// this.loadSPSharingPage()
|
||
]);
|
||
},
|
||
|
||
// Legacy alias
|
||
async loadBeaconsPage() {
|
||
await this.loadServicePointsPage();
|
||
},
|
||
|
||
// Load beacons list
|
||
async loadBeacons() {
|
||
const container = document.getElementById('beaconsList');
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/beacons/list.cfm`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-User-Token': this.config.token,
|
||
'X-Business-ID': this.config.businessId
|
||
},
|
||
body: JSON.stringify({ onlyActive: true })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.beacons = data.BEACONS || [];
|
||
this.renderBeaconsList();
|
||
} else {
|
||
console.error('[Portal] Beacons API error:', data.ERROR);
|
||
container.innerHTML = `<div class="empty-state">Error: ${data.ERROR || 'Unknown error'}</div>`;
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error loading beacons:', err);
|
||
container.innerHTML = `<div class="empty-state">Failed to load beacons</div>`;
|
||
}
|
||
},
|
||
|
||
// Render beacons list
|
||
renderBeaconsList() {
|
||
const container = document.getElementById('beaconsList');
|
||
if (this.beacons.length === 0) {
|
||
container.innerHTML = '<div class="empty-state">No beacons yet. Add your first beacon!</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = this.beacons.map(b => `
|
||
<div class="list-group-item ${b.IsActive ? '' : 'inactive'}">
|
||
<div class="item-info">
|
||
<div class="item-name">${this.escapeHtml(b.Name)}</div>
|
||
<div class="item-detail">${b.UUID || b.NamespaceId || 'No UUID'}</div>
|
||
</div>
|
||
<div class="item-actions">
|
||
<button class="btn btn-sm btn-secondary" onclick="Portal.editBeacon(${b.BeaconID})">Edit</button>
|
||
<button class="btn btn-sm btn-danger" onclick="Portal.deleteBeacon(${b.BeaconID})">Delete</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
},
|
||
|
||
// Load service points list
|
||
async loadServicePoints() {
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/servicepoints/list.cfm`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-User-Token': this.config.token,
|
||
'X-Business-ID': this.config.businessId
|
||
},
|
||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.servicePoints = data.SERVICEPOINTS || data.ServicePoints || [];
|
||
this.renderServicePointsList();
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error loading service points:', err);
|
||
}
|
||
},
|
||
|
||
// Render service points list
|
||
renderServicePointsList() {
|
||
const container = document.getElementById('servicePointsList');
|
||
if (this.servicePoints.length === 0) {
|
||
container.innerHTML = '<div class="empty-state">No service points yet. Add tables, counters, etc.</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = this.servicePoints.map(sp => `
|
||
<div class="list-group-item">
|
||
<div class="item-info">
|
||
<div class="item-name">${this.escapeHtml(sp.Name)}</div>
|
||
</div>
|
||
<div class="item-actions">
|
||
<button class="btn btn-sm btn-secondary" onclick="Portal.editServicePoint(${sp.ServicePointID})">Edit</button>
|
||
<button class="btn btn-sm btn-danger" onclick="Portal.deleteServicePoint(${sp.ServicePointID})">Delete</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
},
|
||
|
||
// Load assignments list
|
||
async loadAssignments() {
|
||
const container = document.getElementById('assignmentsList');
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/assignments/list.cfm`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-User-Token': this.config.token,
|
||
'X-Business-ID': this.config.businessId
|
||
},
|
||
body: JSON.stringify({})
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.assignments = data.ASSIGNMENTS || [];
|
||
this.renderAssignmentsList();
|
||
} else {
|
||
console.error('[Portal] Assignments API error:', data.ERROR);
|
||
container.innerHTML = `<div class="empty-state">Error: ${data.ERROR || 'Unknown error'}</div>`;
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error loading assignments:', err);
|
||
container.innerHTML = `<div class="empty-state">Failed to load assignments</div>`;
|
||
}
|
||
},
|
||
|
||
// Render assignments list
|
||
renderAssignmentsList() {
|
||
const container = document.getElementById('assignmentsList');
|
||
if (this.assignments.length === 0) {
|
||
container.innerHTML = '<div class="empty-state">No assignments yet. Link beacons to service points.</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = this.assignments.map(a => `
|
||
<div class="list-group-item">
|
||
<div class="item-info">
|
||
<div class="item-name">${this.escapeHtml(a.BeaconName)} → ${this.escapeHtml(a.ServicePointName)}</div>
|
||
<div class="item-detail">UUID: ${a.UUID || ''}</div>
|
||
</div>
|
||
<div class="item-actions">
|
||
<button class="btn btn-sm btn-danger" onclick="Portal.deleteAssignment(${a.ServicePointID})">Remove</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
},
|
||
|
||
// Show beacon modal (add/edit)
|
||
showBeaconModal(beaconId = null) {
|
||
const isEdit = beaconId !== null;
|
||
const beacon = isEdit ? this.beacons.find(b => b.BeaconID === beaconId) : {};
|
||
|
||
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Beacon' : 'Add Beacon';
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<form id="beaconForm" class="form">
|
||
<input type="hidden" id="beaconId" value="${beaconId || ''}">
|
||
<div class="form-group">
|
||
<label>Beacon Name</label>
|
||
<input type="text" id="beaconName" class="form-input" value="${this.escapeHtml(beacon.Name || '')}" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>UUID</label>
|
||
<input type="text" id="beaconUUID" class="form-input" value="${beacon.UUID || ''}" placeholder="e.g., FDA50693-A4E2-4FB1-AFCF-C6EB07647825">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>
|
||
<input type="checkbox" id="beaconIsActive" ${beacon.IsActive !== 0 ? 'checked' : ''}> Active
|
||
</label>
|
||
</div>
|
||
<button type="submit" class="btn btn-primary">${isEdit ? 'Save Changes' : 'Add Beacon'}</button>
|
||
</form>
|
||
`;
|
||
this.showModal();
|
||
|
||
document.getElementById('beaconForm').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
this.saveBeacon();
|
||
});
|
||
},
|
||
|
||
// Save beacon
|
||
async saveBeacon() {
|
||
const beaconId = document.getElementById('beaconId').value;
|
||
const payload = {
|
||
Name: document.getElementById('beaconName').value,
|
||
UUID: document.getElementById('beaconUUID').value,
|
||
IsActive: document.getElementById('beaconIsActive').checked
|
||
};
|
||
|
||
if (beaconId) {
|
||
payload.BeaconID = parseInt(beaconId);
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/beacons/save.cfm`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-User-Token': this.config.token,
|
||
'X-Business-ID': this.config.businessId
|
||
},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast('Beacon saved!', 'success');
|
||
this.closeModal();
|
||
await this.loadBeacons();
|
||
} else {
|
||
this.toast(data.ERROR || 'Failed to save beacon', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error saving beacon:', err);
|
||
this.toast('Error saving beacon', 'error');
|
||
}
|
||
},
|
||
|
||
// Edit beacon
|
||
editBeacon(beaconId) {
|
||
this.showBeaconModal(beaconId);
|
||
},
|
||
|
||
// Delete beacon
|
||
async deleteBeacon(beaconId) {
|
||
if (!confirm('Are you sure you want to deactivate this beacon?')) return;
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/beacons/delete.cfm`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-User-Token': this.config.token,
|
||
'X-Business-ID': this.config.businessId
|
||
},
|
||
body: JSON.stringify({ BeaconID: beaconId })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast('Beacon deactivated', 'success');
|
||
await this.loadBeacons();
|
||
} else {
|
||
this.toast(data.ERROR || 'Failed to delete beacon', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error deleting beacon:', err);
|
||
this.toast('Error deleting beacon', 'error');
|
||
}
|
||
},
|
||
|
||
// Show service point modal (add/edit)
|
||
showServicePointModal(servicePointId = null) {
|
||
const isEdit = servicePointId !== null;
|
||
const sp = isEdit ? this.servicePoints.find(s => s.ServicePointID === servicePointId) : {};
|
||
|
||
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Service Point' : 'Add Service Point';
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<form id="servicePointForm" class="form">
|
||
<input type="hidden" id="servicePointId" value="${servicePointId || ''}">
|
||
<div class="form-group">
|
||
<label>Name</label>
|
||
<input type="text" id="servicePointName" class="form-input" value="${this.escapeHtml(sp.Name || '')}" required placeholder="e.g., Table 1, Counter A">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Code (optional)</label>
|
||
<input type="text" id="servicePointCode" class="form-input" value="${sp.Code || ''}" placeholder="Short code for display">
|
||
</div>
|
||
<button type="submit" class="btn btn-primary">${isEdit ? 'Save Changes' : 'Add Service Point'}</button>
|
||
</form>
|
||
`;
|
||
this.showModal();
|
||
|
||
document.getElementById('servicePointForm').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
this.saveServicePoint();
|
||
});
|
||
},
|
||
|
||
// Save service point
|
||
async saveServicePoint() {
|
||
const spId = document.getElementById('servicePointId').value;
|
||
const payload = {
|
||
Name: document.getElementById('servicePointName').value,
|
||
Code: document.getElementById('servicePointCode').value
|
||
};
|
||
|
||
if (spId) {
|
||
payload.ServicePointID = parseInt(spId);
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/servicepoints/save.cfm`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-User-Token': this.config.token,
|
||
'X-Business-ID': this.config.businessId
|
||
},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast('Service point saved!', 'success');
|
||
this.closeModal();
|
||
await this.loadServicePoints();
|
||
} else {
|
||
this.toast(data.ERROR || 'Failed to save service point', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error saving service point:', err);
|
||
this.toast('Error saving service point', 'error');
|
||
}
|
||
},
|
||
|
||
// Edit service point
|
||
editServicePoint(servicePointId) {
|
||
this.showServicePointModal(servicePointId);
|
||
},
|
||
|
||
// Delete service point
|
||
async deleteServicePoint(servicePointId) {
|
||
if (!confirm('Are you sure you want to deactivate this service point?')) return;
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/servicepoints/delete.cfm`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-User-Token': this.config.token,
|
||
'X-Business-ID': this.config.businessId
|
||
},
|
||
body: JSON.stringify({ ServicePointID: servicePointId })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast('Service point deactivated', 'success');
|
||
await this.loadServicePoints();
|
||
} else {
|
||
this.toast(data.ERROR || 'Failed to delete service point', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error deleting service point:', err);
|
||
this.toast('Error deleting service point', 'error');
|
||
}
|
||
},
|
||
|
||
// Show assignment modal
|
||
showAssignmentModal() {
|
||
// Filter out beacons and service points that are already assigned
|
||
const assignedBeaconIds = new Set(this.assignments.map(a => a.BeaconID));
|
||
const assignedSPIds = new Set(this.assignments.map(a => a.ServicePointID));
|
||
|
||
const availableBeacons = this.beacons.filter(b => b.IsActive && !assignedBeaconIds.has(b.BeaconID));
|
||
const availableSPs = this.servicePoints.filter(sp => !assignedSPIds.has(sp.ServicePointID));
|
||
|
||
if (availableBeacons.length === 0) {
|
||
this.toast('No unassigned beacons available', 'warning');
|
||
return;
|
||
}
|
||
if (availableSPs.length === 0) {
|
||
this.toast('No unassigned service points available', 'warning');
|
||
return;
|
||
}
|
||
|
||
document.getElementById('modalTitle').textContent = 'Assign Beacon to Service Point';
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<form id="assignmentForm" class="form">
|
||
<div class="form-group">
|
||
<label>Beacon</label>
|
||
<select id="assignBeaconId" class="form-input" required>
|
||
<option value="">Select a beacon...</option>
|
||
${availableBeacons.map(b => `<option value="${b.BeaconID}">${this.escapeHtml(b.Name)}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Service Point</label>
|
||
<select id="assignServicePointId" class="form-input" required>
|
||
<option value="">Select a service point...</option>
|
||
${availableSPs.map(sp => `<option value="${sp.ServicePointID}">${this.escapeHtml(sp.Name)}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Notes (optional)</label>
|
||
<input type="text" id="assignNotes" class="form-input" maxlength="255" placeholder="Any notes about this assignment">
|
||
</div>
|
||
<button type="submit" class="btn btn-primary">Create Assignment</button>
|
||
</form>
|
||
`;
|
||
this.showModal();
|
||
|
||
document.getElementById('assignmentForm').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
this.saveAssignment();
|
||
});
|
||
},
|
||
|
||
// Save assignment
|
||
async saveAssignment() {
|
||
const payload = {
|
||
BeaconID: parseInt(document.getElementById('assignBeaconId').value),
|
||
ServicePointID: parseInt(document.getElementById('assignServicePointId').value),
|
||
Notes: document.getElementById('assignNotes').value
|
||
};
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/assignments/save.cfm`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-User-Token': this.config.token,
|
||
'X-Business-ID': this.config.businessId
|
||
},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast('Assignment created!', 'success');
|
||
this.closeModal();
|
||
await this.loadAssignments();
|
||
} else {
|
||
this.toast(data.ERROR || 'Failed to create assignment', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error saving assignment:', err);
|
||
this.toast('Error creating assignment', 'error');
|
||
}
|
||
},
|
||
|
||
// Delete assignment
|
||
async deleteAssignment(assignmentId) {
|
||
if (!confirm('Are you sure you want to remove this assignment?')) return;
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/assignments/delete.cfm`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-User-Token': this.config.token,
|
||
'X-Business-ID': this.config.businessId
|
||
},
|
||
body: JSON.stringify({ ServicePointID: assignmentId })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast('Assignment removed', 'success');
|
||
await this.loadAssignments();
|
||
} else {
|
||
this.toast(data.ERROR || 'Failed to remove assignment', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error deleting assignment:', err);
|
||
this.toast('Error removing assignment', 'error');
|
||
}
|
||
},
|
||
|
||
// Escape HTML helper
|
||
escapeHtml(str) {
|
||
if (!str) return '';
|
||
return str.replace(/[&<>"']/g, m => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
}[m]));
|
||
},
|
||
|
||
// =====================
|
||
// Services (Task Types)
|
||
// =====================
|
||
|
||
async loadServicesPage() {
|
||
console.log('[Portal] Loading services page...');
|
||
await Promise.all([
|
||
this.loadServiceTypes(),
|
||
this.loadTaskCategories()
|
||
]);
|
||
},
|
||
|
||
// Available icons for services (matches Flutter TaskType._iconMap)
|
||
serviceIcons: {
|
||
// Service & Staff
|
||
'room_service': { label: 'Room Service', svg: '<path d="M2 17h20v2H2zm11.84-9.21A2.006 2.006 0 0 0 12 5a2.006 2.006 0 0 0-1.84 2.79C6.25 8.6 3.27 11.93 3 16h18c-.27-4.07-3.25-7.4-7.16-8.21z"/>' },
|
||
'support_agent': { label: 'Support Agent', svg: '<path d="M21 12.22C21 6.73 16.74 3 12 3c-4.69 0-9 3.65-9 9.28-.6.34-1 .98-1 1.72v2c0 1.1.9 2 2 2h1v-6.1c0-3.87 3.13-7 7-7s7 3.13 7 7V19h-8v2h8c1.1 0 2-.9 2-2v-1.22c.59-.31 1-.92 1-1.64v-2.3c0-.7-.41-1.31-1-1.62z"/><circle cx="9" cy="13" r="1"/><circle cx="15" cy="13" r="1"/><path d="M18 11.03A6.04 6.04 0 0 0 12.05 6c-3.03 0-6.29 2.51-6.03 6.45a8.075 8.075 0 0 0 4.86-5.89c1.31 2.63 4 4.44 7.12 4.47z"/>' },
|
||
'person': { label: 'Person', svg: '<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>' },
|
||
'groups': { label: 'Groups', svg: '<path d="M12 12.75c1.63 0 3.07.39 4.24.9 1.08.48 1.76 1.56 1.76 2.73V18H6v-1.61c0-1.18.68-2.26 1.76-2.73 1.17-.52 2.61-.91 4.24-.91zM4 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm1.13 1.1c-.37-.06-.74-.1-1.13-.1-.99 0-1.93.21-2.78.58A2.01 2.01 0 0 0 0 16.43V18h4.5v-1.61c0-.83.23-1.61.63-2.29zM20 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4 3.43c0-.81-.48-1.53-1.22-1.85A6.95 6.95 0 0 0 20 14c-.39 0-.76.04-1.13.1.4.68.63 1.46.63 2.29V18H24v-1.57zM12 6c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3z"/>' },
|
||
|
||
// Payment & Money
|
||
'attach_money': { label: 'Money/Cash', svg: '<path d="M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4z"/>' },
|
||
'payments': { label: 'Payments', svg: '<path d="M19 14V6c0-1.1-.9-2-2-2H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zm-9-1c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm13-6v11c0 1.1-.9 2-2 2H4v-2h17V7h2z"/>' },
|
||
'receipt': { label: 'Receipt', svg: '<path d="M18 17H6v-2h12v2zm0-4H6v-2h12v2zm0-4H6V7h12v2zM3 22l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5L18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20z"/>' },
|
||
'credit_card': { label: 'Credit Card', svg: '<path d="M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z"/>' },
|
||
|
||
// Communication
|
||
'chat': { label: 'Chat', svg: '<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/>' },
|
||
'message': { label: 'Message', svg: '<path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/>' },
|
||
'call': { label: 'Call', svg: '<path d="M20.01 15.38c-1.23 0-2.42-.2-3.53-.56a.977.977 0 0 0-1.01.24l-1.57 1.97c-2.83-1.35-5.48-3.9-6.89-6.83l1.95-1.66c.27-.28.35-.67.24-1.02-.37-1.11-.56-2.3-.56-3.53 0-.54-.45-.99-.99-.99H4.19C3.65 3 3 3.24 3 3.99 3 13.28 10.73 21 20.01 21c.71 0 .99-.63.99-1.18v-3.45c0-.54-.45-.99-.99-.99z"/>' },
|
||
'notifications': { label: 'Notification', svg: '<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>' },
|
||
|
||
// Food & Drink
|
||
'restaurant': { label: 'Restaurant', svg: '<path d="M11 9H9V2H7v7H5V2H3v7c0 2.12 1.66 3.84 3.75 3.97V22h2.5v-9.03C11.34 12.84 13 11.12 13 9V2h-2v7zm5-3v8h2.5v8H21V2c-2.76 0-5 2.24-5 4z"/>' },
|
||
'local_bar': { label: 'Bar/Cocktail', svg: '<path d="M21 5V3H3v2l8 9v5H6v2h12v-2h-5v-5l8-9zM7.43 7L5.66 5h12.69l-1.78 2H7.43z"/>' },
|
||
'coffee': { label: 'Coffee', svg: '<path d="M20 3H4v10c0 2.21 1.79 4 4 4h6c2.21 0 4-1.79 4-4v-3h2c1.11 0 2-.9 2-2V5c0-1.11-.89-2-2-2zm0 5h-2V5h2v3zM2 21h18v-2H2v2z"/>' },
|
||
'icecream': { label: 'Ice Cream', svg: '<path d="M8.79 12.4l3.26 6.22 3.17-6.21c-.11-.01-.21-.01-.32-.01-2.69 0-5.15 1.54-6.11 4z"/><path d="M12 2C9.85 2 7.89 2.86 6.46 4.24l1.33 2.54C8.82 5.66 10.32 5 12 5c1.67 0 3.18.66 4.21 1.78l1.33-2.54C16.11 2.86 14.15 2 12 2z"/><path d="M15.74 13.26C14.96 11.87 13.54 11 12 11c-1.52 0-2.94.86-3.71 2.24L12 20.87l3.74-7.61z"/><path d="M18.16 6.44l-1.33 2.54c.61.91.97 2 .97 3.17 0 .38-.05.76-.12 1.13l1.69-.45c.13-.44.21-.9.21-1.38 0-1.92-.66-3.69-1.75-5.01h.33z"/><path d="M5.2 14.83l1.69.45c-.07-.37-.12-.75-.12-1.13 0-1.17.36-2.26.97-3.17L6.41 8.44C5.32 9.76 4.66 11.53 4.66 13.45c0 .48.08.94.21 1.38h.33z"/>' },
|
||
'cake': { label: 'Cake', svg: '<path d="M12 6c1.11 0 2-.9 2-2 0-.38-.1-.73-.29-1.03L12 0l-1.71 2.97c-.19.3-.29.65-.29 1.03 0 1.1.9 2 2 2zm4.6 9.99l-1.07-1.07-1.08 1.07c-1.3 1.3-3.58 1.31-4.89 0l-1.07-1.07-1.09 1.07C6.75 16.64 5.88 17 4.96 17c-.73 0-1.4-.23-1.96-.61V21c0 .55.45 1 1 1h16c.55 0 1-.45 1-1v-4.61c-.56.38-1.23.61-1.96.61-.92 0-1.79-.36-2.44-1.01zM18 9h-5V7h-2v2H6c-1.66 0-3 1.34-3 3v1.54c0 1.08.88 1.96 1.96 1.96.52 0 1.02-.2 1.38-.57l2.14-2.13 2.13 2.13c.74.74 2.03.74 2.77 0l2.14-2.13 2.13 2.13c.37.37.86.57 1.38.57 1.08 0 1.96-.88 1.96-1.96V12C21 10.34 19.66 9 18 9z"/>' },
|
||
'local_pizza': { label: 'Pizza', svg: '<path d="M12 2C8.43 2 5.23 3.54 3.01 6L12 22l8.99-16C18.78 3.55 15.57 2 12 2zM7 7c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2zm5 8c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>' },
|
||
'lunch_dining': { label: 'Lunch/Burger', svg: '<path d="M2 19h20v2H2v-2zm2.56-8.42c-.78-.09-1.56.24-1.56 1.42h18c0-1.13-.78-1.51-1.56-1.42-.25-1.3-1.07-2.41-2.18-3.04.19-.59.29-1.22.29-1.87C17.55 3.05 14.72.22 11.09.22S4.64 3.05 4.64 6.67c0 .65.1 1.27.29 1.87a3.998 3.998 0 0 0-2.17 3.04zm0 1.4h15.1c-.1 1.41-1.29 2.52-2.71 2.52H7.27c-1.42 0-2.61-1.11-2.71-2.52zM2 16v1h20v-1c0-.55-.45-1-1-1H3c-.55 0-1 .45-1 1z"/>' },
|
||
'fastfood': { label: 'Fast Food', svg: '<path d="M21 15c0-4.625-3.507-8.441-8-8.941V4h-2v2.059c-4.493.5-8 4.316-8 8.941v2h18v-2zM5 15c.033-3.576 2.856-6.469 6.378-6.934l.622-.066.622.066C16.144 8.531 18.967 11.424 19 15H5zm-3 4h20v2H2z"/>' },
|
||
'ramen_dining': { label: 'Ramen/Noodles', svg: '<path d="M22 3.51V3h-2.01c.16 1.05-.04 2.18-.56 3.22-.53 1.05-1.37 1.96-2.42 2.6l-1 .6V3h-2v6.07l-.78.47c-.41.24-.83.44-1.24.61-.12.06-.25.1-.38.15-.35.13-.71.23-1.07.32l-.53.12V3h-2v7.35c-.58.03-1.15.01-1.71-.06l-.29-.04V3H4v7.67c-.66-.23-1.28-.52-1.84-.87L2 9.69V3H0v17h2v-8.07c.15.07.3.16.45.23.24.12.49.24.74.34.17.07.34.14.51.2.22.08.44.15.66.21.18.05.36.1.54.15.23.06.46.11.69.15.18.03.35.06.53.09.26.04.52.07.79.09.15.01.3.02.45.04.43.03.87.04 1.32.02.12-.01.24 0 .37-.01.35-.02.7-.05 1.06-.1.05-.01.1-.01.16-.02.43-.07.86-.16 1.29-.27.02 0 .04-.01.06-.02.13-.03.26-.08.39-.12.27-.08.53-.16.79-.25.19-.07.37-.14.55-.21.21-.09.43-.17.63-.27.2-.09.39-.19.59-.29.17-.09.34-.18.51-.28.19-.11.38-.22.56-.34.17-.11.34-.22.51-.34.16-.11.32-.22.48-.34.3-.22.59-.46.87-.71.1-.09.2-.18.3-.28.21-.2.42-.4.62-.62.11-.12.23-.24.33-.37.21-.25.41-.51.61-.78l.31-.44c.13-.2.26-.4.38-.62l.3-.55c.09-.18.18-.37.26-.56.08-.18.16-.35.23-.54.08-.21.15-.43.22-.65.05-.16.11-.32.15-.48.07-.26.13-.53.17-.8L22 3.51zM2 21v-2h10v2H2zm18 0h-6v-2h6v2z"/>' },
|
||
'bakery_dining': { label: 'Bakery', svg: '<path d="M19.28 16.34c.95-.19 1.68-.94 1.68-1.94 0-.71-.37-1.3-.92-1.62.21-.36.33-.77.33-1.19 0-1.21-.87-2.19-2.04-2.44l-.72-1.44c-.42-.83-1.24-1.35-2.13-1.38a6.32 6.32 0 0 0-1.3-1.97C13.39 3.54 12.48 3 11.5 3c-.98 0-1.89.54-2.68 1.36-.49.51-.89 1.12-1.2 1.75a2.5 2.5 0 0 0-2.5.78l-.76 1.53C3.21 8.67 2.35 9.65 2.35 10.89c0 .42.12.83.33 1.19-.55.32-.92.91-.92 1.62 0 1 .73 1.75 1.68 1.94A2.49 2.49 0 0 0 5.67 19h12.66a2.49 2.49 0 0 0 2.23-3.66h-1.28zM4.75 11.17c.54.06.98.47 1.08 1.01.19-.15.4-.26.63-.33-.11-.48-.09-.98.07-1.46-.51-.17-.88-.63-.88-1.17 0-.23.07-.44.18-.63-.29-.26-.47-.62-.47-1.01 0-.58.35-1.07.84-1.29l.51-1.02c.35-.7 1.08-1.14 1.85-1.14l.59.06.43-.69c.54-.87 1.32-1.37 2.07-1.37.73 0 1.5.48 2.04 1.34l.44.71.6-.06c.78 0 1.5.44 1.86 1.14l.51 1.02c.49.22.84.71.84 1.29 0 .39-.18.75-.47 1.01.11.19.18.4.18.63 0 .54-.37 1-.88 1.17.16.48.18.98.07 1.46.23.07.44.18.63.33.1-.54.54-.95 1.08-1.01l.2-.86c-.4-.36-.63-.87-.63-1.43 0-.36.1-.69.27-.98a2.22 2.22 0 0 1-.27-1.07c0-.58.22-1.1.58-1.5l-.28-.56a3.07 3.07 0 0 0-2.75-1.74h-.24c-.67-1.08-1.68-1.7-2.77-1.7-1.11 0-2.14.65-2.81 1.77-.11-.01-.22-.01-.33-.01a3.07 3.07 0 0 0-2.75 1.74l-.28.56c.36.4.58.92.58 1.5 0 .38-.1.74-.27 1.07.17.29.27.62.27.98 0 .56-.23 1.07-.63 1.43l.2.86z"/>' },
|
||
|
||
// Drinks & Refills
|
||
'water_drop': { label: 'Water/Refill', svg: '<path d="M12 2c-5.33 4.55-8 8.48-8 11.8 0 4.98 3.8 8.2 8 8.2s8-3.22 8-8.2c0-3.32-2.67-7.25-8-11.8zm0 18c-3.35 0-6-2.57-6-6.2 0-2.34 1.95-5.44 6-9.14 4.05 3.7 6 6.79 6 9.14 0 3.63-2.65 6.2-6 6.2z"/>' },
|
||
'local_drink': { label: 'Drink/Soda', svg: '<path d="M3 2l2.01 18.23C5.13 21.23 5.97 22 7 22h10c1.03 0 1.87-.77 1.99-1.77L21 2H3zm9 17c-1.66 0-3-1.34-3-3 0-2 3-5.4 3-5.4s3 3.4 3 5.4c0 1.66-1.34 3-3 3zm6.33-11H5.67l-.44-4h13.53l-.43 4z"/>' },
|
||
'wine_bar': { label: 'Wine', svg: '<path d="M6 3l1.5 14.029a3.568 3.568 0 0 0 3.562 3.471h1.877a3.569 3.569 0 0 0 3.562-3.471L18 3H6zm5 12.5c-2 0-4-1.5-4-3.5 0-3 4-5.505 4-5.505S15 9 15 12c0 2-2 3.5-4 3.5zM5 21h14v2H5z"/>' },
|
||
'sports_bar': { label: 'Beer', svg: '<path d="M19 9h-1V4H8v5H7c-1.66 0-3 1.34-3 3v1c0 1.66 1.34 3 3 3h1v1c0 1.1.9 2 2 2h6c1.1 0 2-.9 2-2V9zm-8 4.5c-.55 0-1-.45-1-1V8h2v4.5c0 .55-.45 1-1 1zm3 0c-.55 0-1-.45-1-1V8h2v4.5c0 .55-.45 1-1 1zM7 14c-.55 0-1-.45-1-1v-1c0-.55.45-1 1-1h1v3H7z"/>' },
|
||
'liquor': { label: 'Liquor', svg: '<path d="M3 14c0 1.3.84 2.4 2 2.82V20H3v2h6v-2H7v-3.18C8.16 16.4 9 15.3 9 14V6H3v8zm2-6h2v3H5V8zm15.64 1.35l-.96-.32c-.36-.12-.64-.45-.64-.84V5c0-.55-.45-1-1-1h-3c-.55 0-1 .45-1 1v3.19c0 .39-.28.72-.64.84l-.96.32C11.58 9.65 11 10.46 11 11.37v.13h9v-.13c0-.91-.58-1.72-1.36-2.02zM18 21h-5v-2h5v2zm1-4h-7v-4h7v4z"/>' },
|
||
|
||
// Hookah & Fire
|
||
'local_fire_department': { label: 'Fire/Charcoal', svg: '<path d="M12 12.9l-2.13 2.09c-.56.56-.87 1.29-.87 2.07C9 18.68 10.35 20 12 20s3-1.32 3-2.94c0-.78-.31-1.52-.87-2.07L12 12.9z"/><path d="M16 6l-.44.55C14.38 8.02 12 7.19 12 5.3V2S4 6 4 13c0 2.92 1.56 5.47 3.89 6.86-.56-.79-.89-1.76-.89-2.8 0-1.32.52-2.56 1.47-3.5L12 10.1l3.53 3.47c.95.93 1.47 2.17 1.47 3.5 0 1.02-.31 1.96-.85 2.75 1.89-1.15 3.29-3.06 3.71-5.3.66-3.55-1.07-6.9-3.86-8.52z"/>' },
|
||
'whatshot': { label: 'Hot/Trending', svg: '<path d="M13.5.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/>' },
|
||
'smoke_free': { label: 'No Smoking', svg: '<path d="M2 6l6.99 7H2v3h9.99l7 7 1.26-1.25-17-17zm18.5 7H22v3h-1.5zM18 13h1.5v3H18zm.85-8.12c.62-.61 1-1.45 1-2.38h-1.5c0 1.02-.83 1.85-1.85 1.85v1.5c2.24 0 4 1.83 4 4.07V12H22V9.92c0-2.23-1.28-4.15-3.15-5.04zM14.5 8.7h1.53c1.05 0 1.97.74 1.97 2.05V12h1.5v-1.59c0-1.8-1.6-3.16-3.47-3.16H14.5c-1.02 0-1.85-.98-1.85-2s.83-1.75 1.85-1.75V2a3.35 3.35 0 0 0-3.35 3.35c0 1.85 1.52 3.35 3.35 3.35z"/><path d="M9.5 16H13v-3z"/>' },
|
||
|
||
// Cleaning & Maintenance
|
||
'cleaning_services': { label: 'Cleaning', svg: '<path d="M16 11h-1V3c0-1.1-.9-2-2-2h-2c-1.1 0-2 .9-2 2v8H8c-2.76 0-5 2.24-5 5v7h18v-7c0-2.76-2.24-5-5-5zm3 10h-2v-3c0-.55-.45-1-1-1s-1 .45-1 1v3h-2v-3c0-.55-.45-1-1-1s-1 .45-1 1v3H9v-3c0-.55-.45-1-1-1s-1 .45-1 1v3H5v-5c0-1.65 1.35-3 3-3h8c1.65 0 3 1.35 3 3v5z"/>' },
|
||
'delete_sweep': { label: 'Clear Table', svg: '<path d="M15 16h4v2h-4zm0-8h7v2h-7zm0 4h6v2h-6zM3 18c0 1.1.9 2 2 2h6c1.1 0 2-.9 2-2V8H3v10zM14 5h-3l-1-1H6L5 5H2v2h12z"/>' },
|
||
'auto_fix_high': { label: 'Fix/Repair', svg: '<path d="M7.5 5.6L10 7 8.6 4.5 10 2 7.5 3.4 5 2l1.4 2.5L5 7zm12 9.8L17 14l1.4 2.5L17 19l2.5-1.4L22 19l-1.4-2.5L22 14zM22 2l-2.5 1.4L17 2l1.4 2.5L17 7l2.5-1.4L22 7l-1.4-2.5zm-7.63 5.29a.996.996 0 0 0-1.41 0L1.29 18.96a.996.996 0 0 0 0 1.41l2.34 2.34c.39.39 1.02.39 1.41 0L16.7 11.05a.996.996 0 0 0 0-1.41l-2.33-2.35zm-1.03 5.49l-2.12-2.12 2.44-2.44 2.12 2.12-2.44 2.44z"/>' },
|
||
|
||
// Supplies & Items
|
||
'inventory': { label: 'Inventory', svg: '<path d="M20 2H4c-1 0-2 .9-2 2v3.01c0 .72.43 1.34 1 1.69V20c0 1.1 1.1 2 2 2h14c.9 0 2-.9 2-2V8.7c.57-.35 1-.97 1-1.69V4c0-1.1-1-2-2-2zm-5 12H9v-2h6v2zm5-7H4V4h16v3z"/>' },
|
||
'shopping_basket': { label: 'Shopping', svg: '<path d="M17.21 9l-4.38-6.56a.993.993 0 0 0-.83-.42c-.32 0-.64.14-.83.43L6.79 9H2c-.55 0-1 .45-1 1 0 .09.01.18.04.27l2.54 9.27c.23.84 1 1.46 1.92 1.46h13c.92 0 1.69-.62 1.93-1.46l2.54-9.27L23 10c0-.55-.45-1-1-1h-4.79zM9 9l3-4.4L15 9H9zm3 8c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>' },
|
||
'add_box': { label: 'Add Item', svg: '<path d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"/>' },
|
||
'note_add': { label: 'Add Note', svg: '<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 14h-3v3h-2v-3H8v-2h3v-3h2v3h3v2zm-3-7V3.5L18.5 9H13z"/>' },
|
||
|
||
// Entertainment
|
||
'music_note': { label: 'Music', svg: '<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>' },
|
||
'tv': { label: 'TV', svg: '<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"/>' },
|
||
'sports_esports': { label: 'Gaming', svg: '<path d="M21.58 16.09l-1.09-7.66A3.996 3.996 0 0 0 16.53 5H7.47C5.48 5 3.79 6.46 3.51 8.43l-1.09 7.66C2.2 17.63 3.39 19 4.94 19c.68 0 1.32-.27 1.8-.75L9 16h6l2.25 2.25c.48.48 1.13.75 1.8.75 1.56 0 2.75-1.37 2.53-2.91zM11 11H9v2H8v-2H6v-1h2V8h1v2h2v1zm4-1c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm2 3c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1z"/>' },
|
||
'celebration': { label: 'Party', svg: '<path d="M2 22l14-5-9-9zm12.53-9.47l5.59-5.59a1.25 1.25 0 0 1 1.77 0l.59.59 1.06-1.06-.59-.59a2.758 2.758 0 0 0-3.89 0l-5.59 5.59 1.06 1.06zm-4.47-5.65l-.59.59 1.06 1.06.59-.59a2.758 2.758 0 0 0 0-3.89l-.59-.59-1.06 1.07.59.59c.48.48.48 1.28 0 1.76zm7 5l-.59.59 1.06 1.06.59-.59c.48-.48.48-1.28 0-1.76l-.59-.59-1.06 1.06.59.59v-.36zm-6-4l-.59.59 1.06 1.06.59-.59c.48-.48.48-1.28 0-1.76l-.59-.59-1.06 1.06.59.59v-.36z"/>' },
|
||
|
||
// Comfort & Amenities
|
||
'ac_unit': { label: 'A/C', svg: '<path d="M22 11h-4.17l3.24-3.24-1.41-1.42L15 11h-2V9l4.66-4.66-1.42-1.41L13 6.17V2h-2v4.17L7.76 2.93 6.34 4.34 11 9v2H9L4.34 6.34 2.93 7.76 6.17 11H2v2h4.17l-3.24 3.24 1.41 1.42L9 13h2v2l-4.66 4.66 1.42 1.41L11 17.83V22h2v-4.17l3.24 3.24 1.42-1.41L13 15v-2h2l4.66 4.66 1.41-1.42L17.83 13H22z"/>' },
|
||
'wb_sunny': { label: 'Sunny/Bright', svg: '<path d="M6.76 4.84l-1.8-1.79-1.41 1.41 1.79 1.79 1.42-1.41zM4 10.5H1v2h3v-2zm9-9.95h-2V3.5h2V.55zm7.45 3.91l-1.41-1.41-1.79 1.79 1.41 1.41 1.79-1.79zm-3.21 13.7l1.79 1.8 1.41-1.41-1.8-1.79-1.4 1.4zM20 10.5v2h3v-2h-3zm-8-5c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm-1 16.95h2V19.5h-2v2.95zm-7.45-3.91l1.41 1.41 1.79-1.8-1.41-1.41-1.79 1.8z"/>' },
|
||
'light_mode': { label: 'Light', svg: '<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58a.996.996 0 0 0-1.41 0 .996.996 0 0 0 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37a.996.996 0 0 0-1.41 0 .996.996 0 0 0 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0a.996.996 0 0 0 0-1.41l-1.06-1.06zm1.06-10.96a.996.996 0 0 0 0-1.41.996.996 0 0 0-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36a.996.996 0 0 0 0-1.41.996.996 0 0 0-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/>' },
|
||
'volume_up': { label: 'Volume Up', svg: '<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>' },
|
||
'volume_down': { label: 'Volume Down', svg: '<path d="M18.5 12A4.5 4.5 0 0 0 16 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/>' },
|
||
|
||
// Health & Safety
|
||
'medical_services': { label: 'Medical', svg: '<path d="M20 6h-4V4c0-1.1-.9-2-2-2h-4c-1.1 0-2 .9-2 2v2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-10-2h4v2h-4V4zm6 11h-3v3h-2v-3H8v-2h3v-3h2v3h3v2z"/>' },
|
||
'health_and_safety': { label: 'Health & Safety', svg: '<path d="M10.5 13H8v-3h2.5V7.5h3V10H16v3h-2.5v2.5h-3V13zM12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3z"/>' },
|
||
'child_care': { label: 'Child Care', svg: '<circle cx="14.5" cy="10.5" r="1.25"/><circle cx="9.5" cy="10.5" r="1.25"/><path d="M22.94 12.66c.04-.21.06-.43.06-.66s-.02-.45-.06-.66a4.008 4.008 0 0 0-2.81-3.17 9.114 9.114 0 0 0-2.19-2.91C16.36 3.85 14.28 3 12 3s-4.36.85-5.94 2.26c-.92.81-1.67 1.8-2.19 2.91a3.994 3.994 0 0 0-2.81 3.17c-.04.21-.06.43-.06.66s.02.45.06.66a4.008 4.008 0 0 0 2.81 3.17 8.977 8.977 0 0 0 2.17 2.89C7.62 20.14 9.71 21 12 21s4.38-.86 5.97-2.28c.9-.8 1.65-1.79 2.17-2.89a3.998 3.998 0 0 0 2.8-3.17zM19 14c-.1 0-.19-.02-.29-.03-.2.67-.49 1.29-.86 1.86C16.6 17.74 14.45 19 12 19s-4.6-1.26-5.85-3.17c-.37-.57-.66-1.19-.86-1.86-.1.01-.19.03-.29.03-1.1 0-2-.9-2-2s.9-2 2-2c.1 0 .19.02.29.03.2-.67.49-1.29.86-1.86C7.4 6.26 9.55 5 12 5s4.6 1.26 5.85 3.17c.37.57.66 1.19.86 1.86.1-.01.19-.03.29-.03 1.1 0 2 .9 2 2s-.9 2-2 2zM12 17c2.01 0 3.74-1.23 4.5-3h-9c.76 1.77 2.49 3 4.5 3z"/>' },
|
||
'accessible': { label: 'Accessible', svg: '<circle cx="12" cy="4" r="2"/><path d="M19 13v-2c-1.54.02-3.09-.75-4.07-1.83l-1.29-1.43c-.17-.19-.38-.34-.61-.45-.01 0-.01-.01-.02-.01H13a.863.863 0 0 0-.31-.06c-.02 0-.03-.01-.05-.01h-.02c-.67 0-1.29.33-1.66.85-.03.04-.05.08-.08.13l-1.5 2.5a1.98 1.98 0 0 0 .68 2.72l3.94 2.35v5h2v-6.5l-2.13-1.28 1.29-2.15C13.28 12.02 14.96 13 17 13h2z"/>' },
|
||
|
||
// Location & Navigation
|
||
'directions': { label: 'Directions', svg: '<path d="M21.71 11.29l-9-9a.996.996 0 0 0-1.41 0l-9 9a.996.996 0 0 0 0 1.41l9 9c.39.39 1.02.39 1.41 0l9-9a.996.996 0 0 0 0-1.41zM14 14.5V12h-4v3H8v-4c0-.55.45-1 1-1h5V7.5l3.5 3.5-3.5 3.5z"/>' },
|
||
'meeting_room': { label: 'Meeting Room', svg: '<path d="M14 6v15H3v-2h2V3h9v1h5v15h2v2h-4V6h-3zm-4 5v2h2v-2h-2z"/>' },
|
||
'wc': { label: 'Restroom', svg: '<path d="M5.5 22v-7.5H4V9c0-1.1.9-2 2-2h3c1.1 0 2 .9 2 2v5.5H9.5V22h-4zM18 22v-6h3l-2.54-7.63A2.01 2.01 0 0 0 16.56 7h-.12a2 2 0 0 0-1.9 1.37L12 16h3v6h3zM7.5 6c1.11 0 2-.89 2-2s-.89-2-2-2-2 .89-2 2 .89 2 2 2zm9 0c1.11 0 2-.89 2-2s-.89-2-2-2-2 .89-2 2 .89 2 2 2z"/>' },
|
||
'local_parking': { label: 'Parking', svg: '<path d="M13 3H6v18h4v-6h3c3.31 0 6-2.69 6-6s-2.69-6-6-6zm.2 8H10V7h3.2c1.1 0 2 .9 2 2s-.9 2-2 2z"/>' },
|
||
|
||
// General
|
||
'help': { label: 'Help', svg: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"/>' },
|
||
'info': { label: 'Info', svg: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/>' },
|
||
'star': { label: 'Star/VIP', svg: '<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/>' },
|
||
'favorite': { label: 'Favorite', svg: '<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>' },
|
||
'thumb_up': { label: 'Thumbs Up', svg: '<path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/>' },
|
||
'check_circle': { label: 'Check/Done', svg: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>' },
|
||
'warning': { label: 'Warning', svg: '<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/>' },
|
||
'error': { label: 'Error/Alert', svg: '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>' },
|
||
'schedule': { label: 'Schedule', svg: '<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>' },
|
||
'event': { label: 'Event', svg: '<path d="M17 12h-5v5h5v-5zM16 1v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-1V1h-2zm3 18H5V8h14v11z"/>' }
|
||
},
|
||
|
||
async loadServiceTypes() {
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/tasks/listAllTypes.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.renderServices(data.TASK_TYPES || []);
|
||
} else {
|
||
document.getElementById('servicesList').innerHTML = '<div class="empty-state">Failed to load services</div>';
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error loading services:', err);
|
||
document.getElementById('servicesList').innerHTML = '<div class="empty-state">Error loading services</div>';
|
||
}
|
||
},
|
||
|
||
getServiceIconSvg(iconName) {
|
||
const icon = this.serviceIcons[iconName] || this.serviceIcons['notifications'];
|
||
return `<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">${icon.svg}</svg>`;
|
||
},
|
||
|
||
renderServices(services) {
|
||
const container = document.getElementById('servicesList');
|
||
if (!services.length) {
|
||
container.innerHTML = '<div class="empty-state">No services configured yet. Click "+ Add Service" to create one.</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = services.map((s, index) => `
|
||
<div class="service-item" draggable="true" data-id="${s.TaskTypeID}" data-index="${index}" style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-bottom:1px solid #eee;background:#fff;cursor:grab;transition:background 0.2s, transform 0.2s;">
|
||
<div style="display:flex;align-items:center;gap:12px;">
|
||
<div style="color:#999;cursor:grab;" class="drag-handle">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>
|
||
</div>
|
||
<div style="width:8px;height:36px;background:${s.TaskTypeColor || '#9C27B0'};border-radius:4px;"></div>
|
||
<div style="color:${s.TaskTypeColor || '#6366f1'};">${this.getServiceIconSvg(s.TaskTypeIcon || 'notifications')}</div>
|
||
<div>
|
||
<strong>${this.escapeHtml(s.TaskTypeName)}</strong>
|
||
<div style="color:#666;font-size:12px;">${s.TaskTypeDescription ? this.escapeHtml(s.TaskTypeDescription) : 'No description'}</div>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;">
|
||
<button class="btn btn-sm btn-secondary" onclick="Portal.editService(${s.TaskTypeID})">Edit</button>
|
||
<button class="btn btn-sm btn-danger" onclick="Portal.deleteService(${s.TaskTypeID}, '${this.escapeHtml(s.TaskTypeName).replace(/'/g, "\\'")}')">Delete</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
// Store services for editing
|
||
this._services = services;
|
||
|
||
// Initialize drag and drop
|
||
this.initDragAndDrop();
|
||
},
|
||
|
||
initDragAndDrop() {
|
||
const container = document.getElementById('servicesList');
|
||
const items = container.querySelectorAll('.service-item');
|
||
let draggedItem = null;
|
||
let draggedOverItem = null;
|
||
|
||
items.forEach(item => {
|
||
item.addEventListener('dragstart', (e) => {
|
||
draggedItem = item;
|
||
item.style.opacity = '0.5';
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
});
|
||
|
||
item.addEventListener('dragend', () => {
|
||
draggedItem.style.opacity = '1';
|
||
items.forEach(i => {
|
||
i.style.background = '#fff';
|
||
i.style.transform = '';
|
||
});
|
||
draggedItem = null;
|
||
draggedOverItem = null;
|
||
});
|
||
|
||
item.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
if (item !== draggedItem) {
|
||
item.style.background = '#f0f0ff';
|
||
}
|
||
});
|
||
|
||
item.addEventListener('dragleave', () => {
|
||
item.style.background = '#fff';
|
||
});
|
||
|
||
item.addEventListener('drop', async (e) => {
|
||
e.preventDefault();
|
||
if (item !== draggedItem) {
|
||
// Get all items and their IDs
|
||
const allItems = [...container.querySelectorAll('.service-item')];
|
||
const draggedIndex = allItems.indexOf(draggedItem);
|
||
const dropIndex = allItems.indexOf(item);
|
||
|
||
// Move the dragged item
|
||
if (draggedIndex < dropIndex) {
|
||
item.parentNode.insertBefore(draggedItem, item.nextSibling);
|
||
} else {
|
||
item.parentNode.insertBefore(draggedItem, item);
|
||
}
|
||
|
||
// Save new order
|
||
await this.saveServiceOrder();
|
||
}
|
||
item.style.background = '#fff';
|
||
});
|
||
});
|
||
},
|
||
|
||
async saveServiceOrder() {
|
||
const container = document.getElementById('servicesList');
|
||
const items = container.querySelectorAll('.service-item');
|
||
const order = [...items].map(item => parseInt(item.dataset.id));
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/tasks/reorderTypes.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
BusinessID: this.config.businessId,
|
||
Order: order
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast('Order saved', 'success');
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to save order', 'error');
|
||
await this.loadServiceTypes(); // Reload to reset order
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error saving order:', err);
|
||
this.toast('Error saving order', 'error');
|
||
await this.loadServiceTypes(); // Reload to reset order
|
||
}
|
||
},
|
||
|
||
// Preset color palette (GIF-safe colors)
|
||
colorPalette: [
|
||
// Material Design colors
|
||
'#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5', // Red, Pink, Purple, Deep Purple, Indigo
|
||
'#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50', // Blue, Light Blue, Cyan, Teal, Green
|
||
'#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800', // Light Green, Lime, Yellow, Amber, Orange
|
||
'#FF5722', '#795548', '#9E9E9E', '#607D8B', '#000000', // Deep Orange, Brown, Grey, Blue Grey, Black
|
||
'#6366f1', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', // Tailwind colors
|
||
],
|
||
|
||
buildColorPicker(selectedColor = '#9C27B0') {
|
||
const normalizedSelected = selectedColor.toUpperCase();
|
||
return this.colorPalette.map(color => {
|
||
const isSelected = color.toUpperCase() === normalizedSelected;
|
||
return `
|
||
<label style="display:inline-block;cursor:pointer;margin:3px;">
|
||
<input type="radio" name="serviceColor" value="${color}" ${isSelected ? 'checked' : ''} style="display:none;">
|
||
<div style="width:28px;height:28px;background:${color};border-radius:4px;border:3px solid ${isSelected ? '#333' : 'transparent'};box-sizing:border-box;"></div>
|
||
</label>
|
||
`;
|
||
}).join('');
|
||
},
|
||
|
||
buildIconPicker(selectedIcon = 'notifications') {
|
||
return Object.entries(this.serviceIcons).map(([key, val]) => `
|
||
<label style="display:inline-flex;flex-direction:column;align-items:center;padding:8px;cursor:pointer;border:2px solid ${key === selectedIcon ? '#6366f1' : '#e5e7eb'};border-radius:8px;margin:4px;">
|
||
<input type="radio" name="serviceIcon" value="${key}" ${key === selectedIcon ? 'checked' : ''} style="display:none;">
|
||
<div style="color:${key === selectedIcon ? '#6366f1' : '#666'};">${this.getServiceIconSvg(key)}</div>
|
||
<span style="font-size:10px;color:#666;margin-top:4px;">${val.label}</span>
|
||
</label>
|
||
`).join('');
|
||
},
|
||
|
||
showAddServiceModal() {
|
||
// Build category options for dropdown
|
||
const categoryOptions = (this._taskCategories || []).map(c =>
|
||
`<option value="${c.TaskCategoryID}">${this.escapeHtml(c.Name)}</option>`
|
||
).join('');
|
||
|
||
const html = `
|
||
<form id="addServiceForm" onsubmit="Portal.saveNewService(event)">
|
||
<div class="form-group">
|
||
<label>Service Name</label>
|
||
<input type="text" id="newServiceName" class="form-input" placeholder="e.g., Add Charcoal, Request Napkins" required maxlength="45">
|
||
<small style="color:#666;">This will appear in the customer's bell menu</small>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Description (optional)</label>
|
||
<input type="text" id="newServiceDescription" class="form-input" placeholder="e.g., Staff will bring fresh charcoal" maxlength="100">
|
||
<small style="color:#666;">Subtitle shown under the service name</small>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Category</label>
|
||
<select id="newServiceCategory" class="form-input" required>
|
||
<option value="">Select a category...</option>
|
||
${categoryOptions}
|
||
</select>
|
||
<small style="color:#666;">Tasks created from this service will be assigned to this category</small>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Color</label>
|
||
<div style="display:flex;flex-wrap:wrap;gap:2px;" id="colorPicker">
|
||
${this.buildColorPicker('#9C27B0')}
|
||
</div>
|
||
<small style="color:#666;">This color will be shown on the HUD when tasks are created</small>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Icon</label>
|
||
<div style="display:flex;flex-wrap:wrap;gap:4px;" id="iconPicker">
|
||
${this.buildIconPicker('notifications')}
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
|
||
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
|
||
<button type="submit" class="btn btn-primary">Add Service</button>
|
||
</div>
|
||
</form>
|
||
`;
|
||
document.getElementById('modalTitle').textContent = 'Add Service';
|
||
document.getElementById('modalBody').innerHTML = html;
|
||
this.showModal();
|
||
document.getElementById('newServiceName').focus();
|
||
|
||
// Color selection highlighting
|
||
document.querySelectorAll('#colorPicker label').forEach(label => {
|
||
label.addEventListener('click', () => {
|
||
document.querySelectorAll('#colorPicker label div').forEach(d => d.style.border = '3px solid transparent');
|
||
label.querySelector('div').style.border = '3px solid #333';
|
||
});
|
||
});
|
||
|
||
// Icon selection highlighting
|
||
document.querySelectorAll('#iconPicker label').forEach(label => {
|
||
label.addEventListener('click', () => {
|
||
document.querySelectorAll('#iconPicker label').forEach(l => {
|
||
l.style.borderColor = '#e5e7eb';
|
||
l.querySelector('div').style.color = '#666';
|
||
});
|
||
label.style.borderColor = '#6366f1';
|
||
label.querySelector('div').style.color = '#6366f1';
|
||
});
|
||
});
|
||
},
|
||
|
||
async saveNewService(event) {
|
||
event.preventDefault();
|
||
const name = document.getElementById('newServiceName').value.trim();
|
||
const description = document.getElementById('newServiceDescription').value.trim();
|
||
const categoryId = document.getElementById('newServiceCategory').value;
|
||
const color = document.querySelector('input[name="serviceColor"]:checked')?.value || '#9C27B0';
|
||
const icon = document.querySelector('input[name="serviceIcon"]:checked')?.value || 'notifications';
|
||
if (!name) return;
|
||
|
||
if (!categoryId) {
|
||
this.toast('Please select a category', 'error');
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
BusinessID: this.config.businessId,
|
||
TaskTypeName: name,
|
||
TaskTypeDescription: description,
|
||
TaskTypeColor: color,
|
||
TaskTypeIcon: icon,
|
||
CategoryID: parseInt(categoryId)
|
||
};
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/tasks/saveType.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast('Service added', 'success');
|
||
this.closeModal();
|
||
await this.loadServiceTypes();
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to add service', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error adding service:', err);
|
||
this.toast('Error adding service', 'error');
|
||
}
|
||
},
|
||
|
||
editService(taskTypeId) {
|
||
const service = this._services?.find(s => s.TaskTypeID === taskTypeId);
|
||
if (!service) {
|
||
this.toast('Service not found', 'error');
|
||
return;
|
||
}
|
||
|
||
// Build category options for dropdown
|
||
const categoryOptions = (this._taskCategories || []).map(c =>
|
||
`<option value="${c.TaskCategoryID}" ${service.CategoryID == c.TaskCategoryID ? 'selected' : ''}>${this.escapeHtml(c.Name)}</option>`
|
||
).join('');
|
||
|
||
const html = `
|
||
<form id="editServiceForm" onsubmit="Portal.updateService(event, ${taskTypeId})">
|
||
<div class="form-group">
|
||
<label>Service Name</label>
|
||
<input type="text" id="editServiceName" class="form-input" value="${this.escapeHtml(service.TaskTypeName)}" required maxlength="45">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Description (optional)</label>
|
||
<input type="text" id="editServiceDescription" class="form-input" value="${this.escapeHtml(service.TaskTypeDescription || '')}" maxlength="100">
|
||
<small style="color:#666;">Subtitle shown under the service name</small>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Category</label>
|
||
<select id="editServiceCategory" class="form-input" required>
|
||
<option value="">Select a category...</option>
|
||
${categoryOptions}
|
||
</select>
|
||
<small style="color:#666;">Tasks created from this service will be assigned to this category</small>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Color</label>
|
||
<div style="display:flex;flex-wrap:wrap;gap:2px;" id="colorPicker">
|
||
${this.buildColorPicker(service.TaskTypeColor || '#9C27B0')}
|
||
</div>
|
||
<small style="color:#666;">This color will be shown on the HUD when tasks are created</small>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Icon</label>
|
||
<div style="display:flex;flex-wrap:wrap;gap:4px;" id="iconPicker">
|
||
${this.buildIconPicker(service.TaskTypeIcon || 'notifications')}
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
|
||
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
|
||
<button type="submit" class="btn btn-primary">Save</button>
|
||
</div>
|
||
</form>
|
||
`;
|
||
document.getElementById('modalTitle').textContent = 'Edit Service';
|
||
document.getElementById('modalBody').innerHTML = html;
|
||
this.showModal();
|
||
document.getElementById('editServiceName').focus();
|
||
|
||
// Color selection highlighting
|
||
document.querySelectorAll('#colorPicker label').forEach(label => {
|
||
label.addEventListener('click', () => {
|
||
document.querySelectorAll('#colorPicker label div').forEach(d => d.style.border = '3px solid transparent');
|
||
label.querySelector('div').style.border = '3px solid #333';
|
||
});
|
||
});
|
||
|
||
// Icon selection highlighting
|
||
document.querySelectorAll('#iconPicker label').forEach(label => {
|
||
label.addEventListener('click', () => {
|
||
document.querySelectorAll('#iconPicker label').forEach(l => {
|
||
l.style.borderColor = '#e5e7eb';
|
||
l.querySelector('div').style.color = '#666';
|
||
});
|
||
label.style.borderColor = '#6366f1';
|
||
label.querySelector('div').style.color = '#6366f1';
|
||
});
|
||
});
|
||
},
|
||
|
||
async updateService(event, taskTypeId) {
|
||
event.preventDefault();
|
||
const name = document.getElementById('editServiceName').value.trim();
|
||
const description = document.getElementById('editServiceDescription').value.trim();
|
||
const categoryId = document.getElementById('editServiceCategory').value;
|
||
const color = document.querySelector('input[name="serviceColor"]:checked')?.value || '#9C27B0';
|
||
const icon = document.querySelector('input[name="serviceIcon"]:checked')?.value || 'notifications';
|
||
if (!name) return;
|
||
|
||
if (!categoryId) {
|
||
this.toast('Please select a category', 'error');
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
TaskTypeID: taskTypeId,
|
||
BusinessID: this.config.businessId,
|
||
TaskTypeName: name,
|
||
TaskTypeDescription: description,
|
||
TaskTypeColor: color,
|
||
TaskTypeIcon: icon,
|
||
CategoryID: parseInt(categoryId)
|
||
};
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/tasks/saveType.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast('Service updated', 'success');
|
||
this.closeModal();
|
||
await this.loadServiceTypes();
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to update service', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error updating service:', err);
|
||
this.toast('Error updating service', 'error');
|
||
}
|
||
},
|
||
|
||
async deleteService(taskTypeId, serviceName) {
|
||
if (!confirm(`Delete "${serviceName}"? This cannot be undone.`)) return;
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/tasks/deleteType.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
TaskTypeID: taskTypeId,
|
||
BusinessID: this.config.businessId
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast('Service deleted', 'success');
|
||
await this.loadServiceTypes();
|
||
} else {
|
||
this.toast(data.ERROR || 'Failed to delete service', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error deleting service:', err);
|
||
this.toast('Error deleting service', 'error');
|
||
}
|
||
},
|
||
|
||
// =====================
|
||
// Admin Tasks Management
|
||
// =====================
|
||
|
||
quickTaskTemplates: [],
|
||
scheduledTasks: [],
|
||
_taskCategories: [],
|
||
|
||
async loadAdminTasksPage() {
|
||
console.log('[Portal] Loading admin tasks page...');
|
||
await Promise.all([
|
||
this.loadTaskCategories(),
|
||
this.loadQuickTaskTemplates(),
|
||
this.loadScheduledTasks(),
|
||
this.loadPendingRatings()
|
||
]);
|
||
},
|
||
|
||
// Task Categories
|
||
async loadTaskCategories() {
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/tasks/listCategories.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||
});
|
||
const data = await response.json();
|
||
if (data.OK) {
|
||
this._taskCategories = data.CATEGORIES || [];
|
||
this.renderTaskCategories();
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error loading task categories:', err);
|
||
}
|
||
},
|
||
|
||
renderTaskCategories() {
|
||
const container = document.getElementById('taskCategoriesGrid');
|
||
if (!container) return;
|
||
|
||
if (!this._taskCategories.length) {
|
||
container.innerHTML = `
|
||
<div class="empty-state">
|
||
No categories yet.
|
||
<button class="btn btn-sm btn-primary" onclick="Portal.seedDefaultCategories()" style="margin-left:8px;">Seed Defaults</button>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<div style="display:flex;flex-wrap:wrap;gap:8px;">
|
||
${this._taskCategories.map(c => `
|
||
<div class="category-chip" style="display:inline-flex;align-items:center;gap:8px;padding:8px 12px;background:#f5f5f5;border-radius:8px;border-left:4px solid ${c.Color};">
|
||
<div style="width:20px;height:20px;border-radius:4px;background:${c.Color};"></div>
|
||
<span style="font-weight:500;">${this.escapeHtml(c.Name)}</span>
|
||
<button class="btn btn-sm" onclick="Portal.editTaskCategory(${c.TaskCategoryID})" style="padding:2px 6px;font-size:11px;">Edit</button>
|
||
<button class="btn btn-sm btn-danger" onclick="Portal.deleteTaskCategory(${c.TaskCategoryID})" style="padding:2px 6px;font-size:11px;">×</button>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
async seedDefaultCategories() {
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/tasks/seedCategories.cfm?bid=${this.config.businessId}`);
|
||
const data = await response.json();
|
||
if (data.OK) {
|
||
this.toast(data.MESSAGE || 'Categories created!', 'success');
|
||
await this.loadTaskCategories();
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to seed categories', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error seeding categories:', err);
|
||
this.toast('Error seeding categories', 'error');
|
||
}
|
||
},
|
||
|
||
showAddTaskCategoryModal(categoryId = null) {
|
||
const isEdit = categoryId !== null;
|
||
const category = isEdit ? this._taskCategories.find(c => c.TaskCategoryID === categoryId) : {};
|
||
|
||
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Category' : 'Add Task Category';
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<form id="taskCategoryForm" class="form">
|
||
<input type="hidden" id="taskCategoryId" value="${categoryId || ''}">
|
||
<div class="form-group">
|
||
<label>Category Name</label>
|
||
<input type="text" id="taskName" class="form-input" value="${this.escapeHtml(category.Name || '')}" required placeholder="e.g., Kitchen, Cleaning, Delivery">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Color</label>
|
||
<div style="display:flex;flex-wrap:wrap;gap:4px;" id="taskCategoryColorPicker">
|
||
${this.buildColorPicker(category.Color || '#6366f1')}
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
|
||
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
|
||
<button type="submit" class="btn btn-primary">${isEdit ? 'Save' : 'Create'}</button>
|
||
</div>
|
||
</form>
|
||
`;
|
||
this.showModal();
|
||
|
||
// Color selection highlighting
|
||
document.querySelectorAll('#taskCategoryColorPicker label').forEach(label => {
|
||
label.addEventListener('click', () => {
|
||
document.querySelectorAll('#taskCategoryColorPicker label div').forEach(d => d.style.border = '3px solid transparent');
|
||
label.querySelector('div').style.border = '3px solid #333';
|
||
});
|
||
});
|
||
|
||
document.getElementById('taskCategoryForm').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
this.saveTaskCategory();
|
||
});
|
||
},
|
||
|
||
editTaskCategory(categoryId) {
|
||
this.showAddTaskCategoryModal(categoryId);
|
||
},
|
||
|
||
async saveTaskCategory() {
|
||
const id = document.getElementById('taskCategoryId').value;
|
||
const name = document.getElementById('taskName').value;
|
||
const color = document.querySelector('#taskCategoryColorPicker input[name="serviceColor"]:checked')?.value || '#6366f1';
|
||
|
||
const payload = {
|
||
BusinessID: this.config.businessId,
|
||
Name: name,
|
||
Color: color
|
||
};
|
||
if (id) payload.TaskCategoryID = parseInt(id);
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/tasks/saveCategory.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await response.json();
|
||
if (data.OK) {
|
||
this.toast('Category saved!', 'success');
|
||
this.closeModal();
|
||
await this.loadTaskCategories();
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to save', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error saving category:', err);
|
||
this.toast('Error saving category', 'error');
|
||
}
|
||
},
|
||
|
||
async deleteTaskCategory(categoryId) {
|
||
if (!confirm('Delete this category?')) return;
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/tasks/deleteCategory.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
BusinessID: this.config.businessId,
|
||
TaskCategoryID: categoryId
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
if (data.OK) {
|
||
this.toast(data.MESSAGE || 'Category deleted', 'success');
|
||
await this.loadTaskCategories();
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to delete', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error deleting category:', err);
|
||
this.toast('Error deleting category', 'error');
|
||
}
|
||
},
|
||
|
||
// Quick Task Templates
|
||
async loadQuickTaskTemplates() {
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/list.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.quickTaskTemplates = data.TEMPLATES || [];
|
||
this.renderQuickTaskTemplates();
|
||
} else {
|
||
document.getElementById('quickTasksGrid').innerHTML = '<div class="empty-state">Failed to load quick task templates</div>';
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error loading quick task templates:', err);
|
||
document.getElementById('quickTasksGrid').innerHTML = '<div class="empty-state">Error loading templates</div>';
|
||
}
|
||
},
|
||
|
||
renderQuickTaskTemplates() {
|
||
const gridContainer = document.getElementById('quickTasksGrid');
|
||
const manageContainer = document.getElementById('quickTasksManageList');
|
||
|
||
if (!this.quickTaskTemplates.length) {
|
||
gridContainer.innerHTML = '<div class="empty-state">No quick tasks yet. Click "+ Add Quick Task" to create one.</div>';
|
||
manageContainer.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
// Shortcut buttons grid
|
||
gridContainer.innerHTML = this.quickTaskTemplates.map(t => `
|
||
<button class="quick-task-btn" onclick="Portal.createQuickTask(${t.QuickTaskTemplateID})"
|
||
style="background:${t.Color || '#6366f1'};color:#fff;border:none;padding:16px;border-radius:8px;cursor:pointer;text-align:left;transition:transform 0.1s,box-shadow 0.1s;">
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||
${this.getServiceIconSvg(t.Icon || 'add_box')}
|
||
<strong>${this.escapeHtml(t.Name)}</strong>
|
||
</div>
|
||
<div style="font-size:12px;opacity:0.9;">${this.escapeHtml(t.Title)}</div>
|
||
</button>
|
||
`).join('');
|
||
|
||
// Management list
|
||
manageContainer.innerHTML = `
|
||
<h4 style="margin-bottom:12px;color:#666;">Manage Quick Tasks</h4>
|
||
<div class="list-group">
|
||
${this.quickTaskTemplates.map(t => `
|
||
<div class="list-group-item" style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-bottom:1px solid #eee;">
|
||
<div style="display:flex;align-items:center;gap:12px;">
|
||
<div style="width:8px;height:36px;background:${t.Color || '#6366f1'};border-radius:4px;"></div>
|
||
<div>
|
||
<strong>${this.escapeHtml(t.Name)}</strong>
|
||
<div style="color:#666;font-size:12px;">${this.escapeHtml(t.Title)}</div>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;">
|
||
<button class="btn btn-sm btn-secondary" onclick="Portal.editQuickTask(${t.QuickTaskTemplateID})">Edit</button>
|
||
<button class="btn btn-sm btn-danger" onclick="Portal.deleteQuickTask(${t.QuickTaskTemplateID})">Delete</button>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
},
|
||
|
||
showAddQuickTaskModal(templateId = null) {
|
||
const isEdit = templateId !== null;
|
||
const template = isEdit ? this.quickTaskTemplates.find(t => t.QuickTaskTemplateID === templateId) : {};
|
||
|
||
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Quick Task' : 'Add Quick Task';
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<form id="quickTaskForm" class="form">
|
||
<input type="hidden" id="quickTaskTemplateId" value="${templateId || ''}">
|
||
<input type="hidden" id="quickTaskCategory" value="${template.CategoryID || ''}">
|
||
<div class="form-group">
|
||
<label>Button Name</label>
|
||
<input type="text" id="quickTaskName" class="form-input" value="${this.escapeHtml(template.Name || '')}" required placeholder="e.g., Check Trash">
|
||
<small style="color:#666;">Name shown on the shortcut button</small>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Task Title</label>
|
||
<input type="text" id="quickTitle" class="form-input" value="${this.escapeHtml(template.Title || '')}" required placeholder="e.g., Check and empty trash bins">
|
||
<small style="color:#666;">Title shown on the created task</small>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Task Details (optional)</label>
|
||
<textarea id="quickDetails" class="form-textarea" rows="2" placeholder="Optional instructions">${this.escapeHtml(template.Details || '')}</textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Category</label>
|
||
<div id="quickTaskCategoryGrid" style="display:flex;flex-wrap:wrap;gap:8px;padding:8px 0;">
|
||
<div class="empty-state" style="padding:20px;color:#666;">Loading categories...</div>
|
||
</div>
|
||
<small style="color:#666;">Task will inherit the category's color</small>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Icon</label>
|
||
<div id="quickTaskIconPicker" style="display:flex;flex-wrap:wrap;gap:4px;max-height:150px;overflow-y:auto;padding:8px;border:1px solid #e5e7eb;border-radius:8px;background:#fafafa;">
|
||
${this.buildQuickTaskIconPicker(template.Icon || 'add_box', template.Color || '#6366f1')}
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
|
||
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
|
||
<button type="submit" class="btn btn-primary">${isEdit ? 'Save Changes' : 'Create'}</button>
|
||
</div>
|
||
</form>
|
||
`;
|
||
this.showModal();
|
||
|
||
// Load categories and render as clickable chips
|
||
this.loadTaskCategories().then(() => {
|
||
this.renderQuickTaskCategoryGrid(template.CategoryID);
|
||
});
|
||
|
||
document.getElementById('quickTaskForm').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
this.saveQuickTask();
|
||
});
|
||
},
|
||
|
||
renderQuickTaskCategoryGrid(selectedCategoryId) {
|
||
const container = document.getElementById('quickTaskCategoryGrid');
|
||
if (!container) return;
|
||
|
||
const categories = this._taskCategories || [];
|
||
if (!categories.length) {
|
||
container.innerHTML = '<div class="empty-state" style="padding:20px;color:#666;">No categories defined. Add some above first.</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = categories.map(c => {
|
||
const isSelected = selectedCategoryId == c.TaskCategoryID;
|
||
const color = c.Color || '#6366f1';
|
||
return `
|
||
<div class="quick-task-category-chip" data-id="${c.TaskCategoryID}" data-color="${color}"
|
||
style="display:inline-flex;align-items:center;gap:8px;padding:10px 14px;cursor:pointer;
|
||
border:3px solid ${isSelected ? color : '#e5e7eb'};border-radius:8px;
|
||
background:${isSelected ? color + '20' : '#fff'};transition:all 0.2s;">
|
||
<div style="width:16px;height:16px;border-radius:4px;background:${color};"></div>
|
||
<span style="font-size:13px;color:#333;font-weight:${isSelected ? '600' : '400'};">${this.escapeHtml(c.Name)}</span>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
// Add click handlers
|
||
container.querySelectorAll('.quick-task-category-chip').forEach(chip => {
|
||
chip.addEventListener('click', () => {
|
||
// Deselect all
|
||
container.querySelectorAll('.quick-task-category-chip').forEach(c => {
|
||
c.style.borderColor = '#e5e7eb';
|
||
c.style.background = '#fff';
|
||
c.querySelector('span').style.fontWeight = '400';
|
||
});
|
||
// Select this one
|
||
const color = chip.dataset.color;
|
||
chip.style.borderColor = color;
|
||
chip.style.background = color + '20';
|
||
chip.querySelector('span').style.fontWeight = '600';
|
||
// Update hidden field and icon colors
|
||
document.getElementById('quickTaskCategory').value = chip.dataset.id;
|
||
this.updateQuickTaskIconColors(color);
|
||
});
|
||
});
|
||
|
||
document.getElementById('quickTaskCategory').value = selectedCategoryId || '';
|
||
|
||
// Set up icon picker click handlers
|
||
this.setupQuickTaskIconHandlers();
|
||
},
|
||
|
||
buildQuickTaskIconPicker(selectedIcon = 'add_box', color = '#6366f1') {
|
||
return Object.entries(this.serviceIcons).map(([key, val]) => `
|
||
<label style="display:inline-flex;flex-direction:column;align-items:center;padding:6px;cursor:pointer;border:2px solid ${key === selectedIcon ? color : '#e5e7eb'};border-radius:8px;min-width:50px;">
|
||
<input type="radio" name="quickTaskIconRadio" value="${key}" ${key === selectedIcon ? 'checked' : ''} style="display:none;">
|
||
<div style="color:${key === selectedIcon ? color : '#999'};">${this.getServiceIconSvg(key)}</div>
|
||
<span style="font-size:9px;color:#666;margin-top:2px;text-align:center;max-width:48px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${val.label}</span>
|
||
</label>
|
||
`).join('');
|
||
},
|
||
|
||
setupQuickTaskIconHandlers() {
|
||
document.querySelectorAll('#quickTaskIconPicker label').forEach(label => {
|
||
label.addEventListener('click', () => {
|
||
const categoryChip = document.querySelector('.quick-task-category-chip[style*="font-weight: 600"]') ||
|
||
document.querySelector('.quick-task-category-chip[style*="font-weight:600"]');
|
||
const color = categoryChip?.dataset?.color || '#6366f1';
|
||
document.querySelectorAll('#quickTaskIconPicker label').forEach(l => {
|
||
l.style.borderColor = '#e5e7eb';
|
||
l.querySelector('div').style.color = '#999';
|
||
});
|
||
label.style.borderColor = color;
|
||
label.querySelector('div').style.color = color;
|
||
});
|
||
});
|
||
},
|
||
|
||
updateQuickTaskIconColors(color) {
|
||
const selectedRadio = document.querySelector('input[name="quickTaskIconRadio"]:checked');
|
||
document.querySelectorAll('#quickTaskIconPicker label').forEach(label => {
|
||
const radio = label.querySelector('input[type="radio"]');
|
||
const isSelected = radio === selectedRadio;
|
||
label.style.borderColor = isSelected ? color : '#e5e7eb';
|
||
label.querySelector('div').style.color = isSelected ? color : '#999';
|
||
});
|
||
},
|
||
|
||
editQuickTask(templateId) {
|
||
this.showAddQuickTaskModal(templateId);
|
||
},
|
||
|
||
async saveQuickTask() {
|
||
const id = document.getElementById('quickTaskTemplateId').value;
|
||
const categoryId = document.getElementById('quickTaskCategory')?.value;
|
||
|
||
// Validate category is selected
|
||
if (!categoryId) {
|
||
this.toast('Please select a category', 'error');
|
||
return;
|
||
}
|
||
|
||
// Get icon from radio button
|
||
const selectedIcon = document.querySelector('input[name="quickTaskIconRadio"]:checked')?.value || 'add_box';
|
||
|
||
// Get color from selected category chip
|
||
const selectedChip = document.querySelector('.quick-task-category-chip[data-id="' + categoryId + '"]');
|
||
const color = selectedChip?.dataset?.color || '#6366f1';
|
||
|
||
const payload = {
|
||
BusinessID: this.config.businessId,
|
||
Name: document.getElementById('quickTaskName').value,
|
||
Title: document.getElementById('quickTitle').value,
|
||
Details: document.getElementById('quickDetails').value,
|
||
CategoryID: categoryId || null,
|
||
Icon: selectedIcon,
|
||
Color: color
|
||
};
|
||
|
||
if (id) payload.QuickTaskTemplateID = parseInt(id);
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/save.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast('Quick task saved!', 'success');
|
||
this.closeModal();
|
||
await this.loadQuickTaskTemplates();
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to save', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error saving quick task:', err);
|
||
this.toast('Error saving quick task', 'error');
|
||
}
|
||
},
|
||
|
||
async deleteQuickTask(templateId) {
|
||
if (!confirm('Delete this quick task?')) return;
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/delete.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
BusinessID: this.config.businessId,
|
||
QuickTaskTemplateID: templateId
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast('Quick task deleted', 'success');
|
||
await this.loadQuickTaskTemplates();
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to delete', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error deleting quick task:', err);
|
||
this.toast('Error deleting quick task', 'error');
|
||
}
|
||
},
|
||
|
||
async createQuickTask(templateId) {
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/create.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
BusinessID: this.config.businessId,
|
||
QuickTaskTemplateID: templateId
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast('Task created!', 'success');
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to create task', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error creating quick task:', err);
|
||
this.toast('Error creating task', 'error');
|
||
}
|
||
},
|
||
|
||
// Scheduled Tasks
|
||
async loadScheduledTasks() {
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/list.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.scheduledTasks = data.SCHEDULED_TASKS || [];
|
||
this.renderScheduledTasks();
|
||
} else {
|
||
document.getElementById('scheduledTasksList').innerHTML = '<div class="empty-state">Failed to load scheduled tasks</div>';
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error loading scheduled tasks:', err);
|
||
document.getElementById('scheduledTasksList').innerHTML = '<div class="empty-state">Error loading scheduled tasks</div>';
|
||
}
|
||
},
|
||
|
||
renderScheduledTasks() {
|
||
const container = document.getElementById('scheduledTasksList');
|
||
if (!this.scheduledTasks.length) {
|
||
container.innerHTML = '<div class="empty-state">No scheduled tasks configured. Click "+ Add Scheduled Task" to create one.</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = this.scheduledTasks.map(s => `
|
||
<div class="list-group-item" style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-bottom:1px solid #eee;">
|
||
<div style="flex:1;">
|
||
<div style="display:flex;align-items:center;gap:8px;">
|
||
<span class="status-badge ${s.IsActive ? 'active' : 'inactive'}">${s.IsActive ? 'Active' : 'Paused'}</span>
|
||
<strong>${this.escapeHtml(s.Name)}</strong>
|
||
</div>
|
||
<div style="color:#666;font-size:12px;margin-top:4px;">
|
||
${this.escapeHtml(s.Title)} | Schedule: <code>${this.formatScheduleDisplay(s)}</code>
|
||
</div>
|
||
${s.NextRunOn ? `<div style="color:#999;font-size:11px;">Next run: ${s.NextRunOn}</div>` : ''}
|
||
${s.LastRunOn ? `<div style="color:#999;font-size:11px;">Last run: ${s.LastRunOn}</div>` : ''}
|
||
</div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||
<button class="btn btn-sm btn-secondary" onclick="Portal.runScheduledTaskNow(${s.ScheduledTaskID})" title="Run Now">
|
||
Run Now
|
||
</button>
|
||
<button class="btn btn-sm btn-secondary" onclick="Portal.toggleScheduledTask(${s.ScheduledTaskID}, ${!s.IsActive})">
|
||
${s.IsActive ? 'Pause' : 'Enable'}
|
||
</button>
|
||
<button class="btn btn-sm btn-secondary" onclick="Portal.editScheduledTask(${s.ScheduledTaskID})">Edit</button>
|
||
<button class="btn btn-sm btn-danger" onclick="Portal.deleteScheduledTask(${s.ScheduledTaskID})">Delete</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
},
|
||
|
||
// Format schedule for display in list
|
||
formatScheduleDisplay(task) {
|
||
if (task.ScheduleType === 'interval' && task.IntervalMinutes) {
|
||
const mins = parseInt(task.IntervalMinutes);
|
||
if (mins >= 60 && mins % 60 === 0) {
|
||
const hours = mins / 60;
|
||
return `Every ${hours} hour${hours === 1 ? '' : 's'}`;
|
||
} else {
|
||
return `Every ${mins} minute${mins === 1 ? '' : 's'}`;
|
||
}
|
||
}
|
||
return this.escapeHtml(task.CronExpression);
|
||
},
|
||
|
||
// Parse task schedule into friendly values
|
||
parseCronToFriendly(task) {
|
||
// Check if this is interval-based scheduling
|
||
const isIntervalType = task.ScheduleType === 'interval' || task.ScheduleType === 'interval_after_completion';
|
||
if (isIntervalType && task.IntervalMinutes) {
|
||
const intervalMins = parseInt(task.IntervalMinutes);
|
||
const isAfterCompletion = task.ScheduleType === 'interval_after_completion';
|
||
if (intervalMins >= 60 && intervalMins % 60 === 0) {
|
||
// Express as hours
|
||
return {
|
||
hour: 9, minute: 0, frequency: 'interval',
|
||
selectedDays: [], monthDay: 1, intervalValue: intervalMins / 60,
|
||
intervalUnit: 'hours', intervalMode: isAfterCompletion ? 'after_completion' : 'fixed'
|
||
};
|
||
} else {
|
||
// Express as minutes
|
||
return {
|
||
hour: 9, minute: 0, frequency: 'interval',
|
||
selectedDays: [], monthDay: 1, intervalValue: intervalMins,
|
||
intervalUnit: 'minutes', intervalMode: isAfterCompletion ? 'after_completion' : 'fixed'
|
||
};
|
||
}
|
||
}
|
||
|
||
// Cron-based scheduling
|
||
const cron = task.CronExpression || task;
|
||
const parts = (typeof cron === 'string' ? cron : '0 9 * * *').split(' ');
|
||
const minute = parseInt(parts[0]) || 0;
|
||
const hour = parseInt(parts[1]) || 9;
|
||
const dayOfMonth = parts[2] || '*';
|
||
const month = parts[3] || '*';
|
||
const dayOfWeek = parts[4] || '*';
|
||
|
||
let frequency = 'daily';
|
||
let selectedDays = [];
|
||
let monthDay = 1;
|
||
|
||
if (dayOfMonth !== '*' && !isNaN(parseInt(dayOfMonth))) {
|
||
frequency = 'monthly';
|
||
monthDay = parseInt(dayOfMonth);
|
||
} else if (dayOfWeek === '1-5') {
|
||
frequency = 'weekdays';
|
||
} else if (dayOfWeek !== '*') {
|
||
frequency = 'weekly';
|
||
// Parse days: could be "1,3,5" or "1-5" etc.
|
||
if (dayOfWeek.includes(',')) {
|
||
selectedDays = dayOfWeek.split(',').map(d => parseInt(d));
|
||
} else if (dayOfWeek.includes('-')) {
|
||
const [start, end] = dayOfWeek.split('-').map(d => parseInt(d));
|
||
for (let i = start; i <= end; i++) selectedDays.push(i);
|
||
} else {
|
||
selectedDays = [parseInt(dayOfWeek)];
|
||
}
|
||
}
|
||
|
||
return { hour, minute, frequency, selectedDays, monthDay, intervalValue: 15, intervalUnit: 'minutes', intervalMode: 'fixed' };
|
||
},
|
||
|
||
// Build cron expression from friendly values
|
||
buildCronFromFriendly() {
|
||
const frequency = document.getElementById('scheduleFrequency').value;
|
||
const hour = document.getElementById('scheduleHour').value;
|
||
const minute = document.getElementById('scheduleMinute').value;
|
||
|
||
let dayOfMonth = '*';
|
||
let dayOfWeek = '*';
|
||
|
||
if (frequency === 'weekdays') {
|
||
dayOfWeek = '1-5';
|
||
} else if (frequency === 'weekly') {
|
||
const checkedDays = [...document.querySelectorAll('.day-btn.selected')].map(btn => btn.dataset.day);
|
||
dayOfWeek = checkedDays.length > 0 ? checkedDays.join(',') : '*';
|
||
} else if (frequency === 'monthly') {
|
||
dayOfMonth = document.getElementById('scheduleMonthDay').value;
|
||
} else if (frequency === 'custom') {
|
||
return document.getElementById('scheduledTaskCron').value;
|
||
}
|
||
|
||
return `${minute} ${hour} ${dayOfMonth} * ${dayOfWeek}`;
|
||
},
|
||
|
||
showAddScheduledTaskModal(taskId = null) {
|
||
const isEdit = taskId !== null;
|
||
const task = isEdit ? this.scheduledTasks.find(t => t.ScheduledTaskID === taskId) : {};
|
||
|
||
// Parse existing schedule or use defaults
|
||
const schedule = this.parseCronToFriendly(task);
|
||
|
||
// Build category options
|
||
const categoryOptions = (this._taskCategories || []).map(c =>
|
||
`<option value="${c.TaskCategoryID}" ${task.CategoryID == c.TaskCategoryID ? 'selected' : ''}>${this.escapeHtml(c.Name)}</option>`
|
||
).join('');
|
||
|
||
// Build hour options (12-hour format display, 24-hour value)
|
||
const hourOptions = Array.from({length: 24}, (_, i) => {
|
||
const displayHour = i === 0 ? 12 : (i > 12 ? i - 12 : i);
|
||
const ampm = i < 12 ? 'AM' : 'PM';
|
||
return `<option value="${i}" ${schedule.hour === i ? 'selected' : ''}>${displayHour} ${ampm}</option>`;
|
||
}).join('');
|
||
|
||
// Build minute options (every 5 minutes)
|
||
const minuteOptions = Array.from({length: 12}, (_, i) => {
|
||
const min = i * 5;
|
||
return `<option value="${min}" ${schedule.minute === min ? 'selected' : ''}>${min.toString().padStart(2, '0')}</option>`;
|
||
}).join('');
|
||
|
||
// Build day of month options
|
||
const monthDayOptions = Array.from({length: 31}, (_, i) => {
|
||
const day = i + 1;
|
||
const suffix = day === 1 || day === 21 || day === 31 ? 'st' : (day === 2 || day === 22 ? 'nd' : (day === 3 || day === 23 ? 'rd' : 'th'));
|
||
return `<option value="${day}" ${schedule.monthDay === day ? 'selected' : ''}>${day}${suffix}</option>`;
|
||
}).join('');
|
||
|
||
// Day buttons for weekly
|
||
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||
const dayButtons = dayNames.map((name, i) => {
|
||
const isSelected = schedule.selectedDays.includes(i);
|
||
return `<button type="button" class="day-btn ${isSelected ? 'selected' : ''}" data-day="${i}">${name}</button>`;
|
||
}).join('');
|
||
|
||
document.getElementById('modalTitle').textContent = isEdit ? 'Edit Scheduled Task' : 'Add Scheduled Task';
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<form id="scheduledTaskForm" class="form">
|
||
<input type="hidden" id="scheduledTaskId" value="${taskId || ''}">
|
||
<input type="hidden" id="scheduledTaskCron" value="${task.CronExpression || '0 9 * * *'}">
|
||
|
||
<div class="form-group">
|
||
<label>Task Title</label>
|
||
<input type="text" id="scheduledTitle" class="form-input" value="${this.escapeHtml(task.Title || '')}" required placeholder="e.g., Check all garbage cans">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Task Details (optional)</label>
|
||
<textarea id="scheduledDetails" class="form-textarea" rows="2" placeholder="Optional instructions">${this.escapeHtml(task.Details || '')}</textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Category</label>
|
||
<select id="scheduledTaskCategory" class="form-input" required>
|
||
<option value="">Select a category...</option>
|
||
${categoryOptions}
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Repeat</label>
|
||
<select id="scheduleFrequency" class="form-input" onchange="Portal.onScheduleFrequencyChange()">
|
||
<option value="interval" ${schedule.frequency === 'interval' || schedule.frequency === 'interval_minutes' || schedule.frequency === 'interval_hours' ? 'selected' : ''}>Time Interval</option>
|
||
<option value="daily" ${schedule.frequency === 'daily' ? 'selected' : ''}>Every Day</option>
|
||
<option value="weekdays" ${schedule.frequency === 'weekdays' ? 'selected' : ''}>Weekdays (Mon-Fri)</option>
|
||
<option value="weekly" ${schedule.frequency === 'weekly' ? 'selected' : ''}>Specific Days</option>
|
||
<option value="monthly" ${schedule.frequency === 'monthly' ? 'selected' : ''}>Monthly</option>
|
||
<option value="custom" ${schedule.frequency === 'custom' ? 'selected' : ''}>Custom (Advanced)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div id="intervalContainer" class="form-group" style="display:${schedule.frequency === 'interval' || schedule.frequency === 'interval_minutes' || schedule.frequency === 'interval_hours' ? 'block' : 'none'};">
|
||
<label>Repeat every</label>
|
||
<div class="interval-presets" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px;">
|
||
<button type="button" class="interval-preset-btn" data-minutes="5">5 min</button>
|
||
<button type="button" class="interval-preset-btn" data-minutes="10">10 min</button>
|
||
<button type="button" class="interval-preset-btn" data-minutes="15">15 min</button>
|
||
<button type="button" class="interval-preset-btn" data-minutes="30">30 min</button>
|
||
<button type="button" class="interval-preset-btn" data-minutes="60">1 hour</button>
|
||
<button type="button" class="interval-preset-btn" data-minutes="120">2 hours</button>
|
||
<button type="button" class="interval-preset-btn" data-minutes="240">4 hours</button>
|
||
</div>
|
||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:12px;">
|
||
<span style="color:#666;">Or custom:</span>
|
||
<input type="number" id="scheduleInterval" class="form-input" style="width:80px;" min="1" max="1440" value="${schedule.intervalValue || 15}">
|
||
<select id="intervalUnit" class="form-input" style="width:auto;">
|
||
<option value="minutes" ${schedule.intervalUnit !== 'hours' ? 'selected' : ''}>minutes</option>
|
||
<option value="hours" ${schedule.intervalUnit === 'hours' ? 'selected' : ''}>hours</option>
|
||
</select>
|
||
</div>
|
||
<label style="margin-top:8px;">Interval starts</label>
|
||
<select id="intervalMode" class="form-input" onchange="Portal.updateSchedulePreview()">
|
||
<option value="fixed" ${schedule.intervalMode !== 'after_completion' ? 'selected' : ''}>From when task is enabled (fixed schedule)</option>
|
||
<option value="after_completion" ${schedule.intervalMode === 'after_completion' ? 'selected' : ''}>After previous task is completed</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div id="weeklyDaysContainer" class="form-group" style="display:${schedule.frequency === 'weekly' ? 'block' : 'none'};">
|
||
<label>On these days</label>
|
||
<div class="day-buttons">${dayButtons}</div>
|
||
</div>
|
||
|
||
<div id="monthlyDayContainer" class="form-group" style="display:${schedule.frequency === 'monthly' ? 'block' : 'none'};">
|
||
<label>Day of Month</label>
|
||
<select id="scheduleMonthDay" class="form-input">${monthDayOptions}</select>
|
||
</div>
|
||
|
||
<div id="customCronContainer" class="form-group" style="display:${schedule.frequency === 'custom' ? 'block' : 'none'};">
|
||
<label>Cron Expression</label>
|
||
<input type="text" id="customCronInput" class="form-input" value="${task.CronExpression || '0 9 * * *'}" placeholder="0 9 * * *">
|
||
<small style="color:#666;">Format: minute hour day month weekday</small>
|
||
</div>
|
||
|
||
<div id="timePickerContainer" class="form-group" style="display:${schedule.frequency === 'interval_minutes' || schedule.frequency === 'interval_hours' || schedule.frequency === 'custom' ? 'none' : 'block'};">
|
||
<label>At</label>
|
||
<div style="display:flex;gap:8px;align-items:center;">
|
||
<select id="scheduleHour" class="form-input" style="width:auto;">${hourOptions}</select>
|
||
<span>:</span>
|
||
<select id="scheduleMinute" class="form-input" style="width:auto;">${minuteOptions}</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="schedulePreview" class="schedule-preview" style="background:#f0f4ff;padding:12px;border-radius:8px;margin:16px 0;">
|
||
<span style="color:#666;">This task will run:</span>
|
||
<strong id="schedulePreviewText" style="display:block;margin-top:4px;color:#4f46e5;"></strong>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Internal Name (optional)</label>
|
||
<input type="text" id="scheduledTaskName" class="form-input" value="${this.escapeHtml(task.Name || '')}" placeholder="e.g., garbage-check-daily">
|
||
<small style="color:#666;">Used for tracking; auto-generated if blank</small>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label><input type="checkbox" id="scheduledTaskActive" ${task.IsActive !== false ? 'checked' : ''}> Active</label>
|
||
</div>
|
||
|
||
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
|
||
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
|
||
<button type="submit" class="btn btn-primary">${isEdit ? 'Save Changes' : 'Create'}</button>
|
||
</div>
|
||
</form>
|
||
|
||
<style>
|
||
.day-buttons { display:flex; gap:4px; flex-wrap:wrap; }
|
||
.day-btn {
|
||
padding:8px 12px; border:2px solid #ddd; background:#fff; border-radius:8px;
|
||
cursor:pointer; font-weight:500; transition:all 0.2s;
|
||
}
|
||
.day-btn:hover { border-color:#4f46e5; }
|
||
.day-btn.selected { background:#4f46e5; color:#fff; border-color:#4f46e5; }
|
||
.interval-preset-btn {
|
||
padding:8px 14px; border:2px solid #ddd; background:#fff; border-radius:8px;
|
||
cursor:pointer; font-weight:500; transition:all 0.2s; font-size:14px;
|
||
}
|
||
.interval-preset-btn:hover { border-color:#4f46e5; background:#f0f4ff; }
|
||
.interval-preset-btn.selected { background:#4f46e5; color:#fff; border-color:#4f46e5; }
|
||
</style>
|
||
`;
|
||
this.showModal();
|
||
|
||
// Set up day button click handlers
|
||
document.querySelectorAll('.day-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
btn.classList.toggle('selected');
|
||
this.updateSchedulePreview();
|
||
});
|
||
});
|
||
|
||
// Set up interval preset button click handlers
|
||
document.querySelectorAll('.interval-preset-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const minutes = parseInt(btn.dataset.minutes);
|
||
// Clear all preset selections
|
||
document.querySelectorAll('.interval-preset-btn').forEach(b => b.classList.remove('selected'));
|
||
btn.classList.add('selected');
|
||
// Update the custom input to match
|
||
if (minutes >= 60 && minutes % 60 === 0) {
|
||
document.getElementById('scheduleInterval').value = minutes / 60;
|
||
document.getElementById('intervalUnit').value = 'hours';
|
||
} else {
|
||
document.getElementById('scheduleInterval').value = minutes;
|
||
document.getElementById('intervalUnit').value = 'minutes';
|
||
}
|
||
this.updateSchedulePreview();
|
||
});
|
||
});
|
||
|
||
// Set up change listeners for preview
|
||
['scheduleFrequency', 'scheduleHour', 'scheduleMinute', 'scheduleMonthDay', 'scheduleInterval', 'intervalUnit'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener('change', () => {
|
||
// Clear preset selection when custom value changes
|
||
if (id === 'scheduleInterval' || id === 'intervalUnit') {
|
||
document.querySelectorAll('.interval-preset-btn').forEach(b => b.classList.remove('selected'));
|
||
}
|
||
this.updateSchedulePreview();
|
||
});
|
||
});
|
||
// Also listen for input event on the interval number field
|
||
const intervalInput = document.getElementById('scheduleInterval');
|
||
if (intervalInput) intervalInput.addEventListener('input', () => {
|
||
document.querySelectorAll('.interval-preset-btn').forEach(b => b.classList.remove('selected'));
|
||
this.updateSchedulePreview();
|
||
});
|
||
|
||
// Initial preview update
|
||
this.updateSchedulePreview();
|
||
|
||
// Load categories if not loaded
|
||
if (!this._taskCategories) {
|
||
this.loadTaskCategories();
|
||
}
|
||
|
||
document.getElementById('scheduledTaskForm').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
this.saveScheduledTask();
|
||
});
|
||
},
|
||
|
||
onScheduleFrequencyChange() {
|
||
const freq = document.getElementById('scheduleFrequency').value;
|
||
const isInterval = freq === 'interval';
|
||
|
||
document.getElementById('intervalContainer').style.display = isInterval ? 'block' : 'none';
|
||
document.getElementById('weeklyDaysContainer').style.display = freq === 'weekly' ? 'block' : 'none';
|
||
document.getElementById('monthlyDayContainer').style.display = freq === 'monthly' ? 'block' : 'none';
|
||
document.getElementById('customCronContainer').style.display = freq === 'custom' ? 'block' : 'none';
|
||
document.getElementById('timePickerContainer').style.display = (isInterval || freq === 'custom') ? 'none' : 'block';
|
||
document.getElementById('schedulePreview').style.display = freq === 'custom' ? 'none' : 'block';
|
||
|
||
this.updateSchedulePreview();
|
||
},
|
||
|
||
updateSchedulePreview() {
|
||
const freq = document.getElementById('scheduleFrequency').value;
|
||
const hour = parseInt(document.getElementById('scheduleHour').value);
|
||
const minute = parseInt(document.getElementById('scheduleMinute').value);
|
||
|
||
const displayHour = hour === 0 ? 12 : (hour > 12 ? hour - 12 : hour);
|
||
const ampm = hour < 12 ? 'AM' : 'PM';
|
||
const timeStr = `${displayHour}:${minute.toString().padStart(2, '0')} ${ampm}`;
|
||
|
||
let preview = '';
|
||
if (freq === 'interval' || freq === 'interval_minutes' || freq === 'interval_hours') {
|
||
const intervalVal = parseInt(document.getElementById('scheduleInterval').value) || 15;
|
||
const unit = document.getElementById('intervalUnit').value;
|
||
const unitLabel = unit === 'hours' ? (intervalVal === 1 ? 'hour' : 'hours') : (intervalVal === 1 ? 'minute' : 'minutes');
|
||
const intervalMode = document.getElementById('intervalMode').value;
|
||
if (intervalMode === 'after_completion') {
|
||
preview = `${intervalVal} ${unitLabel} after each task is completed`;
|
||
} else {
|
||
preview = `Every ${intervalVal} ${unitLabel} (continuous)`;
|
||
}
|
||
} else if (freq === 'daily') {
|
||
preview = `Every day at ${timeStr}`;
|
||
} else if (freq === 'weekdays') {
|
||
preview = `Monday through Friday at ${timeStr}`;
|
||
} else if (freq === 'weekly') {
|
||
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||
const selectedDays = [...document.querySelectorAll('.day-btn.selected')].map(btn => dayNames[btn.dataset.day]);
|
||
if (selectedDays.length === 0) {
|
||
preview = `Select at least one day`;
|
||
} else {
|
||
preview = `Every ${selectedDays.join(', ')} at ${timeStr}`;
|
||
}
|
||
} else if (freq === 'monthly') {
|
||
const day = document.getElementById('scheduleMonthDay').value;
|
||
const suffix = day == 1 || day == 21 || day == 31 ? 'st' : (day == 2 || day == 22 ? 'nd' : (day == 3 || day == 23 ? 'rd' : 'th'));
|
||
preview = `Every month on the ${day}${suffix} at ${timeStr}`;
|
||
}
|
||
|
||
document.getElementById('schedulePreviewText').textContent = preview;
|
||
},
|
||
|
||
editScheduledTask(taskId) {
|
||
this.showAddScheduledTaskModal(taskId);
|
||
},
|
||
|
||
async saveScheduledTask() {
|
||
const id = document.getElementById('scheduledTaskId').value;
|
||
const freq = document.getElementById('scheduleFrequency').value;
|
||
const isInterval = freq === 'interval' || freq === 'interval_minutes' || freq === 'interval_hours';
|
||
|
||
// Build cron expression from friendly UI (for non-interval types)
|
||
let cronExpression = '* * * * *'; // Placeholder for interval type
|
||
let scheduleType = 'cron';
|
||
let intervalMinutes = null;
|
||
|
||
if (isInterval) {
|
||
const intervalMode = document.getElementById('intervalMode').value;
|
||
scheduleType = intervalMode === 'after_completion' ? 'interval_after_completion' : 'interval';
|
||
const intervalVal = parseInt(document.getElementById('scheduleInterval').value) || 1;
|
||
const unit = document.getElementById('intervalUnit').value;
|
||
if (unit === 'hours') {
|
||
intervalMinutes = intervalVal * 60;
|
||
} else {
|
||
intervalMinutes = intervalVal;
|
||
}
|
||
// Validate interval
|
||
if (intervalMinutes < 1) {
|
||
this.toast('Interval must be at least 1 minute', 'error');
|
||
return;
|
||
}
|
||
} else if (freq === 'custom') {
|
||
cronExpression = document.getElementById('customCronInput').value;
|
||
} else {
|
||
cronExpression = this.buildCronFromFriendly();
|
||
}
|
||
|
||
// Validate weekly has at least one day selected
|
||
if (freq === 'weekly') {
|
||
const selectedDays = document.querySelectorAll('.day-btn.selected');
|
||
if (selectedDays.length === 0) {
|
||
this.toast('Please select at least one day', 'error');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Auto-generate name if not provided
|
||
let name = document.getElementById('scheduledTaskName').value.trim();
|
||
if (!name) {
|
||
const title = document.getElementById('scheduledTitle').value.trim();
|
||
name = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 50);
|
||
}
|
||
|
||
const payload = {
|
||
BusinessID: this.config.businessId,
|
||
Name: name,
|
||
Title: document.getElementById('scheduledTitle').value,
|
||
Details: document.getElementById('scheduledDetails').value,
|
||
CategoryID: document.getElementById('scheduledTaskCategory').value || null,
|
||
CronExpression: cronExpression,
|
||
ScheduleType: scheduleType,
|
||
IntervalMinutes: intervalMinutes,
|
||
IsActive: document.getElementById('scheduledTaskActive').checked
|
||
};
|
||
|
||
if (id) payload.ScheduledTaskID = parseInt(id);
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/save.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast(`Scheduled task saved! Next run: ${data.NEXT_RUN}`, 'success');
|
||
this.closeModal();
|
||
await this.loadScheduledTasks();
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to save', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error saving scheduled task:', err);
|
||
this.toast('Error saving scheduled task', 'error');
|
||
}
|
||
},
|
||
|
||
async deleteScheduledTask(taskId) {
|
||
if (!confirm('Delete this scheduled task?')) return;
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/delete.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
BusinessID: this.config.businessId,
|
||
ScheduledTaskID: taskId
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast('Scheduled task deleted', 'success');
|
||
await this.loadScheduledTasks();
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to delete', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error deleting scheduled task:', err);
|
||
this.toast('Error deleting scheduled task', 'error');
|
||
}
|
||
},
|
||
|
||
async toggleScheduledTask(taskId, isActive) {
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/toggle.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
BusinessID: this.config.businessId,
|
||
ScheduledTaskID: taskId,
|
||
IsActive: isActive
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast(isActive ? 'Scheduled task enabled' : 'Scheduled task paused', 'success');
|
||
await this.loadScheduledTasks();
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to toggle', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error toggling scheduled task:', err);
|
||
this.toast('Error toggling scheduled task', 'error');
|
||
}
|
||
},
|
||
|
||
async runScheduledTaskNow(taskId) {
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/admin/scheduledTasks/run.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
BusinessID: this.config.businessId,
|
||
ScheduledTaskID: taskId
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast(`Task #${data.TASK_ID} created!`, 'success');
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to run task', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error running scheduled task:', err);
|
||
this.toast('Error running scheduled task', 'error');
|
||
}
|
||
},
|
||
|
||
// Worker Ratings
|
||
pendingRatings: [],
|
||
|
||
async loadPendingRatings() {
|
||
const container = document.getElementById('pendingRatingsList');
|
||
if (!container) return;
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/ratings/listForAdmin.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ BusinessID: this.config.businessId })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.pendingRatings = data.TASKS || [];
|
||
this.renderPendingRatings();
|
||
} else {
|
||
container.innerHTML = '<div class="empty-state">Failed to load ratings</div>';
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error loading pending ratings:', err);
|
||
container.innerHTML = '<div class="empty-state">Error loading ratings</div>';
|
||
}
|
||
},
|
||
|
||
renderPendingRatings() {
|
||
const container = document.getElementById('pendingRatingsList');
|
||
if (!this.pendingRatings.length) {
|
||
container.innerHTML = '<div class="empty-state">No completed tasks to rate from the last 7 days.</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = this.pendingRatings.map(t => `
|
||
<div class="list-group-item" style="display:flex;justify-content:space-between;align-items:center;padding:12px;border-bottom:1px solid #eee;">
|
||
<div style="flex:1;">
|
||
<div style="display:flex;align-items:center;gap:8px;">
|
||
<strong>${this.escapeHtml(t.Title)}</strong>
|
||
</div>
|
||
<div style="color:#666;font-size:12px;margin-top:4px;">
|
||
Worker: <strong>${this.escapeHtml(t.WorkerName)}</strong>
|
||
${t.CustomerName ? ` | Customer: ${this.escapeHtml(t.CustomerName)}` : ''}
|
||
${t.Name ? ` | ${this.escapeHtml(t.Name)}` : ''}
|
||
</div>
|
||
<div style="color:#999;font-size:11px;margin-top:2px;">
|
||
Completed: ${t.CompletedOn}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<button class="btn btn-sm btn-primary" onclick="Portal.showRateWorkerModal(${t.ID}, '${this.escapeHtml(t.WorkerName)}', '${this.escapeHtml(t.Title)}')">
|
||
Rate Worker
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
},
|
||
|
||
showRateWorkerModal(taskId, workerName, taskTitle) {
|
||
document.getElementById('modalTitle').textContent = 'Rate Worker Performance';
|
||
document.getElementById('modalBody').innerHTML = `
|
||
<form id="rateWorkerForm" class="form">
|
||
<input type="hidden" id="ratingTaskId" value="${taskId}">
|
||
<p style="margin-bottom:16px;">
|
||
Rating <strong>${this.escapeHtml(workerName)}</strong> for: <em>${this.escapeHtml(taskTitle)}</em>
|
||
</p>
|
||
|
||
<div class="form-group" style="margin-bottom:16px;">
|
||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:8px;border:1px solid #ddd;border-radius:6px;margin-bottom:8px;">
|
||
<input type="checkbox" id="ratingOnTime" checked>
|
||
<span>Was the worker on time?</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-bottom:16px;">
|
||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:8px;border:1px solid #ddd;border-radius:6px;margin-bottom:8px;">
|
||
<input type="checkbox" id="ratingCompletedScope" checked>
|
||
<span>Was the scope completed?</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-bottom:16px;">
|
||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:8px;border:1px solid #ddd;border-radius:6px;margin-bottom:8px;">
|
||
<input type="checkbox" id="ratingRequiredFollowup">
|
||
<span>Was follow-up required?</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-bottom:16px;">
|
||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:8px;border:1px solid #ddd;border-radius:6px;margin-bottom:8px;">
|
||
<input type="checkbox" id="ratingContinueAllow" checked>
|
||
<span>Continue to allow these tasks?</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div style="display:flex;gap:12px;justify-content:flex-end;margin-top:20px;">
|
||
<button type="button" class="btn btn-secondary" onclick="Portal.closeModal()">Cancel</button>
|
||
<button type="submit" class="btn btn-primary">Submit Rating</button>
|
||
</div>
|
||
</form>
|
||
`;
|
||
this.showModal();
|
||
|
||
document.getElementById('rateWorkerForm').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
this.submitWorkerRating();
|
||
});
|
||
},
|
||
|
||
async submitWorkerRating() {
|
||
const taskId = parseInt(document.getElementById('ratingTaskId').value);
|
||
const onTime = document.getElementById('ratingOnTime').checked;
|
||
const completedScope = document.getElementById('ratingCompletedScope').checked;
|
||
const requiredFollowup = document.getElementById('ratingRequiredFollowup').checked;
|
||
const continueAllow = document.getElementById('ratingContinueAllow').checked;
|
||
|
||
try {
|
||
const response = await fetch(`${this.config.apiBaseUrl}/ratings/createAdminRating.cfm`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
TaskID: taskId,
|
||
AdminUserID: this.config.userId,
|
||
onTime: onTime,
|
||
completedScope: completedScope,
|
||
requiredFollowup: requiredFollowup,
|
||
continueAllow: continueAllow
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.OK) {
|
||
this.toast('Rating submitted successfully!', 'success');
|
||
this.closeModal();
|
||
await this.loadPendingRatings();
|
||
} else {
|
||
this.toast(data.MESSAGE || 'Failed to submit rating', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('[Portal] Error submitting rating:', err);
|
||
this.toast('Error submitting rating', 'error');
|
||
}
|
||
},
|
||
|
||
// ========== SP SHARING ==========
|
||
|
||
_spSharingSelectedBizID: 0,
|
||
_spSharingSearchTimer: null,
|
||
|
||
/* COMMENTED OUT FOR LAUNCH - SP Marketing / Grants (coming soon)
|
||
async loadSPSharingPage() { ... },
|
||
showInviteBusinessModal() { ... },
|
||
closeInviteModal() { ... },
|
||
toggleEconValue() { ... },
|
||
async searchBusinessForInvite() { ... },
|
||
selectInviteBiz(bizID, name) { ... },
|
||
async submitGrantInvite() { ... },
|
||
async revokeGrant(grantID) { ... },
|
||
async acceptGrant(grantID) { ... },
|
||
async declineGrant(grantID) { ... }
|
||
*/
|
||
};
|
||
|
||
// Initialize on load
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
Portal.init();
|
||
});
|