diff --git a/index.php b/index.php index 39303ef..9985aaf 100644 --- a/index.php +++ b/index.php @@ -15,7 +15,10 @@ if ($action === 'api') { try { $pdo->exec("ALTER TABLE tasks ADD COLUMN status_updated_at DATETIME"); } catch(Exception $e) {} try { $pdo->exec("ALTER TABLE tasks ADD COLUMN due_date DATE"); } catch(Exception $e) {} try { $pdo->exec("ALTER TABLE tasks ADD COLUMN custom_assignee_name TEXT"); } catch(Exception $e) {} + try { $pdo->exec("ALTER TABLE tasks ADD COLUMN completed_by INTEGER DEFAULT NULL"); } catch(Exception $e) {} + try { $pdo->exec("ALTER TABLE tasks ADD COLUMN archived_by INTEGER DEFAULT NULL"); } catch(Exception $e) {} try { $pdo->exec("CREATE TABLE IF NOT EXISTS task_attachments (id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, file_name TEXT, file_path TEXT, owner_id INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)"); } catch(Exception $e) {} + try { $pdo->exec("CREATE TABLE IF NOT EXISTS task_subtasks (id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, title TEXT NOT NULL, is_completed INTEGER DEFAULT 0, created_by INTEGER, completed_by INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)"); } catch(Exception $e) {} $format_dt = function($value) { $value = is_string($value) ? trim($value) : $value; @@ -37,14 +40,54 @@ if ($action === 'api') { if(!isset($_SESSION['user_id'])) die(json_encode(['status'=>'error','msg'=>'unauthorized'])); $task_id = $_POST['task_id'] ?? null; $status = $_POST['status'] ?? null; - $stmt = $pdo->prepare("UPDATE tasks SET status = ?, status_updated_at = CURRENT_TIMESTAMP WHERE id = ?"); - $stmt->execute([$status, $task_id]); + $user_id = $_SESSION['user_id']; + + $set_clause = "status = ?, status_updated_at = CURRENT_TIMESTAMP"; + $params = [$status]; + + if ($status === 'completed') { + $set_clause .= ", completed_by = ?"; + $params[] = $user_id; + } elseif ($status === 'archived') { + $set_clause .= ", archived_by = ?"; + $params[] = $user_id; + } + $params[] = $task_id; + + $stmt = $pdo->prepare("UPDATE tasks SET $set_clause WHERE id = ?"); + $stmt->execute($params); $task_to_upd = query("SELECT title FROM tasks WHERE id=?", [$task_id])->fetch(); if($task_to_upd) notify_waha('Moved to '.$status, 'Task Status', $task_to_upd['title']); echo json_encode(['status'=>'ok']); exit; } + if($api_action === 'add_subtask') { + if(!isset($_SESSION['user_id'])) die(json_encode(['status'=>'error','msg'=>'unauthorized'])); + $task_id = $_POST['task_id'] ?? null; + $title = $_POST['title'] ?? null; + $user_id = $_SESSION['user_id']; + if($task_id && $title) { + $stmt = $pdo->prepare("INSERT INTO task_subtasks (task_id, title, created_by) VALUES (?, ?, ?)"); + $stmt->execute([$task_id, $title, $user_id]); + } + echo json_encode(['status'=>'ok']); + exit; + } + + if($api_action === 'toggle_subtask') { + if(!isset($_SESSION['user_id'])) die(json_encode(['status'=>'error','msg'=>'unauthorized'])); + $subtask_id = $_POST['subtask_id'] ?? null; + $is_completed = (int)($_POST['is_completed'] ?? 0); + $user_id = $_SESSION['user_id']; + if($subtask_id) { + $stmt = $pdo->prepare("UPDATE task_subtasks SET is_completed = ?, completed_by = ? WHERE id = ?"); + $stmt->execute([$is_completed, $is_completed ? $user_id : null, $subtask_id]); + } + echo json_encode(['status'=>'ok']); + exit; + } + if($api_action === 'get_tasks_status') { $task_id = $_GET['task_id'] ?? null; diff --git a/views/admin.php b/views/admin.php index 7379180..af0495a 100644 --- a/views/admin.php +++ b/views/admin.php @@ -459,7 +459,7 @@ $active_tab = $_GET['tab'] ?? 'overview';
-
Variables: {action} {module} {item} {user}
+
Variables: {action} {module} {item} {user} {attachment_count} {due_date} {created_date} {updated_date} {assignor} {assignee}
diff --git a/views/tasks.php b/views/tasks.php index 4b53d21..aa598b1 100644 --- a/views/tasks.php +++ b/views/tasks.php @@ -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
-
+
@@ -520,19 +534,40 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
'; - 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 '
'; + echo '
'.$to_initial.'
'; + echo '
'; + + echo ''; + echo '
'.$to_initial.'
'; + echo ''.$to_name.''; echo '
'; - echo '
'; + echo '
'; $att_count = empty($attachments[$t['id']]) ? 0 : count($attachments[$t['id']]); if($att_count > 0) { echo ' '.$att_count.''; } + // 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 ' '.$sub_done.'/'.$sub_count.''; + } + 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 ?>
+ +
+ Completed by: + + + +
+ + +
+ Archived by: + + + +
+
+ + +
+
Subtasks / Progress
+
+ + +
+ onchange="toggleSubtask(, this.checked)"> +
+ + + + +
+ Completed by +
+ +
+
+ + +
No progress updates yet.
+ +
+ +
+ + +
+ +
+
@@ -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) {