Files
TimeTracker/app/templates/tasks/list.html
Dries Peeters 0c316ac5e1 feat: Implement bulk operations and status management improvements
Major improvements:
- Add bulk operations functionality across clients, projects, and tasks
- Implement deletion and status management enhancements
- Add project code field with database migration (022)
- Improve inactive status handling for projects

Backend changes:
- Update project model with new code field and status logic
- Enhance routes for clients, projects, and tasks with bulk actions
- Add migration for project_code field (022_add_project_code_field.py)

Frontend updates:
- Refactor bulk actions widget component
- Update clients list and detail views with bulk operations
- Enhance project list, view, and kanban templates
- Improve task list, edit, view, and kanban displays
- Update base template with UI improvements
- Refine saved filters and time entry templates lists

Testing:
- Add test_project_inactive_status.py for status handling
- Update test_tasks_templates.py with new functionality

Documentation:
- Add BULK_OPERATIONS_IMPROVEMENTS.md
- Add DELETION_AND_STATUS_IMPROVEMENTS.md
- Add docs/QUICK_WINS_IMPLEMENTATION.md
- Update ALL_BUGFIXES_SUMMARY.md and IMPLEMENTATION_COMPLETE.md
2025-10-23 12:41:22 +02:00

359 lines
18 KiB
HTML

