Files
TimeTracker/app/templates/tasks/_kanban.html
T
Dries Peeters 9b7aa3a938 security: Add CSRF token protection to all POST forms" -m " Complete CSRF protection implementation across the entire application. Fixed 31 HTML forms and 4 JavaScript dynamic form generators that were missing CSRF tokens.
Affected modules: Projects, Clients, Tasks, Invoices, Comments, Admin, Search

- All HTML forms now include csrf_token hidden input
- JavaScript forms retrieve token from meta tag in base.html
- API endpoints properly exempted for JSON operations
- 58 POST forms + 4 dynamic JS forms now protected

Security impact: HIGH - Closes critical CSRF vulnerability
Files modified: 20 templates
2025-10-11 09:01:58 +02:00

1204 lines
39 KiB
HTML

{# 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-4">
<div class="d-flex align-items-center gap-2">
<div class="kanban-toolbar-icon">
<i class="fas fa-columns"></i>
</div>
<h5 class="kanban-toolbar-title mb-0">{{ _('Kanban Board') }}</h5>
</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">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<div class="kanban-status-icon kanban-status-{{ col.key }}">
<i class="{{ col.icon }}"></i>
</div>
<h6 class="kanban-column-title mb-0">{{ col.label }}</h6>
</div>
<span class="kanban-count kanban-count-{{ col.key }}">
{{ 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 {% if current_user.active_timer and current_user.active_timer.task_id == task.id %}kanban-card-active{% endif %}"
draggable="true"
data-task-id="{{ task.id }}"
data-status="{{ task.status }}"
data-priority="{{ task.priority }}">
<!-- Card Header with ID and Actions -->
<div class="kanban-card-header">
<div class="kanban-card-id">#{{ task.id }}</div>
<div class="kanban-card-actions">
{% 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">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="kanban-action-btn kanban-action-stop" title="{{ _('Stop Timer') }}">
<i class="fas fa-stop"></i>
</button>
</form>
{% else %}
<form method="POST" action="{{ url_for('timer.start_timer') }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="project_id" value="{{ task.project_id }}">
<input type="hidden" name="task_id" value="{{ task.id }}">
<button type="submit" class="kanban-action-btn kanban-action-play" title="{{ _('Start Timer') }}">
<i class="fas fa-play"></i>
</button>
</form>
{% endif %}
<a href="{{ url_for('tasks.edit_task', task_id=task.id) }}" class="kanban-action-btn kanban-action-edit" title="{{ _('Edit Task') }}">
<i class="fas fa-pen"></i>
</a>
</div>
</div>
<!-- Priority Indicator -->
<div class="kanban-card-priority kanban-priority-{{ task.priority }}"></div>
<!-- Card Content -->
<div class="kanban-card-content">
<h6 class="kanban-card-title">
<a href="javascript:void(0);" onclick="openTaskModal({{ task.id }})">{{ task.name }}</a>
</h6>
{% if task.description %}
<p class="kanban-card-description">
{{ task.description[:90] }}{% if task.description|length > 90 %}...{% endif %}
</p>
{% endif %}
<!-- Badges Row -->
<div class="kanban-card-badges">
<span class="kanban-badge kanban-badge-priority kanban-badge-priority-{{ task.priority }}">
{{ task.priority_display }}
</span>
{% if current_user.active_timer and current_user.active_timer.task_id == task.id %}
<span class="kanban-badge kanban-badge-active">
<i class="fas fa-circle-dot"></i> {{ _('Active') }}
</span>
{% endif %}
{% if task.is_overdue %}
<span class="kanban-badge kanban-badge-overdue">
<i class="fas fa-exclamation-triangle"></i> {{ _('Overdue') }}
</span>
{% endif %}
</div>
<!-- Progress Bar -->
{% if task.estimated_hours %}
<div class="kanban-card-progress">
<div class="kanban-progress-header">
<span class="kanban-progress-label">{{ _('Progress') }}</span>
<span class="kanban-progress-value">{{ task.progress_percentage }}%</span>
</div>
<div class="kanban-progress-bar">
<div class="kanban-progress-fill" style="width: {{ task.progress_percentage }}%"></div>
</div>
</div>
{% endif %}
</div>
<!-- Card Footer -->
<div class="kanban-card-footer">
<div class="kanban-card-meta">
{% if task.assigned_user %}
<div class="kanban-meta-item kanban-meta-assignee" title="{{ task.assigned_user.display_name }}">
<i class="fas fa-user"></i>
<span>{{ task.assigned_user.display_name }}</span>
</div>
{% endif %}
{% if task.due_date %}
<div class="kanban-meta-item kanban-meta-date {% if task.is_overdue %}kanban-meta-overdue{% endif %}" title="{{ _('Due Date') }}">
<i class="fas fa-calendar"></i>
<span>{{ task.due_date.strftime('%b %d') }}</span>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
<style>
/* ============================================
KANBAN BOARD - MODERN REDESIGN
============================================ */
/* Toolbar */
.kanban-board-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 12px;
}
.kanban-toolbar {
padding: 0;
margin-bottom: 24px;
}
.kanban-toolbar-icon {
width: 42px;
height: 42px;
border-radius: 10px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
}
.kanban-toolbar-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.02em;
}
/* Board Layout */
.kanban-board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
align-items: start;
}
/* Column Styles */
.kanban-column {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
display: flex;
flex-direction: column;
min-height: 150px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
transition: box-shadow 0.2s ease;
}
.kanban-column:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.kanban-column-header {
padding: 16px 18px;
border-bottom: 2px solid #f3f4f6;
background: linear-gradient(to bottom, #ffffff 0%, #fafbfc 100%);
border-top-left-radius: 12px;
border-top-right-radius: 12px;
position: sticky;
top: 0;
z-index: 10;
}
.kanban-column-title {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.01em;
}
/* Status Icons */
.kanban-status-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: transform 0.2s ease;
}
.kanban-status-todo {
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
color: #64748b;
}
.kanban-status-in_progress {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
color: #d97706;
}
.kanban-status-review {
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
color: #2563eb;
}
.kanban-status-done {
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
color: #059669;
}
/* Count Badge */
.kanban-count {
min-width: 28px;
height: 28px;
padding: 0 10px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.8125rem;
font-weight: 600;
background: #f3f4f6;
color: #6b7280;
}
.kanban-count-todo { background: #f1f5f9; color: #64748b; }
.kanban-count-in_progress { background: #fef3c7; color: #d97706; }
.kanban-count-review { background: #dbeafe; color: #2563eb; }
.kanban-count-done { background: #d1fae5; color: #059669; }
/* Column Body */
.kanban-column-body {
padding: 16px;
min-height: 150px;
max-height: 75vh;
overflow-y: auto;
overflow-x: hidden;
background: #fafbfc;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.kanban-column-body::-webkit-scrollbar {
width: 6px;
}
.kanban-column-body::-webkit-scrollbar-track {
background: transparent;
}
.kanban-column-body::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.kanban-column-body::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Drag and Drop States */
.kanban-column-body.drag-over {
background: rgba(59, 130, 246, 0.05);
outline: 2px dashed #3b82f6;
outline-offset: -8px;
}
/* ============================================
KANBAN CARDS - ENHANCED DESIGN
============================================ */
.kanban-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 10px;
margin-bottom: 12px;
cursor: grab;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
position: relative;
overflow: hidden;
}
.kanban-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
border-color: #cbd5e1;
}
.kanban-card:active {
cursor: grabbing;
}
.kanban-card.dragging {
opacity: 0.5;
cursor: grabbing;
transform: rotate(3deg);
}
/* Active Card State */
.kanban-card-active {
border-color: #3b82f6 !important;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1), 0 4px 12px rgba(59, 130, 246, 0.15);
}
.kanban-card-active:hover {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15), 0 8px 20px rgba(59, 130, 246, 0.2);
}
/* Card Header */
.kanban-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px 8px 14px;
background: linear-gradient(to bottom, #fafbfc 0%, #ffffff 100%);
border-bottom: 1px solid #f3f4f6;
}
.kanban-card-id {
font-size: 0.75rem;
font-weight: 600;
color: #9ca3af;
letter-spacing: 0.02em;
}
.kanban-card-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s ease;
}
.kanban-card:hover .kanban-card-actions {
opacity: 1;
}
.kanban-action-btn {
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
cursor: pointer;
transition: all 0.15s ease;
background: #f3f4f6;
color: #6b7280;
}
.kanban-action-btn:hover {
transform: scale(1.05);
}
.kanban-action-play {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
}
.kanban-action-play:hover {
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.kanban-action-stop {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
}
.kanban-action-stop:hover {
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
.kanban-action-edit:hover {
background: #e5e7eb;
color: #374151;
}
/* Priority Indicator */
.kanban-card-priority {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
}
.kanban-priority-low {
background: linear-gradient(to bottom, #10b981 0%, #059669 100%);
}
.kanban-priority-medium {
background: linear-gradient(to bottom, #f59e0b 0%, #d97706 100%);
}
.kanban-priority-high {
background: linear-gradient(to bottom, #f97316 0%, #ea580c 100%);
}
.kanban-priority-urgent {
background: linear-gradient(to bottom, #ef4444 0%, #dc2626 100%);
box-shadow: 0 0 8px rgba(239, 68, 68, 0.3);
}
/* Card Content */
.kanban-card-content {
padding: 14px;
}
.kanban-card-title {
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.4;
margin-bottom: 8px;
color: var(--text-primary);
}
.kanban-card-title a {
color: inherit;
text-decoration: none;
transition: color 0.15s ease;
}
.kanban-card-title a:hover {
color: #3b82f6;
}
.kanban-card-description {
font-size: 0.8125rem;
line-height: 1.5;
color: #6b7280;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Badges */
.kanban-card-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.kanban-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 6px;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.kanban-badge i {
font-size: 10px;
}
.kanban-badge-priority-low {
background: #d1fae5;
color: #065f46;
}
.kanban-badge-priority-medium {
background: #fef3c7;
color: #92400e;
}
.kanban-badge-priority-high {
background: #fed7aa;
color: #9a3412;
}
.kanban-badge-priority-urgent {
background: #fee2e2;
color: #991b1b;
}
.kanban-badge-active {
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
color: #1e40af;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
.kanban-badge-overdue {
background: #fee2e2;
color: #991b1b;
}
/* Progress Bar */
.kanban-card-progress {
margin-bottom: 12px;
}
.kanban-progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.kanban-progress-label {
font-size: 0.75rem;
font-weight: 500;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.kanban-progress-value {
font-size: 0.75rem;
font-weight: 600;
color: #3b82f6;
}
.kanban-progress-bar {
height: 6px;
background: #e5e7eb;
border-radius: 3px;
overflow: hidden;
}
.kanban-progress-fill {
height: 100%;
background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%);
border-radius: 3px;
transition: width 0.3s ease;
}
/* Card Footer */
.kanban-card-footer {
padding: 0 14px 14px 14px;
}
.kanban-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.kanban-meta-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
color: #6b7280;
padding: 6px 10px;
background: #f9fafb;
border-radius: 6px;
white-space: nowrap;
}
.kanban-meta-item i {
font-size: 12px;
opacity: 0.7;
}
.kanban-meta-assignee {
color: #0ea5e9;
background: #f0f9ff;
}
.kanban-meta-assignee i {
color: #0ea5e9;
}
.kanban-meta-date {
color: #6b7280;
}
.kanban-meta-overdue {
color: #dc2626;
background: #fef2f2;
font-weight: 600;
}
.kanban-meta-overdue i {
color: #dc2626;
}
/* Responsive Design */
@media (max-width: 768px) {
.kanban-board {
grid-template-columns: 1fr;
}
.kanban-card-meta {
flex-direction: column;
align-items: flex-start;
}
.kanban-meta-item {
width: 100%;
}
}
/* Dark Mode Support */
[data-theme="dark"] .kanban-toolbar-title,
[data-theme="dark"] .kanban-column-title {
color: #f1f5f9;
}
[data-theme="dark"] .kanban-column {
background: #0f172a;
border-color: #1e293b;
}
[data-theme="dark"] .kanban-column-header {
background: linear-gradient(to bottom, #0f172a 0%, #0a0f1e 100%);
border-bottom-color: #1e293b;
}
[data-theme="dark"] .kanban-column-body {
background: #0a0f1e;
}
[data-theme="dark"] .kanban-card {
background: #1e293b;
border-color: #334155;
}
[data-theme="dark"] .kanban-card:hover {
border-color: #475569;
}
[data-theme="dark"] .kanban-card-header {
background: linear-gradient(to bottom, #1e293b 0%, #1e293b 100%);
border-bottom-color: #334155;
}
[data-theme="dark"] .kanban-card-id {
color: #64748b;
}
[data-theme="dark"] .kanban-card-title {
color: #f1f5f9;
}
[data-theme="dark"] .kanban-card-description {
color: #94a3b8;
}
[data-theme="dark"] .kanban-action-btn {
background: #334155;
color: #94a3b8;
}
[data-theme="dark"] .kanban-action-edit:hover {
background: #475569;
color: #e2e8f0;
}
[data-theme="dark"] .kanban-meta-item {
background: #0f172a;
color: #94a3b8;
}
[data-theme="dark"] .kanban-progress-bar {
background: #334155;
}
[data-theme="dark"] .kanban-count {
background: #1e293b;
color: #94a3b8;
}
/* Professional Task Modal Styles */
.task-modal-content { border-radius: 16px; border: none; box-shadow: 0 20px 50px rgba(0,0,0,0.15); overflow: hidden; }
.task-modal-icon { width: 40px; height: 40px; border-radius: 10px; background: linear-gradient(135deg, var(--primary-color) 0%, #2563eb 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 18px; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.25); }
.task-modal-title { font-size: 1.25rem; font-weight: 600; color: var(--text-primary); margin: 0; line-height: 1.4; }
.task-modal-label { display: block; font-size: 0.8125rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.task-modal-field { padding: 12px; background: var(--light-color, #f8fafc); border-radius: 10px; transition: all 0.2s ease; }
.task-modal-field:hover { background: var(--surface-hover, #f1f5f9); }
.task-modal-value { font-size: 0.9375rem; color: var(--text-primary); font-weight: 500; }
.task-modal-section { padding: 0; }
.task-modal-description { padding: 16px; background: var(--light-color, #f8fafc); border-radius: 10px; color: var(--text-primary); line-height: 1.6; border-left: 3px solid var(--primary-color); }
.task-modal-project-card { padding: 14px; background: var(--light-color, #f8fafc); border-radius: 10px; border-left: 3px solid var(--primary-color); display: flex; align-items: center; gap: 12px; transition: all 0.2s ease; }
.task-modal-project-card:hover { background: var(--surface-hover, #f1f5f9); transform: translateX(2px); }
.task-modal-project-icon { width: 36px; height: 36px; border-radius: 8px; background: var(--primary-color); background: linear-gradient(135deg, var(--primary-color) 0%, #2563eb 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 14px; }
.task-modal-progress { height: 10px; border-radius: 10px; background: var(--light-color, #e5e7eb); overflow: hidden; }
.task-modal-progress .progress-bar { background: linear-gradient(90deg, var(--primary-color) 0%, #2563eb 100%); border-radius: 10px; transition: width 0.4s ease; }
.task-modal-progress-text { font-size: 0.875rem; font-weight: 600; color: var(--primary-color); min-width: 45px; text-align: right; }
.task-modal-stat { display: flex; align-items: center; gap: 12px; padding: 14px; background: var(--light-color, #f8fafc); border-radius: 10px; transition: all 0.2s ease; }
.task-modal-stat:hover { background: var(--surface-hover, #f1f5f9); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.05); }
.task-modal-stat-icon { width: 40px; height: 40px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
.task-modal-stat-content { flex-grow: 1; min-width: 0; }
.task-modal-stat-value { font-size: 1.125rem; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
.task-modal-stat-label { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; }
.bg-primary-subtle { background-color: rgba(59, 130, 246, 0.1); }
.bg-info-subtle { background-color: rgba(14, 165, 233, 0.1); }
.bg-success-subtle { background-color: rgba(34, 197, 94, 0.1); }
/* Dark Mode Support */
[data-theme="dark"] .task-modal-content { background: #0f172a; }
[data-theme="dark"] .task-modal-title { color: var(--text-secondary); }
[data-theme="dark"] .task-modal-field { background: #1e293b; }
[data-theme="dark"] .task-modal-field:hover { background: #334155; }
[data-theme="dark"] .task-modal-description { background: #1e293b; color: var(--text-secondary); }
[data-theme="dark"] .task-modal-project-card { background: #1e293b; }
[data-theme="dark"] .task-modal-project-card:hover { background: #334155; }
[data-theme="dark"] .task-modal-progress { background: #1e293b; }
[data-theme="dark"] .task-modal-stat { background: #1e293b; }
[data-theme="dark"] .task-modal-stat:hover { background: #334155; }
[data-theme="dark"] .task-modal-value { color: var(--text-secondary); }
[data-theme="dark"] .modal-header { background: #0f172a; border-color: #1e293b; }
[data-theme="dark"] .modal-body { background: #0f172a; }
[data-theme="dark"] .modal-footer { background: #0f172a; border-color: #1e293b; }
/* Responsive */
@media (max-width: 576px) {
.task-modal-stat { padding: 10px; }
.task-modal-stat-icon { width: 32px; height: 32px; font-size: 14px; }
.task-modal-stat-value { font-size: 1rem; }
.task-modal-stat-label { font-size: 0.7rem; }
}
</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 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-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 card status
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');
}
} catch (err) {
// revert
if (originalParent) originalParent.appendChild(card);
card.dataset.status = originalStatus;
updateCounts();
alert('{{ _('Failed to update task status') }}');
}
});
});
updateCounts();
})();
</script>
<!-- Task Modal JavaScript -->
<script>
// Global function to open task modal
window.openTaskModal = async function(taskId) {
const modal = new bootstrap.Modal(document.getElementById('taskQuickViewModal'));
const modalEl = document.getElementById('taskQuickViewModal');
// Show loading state
document.querySelector('.task-modal-loading').style.display = 'block';
document.querySelector('.task-modal-content-wrapper').style.display = 'none';
modal.show();
try {
// Fetch task details from API
const response = await fetch(`/api/tasks/${taskId}`);
if (!response.ok) throw new Error('Failed to load task');
const task = await response.json();
// Populate modal with task data
document.getElementById('taskModalName').textContent = task.name;
document.getElementById('taskModalId').textContent = `#${task.id}`;
// Status badge
const statusBadge = `<span class="status-badge status-${task.status}">${task.status_display || task.status}</span>`;
document.getElementById('taskModalStatus').innerHTML = statusBadge;
// Priority badge
const priorityBadge = `<span class="priority-badge priority-${task.priority}">${task.priority_display || task.priority}</span>`;
document.getElementById('taskModalPriority').innerHTML = priorityBadge;
// Project info
const projectHtml = `
<div class="task-modal-project-icon">
<i class="fas fa-project-diagram"></i>
</div>
<div>
<div class="fw-semibold" style="color: var(--text-primary);">${task.project?.name || 'N/A'}</div>
<small class="text-muted">${task.project?.client || ''}</small>
</div>
`;
document.getElementById('taskModalProject').innerHTML = projectHtml;
// Description
const descSection = document.getElementById('taskModalDescriptionSection');
if (task.description && task.description.trim()) {
descSection.style.display = 'block';
document.getElementById('taskModalDescription').textContent = task.description;
} else {
descSection.style.display = 'none';
}
// Assigned user
const assignedSection = document.getElementById('taskModalAssignedSection');
if (task.assigned_user) {
assignedSection.style.display = 'block';
const assignedHtml = `
<div class="d-flex align-items-center gap-2">
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center" style="width: 28px; height: 28px;">
<i class="fas fa-user text-primary" style="font-size: 12px;"></i>
</div>
<span>${task.assigned_user.display_name || task.assigned_user.username}</span>
</div>
`;
document.getElementById('taskModalAssigned').innerHTML = assignedHtml;
} else {
assignedSection.style.display = 'none';
}
// Due date
const dueDateSection = document.getElementById('taskModalDueDateSection');
if (task.due_date) {
dueDateSection.style.display = 'block';
const dueDate = new Date(task.due_date);
const isOverdue = task.is_overdue || (dueDate < new Date() && task.status !== 'done');
const dueDateHtml = `
<span class="${isOverdue ? 'text-danger fw-semibold' : ''}">
<i class="fas fa-calendar me-1"></i>
${dueDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
${isOverdue ? '<span class="badge bg-danger ms-2">Overdue</span>' : ''}
</span>
`;
document.getElementById('taskModalDueDate').innerHTML = dueDateHtml;
} else {
dueDateSection.style.display = 'none';
}
// Progress
const progressSection = document.getElementById('taskModalProgressSection');
const estimatedSection2 = document.getElementById('taskModalEstimatedSection2');
if (task.estimated_hours && task.estimated_hours > 0) {
progressSection.style.display = 'block';
estimatedSection2.style.display = 'block';
const progress = task.progress_percentage || 0;
document.getElementById('taskModalProgressBar').style.width = `${progress}%`;
document.getElementById('taskModalProgressText').textContent = `${progress}%`;
const tracked = task.total_hours || 0;
document.getElementById('taskModalHoursInfo').textContent =
`${tracked.toFixed(1)}h tracked / ${task.estimated_hours.toFixed(1)}h estimated`;
document.getElementById('taskModalEstimated').textContent = `${task.estimated_hours.toFixed(1)}h`;
} else {
progressSection.style.display = 'none';
estimatedSection2.style.display = 'none';
}
// Tags
const tagsSection = document.getElementById('taskModalTagsSection');
if (task.tags && task.tags.length > 0) {
tagsSection.style.display = 'block';
const tagsHtml = task.tags.map(tag =>
`<span class="badge bg-light text-dark border">${tag}</span>`
).join('');
document.getElementById('taskModalTags').innerHTML = tagsHtml;
} else {
tagsSection.style.display = 'none';
}
// Stats
document.getElementById('taskModalTotalHours').textContent =
`${(task.total_hours || 0).toFixed(1)}h`;
// Created date
const createdDate = new Date(task.created_at);
document.getElementById('taskModalCreated').textContent =
createdDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
// Completed date
const completedSection = document.getElementById('taskModalCompletedSection');
if (task.completed_at) {
completedSection.style.display = 'block';
const completedDate = new Date(task.completed_at);
document.getElementById('taskModalCompleted').textContent =
completedDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
} else {
completedSection.style.display = 'none';
}
// Update action buttons
document.getElementById('taskModalViewFullBtn').href = `/tasks/${taskId}`;
document.getElementById('taskModalEditBtn').href = `/tasks/${taskId}/edit`;
// Hide loading, show content
document.querySelector('.task-modal-loading').style.display = 'none';
document.querySelector('.task-modal-content-wrapper').style.display = 'block';
} catch (error) {
console.error('Error loading task:', error);
document.querySelector('.task-modal-loading').innerHTML = `
<div class="text-danger">
<i class="fas fa-exclamation-triangle fa-2x mb-3"></i>
<p>Failed to load task details</p>
<button class="btn btn-sm btn-secondary" onclick="bootstrap.Modal.getInstance(document.getElementById('taskQuickViewModal')).hide();">Close</button>
</div>
`;
}
};
</script>
<!-- Professional Task Modal -->
<div class="modal fade" id="taskQuickViewModal" tabindex="-1" aria-labelledby="taskModalTitle" aria-hidden="true" data-bs-backdrop="true" data-bs-keyboard="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg">
<div class="modal-content task-modal-content">
<!-- Modal Header -->
<div class="modal-header border-bottom-0 pb-2">
<div class="d-flex align-items-center gap-2 flex-grow-1">
<div class="task-modal-icon">
<i class="fas fa-tasks"></i>
</div>
<div class="flex-grow-1">
<h5 class="modal-title task-modal-title" id="taskModalTitle">
<span id="taskModalName">Loading...</span>
</h5>
<small class="text-muted" id="taskModalId"></small>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<!-- Modal Body -->
<div class="modal-body pt-2" id="taskModalBody">
<div class="task-modal-loading text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="text-muted mt-3">{{ _('Loading task details...') }}</p>
</div>
<div class="task-modal-content-wrapper" style="display: none;">
<!-- Status & Priority Row -->
<div class="row g-3 mb-4">
<div class="col-sm-6">
<div class="task-modal-field">
<label class="task-modal-label">
<i class="fas fa-flag me-2"></i>{{ _('Status') }}
</label>
<div id="taskModalStatus"></div>
</div>
</div>
<div class="col-sm-6">
<div class="task-modal-field">
<label class="task-modal-label">
<i class="fas fa-exclamation-circle me-2"></i>{{ _('Priority') }}
</label>
<div id="taskModalPriority"></div>
</div>
</div>
</div>
<!-- Project Info -->
<div class="task-modal-section mb-4">
<label class="task-modal-label">
<i class="fas fa-project-diagram me-2"></i>{{ _('Project') }}
</label>
<div class="task-modal-project-card" id="taskModalProject"></div>
</div>
<!-- Description -->
<div class="task-modal-section mb-4" id="taskModalDescriptionSection" style="display: none;">
<label class="task-modal-label">
<i class="fas fa-align-left me-2"></i>{{ _('Description') }}
</label>
<div class="task-modal-description" id="taskModalDescription"></div>
</div>
<!-- Details Grid -->
<div class="row g-3 mb-4">
<div class="col-sm-6" id="taskModalAssignedSection" style="display: none;">
<div class="task-modal-field">
<label class="task-modal-label">
<i class="fas fa-user me-2"></i>{{ _('Assigned To') }}
</label>
<div id="taskModalAssigned" class="task-modal-value"></div>
</div>
</div>
<div class="col-sm-6" id="taskModalDueDateSection" style="display: none;">
<div class="task-modal-field">
<label class="task-modal-label">
<i class="fas fa-calendar me-2"></i>{{ _('Due Date') }}
</label>
<div id="taskModalDueDate" class="task-modal-value"></div>
</div>
</div>
</div>
<!-- Progress Bar (if estimated hours exist) -->
<div class="task-modal-section mb-4" id="taskModalProgressSection" style="display: none;">
<label class="task-modal-label">
<i class="fas fa-chart-line me-2"></i>{{ _('Progress') }}
</label>
<div class="d-flex align-items-center gap-3">
<div class="flex-grow-1">
<div class="progress task-modal-progress">
<div class="progress-bar" role="progressbar" id="taskModalProgressBar" style="width: 0%"></div>
</div>
</div>
<span class="task-modal-progress-text" id="taskModalProgressText">0%</span>
</div>
<div class="d-flex justify-content-between mt-2 small text-muted">
<span id="taskModalHoursInfo"></span>
</div>
</div>
<!-- Tags -->
<div class="task-modal-section mb-4" id="taskModalTagsSection" style="display: none;">
<label class="task-modal-label">
<i class="fas fa-tags me-2"></i>{{ _('Tags') }}
</label>
<div id="taskModalTags" class="d-flex flex-wrap gap-2"></div>
</div>
<!-- Quick Stats -->
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="task-modal-stat">
<div class="task-modal-stat-icon bg-primary-subtle">
<i class="fas fa-clock text-primary"></i>
</div>
<div class="task-modal-stat-content">
<div class="task-modal-stat-value" id="taskModalTotalHours">0h</div>
<div class="task-modal-stat-label">{{ _('Tracked') }}</div>
</div>
</div>
</div>
<div class="col-6 col-md-3" id="taskModalEstimatedSection2">
<div class="task-modal-stat">
<div class="task-modal-stat-icon bg-info-subtle">
<i class="fas fa-hourglass-half text-info"></i>
</div>
<div class="task-modal-stat-content">
<div class="task-modal-stat-value" id="taskModalEstimated">0h</div>
<div class="task-modal-stat-label">{{ _('Estimated') }}</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="task-modal-stat">
<div class="task-modal-stat-icon bg-success-subtle">
<i class="fas fa-calendar-plus text-success"></i>
</div>
<div class="task-modal-stat-content">
<div class="task-modal-stat-value" id="taskModalCreated">-</div>
<div class="task-modal-stat-label">{{ _('Created') }}</div>
</div>
</div>
</div>
<div class="col-6 col-md-3" id="taskModalCompletedSection" style="display: none;">
<div class="task-modal-stat">
<div class="task-modal-stat-icon bg-success-subtle">
<i class="fas fa-check-circle text-success"></i>
</div>
<div class="task-modal-stat-content">
<div class="task-modal-stat-value" id="taskModalCompleted">-</div>
<div class="task-modal-stat-label">{{ _('Completed') }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal Footer -->
<div class="modal-footer border-top-0 pt-2 flex-wrap gap-2">
<a href="#" id="taskModalViewFullBtn" class="btn btn-outline-secondary btn-sm" target="_blank">
<i class="fas fa-external-link-alt me-2"></i>{{ _('View Full Details') }}
</a>
<a href="#" id="taskModalEditBtn" class="btn btn-primary btn-sm">
<i class="fas fa-edit me-2"></i>{{ _('Edit Task') }}
</a>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">
<i class="fas fa-times me-2"></i>{{ _('Close') }}
</button>
</div>
</div>
</div>
</div>