mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-22 22:31:31 -05:00
8b6e61873b
Display formats for dates and times now follow the system settings (Admin settings) by default. Users can override in their profile (User settings) or choose "Use system default" so their view matches the rest of the system. Backend: - User.date_format and User.time_format are nullable; null means use system. - Migration 120 makes these columns nullable (existing rows unchanged). - get_resolved_date_format_key() and get_resolved_time_format_key() in timezone utils return the effective key (user or system) for templates and API. - Context processor injects resolved_date_format_key and resolved_time_format_key so base.html and JS (window.userPrefs) always see the resolved format. - User settings form: "Use system default" option and save logic for null. - User.to_dict() includes resolved date_format, time_format, and timezone for API clients (e.g. mobile). Web: - base.html uses resolved keys for window.userPrefs (no hardcoded fallback). - Replaced display-only strftime() in templates with |user_date, |user_datetime, |user_time, and |format_date so all visible dates/times respect settings. Left <input type="date"> values and URL/API params as YYYY-MM-DD where required. Mobile: - ApiClient.getCurrentUser() and user prefs provider load resolved prefs from /api/v1/users/me. - date_format_utils maps API keys to intl patterns; formatDate, formatTime, formatDateTime, formatDateRange used for display. - Time entries screen (filter dialog), time entry form, time entry card, and home dashboard use user prefs for formatting; API requests still send ISO dates. Co-authored-by: Cursor <cursoragent@cursor.com>
1371 lines
45 KiB
HTML
1371 lines
45 KiB
HTML
{# Reusable Kanban board for tasks. Expects `tasks` and `kanban_columns` in context. #}
|
|
|
|
<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>
|
|
{% if current_user.is_admin %}
|
|
<div>
|
|
<a href="{{ url_for('kanban.list_columns') }}" class="btn btn-sm btn-outline-secondary">
|
|
<i class="fas fa-cog"></i> {{ _('Manage Columns') }}
|
|
</a>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div id="kanbanBoard" class="kanban-board">
|
|
{% for col in kanban_columns %}
|
|
<div class="kanban-column" data-status="{{ col.key }}" data-column-color="{{ col.color }}">
|
|
<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 }}" style="--column-color: {{ col.color }};">
|
|
<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.project %}
|
|
<div class="mb-2">
|
|
<span class="kanban-badge" style="background:#e5e7eb;color:#374151;">{{ task.project.code_display }}</span>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% 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|format_date }}</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;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.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 = {
|
|
{% for col in kanban_columns %}
|
|
'{{ col.key }}': '{{ col.label }}'{% if not loop.last %},{% endif %}
|
|
{% endfor %}
|
|
};
|
|
const updateUrlTemplate = "{{ url_for('tasks.api_update_status', task_id=0) }}"; // will replace 0 with actual id
|
|
// Track columns signature to avoid redundant rebuilds
|
|
let lastColumnsSignature = null;
|
|
|
|
function computeColumnsSignature(columns){
|
|
try {
|
|
return (columns || []).map(c => [c.key, c.label, c.icon, c.color, c.is_active ? 1 : 0].join('|')).join(',');
|
|
} catch(_) { return null; }
|
|
}
|
|
|
|
async function fetchColumnsNoStore(){
|
|
const resp = await fetch('/api/kanban/columns?_=' + Date.now(), {
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Cache-Control': 'no-cache' },
|
|
cache: 'no-store'
|
|
});
|
|
if (!resp.ok) throw new Error('Failed to fetch columns');
|
|
const payload = await resp.json();
|
|
return Array.isArray(payload.columns) ? payload.columns : [];
|
|
}
|
|
|
|
function rebuildBoardColumns(columns){
|
|
const existingCardsByStatus = {};
|
|
document.querySelectorAll('.kanban-column-body').forEach(body => {
|
|
const status = body.getAttribute('data-status');
|
|
existingCardsByStatus[status] = Array.from(body.querySelectorAll('.kanban-card'));
|
|
});
|
|
|
|
const boardEl = document.getElementById('kanbanBoard');
|
|
if (!boardEl) return;
|
|
boardEl.innerHTML = '';
|
|
|
|
columns.forEach(col => {
|
|
const colEl = document.createElement('div');
|
|
colEl.className = 'kanban-column';
|
|
colEl.setAttribute('data-status', col.key);
|
|
colEl.setAttribute('data-column-color', col.color || 'secondary');
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'kanban-column-header';
|
|
header.innerHTML = `
|
|
<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}" style="--column-color: ${col.color || 'var(--primary-color)'};">
|
|
<i class="${col.icon || 'fas fa-circle'}"></i>
|
|
</div>
|
|
<h6 class="kanban-column-title mb-0">${col.label}</h6>
|
|
</div>
|
|
<span class="kanban-count kanban-count-${col.key}">0</span>
|
|
</div>`;
|
|
|
|
const body = document.createElement('div');
|
|
body.className = 'kanban-column-body';
|
|
body.setAttribute('data-status', col.key);
|
|
|
|
const cards = existingCardsByStatus[col.key] || [];
|
|
cards.forEach(card => { body.appendChild(card); });
|
|
|
|
colEl.appendChild(header);
|
|
colEl.appendChild(body);
|
|
boardEl.appendChild(colEl);
|
|
});
|
|
|
|
bindDragAndDrop();
|
|
updateCounts();
|
|
}
|
|
|
|
|
|
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();
|
|
|
|
// Live handler for Kanban column updates, invoked by base listener
|
|
window.handleKanbanColumnsUpdated = async function(data){
|
|
try {
|
|
// Use global toast system
|
|
if (window.toastManager) {
|
|
const action = (data && data.action) ? String(data.action) : 'updated';
|
|
const msg = action === 'created' ? '{{ _('Kanban column created') }}'
|
|
: action === 'deleted' ? '{{ _('Kanban column deleted') }}'
|
|
: action === 'reordered' ? '{{ _('Kanban columns reordered') }}'
|
|
: action === 'toggled' ? '{{ _('Kanban column visibility changed') }}'
|
|
: '{{ _('Kanban columns updated') }}';
|
|
window.toastManager.info(msg, '{{ _('Update') }}', 3000);
|
|
} else {
|
|
try { showToast('{{ _('Kanban columns updated') }}', 'info'); } catch(_) {}
|
|
}
|
|
|
|
const columns = await fetchColumnsNoStore();
|
|
const sig = computeColumnsSignature(columns);
|
|
if (sig && sig !== lastColumnsSignature) {
|
|
rebuildBoardColumns(columns);
|
|
lastColumnsSignature = sig;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to update Kanban columns live:', e);
|
|
try { showToast('{{ _('Failed to refresh kanban columns') }}', 'warning'); } catch(_) {}
|
|
}
|
|
};
|
|
|
|
// Extract drag/drop binding into a function so we can reapply after DOM rebuilds
|
|
function bindDragAndDrop(){
|
|
document.querySelectorAll('.kanban-column-body').forEach(body => {
|
|
// Remove old listeners by cloning
|
|
const clone = body.cloneNode(true);
|
|
body.parentNode.replaceChild(clone, body);
|
|
});
|
|
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();
|
|
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');
|
|
const data = await resp.json();
|
|
if (!data.success) throw new Error(data.error || 'Rejected');
|
|
} catch (err) {
|
|
if (originalParent) originalParent.appendChild(card);
|
|
card.dataset.status = originalStatus;
|
|
updateCounts();
|
|
try { showToast('{{ _('Failed to update task status') }}', 'error'); } catch(_) { alert('{{ _('Failed to update task status') }}'); }
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Initial bind of drag/drop now that function exists
|
|
bindDragAndDrop();
|
|
// Background polling fallback to catch missed events
|
|
try {
|
|
setInterval(async () => {
|
|
try {
|
|
const columns = await fetchColumnsNoStore();
|
|
const sig = computeColumnsSignature(columns);
|
|
if (sig && sig !== lastColumnsSignature) {
|
|
rebuildBoardColumns(columns);
|
|
lastColumnsSignature = sig;
|
|
if (window.toastManager) window.toastManager.info('{{ _('Kanban columns updated') }}', '{{ _('Update') }}', 2000);
|
|
}
|
|
} catch(_) {}
|
|
}, 15000);
|
|
} catch(_) {}
|
|
})();
|
|
</script>
|
|
|
|
<!-- Task Modal JavaScript -->
|
|
<script>
|
|
// Global function to open task modal
|
|
window.openTaskModal = async function(taskId) {
|
|
const modalEl = document.getElementById('taskQuickViewModal');
|
|
|
|
// Ensure modal is attached to body to avoid stacking/pointer issues
|
|
if (modalEl && modalEl.parentElement !== document.body) {
|
|
document.body.appendChild(modalEl);
|
|
}
|
|
|
|
const modal = new bootstrap.Modal(modalEl);
|
|
|
|
// 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>
|
|
|
|
|