{% extends "base.html" %}
{% from "components/ui.html" import page_header, stat_card, badge, confirm_dialog %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Tasks'}
] %}
{{ page_header(
icon_class='fas fa-tasks',
title_text='Tasks',
subtitle_text='Manage your tasks here',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("tasks.create_task") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Task</a>'
) }}
<!-- Task Summary Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6 stagger-animation">
{% set todo_count = tasks|selectattr('status', 'equalto', 'todo')|list|length %}
{% set in_progress_count = tasks|selectattr('status', 'equalto', 'in_progress')|list|length %}
{% set review_count = tasks|selectattr('status', 'equalto', 'review')|list|length %}
{% set done_count = tasks|selectattr('status', 'equalto', 'done')|list|length %}
{{ stat_card('To Do', todo_count, 'fas fa-list', 'slate-500') }}
{{ stat_card('In Progress', in_progress_count, 'fas fa-spinner', 'blue-500') }}
{{ stat_card('In Review', review_count, 'fas fa-eye', 'amber-500') }}
{{ stat_card('Completed', done_count, 'fas fa-check-circle', 'green-500') }}
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">Filter Tasks</h2>
<button type="button" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" id="toggleFilters" onclick="toggleFilterVisibility()" title="{{ _('Toggle Filters') }}">
<i class="fas fa-chevron-up" id="filterToggleIcon"></i>
</button>
</div>
<div id="filterBody">
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" data-filter-form>
<div class="lg:col-span-1">
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
<input type="text" name="search" id="search" value="{{ search or '' }}" class="form-input">
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
<select name="status" id="status" class="form-input">
<option value="">All</option>
<option value="todo" {% if status == 'todo' %}selected{% endif %}>To Do</option>
<option value="in_progress" {% if status == 'in_progress' %}selected{% endif %}>In Progress</option>
<option value="review" {% if status == 'review' %}selected{% endif %}>Review</option>
<option value="done" {% if status == 'done' %}selected{% endif %}>Done</option>
<option value="cancelled" {% if status == 'cancelled' %}selected{% endif %}>Cancelled</option>
</select>
</div>
<div>
<label for="priority" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Priority</label>
<select name="priority" id="priority" class="form-input">
<option value="">All</option>
<option value="low" {% if priority == 'low' %}selected{% endif %}>Low</option>
<option value="medium" {% if priority == 'medium' %}selected{% endif %}>Medium</option>
<option value="high" {% if priority == 'high' %}selected{% endif %}>High</option>
<option value="urgent" {% if priority == 'urgent' %}selected{% endif %}>Urgent</option>
</select>
</div>
<div>
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
<select name="project_id" id="project_id" class="form-input">
<option value="">All</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="assigned_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Assigned To</label>
<select name="assigned_to" id="assigned_to" class="form-input">
<option value="">All</option>
{% for user in users %}
<option value="{{ user.id }}" {% if assigned_to == user.id %}selected{% endif %}>{{ user.display_name }}</option>
{% endfor %}
</select>
</div>
<div class="flex items-center pt-5">
<input type="checkbox" name="overdue" id="overdue" value="1" {% if overdue %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="overdue" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">Overdue only</label>
</div>
<div class="col-span-full flex justify-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Filter</button>
</div>
</form>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<div class="flex justify-between items-center mb-4">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ tasks|length }} task{{ 's' if tasks|length != 1 else '' }} found
</h3>
<div class="flex items-center gap-2">
<button type="button" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" title="Export to CSV">
<i class="fas fa-download mr-1"></i> Export
</button>
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'tasksBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="tasksBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-48 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg">
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
</ul>
</div>
</div>
</div>
<table class="table table-zebra w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
<th class="p-4 w-12">
<input type="checkbox" id="selectAll" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllTasks()">
</th>
<th class="p-4" data-sortable>Name</th>
<th class="p-4" data-sortable>Project</th>
<th class="p-4" data-sortable>Priority</th>
<th class="p-4" data-sortable>Status</th>
<th class="p-4 table-number" data-sortable>Due</th>
<th class="p-4 table-number" data-sortable>Progress</th>
<th class="p-4">Actions</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="p-4">
<input type="checkbox" class="task-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" value="{{ task.id }}" onchange="updateBulkDeleteButton()">
</td>
<td class="p-4">{{ task.name }}</td>
<td class="p-4">{{ task.project.name }}</td>
<td class="p-4">
{% set p = task.priority %}
{% set pcls = {'low':'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
'medium':'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300',
'high':'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
'urgent':'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'}[p] if p in ['low','medium','high','urgent'] else 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap {{ pcls }}">{{ task.priority_display }}</span>
</td>
<td class="p-4">
{% set s = task.status %}
{% set scls = {'todo':'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
'in_progress':'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300',
'review':'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
'done':'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
'cancelled':'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'}[s] if s in ['todo','in_progress','review','done','cancelled'] else 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300' %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap {{ scls }}">{{ task.status_display }}</span>
</td>
<td class="p-4 table-number">
{% if task.due_date %}
{% set overdue = task.is_overdue %}
<span class="chip whitespace-nowrap {{ 'chip-danger' if overdue else 'chip-neutral' }}">{{ task.due_date.strftime('%Y-%m-%d') }}</span>
{% else %}
<span class="text-text-muted-light dark:text-text-muted-dark"></span>
{% endif %}
</td>
<td class="p-4 table-number">
{% set pct = task.progress_percentage or 0 %}
<div class="w-28 h-2 bg-gray-200 dark:bg-gray-700 rounded">
<div class="h-2 rounded {{ 'bg-emerald-500' if pct>=100 else 'bg-primary' }}" style="width: {{ [pct,100]|min }}%"></div>
</div>
</td>
<td class="p-4">
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="text-primary hover:underline">View</a>
</td>
</tr>
{% else %}
{% endfor %}
</tbody>
</table>
{% if not tasks %}
{% from "components/ui.html" import empty_state %}
{% set actions %}
<a href="{{ url_for('tasks.create_task') }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-plus mr-2"></i>Create Your First Task
</a>
<a href="{{ url_for('main.help') }}" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="fas fa-question-circle mr-2"></i>Learn More
</a>
{% endset %}
{{ empty_state('fas fa-tasks', 'No Tasks Found', 'Get started by creating your first task to organize your work and track progress.', actions) }}
{% endif %}
</tbody>
</table>
</div>
<!-- Bulk Delete Form (hidden) -->
<form id="confirmBulkDelete-form" method="POST" action="{{ url_for('tasks.bulk_delete_tasks') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
<!-- Bulk Delete Confirmation Dialog -->
{{ confirm_dialog(
'confirmBulkDelete',
'Delete Selected Tasks',
'Are you sure you want to delete the selected tasks? This action cannot be undone. Tasks with existing time entries will be skipped.',
'Delete',
'Cancel',
'danger'
) }}
{% endblock %}
{% block scripts_extra %}
<style>
.filter-collapsed { display: none !important; }
.filter-toggle-transition { transition: all 0.3s ease-in-out; }
</style>
<script type="application/json" id="i18n-json-tasks-list">
{
"confirm_delete": {{ _('Are you sure you want to delete the task "{name}"?')|tojson }},
"confirm_delete_with_entries": {{ _('Cannot delete task "{name}" because it has time entries. Please delete the time entries first.')|tojson }}
}
</script>
<script>
var i18nTasksList = (function(){ try{ var el=document.getElementById('i18n-json-tasks-list'); return el?JSON.parse(el.textContent):{}; }catch(e){ return {}; } })();
// Bulk delete functions
function toggleAllTasks() {
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.task-checkbox');
checkboxes.forEach(cb => cb.checked = selectAll.checked);
updateBulkDeleteButton();
}
function updateBulkDeleteButton() {
const checkboxes = document.querySelectorAll('.task-checkbox:checked');
const count = checkboxes.length;
const btn = document.getElementById('bulkActionsBtn');
const countSpan = document.getElementById('selectedCount');
if (countSpan) countSpan.textContent = count;
if (btn) btn.disabled = count === 0;
// Update select all checkbox state
const allCheckboxes = document.querySelectorAll('.task-checkbox');
const selectAll = document.getElementById('selectAll');
if (selectAll && allCheckboxes.length > 0) {
selectAll.checked = count === allCheckboxes.length;
selectAll.indeterminate = count > 0 && count < allCheckboxes.length;
}
}
function showBulkDeleteConfirm() {
document.getElementById('confirmBulkDelete').classList.remove('hidden');
return false;
}
// Delete task confirmation (single)
function confirmDeleteTask(taskId, taskName, hasTimeEntries) {
if (hasTimeEntries) {
const msg = (i18nTasksList.confirm_delete_with_entries || 'Cannot delete task "{name}" because it has time entries. Please delete the time entries first.').replace('{name}', taskName);
if (window.showAlert) {
window.showAlert(msg);
} else {
alert(msg);
}
return;
}
const msg = (i18nTasksList.confirm_delete || 'Are you sure you want to delete the task "{name}"?').replace('{name}', taskName);
if (window.showConfirm) {
window.showConfirm(msg).then(function(ok){
if (!ok) return;
const form = document.createElement('form');
form.method = 'POST';
form.action = `/tasks/${taskId}/delete`;
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = document.querySelector('meta[name="csrf-token"]').getAttribute('content') || '';
form.appendChild(csrfInput);
document.body.appendChild(form);
form.submit();
});
} else {
if (confirm(msg)) {
const form = document.createElement('form');
form.method = 'POST';
form.action = `/tasks/${taskId}/delete`;
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = document.querySelector('meta[name="csrf-token"]').getAttribute('content') || '';
form.appendChild(csrfInput);
document.body.appendChild(form);
form.submit();
}
}
}
function toggleFilterVisibility() {
const filterBody = document.getElementById('filterBody');
const toggleIcon = document.getElementById('filterToggleIcon');
const toggleButton = document.getElementById('toggleFilters');
if (!filterBody || !toggleIcon || !toggleButton) return;
if (filterBody.classList.contains('filter-collapsed')) {
filterBody.classList.remove('filter-collapsed');
toggleIcon.className = 'fas fa-chevron-up';
toggleButton.title = '{{ _('Hide Filters') }}';
localStorage.setItem('taskListFiltersVisible', 'true');
} else {
filterBody.classList.add('filter-collapsed');
toggleIcon.className = 'fas fa-chevron-down';
toggleButton.title = '{{ _('Show Filters') }}';
localStorage.setItem('taskListFiltersVisible', 'false');
}
}
document.addEventListener('DOMContentLoaded', function() {
const filterBody = document.getElementById('filterBody');
const toggleIcon = document.getElementById('filterToggleIcon');
const toggleButton = document.getElementById('toggleFilters');
if (!filterBody || !toggleIcon || !toggleButton) return;
const filtersVisible = localStorage.getItem('taskListFiltersVisible');
if (filtersVisible === 'false') {
filterBody.classList.add('filter-collapsed');
toggleIcon.className = 'fas fa-chevron-down';
toggleButton.title = '{{ _('Show Filters') }}';
}
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
// Handle bulk delete confirmation
const form = document.getElementById('confirmBulkDelete-form');
if (form) {
form.addEventListener('submit', function(e) {
// Prevent default to add task IDs first
e.preventDefault();
const checkboxes = document.querySelectorAll('.task-checkbox:checked');
// Clear existing hidden inputs
form.querySelectorAll('input[name="task_ids[]"]').forEach(input => input.remove());
// Add selected task IDs to form
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'task_ids[]';
input.value = cb.value;
form.appendChild(input);
});
// Now submit the form
form.submit();
});
}
});
</script>
{% endblock %}