Merge pull request #49 from DRYTRIX/Feature-KanbanView

Feature kanban view
This commit is contained in:
Dries Peeters
2025-09-11 20:36:10 +02:00
committed by GitHub
8 changed files with 482 additions and 352 deletions
+13 -1
View File
@@ -31,6 +31,7 @@ def timer_status():
'id': active_timer.id,
'project_name': active_timer.project.name,
'project_id': active_timer.project_id,
'task_id': active_timer.task_id,
'start_time': active_timer.start_time.isoformat(),
'current_duration': active_timer.current_duration_seconds,
'duration_formatted': active_timer.duration_formatted
@@ -74,6 +75,7 @@ def api_start_timer():
"""Start timer via API"""
data = request.get_json()
project_id = data.get('project_id')
task_id = data.get('task_id')
if not project_id:
return jsonify({'error': 'Project ID is required'}), 400
@@ -83,6 +85,13 @@ def api_start_timer():
if not project:
return jsonify({'error': 'Invalid project'}), 400
# Validate task if provided
task = None
if task_id:
task = Task.query.filter_by(id=task_id, project_id=project_id).first()
if not task:
return jsonify({'error': 'Invalid task for selected project'}), 400
# Check if user already has an active timer
active_timer = current_user.active_timer
if active_timer:
@@ -93,6 +102,7 @@ def api_start_timer():
new_timer = TimeEntry(
user_id=current_user.id,
project_id=project_id,
task_id=task.id if task else None,
start_time=local_now(),
source='auto'
)
@@ -105,13 +115,15 @@ def api_start_timer():
'user_id': current_user.id,
'timer_id': new_timer.id,
'project_name': project.name,
'task_id': task.id if task else None,
'start_time': new_timer.start_time.isoformat()
})
return jsonify({
'success': True,
'timer_id': new_timer.id,
'project_name': project.name
'project_name': project.name,
'task_id': task.id if task else None
})
@api_bp.route('/api/timer/stop', methods=['POST'])
+63
View File
@@ -346,6 +346,33 @@ main {
.btn-icon.btn-sm { width: 32px; height: 32px; min-height: 32px; }
.btn-icon i { font-size: 0.95rem; }
/* Global theme toggle button */
.theme-toggle {
border: 1px solid var(--border-color);
background: linear-gradient(180deg, #ffffff, #f8fafc);
color: var(--text-secondary);
transition: var(--transition);
opacity: 0.9;
}
.theme-toggle:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(0,0,0,0.12);
opacity: 1;
color: var(--text-primary);
}
.theme-toggle:active { transform: translateY(0); }
[data-theme="dark"] .theme-toggle {
border-color: var(--border-color);
background: #0f172a;
color: var(--text-secondary);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03);
}
[data-theme="dark"] .theme-toggle:hover {
background: #111827;
color: var(--text-primary);
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}
.btn-success {
background: var(--success-color);
color: white;
@@ -2002,6 +2029,33 @@ h6 { font-size: 1rem; }
margin-bottom: 1rem;
}
/* Ensure header subtitle text is white within stats-card headers */
.stats-card .text-muted,
.stats-card p {
color: rgba(255, 255, 255, 0.9) !important;
}
.stats-card h1, .stats-card .h1,
.stats-card h2, .stats-card .h2,
.stats-card h3, .stats-card .h3 {
color: #ffffff !important;
}
/* Header action buttons on stats cards: low opacity default, high on hover */
.stats-card .btn-header {
color: rgba(255, 255, 255, 0.9);
border-color: rgba(255, 255, 255, 0.45);
background: transparent;
opacity: 0.85;
}
.stats-card .btn-header:hover {
opacity: 1;
color: #ffffff;
border-color: rgba(255, 255, 255, 0.9);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(255, 255, 255, 0.15);
}
.stats-card .btn-header i { color: inherit; }
.stats-card h4 {
font-size: 2.25rem;
font-weight: 700;
@@ -2193,6 +2247,15 @@ h6 { font-size: 1rem; }
margin-right: 0.375rem;
}
/* Add small spacing after icons in regular buttons (avoid icon-only/header buttons) */
.btn:not(.btn-icon):not(.btn-header) i {
margin-right: 0.375rem;
}
/* Ensure primary (blue) buttons render white text/icons in light mode */
.btn.btn-primary { color: #ffffff; }
.btn.btn-primary i { color: inherit; }
/* Consistent badge sizing used next to page titles */
.badge.fs-6 {
line-height: 1;
+4 -5
View File
@@ -7,12 +7,11 @@
<div class="row">
<div class="col-12">
{% set actions %}
<a href="{{ url_for('auth.edit_profile') }}" class="btn btn-outline-light btn-sm">
<a href="{{ url_for('auth.edit_profile') }}" class="btn-header btn-outline-primary">
<i class="fas fa-edit"></i> Edit Profile
</a>
<button id="theme-toggle" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-moon me-1"></i>
Toggle Theme
<button id="theme-toggle" class="btn-header btn-outline-primary" type="button">
<i class="fas fa-moon"></i> Toggle Theme
</button>
{% endset %}
{{ page_header('fas fa-user-circle', 'Your Profile', 'Manage your account details', actions) }}
@@ -70,7 +69,7 @@ document.addEventListener('DOMContentLoaded', function() {
document.documentElement.setAttribute('data-theme', theme);
if (meta) meta.setAttribute('content', theme === 'dark' ? '#0b1220' : '#3b82f6');
const icon = btn.querySelector('i');
if (icon) icon.className = theme === 'dark' ? 'fas fa-sun me-1' : 'fas fa-moon me-1';
if (icon) icon.className = theme === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
}
function currentTheme() {
+60 -1
View File
@@ -124,6 +124,7 @@
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" data-bs-display="static" aria-expanded="false">
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 36px; height: 36px;">
@@ -267,7 +268,65 @@
// Use Bootstrap's default dropdown behavior; no custom backdrop
</script>
<!-- Theme toggle logic moved to settings/profile; base keeps only initial theme setup above -->
<!-- Global theme toggle logic -->
<script>
(function(){
try {
const storageKey = 'tt-theme';
const btn = document.getElementById('theme-toggle-global');
const meta = document.getElementById('meta-theme-color');
const root = document.documentElement;
const updateUrl = btn ? btn.getAttribute('data-update-theme-url') : null;
function currentTheme(){
return root.getAttribute('data-theme') || 'light';
}
function updateIcon(theme){
if (!btn) return;
const icon = btn.querySelector('i');
if (icon) icon.className = theme === 'dark' ? 'fas fa-sun' : 'fas fa-moon';
btn.setAttribute('aria-label', theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode');
btn.title = theme === 'dark' ? 'Light mode' : 'Dark mode';
}
function applyTheme(theme, persist){
root.setAttribute('data-theme', theme);
if (meta) meta.setAttribute('content', theme === 'dark' ? '#0b1220' : '#3b82f6');
updateIcon(theme);
if (persist) {
try { localStorage.setItem(storageKey, theme); } catch(e) {}
if (updateUrl) {
try {
fetch(updateUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme })
}).catch(function(){});
} catch(e) {}
}
}
}
if (btn) {
// Initialize icon to current theme
updateIcon(currentTheme());
btn.addEventListener('click', function(){
const next = currentTheme() === 'dark' ? 'light' : 'dark';
applyTheme(next, true);
});
// Enable tooltip if available
try { new bootstrap.Tooltip(btn); } catch(e) {}
}
// Keep icon in sync if theme changes elsewhere
try {
new MutationObserver(function(){ updateIcon(currentTheme()); })
.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
} catch(e) {}
} catch(e) {}
})();
</script>
{% if current_user.is_authenticated %}
<!-- Socket.IO only for authenticated users -->
+248
View File
@@ -0,0 +1,248 @@
{# Reusable Kanban board for tasks. Expects `tasks` in context. #}
{% set kanban_statuses = [
{'key': 'todo', 'label': 'To Do', 'icon': 'fas fa-list-check', 'accent': 'secondary'},
{'key': 'in_progress', 'label': 'In Progress', 'icon': 'fas fa-spinner', 'accent': 'warning'},
{'key': 'review', 'label': 'Review', 'icon': 'fas fa-user-check', 'accent': 'info'},
{'key': 'done', 'label': 'Done', 'icon': 'fas fa-check-circle', 'accent': 'success'}
] %}
<div class="kanban-board-wrapper">
<div class="kanban-toolbar d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center gap-2">
<div class="summary-icon bg-primary bg-opacity-10 text-primary" style="width:34px;height:34px;border-radius:8px;">
<i class="fas fa-columns"></i>
</div>
<span class="fw-semibold">Kanban Board</span>
</div>
<div id="kanban-alert" class="d-none"></div>
</div>
<div id="kanbanBoard" class="kanban-board">
{% for col in kanban_statuses %}
<div class="kanban-column" data-status="{{ col.key }}">
<div class="kanban-column-header sticky-top">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<div class="icon-circle bg-{{ col.accent }} bg-opacity-10 text-{{ col.accent }}">
<i class="{{ col.icon }}"></i>
</div>
<span class="fw-semibold">{{ col.label }}</span>
</div>
<span class="badge rounded-pill bg-{{ col.accent }} kanban-column-count">
{{ tasks|selectattr('status', 'equalto', col.key)|list|length }}
</span>
</div>
</div>
<div class="kanban-column-body" data-status="{{ col.key }}">
{% for task in tasks if task.status == col.key %}
<div class="kanban-card task-card {{ task.priority_class }} {% if current_user.active_timer and current_user.active_timer.task_id == task.id %}is-active{% endif %}" draggable="true" data-task-id="{{ task.id }}" data-status="{{ task.status }}">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="d-flex align-items-center flex-wrap gap-2">
<span class="badge badge-soft-{{ col.accent }} badge-pill status-badge status-{{ task.status }}">{{ task.status_display }}</span>
<span class="badge priority-badge priority-{{ task.priority }}">{{ task.priority_display }}</span>
{% if current_user.active_timer and current_user.active_timer.task_id == task.id %}
<span class="badge badge-soft-primary active-badge">
<i class="fas fa-circle me-1" style="font-size:8px;"></i>Active
</span>
{% endif %}
{% if task.is_overdue %}
<span class="badge bg-danger-subtle text-danger"><i class="fas fa-exclamation-triangle me-1"></i>Overdue</span>
{% endif %}
</div>
<div class="d-flex align-items-center gap-2">
<small class="text-muted">#{{ task.id }}</small>
{% if current_user.active_timer and current_user.active_timer.task_id == task.id %}
<form method="POST" action="{{ url_for('timer.stop_timer') }}" class="d-inline">
<button type="submit" class="btn btn-danger btn-sm">
<i class="fas fa-stop me-1"></i>Stop
</button>
</form>
{% else %}
<form method="POST" action="{{ url_for('timer.start_timer') }}" class="d-inline">
<input type="hidden" name="project_id" value="{{ task.project_id }}">
<input type="hidden" name="task_id" value="{{ task.id }}">
<button type="submit" class="btn btn-success btn-sm">
<i class="fas fa-play me-1"></i>Start
</button>
</form>
{% endif %}
<a href="{{ url_for('tasks.edit_task', task_id=task.id) }}" class="btn btn-outline-secondary btn-sm btn-icon" title="Edit task">
<i class="fas fa-pen"></i>
</a>
</div>
</div>
<h6 class="card-title mb-1">
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="text-decoration-none task-title-link">{{ task.name }}</a>
</h6>
{% if task.description %}
<p class="card-text text-muted small mb-2 task-description">{{ task.description[:110] }}{% if task.description|length > 110 %}...{% endif %}</p>
{% endif %}
{% if task.estimated_hours %}
<div class="d-flex justify-content-between align-items-center small text-muted mb-1">
<span>Progress</span>
<span>{{ task.progress_percentage }}%</span>
</div>
<div class="progress progress-thin mb-2">
<div class="progress-bar" role="progressbar" style="width: {{ task.progress_percentage }}%"></div>
</div>
{% endif %}
<div class="d-flex align-items-center justify-content-between small task-meta-row">
<div class="d-flex align-items-center gap-2">
{% if task.assigned_user %}
<span class="d-inline-flex align-items-center gap-1" title="Assignee">
<i class="fas fa-user text-info"></i>
{{ task.assigned_user.display_name }}
</span>
{% endif %}
</div>
{% if task.due_date %}
<span class="d-inline-flex align-items-center gap-1 {% if task.is_overdue %}text-danger fw-semibold{% endif %}">
<i class="fas fa-calendar"></i>{{ task.due_date.strftime('%b %d') }}
</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
<style>
.kanban-board-wrapper { overflow-x: auto; -webkit-overflow-scrolling: touch; padding-bottom: 8px; }
.kanban-board { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; align-items: start; }
.kanban-column { background: #fff; border: 1px solid var(--border-color); border-radius: var(--border-radius); display: flex; flex-direction: column; min-height: 120px; box-shadow: var(--card-shadow); }
.kanban-column-header { padding: 12px 12px 8px 12px; border-bottom: 1px solid var(--border-color); background: #fff; border-top-left-radius: var(--border-radius); border-top-right-radius: var(--border-radius); font-weight: 600; color: var(--text-primary); position: sticky; top: 0; z-index: 2; box-shadow: 0 1px 0 rgba(0,0,0,0.03); }
.kanban-column-body { padding: 12px; min-height: 120px; max-height: 70vh; overflow-y: auto; background: var(--light-color); }
.kanban-card { margin-bottom: 12px; cursor: grab; border: 1px solid var(--border-color); border-radius: 12px; overflow: hidden; background: #fff; box-shadow: var(--card-shadow); transition: transform .15s ease, box-shadow .15s ease; }
.kanban-card:hover { transform: translateY(-2px); box-shadow: var(--card-shadow-hover); }
.kanban-card.is-active { box-shadow: 0 0 0 2px rgba(59,130,246,0.12), var(--card-shadow); }
.task-meta-row { color: var(--text-muted); }
.kanban-card.priority-low { border-left: 3px solid #22c55e; }
.kanban-card.priority-medium { border-left: 3px solid #eab308; }
.kanban-card.priority-high { border-left: 3px solid #f97316; }
.kanban-card.priority-urgent { border-left: 3px solid #ef4444; }
.kanban-card.dragging { opacity: 0.6; cursor: grabbing; }
.kanban-column-body.drag-over { outline: 2px dashed var(--primary-color); outline-offset: -6px; background: rgba(59, 130, 246, 0.05); }
.icon-circle { width: 28px; height: 28px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; }
#kanban-alert { width: 100%; }
.alert-inline { margin: 0; padding: 6px 10px; }
@media (max-width: 768px) { .kanban-board { grid-template-columns: repeat(1, minmax(260px, 1fr)); } }
[data-theme="dark"] .kanban-column { background: #0f172a; border-color: var(--border-color); box-shadow: var(--card-shadow); }
[data-theme="dark"] .kanban-column-header { background: #0f172a; border-color: var(--border-color); color: var(--text-secondary); box-shadow: 0 1px 0 rgba(255,255,255,0.04); }
[data-theme="dark"] .kanban-card.is-active { box-shadow: 0 0 0 2px rgba(96,165,250,0.25), var(--card-shadow); }
.active-badge { font-weight: 600; }
[data-theme="dark"] .kanban-column-body { background: #0f172a; }
</style>
<script>
(function(){
const board = document.getElementById('kanbanBoard');
if (!board) return;
const statusLabels = { todo: 'To Do', in_progress: 'In Progress', review: 'Review', done: 'Done', cancelled: 'Cancelled' };
const updateUrlTemplate = "{{ url_for('tasks.api_update_status', task_id=0) }}"; // will replace 0 with actual id
function showAlert(kind, msg) {
const holder = document.getElementById('kanban-alert');
if (!holder) return;
holder.className = '';
holder.classList.add('alert', 'alert-' + (kind === 'error' ? 'danger' : (kind === 'info' ? 'info' : 'success')), 'alert-inline');
holder.textContent = msg;
holder.classList.remove('d-none');
window.clearTimeout(holder._t);
holder._t = window.setTimeout(() => { holder.classList.add('d-none'); }, 2500);
}
function updateCounts() {
document.querySelectorAll('.kanban-column').forEach(col => {
const body = col.querySelector('.kanban-column-body');
const count = body ? body.querySelectorAll('.kanban-card').length : 0;
const badge = col.querySelector('.kanban-column-count');
if (badge) badge.textContent = count;
});
}
let dragCard = null;
board.addEventListener('dragstart', (e) => {
const card = e.target.closest('.kanban-card');
if (!card) return;
dragCard = card;
card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', card.dataset.taskId);
});
board.addEventListener('dragend', () => {
if (dragCard) dragCard.classList.remove('dragging');
dragCard = null;
document.querySelectorAll('.kanban-column-body').forEach(b => b.classList.remove('drag-over'));
});
document.querySelectorAll('.kanban-column-body').forEach(body => {
body.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
body.classList.add('drag-over');
});
body.addEventListener('dragleave', () => body.classList.remove('drag-over'));
body.addEventListener('drop', async (e) => {
e.preventDefault();
body.classList.remove('drag-over');
const targetStatus = body.dataset.status;
const taskId = e.dataTransfer.getData('text/plain');
const card = dragCard || board.querySelector(`.kanban-card[data-task-id="${taskId}"]`);
if (!card) return;
const originalParent = card.parentElement;
const originalStatus = card.dataset.status;
if (targetStatus === originalStatus) {
body.appendChild(card);
return;
}
body.appendChild(card);
updateCounts();
// optimistic UI: update badge
const statusBadge = card.querySelector('.status-badge');
if (statusBadge) {
statusBadge.className = `status-badge status-${targetStatus}`;
statusBadge.textContent = statusLabels[targetStatus] || targetStatus;
}
card.dataset.status = targetStatus;
const url = updateUrlTemplate.replace('/0/', `/${taskId}/`);
try {
const resp = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({ status: targetStatus })
});
if (!resp.ok) {
throw new Error('Failed to update status');
}
const data = await resp.json();
if (!data.success) {
throw new Error(data.error || 'Update rejected');
}
showAlert('success', 'Task moved to ' + (statusLabels[targetStatus] || targetStatus));
} catch (err) {
// revert
if (originalParent) originalParent.appendChild(card);
card.dataset.status = originalStatus;
if (statusBadge) {
statusBadge.className = `status-badge status-${originalStatus}`;
statusBadge.textContent = statusLabels[originalStatus] || originalStatus;
}
updateCounts();
showAlert('error', err.message || 'Could not update task');
}
});
});
updateCounts();
})();
</script>
+83 -201
View File
@@ -6,17 +6,28 @@
<div class="container-fluid">
{% from "_components.html" import page_header %}
{% set skeleton = request.args.get('loading') %}
{% set view = request.args.get('view', 'board') %}
<div class="row">
<div class="col-12">
{% set actions %}
<a href="{{ url_for('tasks.create_task') }}" class="btn-header btn-primary">
<i class="fas fa-plus"></i> New Task
</a>
<div class="d-flex align-items-center gap-2">
<a href="{{ url_for('tasks.list_tasks', view='board', search=search, status=status, priority=priority, project_id=project_id, assigned_to=assigned_to, overdue=1 if overdue else None) }}" class="btn-header btn-outline-primary {% if view != 'table' %}active{% endif %}">
<i class="fas fa-columns"></i> Board
</a>
<a href="{{ url_for('tasks.list_tasks', view='table', search=search, status=status, priority=priority, project_id=project_id, assigned_to=assigned_to, overdue=1 if overdue else None) }}" class="btn-header btn-outline-primary {% if view == 'table' %}active{% endif %}">
<i class="fas fa-table"></i> Table
</a>
<a href="{{ url_for('tasks.create_task') }}" class="btn-header btn-primary">
<i class="fas fa-plus"></i> New Task
</a>
</div>
{% endset %}
{{ page_header('fas fa-tasks', 'Tasks', 'Plan and track work • ' ~ (tasks|length) ~ ' total', actions) }}
</div>
</div>
<!-- Summary Cards (Invoices-style) -->
<div class="row mb-4">
<div class="col-md-3 col-sm-6 mb-3">
@@ -171,191 +182,71 @@
</div>
</div>
<!-- Tasks Grid -->
<div class="card mobile-card mb-4">
<div class="card-body">
{% if tasks %}
<div class="row g-4">
{% for task in tasks %}
<div class="col-xl-4 col-lg-6 col-md-6">
<div class="card mobile-card task-card h-100 {{ task.priority_class }}">
<!-- Card Header -->
<div class="card-header border-0 pb-0">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="d-flex align-items-center flex-wrap gap-2">
<span class="status-badge status-{{ task.status }}">
{{ task.status_display }}
</span>
<span class="priority-badge priority-{{ task.priority }}">
{{ task.priority_display }}
</span>
{% if task.is_overdue %}
<span class="badge bg-danger">
<i class="fas fa-exclamation-triangle me-1"></i>Overdue
</span>
{% endif %}
</div>
<small class="text-muted">#{{ task.id }}</small>
</div>
</div>
<!-- Card Body -->
<div class="card-body pt-0">
<h5 class="card-title mb-3">
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="text-decoration-none text-dark task-title-link">
{{ task.name }}
</a>
</h5>
{% if task.description %}
<div class="task-description mb-3">
<p class="card-text text-muted small mb-0">
{{ task.description[:100] }}{% if task.description|length > 100 %}...{% endif %}
</p>
</div>
{% endif %}
<!-- Project Info -->
<div class="d-flex align-items-center mb-3 project-info">
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 28px; height: 28px;">
<i class="fas fa-project-diagram text-primary fa-sm"></i>
</div>
<div>
<span class="text-muted small d-block">{{ task.project.name }}</span>
<span class="text-muted" style="font-size: 0.75rem;">{{ task.project.client }}</span>
</div>
</div>
<!-- Task Meta -->
<div class="task-meta mb-3">
<div class="row g-2">
{% if task.assigned_user %}
<div class="col-12">
<div class="d-flex align-items-center task-meta-item">
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
<i class="fas fa-user text-info fa-xs"></i>
</div>
<span class="text-muted small">{{ task.assigned_user.display_name }}</span>
</div>
</div>
{% endif %}
{% if task.due_date %}
<div class="col-12">
<div class="d-flex align-items-center task-meta-item">
<div class="bg-{% if task.is_overdue %}danger{% else %}secondary{% endif %} bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
<i class="fas fa-calendar text-{% if task.is_overdue %}danger{% else %}secondary{% endif %} fa-xs"></i>
</div>
<span class="text-muted small {% if task.is_overdue %}text-danger fw-bold{% endif %}">
Due: {{ task.due_date.strftime('%b %d') }}
</span>
</div>
</div>
{% endif %}
<div class="col-6">
{% if task.estimated_hours %}
<div class="d-flex align-items-center task-meta-item">
<div class="bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 20px; height: 20px;">
<i class="fas fa-clock text-warning fa-xs"></i>
</div>
<span class="text-muted small">Est: {{ task.estimated_hours }}h</span>
</div>
{% endif %}
</div>
<div class="col-6">
{% if task.total_hours > 0 %}
<div class="d-flex align-items-center task-meta-item">
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 20px; height: 20px;">
<i class="fas fa-stopwatch text-success fa-xs"></i>
</div>
<span class="text-muted small">{{ task.total_hours }}h</span>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Progress Bar -->
{% if task.estimated_hours and task.total_hours > 0 %}
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="text-muted">Progress</small>
<small class="text-muted fw-bold">{{ task.progress_percentage }}%</small>
</div>
<div class="progress" style="height: 6px;">
<div class="progress-bar bg-primary" role="progressbar"
style="width: {{ task.progress_percentage }}%"
aria-valuenow="{{ task.progress_percentage }}"
aria-valuemin="0" aria-valuemax="100">
</div>
</div>
</div>
{% endif %}
</div>
<!-- Card Footer -->
<div class="card-footer border-0 pt-0">
<div class="d-grid gap-2">
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-eye me-2"></i>View Details
</a>
{% if current_user.active_timer and current_user.active_timer.task_id == task.id %}
<form method="POST" action="{{ url_for('timer.stop_timer') }}" class="d-inline">
<button type="submit" class="btn btn-danger btn-sm">
<i class="fas fa-stop me-2"></i>Stop Timer
</button>
</form>
{% elif not current_user.active_timer %}
<a href="{{ url_for('timer.start_timer_for_project', project_id=task.project_id, task_id=task.id) }}" class="btn btn-success btn-sm">
<i class="fas fa-play me-2"></i>Start Timer
</a>
{% if view == 'table' %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>ID</th>
<th>Task</th>
<th>Project</th>
<th>Status</th>
<th>Priority</th>
<th>Assignee</th>
<th>Due</th>
<th>Est.</th>
<th>Progress</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr class="{% if current_user.active_timer and current_user.active_timer.task_id == task.id %}table-active{% endif %}">
<td>#{{ task.id }}</td>
<td>
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="fw-semibold text-decoration-none">{{ task.name }}</a>
{% if task.description %}
<div class="text-muted small">{{ task.description[:90] }}{% if task.description|length > 90 %}...{% endif %}</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</td>
<td>{{ task.project.name if task.project else '-' }}</td>
<td><span class="status-badge status-{{ task.status }}">{{ task.status_display }}</span></td>
<td><span class="priority-badge priority-{{ task.priority }}">{{ task.priority_display }}</span></td>
<td>{{ task.assigned_user.display_name if task.assigned_user else '-' }}</td>
<td>{% if task.due_date %}{{ task.due_date.strftime('%Y-%m-%d') }}{% else %}-{% endif %}</td>
<td>{% if task.estimated_hours %}{{ '%.1f'|format(task.estimated_hours) }}h{% else %}-{% endif %}</td>
<td style="min-width:140px;">
<div class="progress progress-thin">
<div class="progress-bar" role="progressbar" style="width: {{ task.progress_percentage }}%"></div>
</div>
</td>
<td class="text-end">
<div class="btn-group" role="group">
{% if current_user.active_timer and current_user.active_timer.task_id == task.id %}
<form method="POST" action="{{ url_for('timer.stop_timer') }}">
<button type="submit" class="btn btn-danger btn-sm"><i class="fas fa-stop"></i></button>
</form>
{% else %}
<form method="POST" action="{{ url_for('timer.start_timer') }}">
<input type="hidden" name="project_id" value="{{ task.project_id }}">
<input type="hidden" name="task_id" value="{{ task.id }}">
<button type="submit" class="btn btn-success btn-sm"><i class="fas fa-play"></i></button>
</form>
{% endif %}
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="btn btn-outline-primary btn-sm"><i class="fas fa-eye"></i></a>
<a href="{{ url_for('tasks.edit_task', task_id=task.id) }}" class="btn btn-outline-secondary btn-sm"><i class="fas fa-pen"></i></a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<nav aria-label="Task pagination" class="mt-5">
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('tasks.list_tasks', page=pagination.prev_num, **request.args) }}">
<i class="fas fa-chevron-left"></i> Previous
</a>
</li>
{% endif %}
{% for page_num in pagination.iter_pages() %}
{% if page_num %}
{% if page_num != pagination.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('tasks.list_tasks', page=page_num, **request.args) }}">{{ page_num }}</a>
</li>
{% else %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('tasks.list_tasks', page=pagination.next_num, **request.args) }}">
Next <i class="fas fa-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% else %}
{% include 'tasks/_kanban.html' %}
{% endif %}
{% else %}
<!-- Empty State -->
@@ -381,6 +272,8 @@
</div>
</div>
{% endif %}
</div>
</div>
</div>
<style>
@@ -538,22 +431,11 @@
border: 1px solid #ef4444;
}
/* Priority Card Borders */
.task-card.priority-low {
border-left: 4px solid #22c55e;
}
.task-card.priority-medium {
border-left: 4px solid #eab308;
}
.task-card.priority-high {
border-left: 4px solid #f97316;
}
.task-card.priority-urgent {
border-left: 4px solid #ef4444;
}
/* Priority Card Borders (subtle) */
.task-card.priority-low { border-left: 3px solid #22c55e; background: #fff; }
.task-card.priority-medium { border-left: 3px solid #eab308; background: #fff; }
.task-card.priority-high { border-left: 3px solid #f97316; background: #fff; }
.task-card.priority-urgent { border-left: 3px solid #ef4444; background: #fff; }
/* Enhanced Task Card Elements */
.task-title-link {
+10 -143
View File
@@ -162,152 +162,19 @@
</div>
</div>
<div class="card-body">
{% if tasks %}
<div class="row g-3">
{% for task in tasks[:6] %}
<div class="col-md-6 col-lg-4">
<div class="card h-100 task-card {{ task.priority_class }}">
<div class="card-header border-0 pb-0">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="d-flex align-items-center flex-wrap gap-2">
<span class="status-badge status-{{ task.status }}">
{{ task.status_display }}
</span>
<span class="priority-badge priority-{{ task.priority }}">
{{ task.priority_display }}
</span>
{% if task.is_overdue %}
<span class="badge bg-danger">
<i class="fas fa-exclamation-triangle me-1"></i>Overdue
</span>
{% endif %}
</div>
<small class="text-muted">#{{ task.id }}</small>
</div>
</div>
<div class="card-body pt-0">
<h6 class="card-title mb-3">
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="text-decoration-none task-title-link">
{{ task.name }}
</a>
</h6>
{% if task.description %}
<div class="task-description mb-3">
<p class="card-text text-muted small mb-0">{{ task.description[:80] }}{% if task.description|length > 80 %}...{% endif %}</p>
</div>
{% endif %}
<div class="task-meta mb-3">
<div class="row g-2">
{% if task.assigned_user %}
<div class="col-12">
<div class="d-flex align-items-center task-meta-item">
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 20px; height: 20px;">
<i class="fas fa-user text-info fa-xs"></i>
</div>
<span class="text-muted small">{{ task.assigned_user.display_name }}</span>
</div>
</div>
{% endif %}
{% if task.due_date %}
<div class="col-12">
<div class="d-flex align-items-center task-meta-item">
<div class="bg-{% if task.is_overdue %}danger{% else %}secondary{% endif %} bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 20px; height: 20px;">
<i class="fas fa-calendar text-{% if task.is_overdue %}danger{% else %}secondary{% endif %} fa-xs"></i>
</div>
<span class="text-muted small {% if task.is_overdue %}text-danger fw-bold{% endif %}">
Due: {{ task.due_date.strftime('%b %d') }}
</span>
</div>
</div>
{% endif %}
<div class="col-6">
{% if task.estimated_hours %}
<div class="d-flex align-items-center task-meta-item">
<div class="bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 18px; height: 18px;">
<i class="fas fa-clock text-warning fa-xs"></i>
</div>
<span class="text-muted small">Est: {{ task.estimated_hours }}h</span>
</div>
{% endif %}
</div>
<div class="col-6">
{% if task.total_hours > 0 %}
<div class="d-flex align-items-center task-meta-item">
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 18px; height: 18px;">
<i class="fas fa-stopwatch text-success fa-xs"></i>
</div>
<span class="text-muted small">{{ task.total_hours }}h</span>
</div>
{% endif %}
</div>
</div>
</div>
{% if task.estimated_hours and task.total_hours > 0 %}
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="text-muted">Progress</small>
<small class="text-muted fw-bold">{{ task.progress_percentage }}%</small>
</div>
<div class="progress" style="height: 6px;">
<div class="progress-bar bg-primary" role="progressbar"
style="width: {{ task.progress_percentage }}%"
aria-valuenow="{{ task.progress_percentage }}"
aria-valuemin="0" aria-valuemax="100">
</div>
</div>
</div>
{% endif %}
</div>
<div class="card-footer border-0 pt-0">
<div class="d-grid gap-2">
<a href="{{ url_for('tasks.view_task', task_id=task.id) }}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-eye me-2"></i>View Details
</a>
{% if current_user.active_timer and current_user.active_timer.task_id == task.id %}
<form method="POST" action="{{ url_for('timer.stop_timer') }}" class="d-inline">
<button type="submit" class="btn btn-danger btn-sm">
<i class="fas fa-stop me-2"></i>Stop Timer
</button>
</form>
{% elif not current_user.active_timer %}
<a href="{{ url_for('timer.start_timer_for_project', project_id=task.project.id, task_id=task.id) }}" class="btn btn-primary btn-sm" title="Start Timer">
<i class="fas fa-play"></i>
</a>
{% endif %}
{% if current_user.is_admin or task.created_by == current_user.id %}
<a href="{{ url_for('tasks.edit_task', task_id=task.id) }}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-edit me-2"></i>Edit Task
</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% if tasks|length > 6 %}
<div class="text-center mt-3">
<a href="{{ url_for('tasks.list_tasks', project_id=project.id) }}" class="btn btn-outline-primary">
View All {{ tasks|length }} Tasks
{% set project_tasks = tasks %}
{% include 'tasks/_kanban.html' with context %}
{% if not tasks %}
<div class="mt-3">
{% from "_components.html" import empty_state %}
{% set actions %}
<a href="{{ url_for('tasks.create_task') }}?project_id={{ project.id }}" class="btn btn-success btn-sm">
<i class="fas fa-plus"></i> Create First Task
</a>
{% endset %}
{{ empty_state('fas fa-tasks', 'No Tasks Yet', 'Break down this project into manageable tasks to track progress.', actions) }}
</div>
{% endif %}
{% else %}
{% from "_components.html" import empty_state %}
{% set actions %}
<a href="{{ url_for('tasks.create_task') }}?project_id={{ project.id }}" class="btn btn-success btn-sm">
<i class="fas fa-plus"></i> Create First Task
</a>
{% endset %}
{{ empty_state('fas fa-tasks', 'No Tasks Yet', 'Break down this project into manageable tasks to track progress.', actions) }}
{% endif %}
</div>
</div>
</div>
+1 -1
View File
@@ -103,7 +103,7 @@
<a href="{{ url_for('reports.project_report') }}" class="btn btn-primary">
<i class="fas fa-chart-bar"></i> Project Report
</a>
<a href="{{ url_for('reports.task_report') }}" class="btn btn-outline-primary mt-2">
<a href="{{ url_for('reports.task_report') }}" class="btn btn-primary mt-2">
<i class="fas fa-tasks"></i> Finished Tasks Report
</a>
</div>