feat(kanban): add Kanban board view, drag-and-drop, and API support

- Add Kanban board partial and integrate into tasks list
  - app/templates/tasks/_kanban.html
  - app/templates/tasks/list.html
- Update API to support task status moves and ordering
  - app/routes/api.py
- Expose Kanban in layout/navigation and polish UI
  - app/templates/base.html
  - app/static/base.css
- Link/entry points from related pages
  - templates/projects/view.html
  - templates/reports/index.html
- Minor profile page adjustments
  - app/templates/auth/profile.html
This commit is contained in:
Dries Peeters
2025-09-11 20:35:19 +02:00
parent e43aa9936d
commit e5a8728285
8 changed files with 307 additions and 61 deletions

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'])

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;

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() {

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 -->

View File

@@ -3,14 +3,15 @@
{'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'},
{'key': 'cancelled', 'label': 'Cancelled', 'icon': 'fas fa-ban', 'accent': 'danger'}
{'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">
<i class="fas fa-columns text-primary"></i>
<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>
@@ -19,7 +20,7 @@
<div id="kanbanBoard" class="kanban-board">
{% for col in kanban_statuses %}
<div class="kanban-column" data-status="{{ col.key }}">
<div class="kanban-column-header">
<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 }}">
@@ -34,34 +35,71 @@
</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 }}" draggable="true" data-task-id="{{ task.id }}" data-status="{{ task.status }}">
<div class="card-header border-0 pb-0">
<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="status-badge status-{{ task.status }}">{{ task.status_display }}</span>
<span class="priority-badge priority-{{ task.priority }}">{{ task.priority_display }}</span>
<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"><i class="fas fa-exclamation-triangle me-1"></i>Overdue</span>
<span class="badge bg-danger-subtle text-danger"><i class="fas fa-exclamation-triangle me-1"></i>Overdue</span>
{% endif %}
</div>
<small class="text-muted">#{{ task.id }}</small>
<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>
</div>
<div class="card-body pt-0">
<h6 class="card-title mb-2">
<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[:90] }}{% if task.description|length > 90 %}...{% endif %}</p>
<p class="card-text text-muted small mb-2 task-description">{{ task.description[:110] }}{% if task.description|length > 110 %}...{% endif %}</p>
{% endif %}
<div class="d-flex align-items-center justify-content-between small text-muted">
{% 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 title="Assignee"><i class="fas fa-user text-info"></i> {{ task.assigned_user.display_name }}</span>
<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="{% if task.is_overdue %}text-danger fw-semibold{% endif %}"><i class="fas fa-calendar me-1"></i>{{ task.due_date.strftime('%b %d') }}</span>
<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>
@@ -74,20 +112,30 @@
</div>
<style>
.kanban-board { display: grid; grid-template-columns: repeat(5, minmax(260px, 1fr)); gap: 16px; align-items: start; }
.kanban-column { background: var(--card-bg, #fff); border: 1px solid var(--border-color); border-radius: 12px; display: flex; flex-direction: column; min-height: 120px; }
.kanban-column-header { padding: 12px 12px 8px 12px; border-bottom: 1px solid var(--border-color); background: var(--light-color, #f8fafc); border-top-left-radius: 12px; border-top-right-radius: 12px; }
.kanban-column-body { padding: 12px; min-height: 120px; max-height: 70vh; overflow-y: auto; }
.kanban-card { margin-bottom: 12px; cursor: grab; }
.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: 1200px) { .kanban-board { grid-template-columns: repeat(3, minmax(260px, 1fr)); } }
@media (max-width: 768px) { .kanban-board { grid-template-columns: repeat(1, minmax(260px, 1fr)); } }
[data-theme="dark"] .kanban-column { background: #0f172a; border-color: #1f2a44; }
[data-theme="dark"] .kanban-column-header { background: #0b1220; border-color: #1f2a44; }
[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>

View File

@@ -6,12 +6,21 @@
<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>
@@ -173,8 +182,72 @@
</div>
</div>
<div class="card mobile-card mb-4">
<div class="card-body">
{% if tasks %}
{% 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 %}
</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>
{% else %}
{% include 'tasks/_kanban.html' %}
{% endif %}
{% else %}
<!-- Empty State -->
<div class="row">
@@ -199,6 +272,8 @@
</div>
</div>
{% endif %}
</div>
</div>
</div>
<style>
@@ -356,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 {

View File

@@ -162,17 +162,18 @@
</div>
</div>
<div class="card-body">
{% if tasks %}
{% set project_tasks = tasks %}
{% include 'tasks/_kanban.html' with context %}
{% 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) }}
{% 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 %}
</div>
</div>

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>