-
-
-
-
-
-
-
+
-
+
`;
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 = `
+
+
`;
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 = `
+
+ `;
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+