Files
TimeTrack/templates/unified_task_management.html

2499 lines
84 KiB
HTML

{% extends "layout.html" %}
{% block content %}
<div class="management-container task-management-container">
<!-- Header Section -->
<div class="management-header task-header">
<h1><i class="ti ti-clipboard-list"></i> Task Management</h1>
<div class="management-controls task-controls">
<!-- Smart Search -->
<div class="smart-search-container">
<div class="smart-search-box">
<input type="text" id="smart-search-input" class="smart-search-input" placeholder="Search tasks... (e.g., my-tasks priority:high, project:TimeTrack, overdue)">
<button type="button" class="smart-search-clear" id="smart-search-clear" title="Clear search"><i class="ti ti-x"></i></button>
</div>
<div class="smart-search-suggestions" id="smart-search-suggestions" style="display: none;">
<!-- Suggestions will be populated here -->
</div>
</div>
<!-- Actions -->
<div class="management-actions task-actions">
<button id="add-task-btn" class="btn btn-primary"><i class="ti ti-plus"></i> Add Task</button>
<button id="refresh-tasks" class="btn btn-secondary"><i class="ti ti-refresh"></i> Refresh</button>
<button id="toggle-archived" class="btn btn-outline" title="Show/Hide Archived Tasks"><i class="ti ti-archive"></i> Show Archived</button>
</div>
</div>
</div>
<!-- Task Statistics -->
<div class="management-stats task-stats">
<div class="stat-card">
<div class="stat-number" id="total-tasks">0</div>
<div class="stat-label">Total Tasks</div>
</div>
<div class="stat-card">
<div class="stat-number" id="completed-tasks">0</div>
<div class="stat-label">Completed</div>
</div>
<div class="stat-card">
<div class="stat-number" id="in-progress-tasks">0</div>
<div class="stat-label">In Progress</div>
</div>
<div class="stat-card">
<div class="stat-number" id="overdue-tasks">0</div>
<div class="stat-label">Overdue</div>
</div>
<div class="stat-card" id="archived-stat-card" style="display: none;">
<div class="stat-number" id="archived-tasks">0</div>
<div class="stat-label">Archived</div>
</div>
</div>
<!-- Task Board -->
<div class="task-board" id="task-board">
<div class="task-column" data-status="TODO">
<div class="column-header">
<h3><i class="ti ti-circle"></i> To Do</h3>
<span class="task-count">0</span>
</div>
<div class="column-content" id="column-TODO">
<!-- Task cards will be populated here -->
</div>
</div>
<div class="task-column" data-status="IN_PROGRESS">
<div class="column-header">
<h3><i class="ti ti-player-play"></i> In Progress</h3>
<span class="task-count">0</span>
</div>
<div class="column-content" id="column-IN_PROGRESS">
<!-- Task cards will be populated here -->
</div>
</div>
<div class="task-column" data-status="IN_REVIEW">
<div class="column-header">
<h3><i class="ti ti-eye"></i> In Review</h3>
<span class="task-count">0</span>
</div>
<div class="column-content" id="column-IN_REVIEW">
<!-- Task cards will be populated here -->
</div>
</div>
<div class="task-column" data-status="DONE">
<div class="column-header">
<h3><i class="ti ti-circle-check"></i> Done</h3>
<span class="task-count">0</span>
</div>
<div class="column-content" id="column-DONE">
<!-- Task cards will be populated here -->
</div>
</div>
<div class="task-column" data-status="CANCELLED">
<div class="column-header">
<h3><i class="ti ti-circle-x"></i> Cancelled</h3>
<span class="task-count">0</span>
</div>
<div class="column-content" id="column-CANCELLED">
<!-- Task cards will be populated here -->
</div>
</div>
<div class="task-column archived-column" data-status="ARCHIVED" style="display: none;">
<div class="column-header">
<h3><i class="ti ti-archive"></i> Archived</h3>
<span class="task-count">0</span>
</div>
<div class="column-content" id="column-ARCHIVED">
<!-- Archived task cards will be populated here -->
</div>
</div>
</div>
<!-- Loading and Error States -->
<div id="loading-indicator" class="loading-spinner" style="display: none;">
<div class="spinner"></div>
<p>Loading tasks...</p>
</div>
<div id="error-message" class="error-alert" style="display: none;">
<p>Failed to load tasks. Please try again.</p>
</div>
</div>
<!-- Include Task Modal -->
{% include 'task_modal.html' %}
<!-- Styles -->
<style>
/* Smart Search Styles */
.smart-search-container {
margin-bottom: 1rem;
position: relative;
width: 100%;
flex: 1;
}
.smart-search-box {
position: relative;
display: flex;
align-items: center;
}
.smart-search-input {
width: 100%;
padding: 0.5rem 2.5rem 0.5rem 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.2s, box-shadow 0.2s;
background: white;
}
.smart-search-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
}
.smart-search-clear {
position: absolute;
right: 0.5rem;
background: none;
border: none;
font-size: 16px;
color: #999;
cursor: pointer;
padding: 0.25rem;
border-radius: 3px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s, color 0.2s;
}
.smart-search-clear:hover {
background-color: #f0f0f0;
color: #333;
}
.smart-search-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 4px 4px;
max-height: 200px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.suggestion-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
gap: 0.5rem;
transition: background-color 0.1s;
font-size: 14px;
}
.suggestion-item:hover,
.suggestion-item.active {
background-color: #f8f9fa;
}
.suggestion-item:last-child {
border-bottom: none;
}
.suggestion-type {
background: #e9ecef;
color: #495057;
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
}
.suggestion-value {
font-weight: 500;
color: #333;
}
.suggestion-description {
color: #6c757d;
font-size: 0.8rem;
}
/* Task Management Layout */
.task-controls {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
width: 100%;
}
.task-controls .smart-search-container {
flex: 1;
min-width: 300px;
max-width: 600px;
margin-bottom: 0; /* Remove margin to align with buttons */
}
.task-controls .management-actions {
flex-shrink: 0;
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Ensure all buttons and search input have same height */
.smart-search-input,
.task-controls .btn {
height: 38px; /* Standard height for consistency */
}
.task-controls .btn {
padding: 0.5rem 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap; /* Prevent button text from wrapping */
}
/* Responsive adjustments */
@media (max-width: 992px) {
.task-controls {
flex-direction: column;
align-items: stretch;
}
.task-controls .smart-search-container {
max-width: 100%;
margin-bottom: 0.5rem;
}
.task-controls .management-actions {
justify-content: center;
}
}
@media (max-width: 576px) {
.task-controls .management-actions {
flex-wrap: wrap;
gap: 0.25rem;
}
.task-controls .btn {
font-size: 0.875rem;
padding: 0.4rem 0.8rem;
}
}
/* Subtask progress styles */
.task-subtasks {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #e9ecef;
}
.subtask-progress {
display: flex;
align-items: center;
gap: 0.5rem;
}
.subtask-progress-bar {
flex: 1;
height: 6px;
background: #e9ecef;
border-radius: 3px;
overflow: hidden;
}
.subtask-progress-fill {
height: 100%;
background: #28a745;
transition: width 0.3s ease;
}
.subtask-count {
font-size: 0.75rem;
color: #6c757d;
white-space: nowrap;
}
@media (max-width: 768px) {
.task-controls {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.task-controls .smart-search-container {
min-width: auto;
max-width: none;
}
}
.task-board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.task-column {
background: #f8f9fa;
border-radius: 8px;
padding: 1rem;
min-height: 500px;
}
.column-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #ddd;
}
.column-header h3 {
margin: 0;
font-size: 1.1rem;
color: #333;
}
.task-count {
background: #007bff;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.8rem;
}
/* Archived Column Styles */
.archived-column {
background: #f1f3f4;
border: 2px dashed #9aa0a6;
opacity: 0.8;
}
.archived-column .column-header {
border-bottom-color: #9aa0a6;
}
.archived-column .task-count {
background: #9aa0a6;
}
/* Archived Task Card Styles */
.archived-column .task-card {
opacity: 0.7;
border-left: 4px solid #9aa0a6;
}
/* Button Styles */
.btn.btn-outline {
background: transparent;
border: 1px solid #6c757d;
color: #6c757d;
}
.btn.btn-outline:hover {
background: #6c757d;
color: white;
}
.btn.btn-outline.active {
background: #6c757d;
color: white;
}
/* Task Action Buttons */
.task-actions {
display: flex;
justify-content: flex-end;
gap: 0.25rem;
}
.archive-btn, .restore-btn {
background: none;
border: 1px solid #ccc;
border-radius: 4px;
padding: 0.25rem 0.5rem;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.archive-btn:hover {
background: #f8f9fa;
border-color: #6c757d;
}
.restore-btn:hover {
background: #e3f2fd;
border-color: #007bff;
}
/* Task Date Displays */
.task-completed-date, .task-archived-date {
font-size: 0.75rem;
color: #6c757d;
margin-top: 0.25rem;
font-style: italic;
}
.task-completed-date {
color: #28a745;
}
.task-archived-date {
color: #9aa0a6;
}
.column-content {
min-height: 400px;
padding: 0.5rem 0;
}
.task-card {
background: white;
border: 1px solid #ddd;
border-radius: 6px;
padding: 1rem;
margin-bottom: 0.75rem;
cursor: pointer;
transition: box-shadow 0.2s, transform 0.1s;
}
.task-card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transform: translateY(-1px);
}
.task-card.dragging {
opacity: 0.5;
transform: rotate(5deg);
}
.task-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.task-title-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
}
.task-number {
font-family: 'Courier New', monospace;
font-size: 0.75rem;
font-weight: 600;
color: #007bff;
background: #e3f2fd;
padding: 0.125rem 0.375rem;
border-radius: 3px;
align-self: flex-start;
}
.task-title {
font-weight: bold;
margin: 0;
color: #333;
font-size: 0.95rem;
}
.task-project {
font-size: 0.8rem;
color: #666;
margin-bottom: 0.5rem;
}
.task-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: #666;
}
.task-assignee {
font-weight: 500;
display: flex;
align-items: center;
gap: 0.25rem;
}
.task-assignee-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
object-fit: cover;
border: 1px solid #e9ecef;
}
.task-due-date {
color: #666;
}
.task-due-date.overdue {
color: #dc3545;
font-weight: bold;
}
@media (max-width: 768px) {
.task-board {
grid-template-columns: 1fr;
}
}
</style>
<!-- SortableJS for drag and drop -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<script>
// User preferences for date formatting
const USER_DATE_FORMAT = '{{ g.user.preferences.date_format if g.user.preferences else "ISO" }}';
// Date formatting utility function
function formatUserDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
if (isNaN(date.getTime())) return '';
switch (USER_DATE_FORMAT) {
case 'US':
return date.toLocaleDateString('en-US'); // MM/DD/YYYY
case 'EU':
case 'UK':
return date.toLocaleDateString('en-GB'); // DD/MM/YYYY
case 'Readable':
return date.toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
}); // Mon, Dec 25, 2024
case 'ISO':
default:
return date.toISOString().split('T')[0]; // YYYY-MM-DD
}
}
// Date input formatting function - formats ISO date for user input
function formatDateForInput(isoDateString) {
if (!isoDateString) return '';
const date = new Date(isoDateString);
if (isNaN(date.getTime())) return '';
return formatUserDate(isoDateString);
}
// Date parsing function - converts user-formatted date to ISO format
function parseUserDate(dateString) {
if (!dateString || dateString.trim() === '') return null;
const trimmed = dateString.trim();
let date;
switch (USER_DATE_FORMAT) {
case 'US': // MM/DD/YYYY
const usParts = trimmed.split('/');
if (usParts.length === 3) {
const month = parseInt(usParts[0], 10);
const day = parseInt(usParts[1], 10);
const year = parseInt(usParts[2], 10);
if (month >= 1 && month <= 12 && day >= 1 && day <= 31 && year > 1900) {
date = new Date(year, month - 1, day);
}
}
break;
case 'EU':
case 'UK': // DD/MM/YYYY
const euParts = trimmed.split('/');
if (euParts.length === 3) {
const day = parseInt(euParts[0], 10);
const month = parseInt(euParts[1], 10);
const year = parseInt(euParts[2], 10);
if (month >= 1 && month <= 12 && day >= 1 && day <= 31 && year > 1900) {
date = new Date(year, month - 1, day);
}
}
break;
case 'Readable': // Mon, Dec 25, 2024
date = new Date(trimmed);
break;
case 'ISO': // YYYY-MM-DD
default:
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
date = new Date(trimmed);
}
break;
}
if (!date || isNaN(date.getTime())) {
return null;
}
return date.toISOString().split('T')[0];
}
// Date validation function
function validateDateInput(inputElement, errorElement) {
const value = inputElement.value.trim();
if (!value) {
errorElement.style.display = 'none';
return true;
}
const parsedDate = parseUserDate(value);
if (!parsedDate) {
let expectedFormat;
switch (USER_DATE_FORMAT) {
case 'US': expectedFormat = 'MM/DD/YYYY'; break;
case 'EU':
case 'UK': expectedFormat = 'DD/MM/YYYY'; break;
case 'Readable': expectedFormat = 'Mon, Dec 25, 2024'; break;
case 'ISO':
default: expectedFormat = 'YYYY-MM-DD'; break;
}
errorElement.textContent = `Invalid date format. Expected: ${expectedFormat}`;
errorElement.style.display = 'block';
return false;
}
errorElement.style.display = 'none';
return true;
}
// Hybrid Date Input Functions
function setupHybridDateInput(inputId) {
const formattedInput = document.getElementById(inputId);
const nativeInput = document.getElementById(inputId + '-native');
if (!formattedInput || !nativeInput) return;
// Sync from native input to formatted input
nativeInput.addEventListener('change', function() {
if (this.value) {
formattedInput.value = formatDateForInput(this.value);
// Trigger change event on formatted input
formattedInput.dispatchEvent(new Event('change'));
}
});
// Sync from formatted input to native input
formattedInput.addEventListener('change', function() {
const isoDate = parseUserDate(this.value);
if (isoDate) {
nativeInput.value = isoDate;
} else {
nativeInput.value = '';
}
});
// Clear both inputs when formatted input is cleared
formattedInput.addEventListener('input', function() {
if (this.value === '') {
nativeInput.value = '';
}
});
}
function openCalendarPicker(inputId) {
const nativeInput = document.getElementById(inputId + '-native');
if (nativeInput) {
nativeInput.focus();
// Try showPicker() first for modern browsers
if (nativeInput.showPicker) {
try {
nativeInput.showPicker();
} catch (e) {
// Fallback to click if showPicker fails
nativeInput.click();
}
} else {
// Fallback for older browsers
nativeInput.click();
}
}
}
// Smart Search Parser
class SmartTaskSearch {
constructor() {
this.searchTokens = {
'assigned:': {
type: 'user',
suggestions: [],
description: 'Filter by assigned user'
},
'created-by:': {
type: 'user',
suggestions: [],
description: 'Filter by task creator'
},
'project:': {
type: 'project',
suggestions: [],
description: 'Filter by project'
},
'sprint:': {
type: 'sprint',
suggestions: [],
description: 'Filter by sprint'
},
'status:': {
type: 'status',
suggestions: ['not-started', 'in-progress', 'on-hold', 'completed'],
description: 'Filter by task status'
},
'priority:': {
type: 'priority',
suggestions: ['low', 'medium', 'high', 'urgent'],
description: 'Filter by priority level'
},
'due:': {
type: 'date',
suggestions: ['today', 'tomorrow', 'this-week', 'next-week', 'overdue'],
description: 'Filter by due date'
},
'created:': {
type: 'date',
suggestions: ['today', 'yesterday', 'this-week', 'last-week'],
description: 'Filter by creation date'
}
};
this.specialKeywords = [
{ value: 'overdue', type: 'keyword', description: 'Tasks past their due date' },
{ value: 'unassigned', type: 'keyword', description: 'Tasks without assignee' },
{ value: 'my-tasks', type: 'view', description: 'Tasks assigned to me' },
{ value: 'all-tasks', type: 'view', description: 'All tasks in the system' },
{ value: 'project-tasks', type: 'view', description: 'Tasks grouped by project' },
{ value: 'urgent', type: 'keyword', description: 'High priority tasks' }
];
// Add team-tasks only if user has a team
const hasTeam = {{ 'true' if g.user.team_id else 'false' }};
if (hasTeam) {
this.specialKeywords.splice(4, 0, {
value: 'team-tasks',
type: 'view',
description: 'Tasks for my team'
});
}
this.currentSuggestionIndex = -1;
}
parseQuery(searchText) {
const filters = {
assignee: '',
creator: '',
project: '',
sprint: '',
status: '',
priority: '',
dueDate: '',
createdDate: '',
view: '',
keywords: []
};
// Split search text into tokens
const tokens = searchText.match(/\S+/g) || [];
for (const token of tokens) {
if (token.includes(':')) {
const [key, value] = token.split(':', 2);
const fullKey = key + ':';
switch (fullKey) {
case 'assigned:':
filters.assignee = value.replace('@', '');
break;
case 'created-by:':
filters.creator = value.replace('@', '');
break;
case 'project:':
filters.project = value;
break;
case 'sprint:':
filters.sprint = value;
break;
case 'status:':
filters.status = this.normalizeStatus(value);
break;
case 'priority:':
filters.priority = value.toUpperCase();
break;
case 'due:':
filters.dueDate = this.parseDate(value);
break;
case 'created:':
filters.createdDate = this.parseDate(value);
break;
}
} else {
// Handle special keywords and view keywords
const lowerToken = token.toLowerCase();
if (['my-tasks', 'all-tasks', 'team-tasks', 'project-tasks'].includes(lowerToken)) {
filters.view = lowerToken;
} else {
filters.keywords.push(lowerToken);
}
}
}
return filters;
}
normalizeStatus(status) {
const statusMap = {
'not-started': 'TODO',
'to-do': 'TODO',
'todo': 'TODO',
'in-progress': 'IN_PROGRESS',
'in-review': 'IN_REVIEW',
'on-hold': 'IN_REVIEW',
'completed': 'DONE',
'done': 'DONE',
'cancelled': 'CANCELLED',
'archived': 'ARCHIVED'
};
return statusMap[status.toLowerCase()] || status.toUpperCase();
}
parseDate(dateStr) {
const today = new Date();
const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate());
switch (dateStr.toLowerCase()) {
case 'today':
return {
start: todayStart,
end: new Date(todayStart.getTime() + 24 * 60 * 60 * 1000 - 1)
};
case 'yesterday':
const yesterday = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000);
return {
start: yesterday,
end: new Date(yesterday.getTime() + 24 * 60 * 60 * 1000 - 1)
};
case 'tomorrow':
const tomorrow = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000);
return {
start: tomorrow,
end: new Date(tomorrow.getTime() + 24 * 60 * 60 * 1000 - 1)
};
case 'this-week':
const startOfWeek = new Date(todayStart);
startOfWeek.setDate(todayStart.getDate() - todayStart.getDay());
const endOfWeek = new Date(startOfWeek.getTime() + 7 * 24 * 60 * 60 * 1000 - 1);
return { start: startOfWeek, end: endOfWeek };
case 'overdue':
return { end: todayStart, type: 'before' };
default:
// Try to parse as ISO date
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
const date = new Date(dateStr);
return {
start: date,
end: new Date(date.getTime() + 24 * 60 * 60 * 1000 - 1)
};
}
return null;
}
}
async getSuggestions(query, cursorPosition) {
const suggestions = [];
// Get the current token being typed
const beforeCursor = query.substring(0, cursorPosition);
const tokens = beforeCursor.split(/\s+/);
const currentToken = tokens[tokens.length - 1] || '';
// Check if we're typing a token with ':'
if (currentToken.includes(':')) {
const [key, partial] = currentToken.split(':', 2);
const fullKey = key + ':';
if (this.searchTokens[fullKey]) {
const tokenInfo = this.searchTokens[fullKey];
// Get suggestions for this token type
if (tokenInfo.type === 'user') {
const users = await this.fetchUserSuggestions(partial);
users.forEach(user => {
suggestions.push({
type: 'user',
value: `${key}:@${user.username}`,
display: `@${user.username}`,
description: `Assigned to ${user.username}`
});
});
} else if (tokenInfo.type === 'project') {
const projects = await this.fetchProjectSuggestions(partial);
projects.forEach(project => {
suggestions.push({
type: 'project',
value: `${key}:${project.code}`,
display: project.code,
description: project.name
});
});
} else if (tokenInfo.type === 'sprint') {
const sprints = await this.fetchSprintSuggestions(partial);
sprints.forEach(sprint => {
suggestions.push({
type: 'sprint',
value: `${key}:${sprint.name}`,
display: sprint.name,
description: `Sprint (${sprint.status})`
});
});
} else {
// Static suggestions
tokenInfo.suggestions
.filter(s => s.toLowerCase().includes(partial.toLowerCase()))
.forEach(suggestion => {
suggestions.push({
type: tokenInfo.type,
value: `${key}:${suggestion}`,
display: suggestion,
description: tokenInfo.description
});
});
}
}
} else {
// Suggest token keys
Object.keys(this.searchTokens).forEach(tokenKey => {
if (tokenKey.toLowerCase().includes(currentToken.toLowerCase())) {
suggestions.push({
type: 'token',
value: tokenKey,
display: tokenKey,
description: this.searchTokens[tokenKey].description
});
}
});
// Suggest special keywords
this.specialKeywords
.filter(keyword => keyword.value.toLowerCase().includes(currentToken.toLowerCase()))
.forEach(keyword => {
suggestions.push({
type: keyword.type,
value: keyword.value,
display: keyword.value,
description: keyword.description
});
});
}
return suggestions.slice(0, 8); // Limit to 8 suggestions
}
async fetchUserSuggestions(partial) {
try {
const response = await fetch(`/api/search/users?q=${encodeURIComponent(partial)}`);
const data = await response.json();
return data.success ? data.users : [];
} catch (error) {
console.error('Error fetching user suggestions:', error);
return [];
}
}
async fetchProjectSuggestions(partial) {
try {
const response = await fetch(`/api/search/projects?q=${encodeURIComponent(partial)}`);
const data = await response.json();
return data.success ? data.projects : [];
} catch (error) {
console.error('Error fetching project suggestions:', error);
return [];
}
}
async fetchSprintSuggestions(partial) {
try {
const response = await fetch(`/api/search/sprints?q=${encodeURIComponent(partial)}`);
const data = await response.json();
return data.success ? data.sprints : [];
} catch (error) {
console.error('Error fetching sprint suggestions:', error);
return [];
}
}
}
// Task Management Controller
class UnifiedTaskManager {
constructor() {
this.tasks = [];
this.currentView = 'all';
this.filters = {
project: '',
assignee: '',
priority: '',
status: '',
startDate: '',
endDate: '',
dateField: 'created',
sprint: ''
};
this.currentTask = null;
this.sortableInstances = [];
this.showArchived = false;
this.currentUserId = {{ g.user.id|tojson }};
this.smartSearch = new SmartTaskSearch();
this.searchQuery = '';
this.isSearching = false;
}
async init() {
this.setupEventListeners();
this.setupSortable();
await this.loadTasks();
}
getUserAvatar(userId) {
// Find user in team members
const user = window.teamMembers.find(member => member.id === userId);
if (user && user.avatar_url) {
return user.avatar_url;
}
// Generate default avatar using DiceBear API
const username = user ? user.username : `user_${userId}`;
const hash = this.hashCode(username + '_' + userId);
return `https://api.dicebear.com/7.x/initials/svg?seed=${hash}&size=24&backgroundColor=ffffff`;
}
hashCode(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(16);
}
setupEventListeners() {
// Smart Search
this.setupSmartSearch();
// Actions
document.getElementById('add-task-btn').addEventListener('click', () => {
this.openTaskModal();
});
document.getElementById('refresh-tasks').addEventListener('click', () => {
this.loadTasks();
});
document.getElementById('toggle-archived').addEventListener('click', () => {
this.toggleArchivedView();
});
// Date validation
document.getElementById('task-due-date').addEventListener('blur', () => {
const input = document.getElementById('task-due-date');
const error = document.getElementById('task-due-date-error');
validateDateInput(input, error);
});
// Setup hybrid date inputs
setupHybridDateInput('task-due-date');
}
setupSortable() {
const columns = document.querySelectorAll('.column-content');
columns.forEach(column => {
const sortable = Sortable.create(column, {
group: 'tasks',
animation: 150,
onEnd: (evt) => {
this.handleTaskMove(evt);
}
});
this.sortableInstances.push(sortable);
});
}
setupSmartSearch() {
const searchInput = document.getElementById('smart-search-input');
const searchClear = document.getElementById('smart-search-clear');
const suggestions = document.getElementById('smart-search-suggestions');
let suggestionTimeout;
// Search input handling
searchInput.addEventListener('input', async (e) => {
const query = e.target.value;
this.searchQuery = query;
// Clear previous timeout
clearTimeout(suggestionTimeout);
if (query.trim() === '') {
suggestions.style.display = 'none';
this.clearSmartSearch();
return;
}
// Debounce suggestions
suggestionTimeout = setTimeout(async () => {
await this.showSuggestions(query, e.target.selectionStart);
}, 200);
});
// Handle key navigation
searchInput.addEventListener('keydown', (e) => {
if (suggestions.style.display === 'none') return;
const suggestionItems = suggestions.querySelectorAll('.suggestion-item');
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this.smartSearch.currentSuggestionIndex =
Math.min(this.smartSearch.currentSuggestionIndex + 1, suggestionItems.length - 1);
this.updateSuggestionSelection();
break;
case 'ArrowUp':
e.preventDefault();
this.smartSearch.currentSuggestionIndex =
Math.max(this.smartSearch.currentSuggestionIndex - 1, -1);
this.updateSuggestionSelection();
break;
case 'Enter':
e.preventDefault();
if (this.smartSearch.currentSuggestionIndex >= 0) {
this.applySuggestion(suggestionItems[this.smartSearch.currentSuggestionIndex]);
} else {
this.applySmartSearch();
}
break;
case 'Escape':
suggestions.style.display = 'none';
break;
case 'Tab':
if (this.smartSearch.currentSuggestionIndex >= 0) {
e.preventDefault();
this.applySuggestion(suggestionItems[this.smartSearch.currentSuggestionIndex]);
}
break;
}
});
// Apply search on enter or when losing focus
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && suggestions.style.display === 'none') {
this.applySmartSearch();
}
});
searchInput.addEventListener('blur', () => {
// Delay hiding suggestions to allow clicking
setTimeout(() => {
suggestions.style.display = 'none';
}, 200);
});
// Clear search
searchClear.addEventListener('click', () => {
searchInput.value = '';
this.searchQuery = '';
suggestions.style.display = 'none';
this.clearSmartSearch();
});
// Hide suggestions when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.smart-search-container')) {
suggestions.style.display = 'none';
}
});
}
async showSuggestions(query, cursorPosition) {
const suggestions = document.getElementById('smart-search-suggestions');
const suggestionItems = await this.smartSearch.getSuggestions(query, cursorPosition);
if (suggestionItems.length === 0) {
suggestions.style.display = 'none';
return;
}
suggestions.innerHTML = '';
suggestionItems.forEach((item, index) => {
const div = document.createElement('div');
div.className = 'suggestion-item';
div.innerHTML = `
<span class="suggestion-type">${item.type}</span>
<span class="suggestion-value">${item.display}</span>
<span class="suggestion-description">${item.description}</span>
`;
div.addEventListener('click', () => {
this.applySuggestion(div, item);
});
suggestions.appendChild(div);
});
suggestions.style.display = 'block';
this.smartSearch.currentSuggestionIndex = -1;
}
updateSuggestionSelection() {
const suggestionItems = document.querySelectorAll('.suggestion-item');
suggestionItems.forEach((item, index) => {
item.classList.toggle('active', index === this.smartSearch.currentSuggestionIndex);
});
}
applySuggestion(suggestionElement, suggestionData = null) {
const searchInput = document.getElementById('smart-search-input');
const suggestions = document.getElementById('smart-search-suggestions');
if (!suggestionData) {
const type = suggestionElement.querySelector('.suggestion-type').textContent;
const value = suggestionElement.querySelector('.suggestion-value').textContent;
suggestionData = { type, value, display: value };
}
// Get current query and replace last token
const currentQuery = searchInput.value;
const cursorPos = searchInput.selectionStart;
const beforeCursor = currentQuery.substring(0, cursorPos);
const afterCursor = currentQuery.substring(cursorPos);
const tokens = beforeCursor.split(/\s+/);
tokens[tokens.length - 1] = suggestionData.value;
const newQuery = tokens.join(' ') + ' ' + afterCursor;
searchInput.value = newQuery.trim();
this.searchQuery = newQuery.trim();
// Position cursor after the completed token
const newCursorPos = tokens.join(' ').length;
searchInput.setSelectionRange(newCursorPos, newCursorPos);
suggestions.style.display = 'none';
searchInput.focus();
// Apply the search immediately if it's a complete filter
if (suggestionData.type !== 'token') {
this.applySmartSearch();
}
}
applySmartSearch() {
if (!this.searchQuery.trim()) {
this.clearSmartSearch();
return;
}
const parsedFilters = this.smartSearch.parseQuery(this.searchQuery);
// Convert smart search filters to existing filter format
if (parsedFilters.assignee) {
// Find user by username
const user = this.getTeamMemberByUsername(parsedFilters.assignee);
this.filters.assignee = user ? user.id : '';
}
if (parsedFilters.project) {
// Find project by code
const project = this.getProjectByCode(parsedFilters.project);
this.filters.project = project ? project.id : '';
}
if (parsedFilters.sprint) {
// Find sprint by name
const sprint = this.getSprintByName(parsedFilters.sprint);
this.filters.sprint = sprint ? sprint.id : parsedFilters.sprint;
}
if (parsedFilters.status) {
this.filters.status = parsedFilters.status;
}
if (parsedFilters.priority) {
this.filters.priority = parsedFilters.priority;
}
// Handle date filters
if (parsedFilters.dueDate) {
this.filters.dateField = 'due';
if (parsedFilters.dueDate.start) {
this.filters.startDate = parsedFilters.dueDate.start.toISOString().split('T')[0];
}
if (parsedFilters.dueDate.end) {
this.filters.endDate = parsedFilters.dueDate.end.toISOString().split('T')[0];
}
}
if (parsedFilters.createdDate) {
this.filters.dateField = 'created';
if (parsedFilters.createdDate.start) {
this.filters.startDate = parsedFilters.createdDate.start.toISOString().split('T')[0];
}
if (parsedFilters.createdDate.end) {
this.filters.endDate = parsedFilters.createdDate.end.toISOString().split('T')[0];
}
}
// Handle view switching
if (parsedFilters.view) {
switch (parsedFilters.view) {
case 'my-tasks':
this.currentView = 'personal';
break;
case 'all-tasks':
this.currentView = 'all';
break;
case 'team-tasks':
this.currentView = 'team';
break;
case 'project-tasks':
this.currentView = 'project';
break;
}
}
// Handle special keywords
if (parsedFilters.keywords.includes('overdue')) {
this.filters.dateField = 'due';
this.filters.endDate = new Date().toISOString().split('T')[0];
}
if (parsedFilters.keywords.includes('unassigned')) {
this.filters.assignee = 'unassigned';
}
if (parsedFilters.keywords.includes('urgent')) {
this.filters.priority = 'URGENT';
}
this.isSearching = true;
this.applyFilters();
}
clearSmartSearch() {
// Reset all filters and view
this.filters = {
project: '',
assignee: '',
priority: '',
status: '',
startDate: '',
endDate: '',
dateField: 'created',
sprint: ''
};
this.currentView = 'all'; // Reset to default view
this.isSearching = false;
this.applyFilters();
}
// Helper methods for finding data by name/code
getTeamMemberByUsername(username) {
// This would need to be populated from the available team members
// For now, return null and let the backend handle it
return null;
}
getProjectByCode(code) {
// This would need to be populated from available projects
return null;
}
getSprintByName(name) {
// This would need to be populated from available sprints
return null;
}
async loadSprintsForModal() {
try {
const response = await fetch('/api/sprints');
const data = await response.json();
if (data.success) {
// Update task modal sprint dropdown
const taskSprintSelect = document.getElementById('task-sprint');
// Clear existing options except the default "No Sprint" option
taskSprintSelect.innerHTML = '<option value="">No Sprint</option>';
// Add sprint options to modal dropdown
data.sprints.forEach(sprint => {
const modalOption = document.createElement('option');
modalOption.value = sprint.id;
modalOption.textContent = `${sprint.name} (${sprint.status})`;
taskSprintSelect.appendChild(modalOption);
});
}
} catch (error) {
console.error('Error loading sprints:', error);
}
}
async loadTasks() {
document.getElementById('loading-indicator').style.display = 'flex';
document.getElementById('error-message').style.display = 'none';
try {
const response = await fetch('/api/tasks/unified');
const data = await response.json();
if (data.success) {
this.tasks = data.tasks;
this.renderTasks();
this.updateStatistics();
} else {
throw new Error(data.message || 'Failed to load tasks');
}
} catch (error) {
console.error('Error loading tasks:', error);
document.getElementById('error-message').style.display = 'block';
} finally {
document.getElementById('loading-indicator').style.display = 'none';
}
}
renderTasks() {
// Clear all columns
document.querySelectorAll('.column-content').forEach(column => {
column.innerHTML = '';
});
// Filter tasks based on current view and filters
const filteredTasks = this.getFilteredTasks();
// Group tasks by status
const tasksByStatus = {
'TODO': [],
'IN_PROGRESS': [],
'IN_REVIEW': [],
'DONE': [],
'CANCELLED': [],
'ARCHIVED': []
};
filteredTasks.forEach(task => {
if (tasksByStatus[task.status]) {
tasksByStatus[task.status].push(task);
}
});
// Render tasks in each column
Object.keys(tasksByStatus).forEach(status => {
const column = document.getElementById(`column-${status}`);
const tasks = tasksByStatus[status];
tasks.forEach(task => {
const taskCard = this.createTaskCard(task);
column.appendChild(taskCard);
});
// Update task count
const countElement = column.parentElement.querySelector('.task-count');
countElement.textContent = tasks.length;
});
}
getFilteredTasks() {
return this.tasks.filter(task => {
// View filter
if (this.currentView === 'personal' && task.assigned_to_id !== this.currentUserId) {
return false;
}
if (this.currentView === 'team' && !task.is_team_task) {
return false;
}
if (this.currentView === 'project' && !task.project_id) {
return false;
}
// Project filter
if (this.filters.project && task.project_id != this.filters.project) {
return false;
}
// Assignee filter
if (this.filters.assignee) {
if (this.filters.assignee === 'unassigned' && task.assigned_to_id) {
return false;
}
if (this.filters.assignee !== 'unassigned' && task.assigned_to_id != this.filters.assignee) {
return false;
}
}
// Priority filter
if (this.filters.priority && task.priority !== this.filters.priority) {
return false;
}
// Status filter
if (this.filters.status && task.status !== this.filters.status) {
return false;
}
// Date range filter
if (this.filters.startDate || this.filters.endDate) {
let taskDate = null;
switch (this.filters.dateField) {
case 'created':
taskDate = task.created_at ? new Date(task.created_at) : null;
break;
case 'due':
taskDate = task.due_date ? new Date(task.due_date) : null;
break;
case 'completed':
taskDate = task.completed_date ? new Date(task.completed_date) : null;
break;
}
if (taskDate) {
if (this.filters.startDate && taskDate < new Date(this.filters.startDate)) {
return false;
}
if (this.filters.endDate && taskDate > new Date(this.filters.endDate)) {
return false;
}
} else if (this.filters.startDate || this.filters.endDate) {
// If we're filtering by date but task has no date in the selected field, exclude it
return false;
}
}
// Sprint filter
if (this.filters.sprint) {
if (this.filters.sprint === 'no-sprint' && task.sprint_id) {
return false;
}
if (this.filters.sprint === 'current' && !task.is_current_sprint) {
return false;
}
if (this.filters.sprint !== 'no-sprint' && this.filters.sprint !== 'current' &&
task.sprint_id != this.filters.sprint) {
return false;
}
}
return true;
});
}
createTaskCard(task) {
const card = document.createElement('div');
card.className = 'management-card task-card';
card.dataset.taskId = task.id;
card.addEventListener('click', () => this.openTaskModal(task));
const dueDate = task.due_date ? new Date(task.due_date) : null;
const isOverdue = dueDate && dueDate < new Date() && task.status !== 'COMPLETED' && task.status !== 'ARCHIVED';
// Add archive/restore buttons for completed and archived tasks
let actionButtons = '';
if (task.status === 'COMPLETED') {
actionButtons = `
<div class="task-actions">
<button class="archive-btn" onclick="taskManager.archiveTask(${task.id}); event.stopPropagation();" title="Archive Task"><i class="ti ti-archive"></i></button>
</div>
`;
} else if (task.status === 'ARCHIVED') {
actionButtons = `
<div class="task-actions">
<button class="restore-btn" onclick="taskManager.restoreTask(${task.id}); event.stopPropagation();" title="Restore Task"><i class="ti ti-arrow-back-up"></i></button>
</div>
`;
}
card.innerHTML = `
<div class="task-card-header">
<div class="task-title-section">
<span class="task-number">${task.task_number || 'TSK-???'}</span>
<h4 class="task-title">${task.name}</h4>
</div>
<span class="priority-badge ${task.priority}">${task.priority}</span>
</div>
${task.project_name ? `<div class="task-project">${task.project_code} - ${task.project_name}</div>` : ''}
<div class="task-meta">
<span class="task-assignee">
${task.assigned_to_id ? `<img src="${this.getUserAvatar(task.assigned_to_id)}" alt="${task.assigned_to_name}" class="task-assignee-avatar">` : ''}
${task.assigned_to_name || 'Unassigned'}
</span>
${dueDate ? `<span class="task-due-date ${isOverdue ? 'overdue' : ''}">${formatUserDate(task.due_date)}</span>` : ''}
</div>
${task.subtasks && task.subtasks.length > 0 ? this.renderSubtaskProgress(task.subtasks) : ''}
${task.status === 'COMPLETED' ? `<div class="task-completed-date">Completed: ${formatUserDate(task.completed_date)}</div>` : ''}
${task.status === 'ARCHIVED' ? `<div class="task-archived-date">Archived: ${formatUserDate(task.archived_date)}</div>` : ''}
${actionButtons}
`;
return card;
}
renderSubtaskProgress(subtasks) {
if (!subtasks || subtasks.length === 0) return '';
const completedCount = subtasks.filter(s => s.status === 'COMPLETED').length;
const totalCount = subtasks.length;
const percentage = Math.round((completedCount / totalCount) * 100);
return `
<div class="task-subtasks">
<div class="subtask-progress">
<div class="subtask-progress-bar">
<div class="subtask-progress-fill" style="width: ${percentage}%"></div>
</div>
<span class="subtask-count">${completedCount}/${totalCount} subtasks</span>
</div>
</div>
`;
}
applyFilters() {
this.renderTasks();
}
updateStatistics() {
const total = this.tasks.length;
const completed = this.tasks.filter(t => t.status === 'COMPLETED').length;
const inProgress = this.tasks.filter(t => t.status === 'IN_PROGRESS').length;
const archived = this.tasks.filter(t => t.status === 'ARCHIVED').length;
const overdue = this.tasks.filter(t => {
const dueDate = t.due_date ? new Date(t.due_date) : null;
return dueDate && dueDate < new Date() && t.status !== 'COMPLETED' && t.status !== 'ARCHIVED';
}).length;
document.getElementById('total-tasks').textContent = total;
document.getElementById('completed-tasks').textContent = completed;
document.getElementById('in-progress-tasks').textContent = inProgress;
document.getElementById('overdue-tasks').textContent = overdue;
document.getElementById('archived-tasks').textContent = archived;
}
toggleArchivedView() {
this.showArchived = !this.showArchived;
const toggleBtn = document.getElementById('toggle-archived');
const archivedColumn = document.querySelector('.archived-column');
const archivedStatCard = document.getElementById('archived-stat-card');
if (this.showArchived) {
toggleBtn.innerHTML = '<i class="ti ti-archive"></i> Hide Archived';
toggleBtn.classList.add('active');
archivedColumn.style.display = 'block';
archivedStatCard.style.display = 'block';
} else {
toggleBtn.innerHTML = '<i class="ti ti-archive"></i> Show Archived';
toggleBtn.classList.remove('active');
archivedColumn.style.display = 'none';
archivedStatCard.style.display = 'none';
}
this.renderTasks();
}
async archiveTask(taskId) {
if (confirm('Are you sure you want to archive this task? It will be moved to the archived section.')) {
try {
const response = await fetch(`/api/tasks/${taskId}/archive`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const data = await response.json();
if (data.success) {
await this.loadTasks();
} else {
alert('Failed to archive task: ' + data.message);
}
} catch (error) {
console.error('Error archiving task:', error);
alert('Failed to archive task: ' + error.message);
}
}
}
async restoreTask(taskId) {
if (confirm('Are you sure you want to restore this task? It will be moved back to completed status.')) {
try {
const response = await fetch(`/api/tasks/${taskId}/restore`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const data = await response.json();
if (data.success) {
await this.loadTasks();
} else {
alert('Failed to restore task: ' + data.message);
}
} catch (error) {
console.error('Error restoring task:', error);
alert('Failed to restore task: ' + error.message);
}
}
}
async refreshTaskCard(taskId) {
// Fetch updated task data
try {
const response = await fetch(`/api/tasks/${taskId}`);
const task = await response.json();
if (task) {
// Find and replace the task card
const oldCard = document.querySelector(`[data-task-id="${taskId}"]`);
if (oldCard) {
const newCard = this.createTaskCard(task);
oldCard.replaceWith(newCard);
}
// Update task in local array
const index = this.tasks.findIndex(t => t.id == taskId);
if (index !== -1) {
this.tasks[index] = task;
}
}
} catch (error) {
console.error('Error refreshing task card:', error);
}
}
async handleTaskMove(evt) {
const taskId = evt.item.dataset.taskId;
const newStatus = evt.to.id.replace('column-', '');
try {
const response = await fetch(`/api/tasks/${taskId}/status`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ status: newStatus })
});
const data = await response.json();
if (data.success) {
// Update local task data
const task = this.tasks.find(t => t.id == taskId);
if (task) {
task.status = newStatus;
this.updateStatistics();
}
} else {
throw new Error(data.message || 'Failed to update task');
}
} catch (error) {
console.error('Error updating task status:', error);
// Revert the move
this.renderTasks();
}
}
async openTaskModal(task = null) {
this.currentTask = task;
const modal = document.getElementById('task-modal');
// Load sprints for the modal dropdown
await this.loadSprintsForModal();
if (task) {
document.getElementById('modal-title').textContent = 'Edit Task';
document.getElementById('task-id').value = task.id;
document.getElementById('task-name').value = task.name;
document.getElementById('task-description').value = task.description || '';
document.getElementById('task-priority').value = task.priority;
document.getElementById('task-project').value = task.project_id || '';
document.getElementById('task-assignee').value = task.assigned_to_id || '';
document.getElementById('task-due-date').value = formatDateForInput(task.due_date) || '';
document.getElementById('task-estimated-hours').value = task.estimated_hours || '';
document.getElementById('task-status').value = task.status;
document.getElementById('task-sprint').value = task.sprint_id || '';
document.getElementById('delete-task-btn').style.display = 'inline-block';
// Load dependencies
await this.loadDependencies(task.id);
// Initialize subtasks
initializeSubtasks(task.id);
// Show comments section and load comments
document.getElementById('comments-section').style.display = 'block';
await this.loadComments(task.id);
} else {
document.getElementById('modal-title').textContent = 'Add New Task';
document.getElementById('task-form').reset();
document.getElementById('task-id').value = '';
document.getElementById('delete-task-btn').style.display = 'none';
// Clear dependencies
this.clearDependencies();
// Initialize empty subtasks
initializeSubtasks(null);
// Hide comments section for new tasks
document.getElementById('comments-section').style.display = 'none';
}
modal.style.display = 'block';
}
async saveTask() {
// Validate date input before saving
const dueDateInput = document.getElementById('task-due-date');
const dueDateError = document.getElementById('task-due-date-error');
if (!validateDateInput(dueDateInput, dueDateError)) {
dueDateInput.focus();
return;
}
const taskData = {
name: document.getElementById('task-name').value,
description: document.getElementById('task-description').value,
priority: document.getElementById('task-priority').value,
project_id: document.getElementById('task-project').value || null,
assigned_to_id: document.getElementById('task-assignee').value || null,
due_date: parseUserDate(document.getElementById('task-due-date').value) || null,
estimated_hours: document.getElementById('task-estimated-hours').value || null,
status: document.getElementById('task-status').value,
sprint_id: document.getElementById('task-sprint').value || null
};
const taskId = document.getElementById('task-id').value;
const isEdit = taskId !== '';
try {
const response = await fetch(`/api/tasks${isEdit ? `/${taskId}` : ''}`, {
method: isEdit ? 'PUT' : 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(taskData)
});
const data = await response.json();
if (data.success) {
// Save subtasks if we have a task ID
const savedTaskId = isEdit ? taskId : data.task.id;
if (savedTaskId) {
await saveSubtasks(savedTaskId);
}
closeTaskModal();
await this.loadTasks();
} else {
throw new Error(data.message || 'Failed to save task');
}
} catch (error) {
console.error('Error saving task:', error);
alert('Failed to save task: ' + error.message);
}
}
async deleteTask() {
if (!this.currentTask) return;
if (confirm('Are you sure you want to delete this task?')) {
try {
const response = await fetch(`/api/tasks/${this.currentTask.id}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
closeTaskModal();
await this.loadTasks();
} else {
throw new Error(data.message || 'Failed to delete task');
}
} catch (error) {
console.error('Error deleting task:', error);
alert('Failed to delete task: ' + error.message);
}
}
}
// Dependency management methods
async loadDependencies(taskId) {
try {
const response = await fetch(`/api/tasks/${taskId}/dependencies`);
const data = await response.json();
if (data.success) {
this.renderDependencies(data.dependencies);
} else {
console.error('Failed to load dependencies:', data.message);
}
} catch (error) {
console.error('Error loading dependencies:', error);
}
}
renderDependencies(dependencies) {
const blockedByContainer = document.getElementById('blocked-by-container');
const blocksContainer = document.getElementById('blocks-container');
// Clear existing dependencies
blockedByContainer.innerHTML = '';
blocksContainer.innerHTML = '';
// Render blocked by dependencies
dependencies.blocked_by.forEach(dep => {
const depElement = this.createDependencyElement(dep, 'blocked_by');
blockedByContainer.appendChild(depElement);
});
// Render blocks dependencies
dependencies.blocks.forEach(dep => {
const depElement = this.createDependencyElement(dep, 'blocks');
blocksContainer.appendChild(depElement);
});
}
createDependencyElement(dependency, type) {
const element = document.createElement('div');
element.className = 'dependency-item';
element.innerHTML = `
<div class="dependency-task-info">
<span class="dependency-task-number">${dependency.task_number}</span>
<span class="dependency-task-title">${dependency.name}</span>
</div>
<button class="dependency-remove-btn" onclick="taskManager.removeDependency(${dependency.id}, '${type}')">Remove</button>
`;
return element;
}
clearDependencies() {
document.getElementById('blocked-by-container').innerHTML = '';
document.getElementById('blocks-container').innerHTML = '';
document.getElementById('blocked-by-input').value = '';
document.getElementById('blocks-input').value = '';
}
async addDependency(taskNumber, type) {
const taskId = document.getElementById('task-id').value;
if (!taskId) {
alert('Please save the task first before adding dependencies.');
return;
}
if (!taskNumber || taskNumber.trim() === '') {
alert('Please enter a task number.');
return;
}
try {
const response = await fetch(`/api/tasks/${taskId}/dependencies`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
task_number: taskNumber.trim(),
type: type
})
});
const data = await response.json();
if (data.success) {
// Reload dependencies
await this.loadDependencies(taskId);
// Clear input
document.getElementById(`${type.replace('_', '-')}-input`).value = '';
} else {
alert('Failed to add dependency: ' + data.message);
}
} catch (error) {
console.error('Error adding dependency:', error);
alert('Failed to add dependency: ' + error.message);
}
}
async removeDependency(dependencyTaskId, type) {
const taskId = document.getElementById('task-id').value;
if (!taskId) return;
if (confirm('Are you sure you want to remove this dependency?')) {
try {
const response = await fetch(`/api/tasks/${taskId}/dependencies/${dependencyTaskId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: type
})
});
const data = await response.json();
if (data.success) {
// Reload dependencies
await this.loadDependencies(taskId);
} else {
alert('Failed to remove dependency: ' + data.message);
}
} catch (error) {
console.error('Error removing dependency:', error);
alert('Failed to remove dependency: ' + error.message);
}
}
}
// Comment management methods
async loadComments(taskId) {
try {
const response = await fetch(`/api/tasks/${taskId}/comments`);
const data = await response.json();
if (data.success) {
this.renderComments(data.comments);
// Show/hide team visibility option based on company settings
const visibilitySelect = document.getElementById('comment-visibility');
if (data.allow_team_visibility) {
visibilitySelect.style.display = 'inline-block';
} else {
visibilitySelect.style.display = 'none';
}
} else {
console.error('Failed to load comments:', data.message);
}
} catch (error) {
console.error('Error loading comments:', error);
}
}
renderComments(comments) {
const container = document.getElementById('comments-container');
container.innerHTML = '';
if (comments.length === 0) {
container.innerHTML = '<p class="no-comments">No comments yet. Be the first to comment!</p>';
return;
}
comments.forEach(comment => {
const commentElement = this.createCommentElement(comment);
container.appendChild(commentElement);
});
}
createCommentElement(comment) {
const element = document.createElement('div');
element.className = 'comment-item';
element.dataset.commentId = comment.id;
const visibilityBadge = comment.visibility === 'Team' ?
'<span class="comment-visibility-badge team"><i class="ti ti-users"></i> Team</span>' : '';
const editedText = comment.is_edited ?
` <span class="comment-edited">(edited)</span>` : '';
element.innerHTML = `
<div class="comment-header">
<div class="comment-author-info">
<img src="${comment.author.avatar_url}" alt="${comment.author.username}" class="comment-author-avatar">
<div class="comment-author-details">
<span class="comment-author">${comment.author.username}</span>
<span class="comment-time">${this.formatRelativeTime(comment.created_at)}${editedText}</span>
</div>
</div>
${visibilityBadge}
</div>
<div class="comment-content">${this.escapeHtml(comment.content)}</div>
<div class="comment-actions">
${comment.can_edit ? '<button class="comment-action" onclick="taskManager.editComment(' + comment.id + ')">Edit</button>' : ''}
${comment.can_delete ? '<button class="comment-action" onclick="taskManager.deleteComment(' + comment.id + ')">Delete</button>' : ''}
<button class="comment-action" onclick="taskManager.replyToComment(${comment.id})">Reply</button>
</div>
<div class="comment-edit-form" id="comment-edit-${comment.id}" style="display: none;">
<textarea class="comment-edit-textarea">${this.escapeHtml(comment.content)}</textarea>
<div class="comment-edit-actions">
<button class="btn btn-sm btn-primary" onclick="taskManager.saveCommentEdit(${comment.id})">Save</button>
<button class="btn btn-sm btn-secondary" onclick="taskManager.cancelCommentEdit(${comment.id})">Cancel</button>
</div>
</div>
<div class="comment-reply-form" id="comment-reply-${comment.id}" style="display: none;">
<textarea placeholder="Write a reply..." rows="2"></textarea>
<div class="comment-edit-actions">
<button class="btn btn-sm btn-primary" onclick="taskManager.saveReply(${comment.id})">Reply</button>
<button class="btn btn-sm btn-secondary" onclick="taskManager.cancelReply(${comment.id})">Cancel</button>
</div>
</div>
`;
// Add replies if any
if (comment.replies && comment.replies.length > 0) {
const repliesContainer = document.createElement('div');
repliesContainer.className = 'comment-replies';
comment.replies.forEach(reply => {
const replyElement = this.createReplyElement(reply);
repliesContainer.appendChild(replyElement);
});
element.appendChild(repliesContainer);
}
return element;
}
createReplyElement(reply) {
const element = document.createElement('div');
element.className = 'comment-reply';
element.dataset.commentId = reply.id;
const editedText = reply.is_edited ?
` <span class="comment-edited">(edited)</span>` : '';
element.innerHTML = `
<div class="comment-header">
<div class="comment-author-info">
<img src="${reply.author.avatar_url}" alt="${reply.author.username}" class="comment-author-avatar">
<div class="comment-author-details">
<span class="comment-author">${reply.author.username}</span>
<span class="comment-time">${this.formatRelativeTime(reply.created_at)}${editedText}</span>
</div>
</div>
</div>
<div class="comment-content">${this.escapeHtml(reply.content)}</div>
<div class="comment-actions">
${reply.can_edit ? '<button class="comment-action" onclick="taskManager.editComment(' + reply.id + ')">Edit</button>' : ''}
${reply.can_delete ? '<button class="comment-action" onclick="taskManager.deleteComment(' + reply.id + ')">Delete</button>' : ''}
</div>
`;
return element;
}
async addComment() {
const taskId = document.getElementById('task-id').value;
const contentTextarea = document.getElementById('new-comment');
const content = contentTextarea.value.trim();
const visibility = document.getElementById('comment-visibility').value;
if (!taskId || !content) {
alert('Please enter a comment');
return;
}
try {
const response = await fetch(`/api/tasks/${taskId}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: content,
visibility: visibility
})
});
const data = await response.json();
if (data.success) {
contentTextarea.value = '';
await this.loadComments(taskId);
} else {
alert('Failed to post comment: ' + data.message);
}
} catch (error) {
console.error('Error posting comment:', error);
alert('Failed to post comment: ' + error.message);
}
}
editComment(commentId) {
const commentElement = document.querySelector(`[data-comment-id="${commentId}"]`);
const editForm = document.getElementById(`comment-edit-${commentId}`);
const content = commentElement.querySelector('.comment-content');
content.style.display = 'none';
editForm.style.display = 'block';
}
cancelCommentEdit(commentId) {
const editForm = document.getElementById(`comment-edit-${commentId}`);
const content = document.querySelector(`[data-comment-id="${commentId}"] .comment-content`);
content.style.display = 'block';
editForm.style.display = 'none';
}
async saveCommentEdit(commentId) {
const editForm = document.getElementById(`comment-edit-${commentId}`);
const textarea = editForm.querySelector('textarea');
const content = textarea.value.trim();
if (!content) {
alert('Comment cannot be empty');
return;
}
try {
const response = await fetch(`/api/comments/${commentId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: content
})
});
const data = await response.json();
if (data.success) {
const taskId = document.getElementById('task-id').value;
await this.loadComments(taskId);
} else {
alert('Failed to update comment: ' + data.message);
}
} catch (error) {
console.error('Error updating comment:', error);
alert('Failed to update comment: ' + error.message);
}
}
async deleteComment(commentId) {
if (!confirm('Are you sure you want to delete this comment?')) {
return;
}
try {
const response = await fetch(`/api/comments/${commentId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
});
// Check if response is JSON
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
console.error('Response is not JSON:', await response.text());
if (response.status === 401) {
alert('Your session has expired. Please refresh the page and login again.');
window.location.href = '/login';
return;
} else if (response.status === 404) {
alert('Comment not found. It may have been already deleted.');
} else {
alert('Failed to delete comment. Please try again.');
}
return;
}
const data = await response.json();
if (data.success) {
const taskId = document.getElementById('task-id').value;
await this.loadComments(taskId);
} else {
alert('Failed to delete comment: ' + data.message);
}
} catch (error) {
console.error('Error deleting comment:', error);
alert('Failed to delete comment: ' + error.message);
}
}
replyToComment(commentId) {
const replyForm = document.getElementById(`comment-reply-${commentId}`);
replyForm.style.display = 'block';
replyForm.querySelector('textarea').focus();
}
cancelReply(commentId) {
const replyForm = document.getElementById(`comment-reply-${commentId}`);
replyForm.style.display = 'none';
replyForm.querySelector('textarea').value = '';
}
async saveReply(parentCommentId) {
const taskId = document.getElementById('task-id').value;
const replyForm = document.getElementById(`comment-reply-${parentCommentId}`);
const content = replyForm.querySelector('textarea').value.trim();
if (!content) {
alert('Please enter a reply');
return;
}
try {
const response = await fetch(`/api/tasks/${taskId}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: content,
parent_comment_id: parentCommentId,
visibility: 'COMPANY' // Replies inherit parent visibility
})
});
const data = await response.json();
if (data.success) {
replyForm.style.display = 'none';
replyForm.querySelector('textarea').value = '';
await this.loadComments(taskId);
} else {
alert('Failed to post reply: ' + data.message);
}
} catch (error) {
console.error('Error posting reply:', error);
alert('Failed to post reply: ' + error.message);
}
}
formatRelativeTime(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) {
return 'just now';
} else if (diffMins < 60) {
return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
} else if (diffHours < 24) {
return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
} else if (diffDays < 7) {
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
} else {
return date.toLocaleDateString();
}
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Global functions
let taskManager;
document.addEventListener('DOMContentLoaded', function() {
taskManager = new UnifiedTaskManager();
taskManager.init();
});
function closeTaskModal() {
document.getElementById('task-modal').style.display = 'none';
taskManager.currentTask = null;
}
function saveTask() {
taskManager.saveTask();
}
function deleteTask() {
taskManager.deleteTask();
}
function addComment() {
taskManager.addComment();
}
function addSubtask() {
// TODO: Implement subtask functionality
}
function addBlockedBy() {
const input = document.getElementById('blocked-by-input');
const taskNumber = input.value.trim();
if (taskNumber) {
taskManager.addDependency(taskNumber, 'blocked_by');
}
}
function addBlocks() {
const input = document.getElementById('blocks-input');
const taskNumber = input.value.trim();
if (taskNumber) {
taskManager.addDependency(taskNumber, 'blocks');
}
}
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('task-modal');
if (event.target === modal) {
closeTaskModal();
}
};
// Make team members available globally for subtasks
window.teamMembers = {{ team_members | tojson }};
// Make refreshTaskCard available globally
window.refreshTaskCard = function(taskId) {
if (window.taskManager) {
window.taskManager.refreshTaskCard(taskId);
}
};
</script>
<!-- Include subtasks functionality -->
<script src="{{ url_for('static', filename='js/subtasks.js') }}"></script>
{% endblock %}