mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-04 03:09:50 -05:00
Merge pull request #49 from DRYTRIX/Feature-KanbanView
Feature kanban view
This commit is contained in:
+13
-1
@@ -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'])
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 -->
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user