tractatus/public/js/admin/calendar.js
TheFlow 8c1eeb3a7a fix(calendar): Add cache-busting and better error handling
- Added cache: 'no-store' to prevent cached 500 errors
- Enhanced error messages with status codes
- Display detailed error messages to user
- Log API response text for debugging
- Helps diagnose mobile loading issues
2025-10-24 12:13:21 +13:00

716 lines
27 KiB
JavaScript

/**
* Admin Calendar - Task Management
*
* Manages scheduled tasks with filtering, CRUD operations, and session-init integration
*/
(function() {
'use strict';
// State
let currentView = 'list';
let currentFilters = {
status: '',
category: '',
priority: '',
projectId: '',
includeCompleted: false
};
let currentPage = 0;
const pageSize = 50;
let allTasks = [];
// DOM Elements
const tasksContainer = document.getElementById('tasks-container');
const addTaskBtn = document.getElementById('add-task-btn');
const taskModal = document.getElementById('task-modal');
const taskDetailModal = document.getElementById('task-detail-modal');
const taskForm = document.getElementById('task-form');
const paginationContainer = document.getElementById('pagination-container');
// Initialize
document.addEventListener('DOMContentLoaded', init);
function init() {
setupEventListeners();
loadStats();
loadTasks();
}
function setupEventListeners() {
// Add task button
addTaskBtn.addEventListener('click', () => openTaskModal());
// Modal controls
document.getElementById('modal-close').addEventListener('click', closeTaskModal);
document.getElementById('modal-cancel').addEventListener('click', closeTaskModal);
document.getElementById('detail-modal-close').addEventListener('click', closeDetailModal);
// Task form submission
taskForm.addEventListener('submit', handleTaskSubmit);
// Filters
document.getElementById('filter-status').addEventListener('change', handleFilterChange);
document.getElementById('filter-category').addEventListener('change', handleFilterChange);
document.getElementById('filter-priority').addEventListener('change', handleFilterChange);
document.getElementById('filter-project').addEventListener('change', handleFilterChange);
document.getElementById('include-completed').addEventListener('change', handleFilterChange);
document.getElementById('clear-filters-btn').addEventListener('click', clearFilters);
// View toggle
document.getElementById('view-list').addEventListener('click', () => setView('list'));
document.getElementById('view-timeline').addEventListener('click', () => setView('timeline'));
// Pagination
document.getElementById('pagination-prev').addEventListener('click', () => changePage(-1));
document.getElementById('pagination-next').addEventListener('click', () => changePage(1));
// Event delegation for task actions
tasksContainer.addEventListener('click', handleTaskAction);
document.getElementById('task-detail-content').addEventListener('click', handleDetailAction);
}
function handleTaskAction(e) {
const button = e.target.closest('button[data-action]');
if (!button) return;
const action = button.dataset.action;
const taskId = button.dataset.taskId;
if (action === 'view') viewTask(taskId);
else if (action === 'complete') completeTask(taskId);
else if (action === 'edit') editTask(taskId);
else if (action === 'dismiss') dismissTask(taskId);
}
function handleDetailAction(e) {
const button = e.target.closest('button[data-action]');
if (!button) return;
const action = button.dataset.action;
const taskId = button.dataset.taskId;
if (action === 'complete') completeTask(taskId);
else if (action === 'edit') editTask(taskId);
else if (action === 'dismiss') dismissTask(taskId);
else if (action === 'delete') deleteTask(taskId);
}
async function loadStats() {
try {
const token = localStorage.getItem('admin_token');
const response = await fetch('/api/calendar/stats', {
cache: 'no-store',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
const errorText = await response.text();
console.error('Stats API error:', response.status, errorText);
throw new Error(`Failed to load stats: ${response.status}`);
}
const data = await response.json();
const stats = data.stats;
document.getElementById('stat-total').textContent = stats.total || 0;
document.getElementById('stat-pending').textContent = stats.pending || 0;
document.getElementById('stat-overdue').textContent = stats.overdue || 0;
document.getElementById('stat-due-soon').textContent = stats.dueSoon || 0;
document.getElementById('stat-completed').textContent = stats.completed || 0;
} catch (error) {
console.error('Error loading stats:', error);
showError('Failed to load statistics: ' + error.message);
}
}
async function loadTasks() {
try {
const token = localStorage.getItem('admin_token');
// Build query parameters
const params = new URLSearchParams({
limit: pageSize,
skip: currentPage * pageSize
});
if (currentFilters.status) params.append('status', currentFilters.status);
if (currentFilters.category) params.append('category', currentFilters.category);
if (currentFilters.priority) params.append('priority', currentFilters.priority);
if (currentFilters.projectId) params.append('projectId', currentFilters.projectId);
if (currentFilters.includeCompleted) params.append('includeCompleted', 'true');
const response = await fetch(`/api/calendar/tasks?${params}`, {
cache: 'no-store',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
const errorText = await response.text();
console.error('Tasks API error:', response.status, errorText);
throw new Error(`Failed to load tasks: ${response.status}`);
}
const data = await response.json();
allTasks = data.tasks;
renderTasks(allTasks);
updatePagination(data.pagination);
} catch (error) {
console.error('Error loading tasks:', error);
tasksContainer.innerHTML = `
<div class="text-center py-12 text-red-600">
<p>Failed to load tasks: ${error.message}</p>
<p class="text-sm mt-2">Check console for details</p>
</div>
`;
}
}
function renderTasks(tasks) {
if (!tasks || tasks.length === 0) {
tasksContainer.innerHTML = `
<div class="text-center py-12 text-gray-500">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
<p class="mt-2">No tasks found</p>
</div>
`;
return;
}
if (currentView === 'list') {
renderListView(tasks);
} else {
renderTimelineView(tasks);
}
}
function renderListView(tasks) {
const html = tasks.map(task => {
const statusColor = getStatusColor(task.status);
const priorityColor = getPriorityColor(task.priority);
const dueDate = new Date(task.dueDate);
const isOverdue = task.status === 'overdue' || (task.status === 'pending' && dueDate < new Date());
return `
<div class="bg-white rounded-lg shadow p-4 mb-3 hover:shadow-md transition-shadow">
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center space-x-2 mb-2">
<span class="px-2 py-1 text-xs rounded-full ${statusColor}">${task.status}</span>
<span class="px-2 py-1 text-xs rounded-full ${priorityColor}">${task.priority}</span>
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-700">${task.category}</span>
${task.recurrence !== 'once' ? `<span class="px-2 py-1 text-xs rounded-full bg-purple-100 text-purple-700">↻ ${task.recurrence}</span>` : ''}
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-1">${escapeHtml(task.title)}</h3>
<p class="text-sm text-gray-600 mb-2">${escapeHtml(task.description.substring(0, 150))}${task.description.length > 150 ? '...' : ''}</p>
<div class="flex items-center space-x-4 text-xs text-gray-500">
<div class="flex items-center">
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span class="${isOverdue ? 'text-red-600 font-semibold' : ''}">${formatDate(dueDate)}</span>
</div>
${task.assignedTo ? `
<div class="flex items-center">
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
${escapeHtml(task.assignedTo)}
</div>
` : ''}
${task.documentRef ? `
<div class="flex items-center">
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<span class="text-blue-600">Document linked</span>
</div>
` : ''}
</div>
</div>
<div class="ml-4 flex flex-col space-y-2">
<button data-action="view" data-task-id="${task.id}" class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200">
View
</button>
${task.status === 'pending' || task.status === 'overdue' ? `
<button data-action="complete" data-task-id="${task.id}" class="px-3 py-1 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200">
Complete
</button>
` : ''}
<button data-action="edit" data-task-id="${task.id}" class="px-3 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200">
Edit
</button>
${task.status === 'pending' ? `
<button data-action="dismiss" data-task-id="${task.id}" class="px-3 py-1 text-xs bg-yellow-100 text-yellow-700 rounded hover:bg-yellow-200">
Dismiss
</button>
` : ''}
</div>
</div>
</div>
`;
}).join('');
tasksContainer.innerHTML = html;
}
function renderTimelineView(tasks) {
// Group tasks by due date
const grouped = {};
tasks.forEach(task => {
const date = new Date(task.dueDate).toDateString();
if (!grouped[date]) grouped[date] = [];
grouped[date].push(task);
});
const html = Object.keys(grouped).sort((a, b) => new Date(a) - new Date(b)).map(date => {
const dateTasks = grouped[date];
return `
<div class="mb-6">
<h3 class="text-lg font-semibold text-gray-900 mb-3 border-b border-gray-200 pb-2">
${formatDateHeader(new Date(date))}
</h3>
<div class="space-y-2">
${dateTasks.map(task => `
<div class="bg-white rounded-lg shadow p-3 hover:shadow-md transition-shadow flex justify-between items-center">
<div class="flex-1">
<div class="flex items-center space-x-2 mb-1">
<span class="px-2 py-1 text-xs rounded-full ${getStatusColor(task.status)}">${task.status}</span>
<span class="px-2 py-1 text-xs rounded-full ${getPriorityColor(task.priority)}">${task.priority}</span>
</div>
<h4 class="font-medium text-gray-900">${escapeHtml(task.title)}</h4>
<p class="text-sm text-gray-600">${escapeHtml(task.category)}</p>
</div>
<div class="flex space-x-2">
<button data-action="view" data-task-id="${task.id}" class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200">
View
</button>
${task.status === 'pending' || task.status === 'overdue' ? `
<button data-action="complete" data-task-id="${task.id}" class="px-3 py-1 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200">
Complete
</button>
` : ''}
</div>
</div>
`).join('')}
</div>
</div>
`;
}).join('');
tasksContainer.innerHTML = html || '<div class="text-center py-12 text-gray-500">No tasks to display</div>';
}
function updatePagination(pagination) {
if (!pagination) return;
const showing = Math.min(pagination.skip + pagination.limit, pagination.total);
document.getElementById('pagination-showing').textContent = showing;
document.getElementById('pagination-total').textContent = pagination.total;
document.getElementById('pagination-prev').disabled = pagination.skip === 0;
document.getElementById('pagination-next').disabled = !pagination.hasMore;
if (pagination.total > 0) {
paginationContainer.classList.remove('hidden');
} else {
paginationContainer.classList.add('hidden');
}
}
function changePage(delta) {
currentPage += delta;
if (currentPage < 0) currentPage = 0;
loadTasks();
}
function setView(view) {
currentView = view;
// Update button styles
document.getElementById('view-list').className = view === 'list'
? 'px-3 py-2 text-sm font-medium rounded-md bg-blue-100 text-blue-700'
: 'px-3 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-100';
document.getElementById('view-timeline').className = view === 'timeline'
? 'px-3 py-2 text-sm font-medium rounded-md bg-blue-100 text-blue-700'
: 'px-3 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-100';
renderTasks(allTasks);
}
function handleFilterChange(e) {
const id = e.target.id;
if (id === 'filter-status') currentFilters.status = e.target.value;
else if (id === 'filter-category') currentFilters.category = e.target.value;
else if (id === 'filter-priority') currentFilters.priority = e.target.value;
else if (id === 'filter-project') currentFilters.projectId = e.target.value;
else if (id === 'include-completed') currentFilters.includeCompleted = e.target.checked;
currentPage = 0;
loadTasks();
}
function clearFilters() {
currentFilters = {
status: '',
category: '',
priority: '',
projectId: '',
includeCompleted: false
};
document.getElementById('filter-status').value = '';
document.getElementById('filter-category').value = '';
document.getElementById('filter-priority').value = '';
document.getElementById('filter-project').value = '';
document.getElementById('include-completed').checked = false;
currentPage = 0;
loadTasks();
}
function openTaskModal(taskId = null) {
document.getElementById('modal-title').textContent = taskId ? 'Edit Task' : 'Add Task';
if (taskId) {
loadTaskForEdit(taskId);
} else {
taskForm.reset();
document.getElementById('task-id').value = '';
document.getElementById('task-show-in-session-init').checked = true;
document.getElementById('task-reminder-days').value = '7';
document.getElementById('task-assigned-to').value = 'PM';
}
taskModal.classList.remove('hidden');
}
function closeTaskModal() {
taskModal.classList.add('hidden');
taskForm.reset();
}
function closeDetailModal() {
taskDetailModal.classList.add('hidden');
}
async function loadTaskForEdit(taskId) {
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`/api/calendar/tasks/${taskId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Failed to load task');
const data = await response.json();
const task = data.task;
document.getElementById('task-id').value = task.id;
document.getElementById('task-title').value = task.title;
document.getElementById('task-description').value = task.description;
document.getElementById('task-due-date').value = formatDateTimeLocal(new Date(task.dueDate));
document.getElementById('task-priority').value = task.priority;
document.getElementById('task-category').value = task.category;
document.getElementById('task-recurrence').value = task.recurrence;
document.getElementById('task-document-ref').value = task.documentRef || '';
document.getElementById('task-assigned-to').value = task.assignedTo || 'PM';
document.getElementById('task-show-in-session-init').checked = task.showInSessionInit;
document.getElementById('task-reminder-days').value = task.reminderDaysBefore;
} catch (error) {
console.error('Error loading task:', error);
showError('Failed to load task details');
closeTaskModal();
}
}
async function handleTaskSubmit(e) {
e.preventDefault();
const taskId = document.getElementById('task-id').value;
const taskData = {
title: document.getElementById('task-title').value,
description: document.getElementById('task-description').value,
dueDate: document.getElementById('task-due-date').value,
priority: document.getElementById('task-priority').value,
category: document.getElementById('task-category').value,
recurrence: document.getElementById('task-recurrence').value,
documentRef: document.getElementById('task-document-ref').value || null,
assignedTo: document.getElementById('task-assigned-to').value,
showInSessionInit: document.getElementById('task-show-in-session-init').checked,
reminderDaysBefore: parseInt(document.getElementById('task-reminder-days').value)
};
try {
const token = localStorage.getItem('admin_token');
const url = taskId ? `/api/calendar/tasks/${taskId}` : '/api/calendar/tasks';
const method = taskId ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(taskData)
});
if (!response.ok) throw new Error('Failed to save task');
closeTaskModal();
loadTasks();
loadStats();
showSuccess(taskId ? 'Task updated successfully' : 'Task created successfully');
} catch (error) {
console.error('Error saving task:', error);
showError('Failed to save task. Please try again.');
}
}
async function viewTask(taskId) {
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`/api/calendar/tasks/${taskId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Failed to load task');
const data = await response.json();
const task = data.task;
const html = `
<div class="space-y-4">
<div class="flex items-center space-x-2">
<span class="px-3 py-1 text-sm rounded-full ${getStatusColor(task.status)}">${task.status}</span>
<span class="px-3 py-1 text-sm rounded-full ${getPriorityColor(task.priority)}">${task.priority}</span>
<span class="px-3 py-1 text-sm rounded-full bg-gray-100 text-gray-700">${task.category}</span>
</div>
<h2 class="text-2xl font-bold text-gray-900">${escapeHtml(task.title)}</h2>
<div class="bg-gray-50 rounded-lg p-4">
<p class="text-gray-700">${escapeHtml(task.description)}</p>
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="font-medium text-gray-700">Due Date:</span>
<span class="text-gray-900 ml-2">${formatDateTime(new Date(task.dueDate))}</span>
</div>
<div>
<span class="font-medium text-gray-700">Recurrence:</span>
<span class="text-gray-900 ml-2">${task.recurrence}</span>
</div>
<div>
<span class="font-medium text-gray-700">Assigned To:</span>
<span class="text-gray-900 ml-2">${escapeHtml(task.assignedTo || 'Unassigned')}</span>
</div>
<div>
<span class="font-medium text-gray-700">Session-Init:</span>
<span class="text-gray-900 ml-2">${task.showInSessionInit ? 'Yes' : 'No'}</span>
</div>
</div>
${task.documentRef ? `
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-start">
<svg class="h-5 w-5 text-blue-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<div class="ml-3">
<p class="text-sm font-medium text-blue-900">Linked Document</p>
<p class="text-sm text-blue-700 mt-1">${escapeHtml(task.documentRef)}</p>
</div>
</div>
</div>
` : ''}
<div class="flex justify-end space-x-3 pt-4 border-t">
${task.status === 'pending' || task.status === 'overdue' ? `
<button data-action="complete" data-task-id="${task.id}" class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700">
Mark Complete
</button>
` : ''}
<button data-action="edit" data-task-id="${task.id}" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Edit Task
</button>
${task.status === 'pending' ? `
<button data-action="dismiss" data-task-id="${task.id}" class="px-4 py-2 bg-yellow-600 text-white rounded-md hover:bg-yellow-700">
Dismiss
</button>
` : ''}
<button data-action="delete" data-task-id="${task.id}" class="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700">
Delete
</button>
</div>
</div>
`;
document.getElementById('task-detail-content').innerHTML = html;
taskDetailModal.classList.remove('hidden');
} catch (error) {
console.error('Error viewing task:', error);
showError('Failed to load task details');
}
}
async function completeTask(taskId) {
if (!confirm('Mark this task as completed?')) return;
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`/api/calendar/tasks/${taskId}/complete`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Failed to complete task');
closeDetailModal();
loadTasks();
loadStats();
showSuccess('Task marked as completed');
} catch (error) {
console.error('Error completing task:', error);
showError('Failed to complete task');
}
}
async function dismissTask(taskId) {
const reason = prompt('Reason for dismissing this task?');
if (!reason) return;
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`/api/calendar/tasks/${taskId}/dismiss`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ reason })
});
if (!response.ok) throw new Error('Failed to dismiss task');
closeDetailModal();
loadTasks();
loadStats();
showSuccess('Task dismissed');
} catch (error) {
console.error('Error dismissing task:', error);
showError('Failed to dismiss task');
}
}
async function deleteTask(taskId) {
if (!confirm('Are you sure you want to delete this task? This action cannot be undone.')) return;
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`/api/calendar/tasks/${taskId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Failed to delete task');
closeDetailModal();
loadTasks();
loadStats();
showSuccess('Task deleted successfully');
} catch (error) {
console.error('Error deleting task:', error);
showError('Failed to delete task');
}
}
function editTask(taskId) {
closeDetailModal();
openTaskModal(taskId);
}
// Utility functions
function getStatusColor(status) {
const colors = {
pending: 'bg-yellow-100 text-yellow-800',
in_progress: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
dismissed: 'bg-gray-100 text-gray-800',
overdue: 'bg-red-100 text-red-800'
};
return colors[status] || 'bg-gray-100 text-gray-800';
}
function getPriorityColor(priority) {
const colors = {
LOW: 'bg-gray-100 text-gray-700',
MEDIUM: 'bg-blue-100 text-blue-700',
HIGH: 'bg-orange-100 text-orange-700',
CRITICAL: 'bg-red-100 text-red-700'
};
return colors[priority] || 'bg-gray-100 text-gray-700';
}
function formatDate(date) {
const options = { year: 'numeric', month: 'short', day: 'numeric' };
return date.toLocaleDateString('en-US', options);
}
function formatDateTime(date) {
const options = { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
return date.toLocaleDateString('en-US', options);
}
function formatDateHeader(date) {
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (date.toDateString() === today.toDateString()) return 'Today';
if (date.toDateString() === tomorrow.toDateString()) return 'Tomorrow';
return formatDate(date);
}
function formatDateTimeLocal(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showSuccess(message) {
alert(message);
}
function showError(message) {
alert('Error: ' + message);
}
})();