mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-02-14 16:38:41 -06:00
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
359 lines
18 KiB
HTML
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 %} |