feat: implement subtasks, track task completion/archival users, and enhance kanban UI with drag-and-drop states

This commit is contained in:
root
2026-03-29 19:46:21 +07:00
parent afb7ef2a37
commit 78d309ae4e
3 changed files with 169 additions and 12 deletions

View File

@@ -194,12 +194,14 @@ $custom_like = $custom_tokens ? implode(' OR ', array_fill(0, count($custom_toke
$params = array_merge([$user_id], $my_group_ids, $my_group_ids, [$user_id], $custom_tokens, $custom_tokens);
$sql = "SELECT t.*, u.username as assignor_user_name, g.name as assignor_group_name, au.username as assignee_user_name, ag.name as assignee_group_name
$sql = "SELECT t.*, u.username as assignor_user_name, g.name as assignor_group_name, au.username as assignee_user_name, ag.name as assignee_group_name, cu.username as completed_by_username, aru.username as archived_by_username
FROM tasks t
LEFT JOIN users u ON t.assignor_id = u.id
LEFT JOIN groups g ON t.assignor_group_id = g.id
LEFT JOIN users au ON (t.assignee_type='user' AND t.assignee_id = au.id)
LEFT JOIN groups ag ON (t.assignee_type='group' AND t.assignee_id = ag.id)
LEFT JOIN users cu ON t.completed_by = cu.id
LEFT JOIN users aru ON t.archived_by = aru.id
WHERE (t.assignee_type='user' AND t.assignee_id = ?)
OR (t.assignee_type='group' AND t.assignee_id IN ($group_in))
OR (t.assignor_type='group' AND t.assignor_group_id IN ($group_in))
@@ -219,6 +221,7 @@ $tasks = $stmt->fetchAll();
$t_ids = array_column($tasks, 'id');
$attachments = [];
$subtasks = [];
if($t_ids) {
$in = str_repeat('?,', count($t_ids) - 1) . '?';
$stmt = $pdo->prepare("SELECT ta.*, u.username as owner_name FROM task_attachments ta LEFT JOIN users u ON ta.owner_id = u.id WHERE ta.task_id IN ($in) ORDER BY ta.created_at ASC");
@@ -226,6 +229,12 @@ if($t_ids) {
foreach($stmt->fetchAll() as $row) {
$attachments[$row['task_id']][] = $row;
}
$stmt2 = $pdo->prepare("SELECT ts.*, cbu.username as completed_by_username FROM task_subtasks ts LEFT JOIN users cbu ON ts.completed_by = cbu.id WHERE ts.task_id IN ($in) ORDER BY ts.created_at ASC");
$stmt2->execute($t_ids);
foreach($stmt2->fetchAll() as $row) {
$subtasks[$row['task_id']][] = $row;
}
}
$kanban = ['pending' => [], 'in_progress' => [], 'completed' => [], 'archived' => []];
@@ -266,7 +275,7 @@ usort($kanban['pending'], function($a, $b) {
.kanban-task {
background: var(--surface); padding: 1rem; border-radius: 10px;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05), 0 2px 4px -1px rgba(0,0,0,0.03);
border: 1px solid rgba(0,0,0,0.05); cursor: grab; transition: transform 0.2s, box-shadow 0.2s;
border: 1px solid rgba(0,0,0,0.05); cursor: grab; transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s;
border-left: 4px solid var(--border);
}
body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-left: 4px solid var(--primary); }
@@ -277,6 +286,7 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
.kanban-task.expanded { box-shadow: 0 10px 15px -3px rgba(0,0,0,0.05); }
.kanban-task:hover:not(.expanded) { transform: translateY(-2px); box-shadow: 0 10px 15px -3px rgba(0,0,0,0.05); }
.kanban-task.dragging { opacity: 0.4; transform: scale(1.02); box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04); cursor: grabbing; z-index: 100; }
.kanban-task-header { cursor: pointer; display: flex; flex-direction: column; gap: 0.5rem; }
.kanban-task-title {
@@ -312,6 +322,10 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
.role-from { background: rgba(var(--primary-rgb), 0.1); color: var(--primary); }
.role-to { background: rgba(16, 185, 129, 0.1); color: #10B981; }
.kanban-task:not(.expanded) .expanded-only { display: none !important; }
.kanban-task.expanded .avatar-collapsed-only { display: none !important; }
.kanban-task.expanded .expanded-only { display: inline-flex !important; }
/* Custom Autocomplete Dropdown */
.autocomplete-dropdown {
position: absolute; top: 100%; left: 0; right: 0; background: var(--surface);
@@ -485,7 +499,7 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
</div>
<div class="kanban-tasks" id="col-<?= $col_id ?>">
<?php foreach($kanban[$col_id] as $t): ?>
<div class="kanban-task status-<?= $col_id ?>" draggable="true" ondragstart="drag(event, <?= $t['id'] ?>)" id="task-<?= $t['id'] ?>" data-due="<?= $t['due_date'] ? strtotime($t['due_date']) : PHP_INT_MAX ?>" data-created="<?= strtotime($t['created_at']) ?>">
<div class="kanban-task status-<?= $col_id ?>" draggable="true" ondragstart="drag(event, <?= $t['id'] ?>)" ondragend="dragEnd(event)" id="task-<?= $t['id'] ?>" data-due="<?= $t['due_date'] ? strtotime($t['due_date']) : PHP_INT_MAX ?>" data-created="<?= strtotime($t['created_at']) ?>">
<div class="kanban-task-header" onclick="toggleTaskExpand(this)">
<div style="display:flex; justify-content:space-between; align-items:flex-start; width:100%; min-width:0; gap:0.5rem;">
@@ -520,19 +534,40 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
<div style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:0.75rem; margin-top:0.5rem;">
<?php
echo '<span class="badge-role role-to" style="padding:2px 8px;">';
if($t['assignee_type'] == 'custom') echo '👤 '.htmlspecialchars((string)$t['custom_assignee_name']);
elseif($t['assignee_type'] == 'group') echo '👥 '.htmlspecialchars($t['assignee_group_name']);
else echo '👤 '.htmlspecialchars($t['assignee_user_name'] ?? '');
$to_name = '';
if($t['assignee_type'] == 'custom') $to_name = htmlspecialchars((string)$t['custom_assignee_name']);
elseif($t['assignee_type'] == 'group') $to_name = htmlspecialchars($t['assignee_group_name']);
else $to_name = htmlspecialchars($t['assignee_user_name'] ?? '');
$to_initial = strtoupper(mb_substr($to_name, 0, 1));
$col_idx = crc32($to_name) % 8;
$bg_colors = ['#4F46E5','#10B981','#F59E0B','#EF4444','#8B5CF6','#06B6D4','#EC4899','#84CC16'];
$av_bg = $bg_colors[$col_idx];
echo '<div class="avatar-collapsed-only" style="display:flex; align-items:center;" title="To: ' . $to_name . '">';
echo '<div style="width:24px; height:24px; border-radius:50%; background:'.$av_bg.'; color:white; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:0.75rem; border:2px solid var(--surface); margin-right:-8px; z-index:1;">'.$to_initial.'</div>';
echo '</div>';
echo '<span class="badge-role role-to expanded-only" style="padding:2px 8px; align-items:center; gap:0.4rem;">';
echo '<div style="width:18px; height:18px; border-radius:50%; background:'.$av_bg.'; color:white; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:0.6rem;">'.$to_initial.'</div>';
echo '<span>'.$to_name.'</span>';
echo '</span>';
echo '<div style="display:flex; align-items:center; gap:0.5rem;">';
echo '<div style="display:flex; align-items:center; gap:0.5rem; margin-left:auto;">';
$att_count = empty($attachments[$t['id']]) ? 0 : count($attachments[$t['id']]);
if($att_count > 0) {
echo '<span style="color:var(--text-light); font-weight:600; display:flex; align-items:center; gap:0.15rem;" title="'.$att_count.' attachment(s)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg> '.$att_count.'</span>';
}
// We could show subtasks count too on collapsed state
$sub_count = empty($subtasks[$t['id']]) ? 0 : count($subtasks[$t['id']]);
$sub_done = 0;
if($sub_count > 0) {
foreach($subtasks[$t['id']] as $st) if($st['is_completed']) $sub_done++;
echo '<span style="color:var(--text-light); font-weight:600; display:flex; align-items:center; gap:0.15rem;" title="'.$sub_done.'/'.$sub_count.' subtasks"><i class="fas fa-check-square" style="font-size:0.75rem;"></i> '.$sub_done.'/'.$sub_count.'</span>';
}
if($t['due_date']) {
$days_diff = (int)round((strtotime($t['due_date']) - strtotime(date('Y-m-d'))) / 86400);
if($days_diff < 0) {
@@ -580,7 +615,56 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
?>
</span>
</div>
<?php if($t['status'] === 'completed'): ?>
<div style="font-size:0.8rem; display:flex; justify-content:space-between; align-items:center;">
<span style="color:var(--text-light);">Completed by:</span>
<span style="color:#10B981; font-weight:600;">
<?= htmlspecialchars($t['completed_by_username'] ?? 'System') ?>
</span>
</div>
<?php endif; ?>
<?php if($t['status'] === 'archived'): ?>
<div style="font-size:0.8rem; display:flex; justify-content:space-between; align-items:center;">
<span style="color:var(--text-light);">Archived by:</span>
<span style="color:#6B7280; font-weight:600;">
<?= htmlspecialchars($t['archived_by_username'] ?? 'System (Auto)') ?>
</span>
</div>
<?php endif; ?>
</div>
<?php if($t['status'] === 'in_progress' || !empty($subtasks[$t['id']])): ?>
<div style="margin-top:0.75rem; border-top:1px dashed var(--border); padding-top:0.75rem;">
<div style="font-size:0.75rem; font-weight:600; color:var(--text-light); margin-bottom:0.5rem;">Subtasks / Progress</div>
<div style="display:flex; flex-direction:column; gap:0.4rem; margin-bottom:0.5rem;" id="subtasks-container-<?= $t['id'] ?>">
<?php if(!empty($subtasks[$t['id']])): ?>
<?php foreach($subtasks[$t['id']] as $st): ?>
<div style="display:flex; align-items:flex-start; gap:0.5rem; font-size:0.8rem;">
<input type="checkbox" style="margin-top:0.2rem;" <?= $st['is_completed'] ? 'checked' : '' ?> <?= $t['status'] !== 'in_progress' ? 'disabled' : '' ?> onchange="toggleSubtask(<?= $st['id'] ?>, this.checked)">
<div style="flex:1;">
<span style="<?= $st['is_completed'] ? 'text-decoration:line-through; color:var(--text-light);' : 'color:var(--text);' ?>">
<?= htmlspecialchars($st['title']) ?>
</span>
<?php if($st['is_completed']): ?>
<div style="font-size:0.65rem; color:var(--text-light); margin-top:0.1rem;">
Completed by <?= htmlspecialchars($st['completed_by_username'] ?? 'Unknown user') ?>
</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php else: ?>
<div style="font-size:0.75rem; color:var(--text-light); font-style:italic;">No progress updates yet.</div>
<?php endif; ?>
</div>
<?php if($t['status'] === 'in_progress'): ?>
<form method="POST" onsubmit="event.preventDefault(); addSubtask(<?= $t['id'] ?>, this);" style="display:flex; gap:0.25rem;">
<input type="text" name="title" placeholder="Add progress or subtask..." required style="flex:1; border:1px solid var(--border); background:var(--bg); color:var(--text); border-radius:4px; padding:0.25rem 0.5rem; font-size:0.75rem; min-width:0;">
<button type="submit" style="background:var(--primary); color:white; border:none; border-radius:4px; padding:0.25rem 0.5rem; font-size:0.75rem; cursor:pointer;" title="Add">Add</button>
</form>
<?php endif; ?>
</div>
<?php endif; ?>
<div style="margin-top:0.75rem; border-top:1px dashed var(--border); padding-top:0.75rem;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.5rem;">
@@ -711,6 +795,10 @@ function allowDrop(ev) {
function drag(ev, taskId) {
ev.dataTransfer.setData("text", taskId);
setTimeout(() => ev.target.classList.add('dragging'), 0);
}
function dragEnd(ev) {
ev.target.classList.remove('dragging');
}
function sortPendingTasks() {
@@ -765,7 +853,33 @@ function drop(ev, newStatus) {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `task_id=${taskId}&status=${newStatus}`
}).then(() => showToast('Task updated.'));
}).then(() => {
showToast('Task updated.');
// Soft reload to show updated completions/archives visually
setTimeout(() => window.location.reload(), 300);
});
}
function addSubtask(taskId, form) {
let title = form.title.value;
if(!title) return;
fetch('?action=api&api_action=add_subtask', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `task_id=${taskId}&title=${encodeURIComponent(title)}`
}).then(() => {
form.reset();
window.location.reload();
});
}
function toggleSubtask(subtaskId, isCompleted) {
fetch('?action=api&api_action=toggle_subtask', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `subtask_id=${subtaskId}&is_completed=${isCompleted ? 1 : 0}`
}).then(() => {
// Quick visual toggle doesn't strictly need reload, but reloading ensures names are updated
window.location.reload();
});
}
function toggleTaskExpand(header) {