From 51e979a67998d6c2938986694dcfe7e3f66f30b5 Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Tue, 27 Jan 2026 21:32:47 -0800 Subject: [PATCH] Remove URL params - use localStorage for auth - HUD reads businessId from localStorage instead of ?b= param - Portal opens HUD/quick-tasks without URL params - Business select auto-proceeds on selection (no button needed) - Quick tasks reads from payfrit_portal_business localStorage Co-Authored-By: Claude Opus 4.5 --- hud/hud.js | 2 +- portal/login.html | 10 +- portal/portal.js | 989 ++++++++++++++++++++++++++++++++++++---- portal/quick-tasks.html | 383 ++++++++++++++++ 4 files changed, 1278 insertions(+), 106 deletions(-) create mode 100644 portal/quick-tasks.html diff --git a/hud/hud.js b/hud/hud.js index 1da5ea1..70b1bac 100644 --- a/hud/hud.js +++ b/hud/hud.js @@ -12,7 +12,7 @@ const HUD = { pollInterval: 1000, // Poll every second for smooth animation targetSeconds: 60, // Target time to accept a task apiBaseUrl: '/api/tasks', // API endpoint - businessId: parseInt(new URLSearchParams(window.location.search).get('b')) || 47, + businessId: parseInt(localStorage.getItem('payfrit_portal_business')) || 0, }, // State diff --git a/portal/login.html b/portal/login.html index dc4bbc1..ec5c4e9 100644 --- a/portal/login.html +++ b/portal/login.html @@ -182,7 +182,7 @@ @@ -210,16 +210,14 @@
-
diff --git a/portal/portal.js b/portal/portal.js index 1ae5bd9..cec20b3 100644 --- a/portal/portal.js +++ b/portal/portal.js @@ -167,9 +167,14 @@ const Portal = { this.navigate(hash); }, - // Open HUD in new window with business ID + // Open HUD in new window (uses localStorage for business ID) openHUD() { - window.open('/hud/index.html?b=' + this.config.businessId, '_blank'); + 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 @@ -1986,7 +1991,10 @@ const Portal = { async loadServicesPage() { console.log('[Portal] Loading services page...'); - await this.loadServiceTypes(); + await Promise.all([ + this.loadServiceTypes(), + this.loadTaskCategories() + ]); }, // Available icons for services (matches Flutter TaskType._iconMap) @@ -2230,11 +2238,12 @@ const Portal = { // Preset color palette (GIF-safe colors) colorPalette: [ - '#FF0000', '#FF4500', '#FF6600', '#FF9900', '#FFCC00', // Reds to Yellows - '#FFFF00', '#CCFF00', '#99FF00', '#66FF00', '#33FF00', // Yellows to Greens - '#00FF00', '#00FF66', '#00FF99', '#00FFCC', '#00FFFF', // Greens to Cyans - '#00CCFF', '#0099FF', '#0066FF', '#0033FF', '#0000FF', // Cyans to Blues - '#6600FF', '#9900FF', '#CC00FF', '#FF00FF', '#FF0099', // Blues to Magentas + // 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') { @@ -2261,6 +2270,11 @@ const Portal = { }, showAddServiceModal() { + // Build category options for dropdown + const categoryOptions = (this._taskCategories || []).map(c => + `` + ).join(''); + const html = `
@@ -2273,6 +2287,14 @@ const Portal = { Subtitle shown under the service name
+
+ + + Tasks created from this service will be assigned to this category +
@@ -2322,21 +2344,30 @@ const Portal = { 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({ - BusinessID: this.config.businessId, - TaskTypeName: name, - TaskTypeDescription: description, - TaskTypeColor: color, - TaskTypeIcon: icon - }) + body: JSON.stringify(payload) }); const data = await response.json(); @@ -2360,6 +2391,11 @@ const Portal = { return; } + // Build category options for dropdown + const categoryOptions = (this._taskCategories || []).map(c => + `` + ).join(''); + const html = `
@@ -2371,6 +2407,14 @@ const Portal = { Subtitle shown under the service name
+
+ + + Tasks created from this service will be assigned to this category +
@@ -2420,22 +2464,31 @@ const Portal = { 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({ - TaskTypeID: taskTypeId, - BusinessID: this.config.businessId, - TaskTypeName: name, - TaskTypeDescription: description, - TaskTypeColor: color, - TaskTypeIcon: icon - }) + body: JSON.stringify(payload) }); const data = await response.json(); @@ -2484,15 +2537,180 @@ const Portal = { quickTaskTemplates: [], scheduledTasks: [], + _taskCategories: [], async loadAdminTasksPage() { console.log('[Portal] Loading admin tasks page...'); await Promise.all([ + this.loadTaskCategories(), this.loadQuickTaskTemplates(), - this.loadScheduledTasks() + this.loadScheduledTasks(), + this.loadPendingRatings() ]); }, + // Task Categories + async loadTaskCategories() { + try { + const response = await fetch(`${this.config.apiBaseUrl}/tasks/listCategories.cfm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ BusinessID: this.config.businessId }) + }); + const data = await response.json(); + if (data.OK) { + this._taskCategories = data.CATEGORIES || []; + this.renderTaskCategories(); + } + } catch (err) { + console.error('[Portal] Error loading task categories:', err); + } + }, + + renderTaskCategories() { + const container = document.getElementById('taskCategoriesGrid'); + if (!container) return; + + if (!this._taskCategories.length) { + container.innerHTML = ` +
+ No categories yet. + +
`; + return; + } + + container.innerHTML = ` +
+ ${this._taskCategories.map(c => ` +
+
+ ${this.escapeHtml(c.TaskCategoryName)} + + +
+ `).join('')} +
+ `; + }, + + async seedDefaultCategories() { + try { + const response = await fetch(`${this.config.apiBaseUrl}/tasks/seedCategories.cfm?bid=${this.config.businessId}`); + const data = await response.json(); + if (data.OK) { + this.toast(data.MESSAGE || 'Categories created!', 'success'); + await this.loadTaskCategories(); + } else { + this.toast(data.MESSAGE || 'Failed to seed categories', 'error'); + } + } catch (err) { + console.error('[Portal] Error seeding categories:', err); + this.toast('Error seeding categories', 'error'); + } + }, + + showAddTaskCategoryModal(categoryId = null) { + const isEdit = categoryId !== null; + const category = isEdit ? this._taskCategories.find(c => c.TaskCategoryID === categoryId) : {}; + + document.getElementById('modalTitle').textContent = isEdit ? 'Edit Category' : 'Add Task Category'; + document.getElementById('modalBody').innerHTML = ` + + +
+ + +
+
+ +
+ ${this.buildColorPicker(category.TaskCategoryColor || '#6366f1')} +
+
+
+ + +
+ + `; + this.showModal(); + + // Color selection highlighting + document.querySelectorAll('#taskCategoryColorPicker label').forEach(label => { + label.addEventListener('click', () => { + document.querySelectorAll('#taskCategoryColorPicker label div').forEach(d => d.style.border = '3px solid transparent'); + label.querySelector('div').style.border = '3px solid #333'; + }); + }); + + document.getElementById('taskCategoryForm').addEventListener('submit', (e) => { + e.preventDefault(); + this.saveTaskCategory(); + }); + }, + + editTaskCategory(categoryId) { + this.showAddTaskCategoryModal(categoryId); + }, + + async saveTaskCategory() { + const id = document.getElementById('taskCategoryId').value; + const name = document.getElementById('taskCategoryName').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 { @@ -2520,7 +2738,7 @@ const Portal = { const manageContainer = document.getElementById('quickTasksManageList'); if (!this.quickTaskTemplates.length) { - gridContainer.innerHTML = '
No quick task templates. Click "+ Add Template" to create one.
'; + gridContainer.innerHTML = '
No quick tasks yet. Click "+ Add Quick Task" to create one.
'; manageContainer.innerHTML = ''; return; } @@ -2539,7 +2757,7 @@ const Portal = { // Management list manageContainer.innerHTML = ` -

Manage Templates

+

Manage Quick Tasks

${this.quickTaskTemplates.map(t => `
@@ -2564,22 +2782,13 @@ const Portal = { const isEdit = templateId !== null; const template = isEdit ? this.quickTaskTemplates.find(t => t.QuickTaskTemplateID === templateId) : {}; - // Build category options - const categoryOptions = (this._taskCategories || []).map(c => - `` - ).join(''); - - // Build icon options - const iconOptions = Object.keys(this.serviceIcons).map(key => - `` - ).join(''); - - document.getElementById('modalTitle').textContent = isEdit ? 'Edit Quick Task Template' : 'Add Quick Task Template'; + document.getElementById('modalTitle').textContent = isEdit ? 'Edit Quick Task' : 'Add Quick Task'; document.getElementById('modalBody').innerHTML = `
+
- + Name shown on the shortcut button
@@ -2592,37 +2801,31 @@ const Portal = {
-
-
- - -
-
- - +
+ +
+
Loading categories...
+ Task will inherit the category's color
- - + +
+ ${this.buildQuickTaskIconPicker(template.Icon || 'add_box', template.Color || '#6366f1')} +
- +
`; this.showModal(); - // Load categories if not loaded - if (!this._taskCategories) { - this.loadTaskCategories(); - } + // Load categories and render as clickable chips + this.loadTaskCategories().then(() => { + this.renderQuickTaskCategoryGrid(template.CategoryID); + }); document.getElementById('quickTaskForm').addEventListener('submit', (e) => { e.preventDefault(); @@ -2630,36 +2833,121 @@ const Portal = { }); }, + renderQuickTaskCategoryGrid(selectedCategoryId) { + const container = document.getElementById('quickTaskCategoryGrid'); + if (!container) return; + + const categories = this._taskCategories || []; + if (!categories.length) { + container.innerHTML = '
No categories defined. Add some above first.
'; + return; + } + + container.innerHTML = categories.map(c => { + const isSelected = selectedCategoryId == c.TaskCategoryID; + const color = c.TaskCategoryColor || '#6366f1'; + return ` +
+
+ ${this.escapeHtml(c.TaskCategoryName)} +
+ `; + }).join(''); + + // Add click handlers + container.querySelectorAll('.quick-task-category-chip').forEach(chip => { + chip.addEventListener('click', () => { + // Deselect all + container.querySelectorAll('.quick-task-category-chip').forEach(c => { + c.style.borderColor = '#e5e7eb'; + c.style.background = '#fff'; + c.querySelector('span').style.fontWeight = '400'; + }); + // Select this one + const color = chip.dataset.color; + chip.style.borderColor = color; + chip.style.background = color + '20'; + chip.querySelector('span').style.fontWeight = '600'; + // Update hidden field and icon colors + document.getElementById('quickTaskCategory').value = chip.dataset.id; + this.updateQuickTaskIconColors(color); + }); + }); + + document.getElementById('quickTaskCategory').value = selectedCategoryId || ''; + + // Set up icon picker click handlers + this.setupQuickTaskIconHandlers(); + }, + + buildQuickTaskIconPicker(selectedIcon = 'add_box', color = '#6366f1') { + return Object.entries(this.serviceIcons).map(([key, val]) => ` + + `).join(''); + }, + + setupQuickTaskIconHandlers() { + document.querySelectorAll('#quickTaskIconPicker label').forEach(label => { + label.addEventListener('click', () => { + const categoryChip = document.querySelector('.quick-task-category-chip[style*="font-weight: 600"]') || + document.querySelector('.quick-task-category-chip[style*="font-weight:600"]'); + const color = categoryChip?.dataset?.color || '#6366f1'; + document.querySelectorAll('#quickTaskIconPicker label').forEach(l => { + l.style.borderColor = '#e5e7eb'; + l.querySelector('div').style.color = '#999'; + }); + label.style.borderColor = color; + label.querySelector('div').style.color = color; + }); + }); + }, + + updateQuickTaskIconColors(color) { + const selectedRadio = document.querySelector('input[name="quickTaskIconRadio"]:checked'); + document.querySelectorAll('#quickTaskIconPicker label').forEach(label => { + const radio = label.querySelector('input[type="radio"]'); + const isSelected = radio === selectedRadio; + label.style.borderColor = isSelected ? color : '#e5e7eb'; + label.querySelector('div').style.color = isSelected ? color : '#999'; + }); + }, + editQuickTask(templateId) { this.showAddQuickTaskModal(templateId); }, - async loadTaskCategories() { - try { - const response = await fetch(`${this.config.apiBaseUrl}/tasks/listCategories.cfm`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ BusinessID: this.config.businessId }) - }); - const data = await response.json(); - if (data.OK) { - this._taskCategories = data.CATEGORIES || []; - } - } catch (err) { - console.error('[Portal] Error loading task categories:', err); - } - }, - async saveQuickTask() { const id = document.getElementById('quickTaskTemplateId').value; + const 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('quickTaskTitle').value, Details: document.getElementById('quickTaskDetails').value, - CategoryID: document.getElementById('quickTaskCategory').value || null, - Icon: document.getElementById('quickTaskIcon').value, - Color: document.getElementById('quickTaskColor').value + CategoryID: categoryId || null, + Icon: selectedIcon, + Color: color }; if (id) payload.QuickTaskTemplateID = parseInt(id); @@ -2673,20 +2961,20 @@ const Portal = { const data = await response.json(); if (data.OK) { - this.toast('Template saved!', 'success'); + 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 template:', err); - this.toast('Error saving template', 'error'); + console.error('[Portal] Error saving quick task:', err); + this.toast('Error saving quick task', 'error'); } }, async deleteQuickTask(templateId) { - if (!confirm('Delete this quick task template?')) return; + if (!confirm('Delete this quick task?')) return; try { const response = await fetch(`${this.config.apiBaseUrl}/admin/quickTasks/delete.cfm`, { @@ -2700,14 +2988,14 @@ const Portal = { const data = await response.json(); if (data.OK) { - this.toast('Template deleted', 'success'); + 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 template:', err); - this.toast('Error deleting template', 'error'); + console.error('[Portal] Error deleting quick task:', err); + this.toast('Error deleting quick task', 'error'); } }, @@ -2771,7 +3059,7 @@ const Portal = { ${this.escapeHtml(s.Name)}
- ${this.escapeHtml(s.Title)} | Schedule: ${this.escapeHtml(s.CronExpression)} + ${this.escapeHtml(s.Title)} | Schedule: ${this.formatScheduleDisplay(s)}
${s.NextRunOn ? `
Next run: ${s.NextRunOn}
` : ''} ${s.LastRunOn ? `
Last run: ${s.LastRunOn}
` : ''} @@ -2790,57 +3078,314 @@ const Portal = { `).join(''); }, + // Format schedule for display in list + formatScheduleDisplay(task) { + if (task.ScheduleType === 'interval' && task.IntervalMinutes) { + const mins = parseInt(task.IntervalMinutes); + if (mins >= 60 && mins % 60 === 0) { + const hours = mins / 60; + return `Every ${hours} hour${hours === 1 ? '' : 's'}`; + } else { + return `Every ${mins} minute${mins === 1 ? '' : 's'}`; + } + } + return this.escapeHtml(task.CronExpression); + }, + + // Parse task schedule into friendly values + parseCronToFriendly(task) { + // Check if this is interval-based scheduling + const isIntervalType = task.ScheduleType === 'interval' || task.ScheduleType === 'interval_after_completion'; + if (isIntervalType && task.IntervalMinutes) { + const intervalMins = parseInt(task.IntervalMinutes); + const isAfterCompletion = task.ScheduleType === 'interval_after_completion'; + if (intervalMins >= 60 && intervalMins % 60 === 0) { + // Express as hours + return { + hour: 9, minute: 0, frequency: 'interval', + selectedDays: [], monthDay: 1, intervalValue: intervalMins / 60, + intervalUnit: 'hours', intervalMode: isAfterCompletion ? 'after_completion' : 'fixed' + }; + } else { + // Express as minutes + return { + hour: 9, minute: 0, frequency: 'interval', + selectedDays: [], monthDay: 1, intervalValue: intervalMins, + intervalUnit: 'minutes', intervalMode: isAfterCompletion ? 'after_completion' : 'fixed' + }; + } + } + + // Cron-based scheduling + const cron = task.CronExpression || task; + const parts = (typeof cron === 'string' ? cron : '0 9 * * *').split(' '); + const minute = parseInt(parts[0]) || 0; + const hour = parseInt(parts[1]) || 9; + const dayOfMonth = parts[2] || '*'; + const month = parts[3] || '*'; + const dayOfWeek = parts[4] || '*'; + + let frequency = 'daily'; + let selectedDays = []; + let monthDay = 1; + + if (dayOfMonth !== '*' && !isNaN(parseInt(dayOfMonth))) { + frequency = 'monthly'; + monthDay = parseInt(dayOfMonth); + } else if (dayOfWeek === '1-5') { + frequency = 'weekdays'; + } else if (dayOfWeek !== '*') { + frequency = 'weekly'; + // Parse days: could be "1,3,5" or "1-5" etc. + if (dayOfWeek.includes(',')) { + selectedDays = dayOfWeek.split(',').map(d => parseInt(d)); + } else if (dayOfWeek.includes('-')) { + const [start, end] = dayOfWeek.split('-').map(d => parseInt(d)); + for (let i = start; i <= end; i++) selectedDays.push(i); + } else { + selectedDays = [parseInt(dayOfWeek)]; + } + } + + return { hour, minute, frequency, selectedDays, monthDay, intervalValue: 15, intervalUnit: 'minutes', intervalMode: 'fixed' }; + }, + + // Build cron expression from friendly values + buildCronFromFriendly() { + const frequency = document.getElementById('scheduleFrequency').value; + const hour = document.getElementById('scheduleHour').value; + const minute = document.getElementById('scheduleMinute').value; + + let dayOfMonth = '*'; + let dayOfWeek = '*'; + + if (frequency === 'weekdays') { + dayOfWeek = '1-5'; + } else if (frequency === 'weekly') { + const checkedDays = [...document.querySelectorAll('.day-btn.selected')].map(btn => btn.dataset.day); + dayOfWeek = checkedDays.length > 0 ? checkedDays.join(',') : '*'; + } else if (frequency === 'monthly') { + dayOfMonth = document.getElementById('scheduleMonthDay').value; + } else if (frequency === 'custom') { + return document.getElementById('scheduledTaskCron').value; + } + + return `${minute} ${hour} ${dayOfMonth} * ${dayOfWeek}`; + }, + showAddScheduledTaskModal(taskId = null) { const isEdit = taskId !== null; const task = isEdit ? this.scheduledTasks.find(t => t.ScheduledTaskID === taskId) : {}; + // Parse existing schedule or use defaults + const schedule = this.parseCronToFriendly(task); + // Build category options const categoryOptions = (this._taskCategories || []).map(c => `` ).join(''); + // Build hour options (12-hour format display, 24-hour value) + const hourOptions = Array.from({length: 24}, (_, i) => { + const displayHour = i === 0 ? 12 : (i > 12 ? i - 12 : i); + const ampm = i < 12 ? 'AM' : 'PM'; + return ``; + }).join(''); + + // Build minute options (every 5 minutes) + const minuteOptions = Array.from({length: 12}, (_, i) => { + const min = i * 5; + return ``; + }).join(''); + + // Build day of month options + const monthDayOptions = Array.from({length: 31}, (_, i) => { + const day = i + 1; + const suffix = day === 1 || day === 21 || day === 31 ? 'st' : (day === 2 || day === 22 ? 'nd' : (day === 3 || day === 23 ? 'rd' : 'th')); + return ``; + }).join(''); + + // Day buttons for weekly + const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const dayButtons = dayNames.map((name, i) => { + const isSelected = schedule.selectedDays.includes(i); + return ``; + }).join(''); + document.getElementById('modalTitle').textContent = isEdit ? 'Edit Scheduled Task' : 'Add Scheduled Task'; document.getElementById('modalBody').innerHTML = `
-
- - - Internal name for this scheduled task -
+ +
- - Title shown on the created task +
+
+
- +
+
- - - Format: minute hour day month weekday
- Examples: 0 9 * * * (daily 9am), 0 9 * * 1-5 (weekdays 9am), 30 14 * * * (daily 2:30pm)
+ +
+ +
+ +
+ + + + + + + +
+
+ Or custom: + + +
+ + +
+ +
+ +
${dayButtons}
+
+ +
+ + +
+ +
+ + + Format: minute hour day month weekday +
+ +
+ +
+ + : + +
+
+ +
+ This task will run: + +
+ +
+ + + Used for tracking; auto-generated if blank +
+
+
+ + `; this.showModal(); + // Set up day button click handlers + document.querySelectorAll('.day-btn').forEach(btn => { + btn.addEventListener('click', () => { + btn.classList.toggle('selected'); + this.updateSchedulePreview(); + }); + }); + + // Set up interval preset button click handlers + document.querySelectorAll('.interval-preset-btn').forEach(btn => { + btn.addEventListener('click', () => { + const minutes = parseInt(btn.dataset.minutes); + // Clear all preset selections + document.querySelectorAll('.interval-preset-btn').forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + // Update the custom input to match + if (minutes >= 60 && minutes % 60 === 0) { + document.getElementById('scheduleInterval').value = minutes / 60; + document.getElementById('intervalUnit').value = 'hours'; + } else { + document.getElementById('scheduleInterval').value = minutes; + document.getElementById('intervalUnit').value = 'minutes'; + } + this.updateSchedulePreview(); + }); + }); + + // Set up change listeners for preview + ['scheduleFrequency', 'scheduleHour', 'scheduleMinute', 'scheduleMonthDay', 'scheduleInterval', 'intervalUnit'].forEach(id => { + const el = document.getElementById(id); + if (el) el.addEventListener('change', () => { + // Clear preset selection when custom value changes + if (id === 'scheduleInterval' || id === 'intervalUnit') { + document.querySelectorAll('.interval-preset-btn').forEach(b => b.classList.remove('selected')); + } + this.updateSchedulePreview(); + }); + }); + // Also listen for input event on the interval number field + const intervalInput = document.getElementById('scheduleInterval'); + if (intervalInput) intervalInput.addEventListener('input', () => { + document.querySelectorAll('.interval-preset-btn').forEach(b => b.classList.remove('selected')); + this.updateSchedulePreview(); + }); + + // Initial preview update + this.updateSchedulePreview(); + // Load categories if not loaded if (!this._taskCategories) { this.loadTaskCategories(); @@ -2852,19 +3397,121 @@ const Portal = { }); }, + 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('scheduledTaskTitle').value.trim(); + name = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 50); + } + const payload = { BusinessID: this.config.businessId, - Name: document.getElementById('scheduledTaskName').value, + Name: name, Title: document.getElementById('scheduledTaskTitle').value, Details: document.getElementById('scheduledTaskDetails').value, CategoryID: document.getElementById('scheduledTaskCategory').value || null, - CronExpression: document.getElementById('scheduledTaskCron').value, + CronExpression: cronExpression, + ScheduleType: scheduleType, + IntervalMinutes: intervalMinutes, IsActive: document.getElementById('scheduledTaskActive').checked }; @@ -2963,6 +3610,150 @@ const Portal = { console.error('[Portal] Error running scheduled task:', err); this.toast('Error running scheduled task', 'error'); } + }, + + // Worker Ratings + pendingRatings: [], + + async loadPendingRatings() { + const container = document.getElementById('pendingRatingsList'); + if (!container) return; + + try { + const response = await fetch(`${this.config.apiBaseUrl}/ratings/listForAdmin.cfm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ BusinessID: this.config.businessId }) + }); + const data = await response.json(); + + if (data.OK) { + this.pendingRatings = data.TASKS || []; + this.renderPendingRatings(); + } else { + container.innerHTML = '
Failed to load ratings
'; + } + } catch (err) { + console.error('[Portal] Error loading pending ratings:', err); + container.innerHTML = '
Error loading ratings
'; + } + }, + + renderPendingRatings() { + const container = document.getElementById('pendingRatingsList'); + if (!this.pendingRatings.length) { + container.innerHTML = '
No completed tasks to rate from the last 7 days.
'; + return; + } + + container.innerHTML = this.pendingRatings.map(t => ` +
+
+
+ ${this.escapeHtml(t.TaskTitle)} +
+
+ Worker: ${this.escapeHtml(t.WorkerName)} + ${t.CustomerName ? ` | Customer: ${this.escapeHtml(t.CustomerName)}` : ''} + ${t.ServicePointName ? ` | ${this.escapeHtml(t.ServicePointName)}` : ''} +
+
+ Completed: ${t.CompletedOn} +
+
+
+ +
+
+ `).join(''); + }, + + showRateWorkerModal(taskId, workerName, taskTitle) { + document.getElementById('modalTitle').textContent = 'Rate Worker Performance'; + document.getElementById('modalBody').innerHTML = ` +
+ +

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

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+
+ `; + this.showModal(); + + document.getElementById('rateWorkerForm').addEventListener('submit', (e) => { + e.preventDefault(); + this.submitWorkerRating(); + }); + }, + + async submitWorkerRating() { + const taskId = parseInt(document.getElementById('ratingTaskId').value); + const onTime = document.getElementById('ratingOnTime').checked; + const completedScope = document.getElementById('ratingCompletedScope').checked; + const requiredFollowup = document.getElementById('ratingRequiredFollowup').checked; + const continueAllow = document.getElementById('ratingContinueAllow').checked; + + try { + const response = await fetch(`${this.config.apiBaseUrl}/ratings/createAdminRating.cfm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + TaskID: taskId, + AdminUserID: this.config.userId, + onTime: onTime, + completedScope: completedScope, + requiredFollowup: requiredFollowup, + continueAllow: continueAllow + }) + }); + const data = await response.json(); + + if (data.OK) { + this.toast('Rating submitted successfully!', 'success'); + this.closeModal(); + await this.loadPendingRatings(); + } else { + this.toast(data.MESSAGE || 'Failed to submit rating', 'error'); + } + } catch (err) { + console.error('[Portal] Error submitting rating:', err); + this.toast('Error submitting rating', 'error'); + } } }; diff --git a/portal/quick-tasks.html b/portal/quick-tasks.html new file mode 100644 index 0000000..5cf9dc5 --- /dev/null +++ b/portal/quick-tasks.html @@ -0,0 +1,383 @@ + + + + + + Quick Tasks + + + + + +
+ + arrow_back Back to Portal + +

Quick Tasks

+

Tap to instantly create a task

+
+ +
+ sync +

Loading...

+
+ +
+ + + +