Files
Task-Management/views/tasks.php
I-Fun 0d27befed0 fix: resolve 5 bugs — auth, ownership, crash, notification, and HTML issues
- dashboard.php: fix PDO parameter count crash for users with no groups
  (used max(count,1) for placeholder count but provided 0 group params)
- index.php: add missing auth check on get_tasks_status API endpoint
  (was accessible without a session, exposing all task data)
- index.php: enforce ownership check on delete_comment API
  (any logged-in user could delete any comment; now requires authorship or admin)
- tasks.php: fix task creation notification using undefined $_SESSION['username']
  (replaced with $current_user['username'] so {assignor} template works correctly)
- tasks.php: remove duplicate id attribute on att-lightbox-body element

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 23:21:15 +07:00

1523 lines
83 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// DB schema migrations for new Kanban features
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN assignor_type TEXT DEFAULT 'user'"); } catch(Exception $e) {}
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN assignor_group_id INTEGER"); } catch(Exception $e) {}
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 DATETIME"); } catch(Exception $e) {}
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN tags TEXT 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 system_config (k TEXT PRIMARY KEY, v TEXT)");
$pdo->exec("INSERT OR IGNORE INTO system_config (k, v) VALUES ('completed_days', '7')");
$pdo->exec("INSERT OR IGNORE INTO system_config (k, v) VALUES ('archived_days', '90')");
} catch(Exception $e) {}
// Auto-cleanup lifecycle hooks
$conf_comp = query("SELECT v FROM system_config WHERE k='completed_days'")->fetchColumn() ?: 7;
$conf_arch = query("SELECT v FROM system_config WHERE k='archived_days'")->fetchColumn() ?: 90;
if(is_numeric($conf_comp)) {
$pdo->exec("UPDATE tasks SET status='archived', status_updated_at=CURRENT_TIMESTAMP WHERE status='completed' AND datetime(COALESCE(status_updated_at, created_at), '+$conf_comp days') <= CURRENT_TIMESTAMP");
}
if(is_numeric($conf_arch)) {
$pdo->exec("DELETE FROM tasks WHERE status='archived' AND datetime(COALESCE(status_updated_at, created_at), '+$conf_arch days') <= CURRENT_TIMESTAMP");
}
// Handle Task Deletions
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_task'])) {
$del_id = $_POST['delete_task_id'];
$task_check = query("SELECT assignor_type, assignor_id, assignor_group_id FROM tasks WHERE id=?", [$del_id])->fetch();
$user_groups = get_user_groups($user_id);
$my_group_ids = array_column($user_groups, 'id');
$can_delete = false;
if($task_check) {
if($task_check['assignor_type'] === 'user' && $task_check['assignor_id'] == $user_id) $can_delete = true;
if($task_check['assignor_type'] === 'group' && in_array($task_check['assignor_group_id'], $my_group_ids)) $can_delete = true;
if(!empty($_SESSION['is_admin'])) $can_delete = true;
}
if($can_delete) {
$task_to_del = query("SELECT title, assignee_type, assignee_id FROM tasks WHERE id=?", [$del_id])->fetch();
query("DELETE FROM tasks WHERE id=?", [$del_id]);
if($task_to_del) {
$ctx = ['action' => 'Deleted', 'module' => 'Task', 'item' => $task_to_del['title']];
if($task_to_del['assignee_type'] === 'user' && $task_to_del['assignee_id']) {
notify_user($task_to_del['assignee_id'], "Task '{$task_to_del['title']}' has been deleted.", $ctx);
} elseif($task_to_del['assignee_type'] === 'group' && $task_to_del['assignee_id']) {
notify_group($task_to_del['assignee_id'], "Group task '{$task_to_del['title']}' has been deleted.", $ctx);
}
}
}
}
// Handle attachment deletion
if($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_POST['delete_attachment']) || isset($_POST['delete_attachment_ajax']))) {
$att_id = $_POST['attachment_id'];
$att = query("SELECT owner_id, file_path FROM task_attachments WHERE id=?", [$att_id])->fetch();
if($att && ($att['owner_id'] == $user_id || !empty($_SESSION['is_admin']))) {
@unlink($att['file_path']);
query("DELETE FROM task_attachments WHERE id=?", [$att_id]);
}
if(isset($_POST['delete_attachment_ajax'])) {
echo "OK"; exit;
}
}
// Handle AJAX file upload for existing tasks
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['upload_attachment_ajax'])) {
$task_id = (int)($_POST['task_id'] ?? 0);
$task = query("SELECT id FROM tasks WHERE id=?", [$task_id])->fetch();
if($task && isset($_FILES['attachment_file'])) {
$files = $_FILES['attachment_file'];
$names = is_array($files['name']) ? $files['name'] : [$files['name']];
$tmps = is_array($files['tmp_name']) ? $files['tmp_name'] : [$files['tmp_name']];
$errors = is_array($files['error']) ? $files['error'] : [$files['error']];
for($i = 0; $i < count($names); $i++) {
if($names[$i] && $errors[$i] === 0) {
$ext = strtolower(pathinfo($names[$i], PATHINFO_EXTENSION));
$path = 'uploads/t_' . time() . '_' . rand(100, 999) . '.' . $ext;
if(!is_dir('uploads')) mkdir('uploads');
if(move_uploaded_file($tmps[$i], $path)) {
$s = $pdo->prepare("INSERT INTO task_attachments (task_id, file_name, file_path, owner_id) VALUES (?, ?, ?, ?)");
$s->execute([$task_id, $names[$i], $path, $user_id]);
}
}
}
echo json_encode(['success' => true]); exit;
}
echo json_encode(['success' => false, 'error' => 'Invalid task']); exit;
}
// Handle form submission
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_task'])) {
$title = $_POST['title'];
$desc = $_POST['description'] ?? '';
$due_date = $_POST['due_date'] ?? null;
if(trim((string)$due_date) === '') $due_date = null;
// Parse From
$from = $_POST['from_assignor']; // 'user_1' or 'group_2'
$f_parts = explode('_', $from);
$assignor_type = $f_parts[0]; // 'user' or 'group'
$assignor_group_id = ($assignor_type === 'group') ? $f_parts[1] : null;
// Parse To Array (multi-assignee fallback to custom string)
$to_raw = trim((string)($_POST['to_assignee'] ?? ''));
$to_list = array_filter(array_map('trim', explode(',', $to_raw)));
$a_type = 'custom';
$a_id = null;
$custom_name = $to_raw;
if(count($to_list) === 1) {
$single = reset($to_list);
$u = query("SELECT id FROM users WHERE username=?", [$single])->fetch();
if($u) {
$a_type = 'user';
$a_id = $u['id'];
$custom_name = null;
} else {
$g = query("SELECT id FROM groups WHERE name=?", [$single])->fetch();
if($g) {
$a_type = 'group';
$a_id = $g['id'];
$custom_name = null;
}
}
}
$tags = trim($_POST['tags'] ?? '');
$stmt = $pdo->prepare("INSERT INTO tasks (title, description, assignor_id, assignor_type, assignor_group_id, assignee_type, assignee_id, custom_assignee_name, status_updated_at, due_date, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?)");
if(!$stmt->execute([$title, $desc, $user_id, $assignor_type, $assignor_group_id, $a_type, $a_id, $custom_name, $due_date, $tags])) {
if(isset($_POST['ajax_submit'])) { echo json_encode(['success'=>false, 'error'=>'Task insert failed']); exit; }
}
$new_task_id = $pdo->lastInsertId();
// Process attachments bundled in this single request
if(isset($_FILES['attachment_file'])) {
$files = $_FILES['attachment_file'];
$names = is_array($files['name']) ? $files['name'] : [$files['name']];
$tmps = is_array($files['tmp_name']) ? $files['tmp_name'] : [$files['tmp_name']];
$errors = is_array($files['error']) ? $files['error'] : [$files['error']];
for($i=0; $i<count($names); $i++) {
if($names[$i] && $errors[$i] === 0) {
$ext = strtolower(pathinfo($names[$i], PATHINFO_EXTENSION));
$path = 'uploads/t_' . time() . '_' . rand(100,999) . '.' . $ext;
if(!is_dir('uploads')) mkdir('uploads');
if(move_uploaded_file($tmps[$i], $path)) {
$stmt = $pdo->prepare("INSERT INTO task_attachments (task_id, file_name, file_path, owner_id) VALUES (?, ?, ?, ?)");
$stmt->execute([$new_task_id, $names[$i], $path, $user_id]);
}
}
}
}
// Handle notifications
$ctx = ['action' => 'Created', 'module' => 'Task', 'item' => $title, 'assignor' => $current_user['username'] ?? 'System', 'due_date' => $due_date ?: 'None'];
foreach($to_list as $rec) {
$u = query("SELECT id FROM users WHERE username=?", [$rec])->fetch();
if($u) notify_user($u['id'], "You have been assigned a new task: $title", $ctx);
else {
$g = query("SELECT id FROM groups WHERE name=?", [$rec])->fetch();
if($g) notify_group($g['id'], "Your group has been assigned a new task: $title", $ctx);
}
}
if(isset($_POST['ajax_submit'])) {
echo json_encode(['success'=>true, 'redirect'=>'?action=tasks&msg_title='.urlencode('Task Created').'&msg_body='.urlencode('Task successfully created.')]);
exit;
}
header('Location: ?action=tasks');
exit;
}
// Fetch Assignable Entities for "From"
if(!isset($user_groups)) $user_groups = get_user_groups($user_id);
if(!isset($my_group_ids)) $my_group_ids = array_column($user_groups, 'id');
$from_options = [];
$from_options[] = ['val' => 'user_'.$user_id, 'label' => 'Myself ('.htmlspecialchars($current_user['username']).')'];
foreach($user_groups as $g) {
$from_options[] = ['val' => 'group_'.$g['id'], 'label' => htmlspecialchars($g['name']) . ' (Group)'];
}
$all_u = query("SELECT id, username FROM users")->fetchAll();
$all_g = query("SELECT id, name FROM groups")->fetchAll();
$group_in = implode(',', array_fill(0, count($my_group_ids), '?')) ?: '0';
$custom_tokens = [];
if(!empty($current_user['username'])) $custom_tokens[] = (string)$current_user['username'];
foreach($user_groups as $g) {
if(!empty($g['name'])) $custom_tokens[] = (string)$g['name'];
}
$custom_tokens = array_values(array_unique(array_filter(array_map('trim', $custom_tokens), fn($v) => $v !== '')));
$custom_in = implode(',', array_fill(0, count($custom_tokens), '?')) ?: '0';
$custom_like = $custom_tokens ? implode(' OR ', array_fill(0, count($custom_tokens), "(','||lower(replace(t.custom_assignee_name,' ',''))||',') LIKE ('%,'||lower(replace(?,' ',''))||',%')")) : '0';
$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, u.profile_pic as assignor_user_pic,
g.name as assignor_group_name, g.profile_pic as assignor_group_pic,
au.username as assignee_user_name, au.profile_pic as assignee_user_pic,
ag.name as assignee_group_name, ag.profile_pic as assignee_group_pic,
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))
OR (t.assignor_type='user' AND t.assignor_id = ?)
OR (
COALESCE(t.assignee_type,'custom')='custom'
AND t.custom_assignee_name IS NOT NULL
AND (
t.custom_assignee_name IN ($custom_in)
OR ($custom_like)
)
)
ORDER BY t.created_at DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$tasks = $stmt->fetchAll();
$t_ids = array_column($tasks, 'id');
$attachments = [];
$comments = [];
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");
$stmt->execute($t_ids);
foreach($stmt->fetchAll() as $row) {
$attachments[$row['task_id']][] = $row;
}
$stmt2 = $pdo->prepare("SELECT tc.*, cu.username as created_by_username FROM task_comments tc LEFT JOIN users cu ON tc.created_by = cu.id WHERE tc.task_id IN ($in) ORDER BY tc.created_at ASC");
$stmt2->execute($t_ids);
foreach($stmt2->fetchAll() as $row) {
$comments[$row['task_id']][] = $row;
}
}
// Pre-fetch profile pics for users
$all_usernames = [];
foreach($tasks as $t) {
if($t['assignee_type'] == 'user' || $t['assignee_type'] == 'custom') {
$names = array_filter(array_map('trim', explode(',', $t['assignee_user_name'] ?? $t['custom_assignee_name'] ?? '')));
foreach($names as $n) $all_usernames[] = $n;
}
if($t['assignor_type'] == 'user') $all_usernames[] = $t['assignor_user_name'];
}
$user_pics = [];
if($all_usernames) {
$all_usernames = array_unique($all_usernames);
$in_u = str_repeat('?,', count($all_usernames) - 1) . '?';
$stmt_u = $pdo->prepare("SELECT username, profile_pic FROM users WHERE username IN ($in_u)");
$stmt_u->execute(array_values($all_usernames));
foreach($stmt_u->fetchAll() as $row) {
$user_pics[$row['username']] = $row['profile_pic'];
}
}
// Pre-fetch group pics
$all_groupnames = [];
foreach($tasks as $t) {
if($t['assignee_type'] == 'group') $all_groupnames[] = $t['assignee_group_name'];
if($t['assignor_type'] == 'group') $all_groupnames[] = $t['assignor_group_name'];
}
if($all_groupnames) {
$all_groupnames = array_unique($all_groupnames);
$in_g = str_repeat('?,', count($all_groupnames) - 1) . '?';
$stmt_g = $pdo->prepare("SELECT name, profile_pic FROM groups WHERE name IN ($in_g)");
$stmt_g->execute(array_values($all_groupnames));
foreach($stmt_g->fetchAll() as $row) {
$user_pics[$row['name']] = $row['profile_pic'];
}
}
$kanban = ['pending' => [], 'in_progress' => [], 'completed' => [], 'archived' => []];
foreach($tasks as $t) {
if(isset($kanban[$t['status']])) $kanban[$t['status']][] = $t;
}
// Sort Pending by Due Date (Earliest first)
usort($kanban['pending'], function($a, $b) {
$da = $a['due_date'] ? strtotime($a['due_date']) : PHP_INT_MAX;
$db = $b['due_date'] ? strtotime($b['due_date']) : PHP_INT_MAX;
if($da == $db) return strtotime($b['created_at'] . ' UTC') - strtotime($a['created_at'] . ' UTC');
return $da - $db;
});
?>
<style>
/* Kanban UI Enhancements */
.kanban-board {
display: flex; gap: 1.5rem; overflow-x: auto; padding-bottom: 2rem; min-height: 60vh;
}
.kanban-col {
background: var(--bg); border: 1px solid var(--border); border-radius: 12px;
flex: 1; min-width: 280px; display: flex; flex-direction: column;
}
.kanban-col-header {
padding: 1rem; font-weight: 600; font-size: 1rem;
display: flex; justify-content: space-between; align-items: center;
border-bottom: 2px solid; color: var(--text); border-radius: 12px 12px 0 0;
}
#col-header-pending { border-bottom-color: #F59E0B; background: rgba(245,158,11,0.05); }
#col-header-in_progress { border-bottom-color: #3B82F6; background: rgba(59,130,246,0.05); }
#col-header-completed { border-bottom-color: #10B981; background: rgba(16,185,129,0.05); }
#col-header-archived { border-bottom-color: #6B7280; background: rgba(107,114,128,0.05); }
.kanban-tasks {
padding: 1rem; flex: 1; display: flex; flex-direction: column; gap: 1rem;
}
.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); 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); }
.kanban-task.status-pending { border-left-color: #F59E0B; }
.kanban-task.status-in_progress { border-left-color: #3B82F6; }
.kanban-task.status-completed { border-left-color: #10B981; opacity: 0.8; }
.kanban-task.status-archived { border-left-color: #6B7280; opacity: 0.6; }
.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: grab; display: flex; flex-direction: column; gap: 0.5rem; }
.kanban-task-title {
font-weight: 700; font-size: 1.05rem; color: var(--text);
/* Clamp to 2 lines when collapsed */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
min-width: 0;
}
/* Remove clamp when card is expanded */
.kanban-task.expanded .kanban-task-title {
display: block;
overflow: visible;
-webkit-line-clamp: unset;
}
.kanban-task-body {
display: none; padding-top: 0.75rem; margin-top: 0.5rem; border-top: 1px dashed var(--border); position: relative; padding-bottom: 2.5rem;
}
.kanban-task.expanded .kanban-task-body { display: block; }
.chevron { transition: transform 0.2s; font-size: 0.8rem; color: var(--text-light); }
.kanban-task.expanded .chevron { transform: rotate(180deg); }
.kanban-task-desc {
font-size: 0.875rem; color: var(--text-light); margin-bottom: 1rem; line-height: 1.4;
word-wrap: break-word;
}
.badge-role {
padding: 3px 8px; border-radius: 20px; font-weight: 600; display: inline-flex; align-items: center; gap: 4px; border: 1px solid transparent; text-shadow: none !important;
}
.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; }
/* Hide header metadata row when expanded to avoid duplication */
.kanban-task.expanded .kanban-task-header > div:last-child { display: none !important; }
/* Custom Autocomplete Dropdown */
.autocomplete-dropdown {
position: absolute; top: 100%; left: 0; right: 0; background: var(--surface);
border: 1px solid var(--border); border-radius: 0.5rem; max-height: 200px;
overflow-y: auto; z-index: 100; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
display: none; margin-top: 4px;
}
.autocomplete-item {
padding: 0.75rem 1rem; cursor: pointer; border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between; align-items: center; color: var(--text);
}
.autocomplete-item:last-child { border-bottom: none; }
.autocomplete-item:hover { background: rgba(var(--primary-rgb), 0.05); }
.autocomplete-badge {
font-size: 0.75rem; background: var(--bg); padding: 2px 6px; border-radius: 12px; color: var(--text-light); border: 1px solid var(--border);
}
.att-overlay {
position:absolute; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.6);
display:flex; align-items:center; justify-content:center; gap:0.4rem;
opacity:0; transition:opacity 0.2s; z-index:5;
}
.att-item:hover .att-overlay { opacity:1; }
.att-btn {
color:white; background:rgba(255,255,255,0.25); border-radius:4px; width:22px; height:22px;
display:flex; align-items:center; justify-content:center; text-decoration:none;
transition:background 0.2s;
}
.att-btn:hover { background:rgba(255,255,255,0.5); color:white; }
/* Attachment Lightbox */
#att-lightbox {
display:none; position:fixed; inset:0; background:rgba(0,0,0,0.75);
z-index:10000; align-items:center; justify-content:center;
animation: lbFadeIn 0.18s ease;
}
#att-lightbox.open { display:flex; }
@keyframes lbFadeIn { from{opacity:0} to{opacity:1} }
#att-lightbox-inner {
position:relative; background:var(--surface); border-radius:1rem;
max-width:90vw; max-height:90vh; overflow:hidden;
display:flex; flex-direction:column;
box-shadow:0 25px 60px rgba(0,0,0,0.4);
animation: lbScaleIn 0.18s ease;
}
@keyframes lbScaleIn { from{transform:scale(0.92)} to{transform:scale(1)} }
#att-lightbox-header {
display:flex; align-items:center; justify-content:space-between;
padding:0.75rem 1rem; border-bottom:1px solid var(--border);
gap:1rem; min-width:0;
}
#att-lightbox-title {
font-size:0.875rem; font-weight:600; color:var(--text);
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
#att-lightbox-close {
flex-shrink:0; width:28px; height:28px; border-radius:50%;
background:var(--bg); border:1px solid var(--border);
color:var(--text-light); cursor:pointer; font-size:1rem; line-height:1;
display:flex; align-items:center; justify-content:center;
transition:background 0.15s, color 0.15s;
}
#att-lightbox-close:hover { background:#EF4444; color:white; border-color:#EF4444; }
#att-lightbox-body {
overflow:auto; flex:1;
display:flex; align-items:center; justify-content:center;
padding:1rem;
}
#att-lightbox-body img {
max-width:100%; max-height:calc(90vh - 80px);
border-radius:0.5rem; object-fit:contain; display:block;
}
#att-lightbox-body pre {
margin:0; font-size:0.8rem; font-family:monospace; white-space:pre-wrap;
word-break:break-all; color:var(--text); max-height:calc(90vh - 80px);
max-width:80vw; overflow:auto;
}
#att-lightbox-body iframe {
border:none; width:80vw; height:75vh;
}
</style>
<!-- Attachment Lightbox Modal -->
<div id="att-lightbox" onclick="lbClickOutside(event)">
<div id="att-lightbox-inner">
<div id="att-lightbox-header">
<span id="att-lightbox-title">Preview</span>
<button id="att-lightbox-close" onclick="closeAttachmentPreview()" title="Close">✕</button>
</div>
<div id="att-lightbox-body"></div>
</div>
</div>
<div class="card" style="margin-bottom: 1.5rem; padding: 0.75rem 1rem; box-shadow:0 1px 3px rgba(0,0,0,0.1); border-radius:12px;">
<form id="create-task-form" method="POST" enctype="multipart/form-data" style="display: flex; flex-direction: column;">
<div style="display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap;">
<div style="flex:1; min-width:150px; display:flex; align-items:center; border-right:1px solid var(--border); padding-right:0.5rem;">
<span title="Task Title">📝</span>
<input type="text" name="title" style="width:100%; border:none; outline:none; font-size:1rem; padding:0.25rem 0.5rem; background:transparent; color:var(--text);" required placeholder="What needs to be done?">
</div>
<div style="display:flex; align-items:center; gap:0.5rem; position:relative;">
<select name="from_assignor" style="border:1px solid var(--border); border-radius:20px; padding:4px 10px; font-size:0.75rem; background:var(--surface); color:var(--text-light); cursor:pointer;" required title="From (Assignor)">
<?php foreach($from_options as $opt): ?>
<option value="<?= $opt['val'] ?>"><?= $opt['label'] ?></option>
<?php endforeach; ?>
</select>
<div style="position:relative;" title="To (Assignee)">
<div id="to-icon-btn" style="cursor:pointer; display:flex; align-items:center; justify-content:center; width:30px; height:30px; border-radius:50%; background:var(--surface); border:1px solid var(--border); color:var(--text-light); transition:background 0.2s;" onclick="document.getElementById('to-dropdown-container').style.display='block'; document.getElementById('to-input').focus(); renderDropdown(document.getElementById('to-input').value);"><i class="fas fa-user" style="font-size:0.85rem;"></i></div>
<div id="to-dropdown-container" style="display:none; position:absolute; top:35px; right:-100px; background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:0.5rem; z-index:100; box-shadow:0 10px 15px -3px rgba(0,0,0,0.1); width:200px;">
<input type="text" name="assignee_input_raw" id="to-input" class="form-control" style="padding:0.4rem; font-size:0.8rem; width:100%; border:1px solid var(--border); border-radius:4px;" autocomplete="off" placeholder="Type user/group...">
<div id="to-dropdown" class="autocomplete-dropdown" style="position:static; box-shadow:none; border:none; margin-top:4px; max-height:150px;"></div>
</div>
</div>
<div style="position:relative;" title="Due Date & Time">
<div id="due-icon-btn" style="cursor:pointer; display:flex; align-items:center; justify-content:center; width:30px; height:30px; border-radius:50%; background:var(--surface); border:1px solid var(--border); color:var(--text-light); transition:all 0.2s;" onclick="duePicker.open();">
<i class="fas fa-calendar-alt" style="font-size:0.85rem;"></i>
</div>
<input type="text" name="due_date" id="due-picker-input" style="visibility:hidden; position:absolute; width:1px; height:1px;">
</div>
<label title="Attach Files" id="attach-icon-btn" style="cursor:pointer; display:flex; align-items:center; justify-content:center; width:30px; height:30px; border-radius:50%; background:var(--surface); border:1px solid var(--border); color:var(--text-light); transition:background 0.2s;">
<i class="fas fa-paperclip" style="font-size:0.85rem;"></i>
<input type="file" multiple style="display:none;" onchange="handleTaskFilesSelect(this)">
</label>
<div style="position:relative;" title="Tags">
<div id="tag-icon-btn" style="cursor:pointer; display:flex; align-items:center; justify-content:center; width:30px; height:30px; border-radius:50%; background:var(--surface); border:1px solid var(--border); color:var(--text-light); transition:background 0.2s;" onclick="document.getElementById('tags-dropdown-container').style.display='block';">
<i class="fas fa-tag" style="font-size:0.85rem;"></i>
</div>
<div id="tags-dropdown-container" style="display:none; position:absolute; top:35px; right:-20px; background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:0.75rem; z-index:100; box-shadow:0 10px 15px -3px rgba(0,0,0,0.1); width:280px; align-items:stretch;">
<label style="font-size:0.75rem; font-weight:600; color:var(--text); margin-bottom:0.4rem; display:block; text-align:left;">Tags</label>
<input type="text" name="tags" class="form-control tagify-input" style="font-size:0.8rem; border:1px solid var(--border); border-radius:4px; padding:4px; max-width:100%;" placeholder="Add tags...">
</div>
</div>
<button type="button" class="btn" style="padding:0.3rem 1rem; border-radius:20px; font-weight:600; font-size:0.8rem;" onclick="submitCreateTaskForm()">Create</button>
<button type="submit" id="hidden-create-btn" name="create_task" style="display:none;"></button>
</div>
</div>
<input type="text" name="description" style="width:100%; border:none; outline:none; font-size:0.8rem; padding:0.5rem 0.5rem 0 0.5rem; margin-top:0.5rem; border-top:1px dashed var(--border); background:transparent; color:var(--text-light);" placeholder="Description (Optional)...">
<!-- Dedicated Setup Row -->
<div id="create-task-details-row" style="display:none; flex-wrap:wrap; gap:0.5rem; margin-top:0.75rem; align-items:center;">
<!-- Assignee Tag Container -->
<div id="display-to-container" style="display:none; flex-wrap:wrap; gap:0.4rem;"></div>
<div id="display-due" style="display:none; align-items:center; gap:0.25rem; font-size:0.75rem; background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:2px 8px; color:var(--text-light);">
<i class="fas fa-calendar-alt" style="font-size:0.6rem;"></i> <span id="display-due-text"></span>
<i class="fas fa-times" style="cursor:pointer; margin-left:4px; opacity:0.6;" onclick="clearDueDate()" title="Remove Due Date"></i>
</div>
<div id="temp-uploads-container" style="display:none; flex-wrap:wrap; gap:0.4rem; padding-left:0.25rem;"></div>
</div>
<div id="temp-attachment-inputs"></div>
</form>
</div>
<div class="kanban-board">
<?php
$col_badge_color = ['pending'=>'#F59E0B','in_progress'=>'#3B82F6','completed'=>'#10B981','archived'=>'#6B7280'];
foreach(['pending'=>['Pending','🟠'], 'in_progress'=>['In Progress','🔵'], 'completed'=>['Completed','🟢'], 'archived'=>['Archived','🗄️']] as $col_id => $col_data): ?>
<div class="kanban-col" data-status="<?= $col_id ?>" ondragover="allowDrop(event)" ondrop="drop(event, '<?= $col_id ?>')">
<div class="kanban-col-header" id="col-header-<?= $col_id ?>">
<div><?= $col_data[1] ?> <?= $col_data[0] ?></div>
<span class="badge" style="background:<?= $col_badge_color[$col_id] ?>; color:#fff; border-radius:12px; padding:2px 10px; font-size:0.8rem; font-weight:700;"><?= count($kanban[$col_id]) ?></span>
</div>
<div class="kanban-tasks" id="col-<?= $col_id ?>">
<?php foreach($kanban[$col_id] as $t): ?>
<div class="kanban-task status-<?= $col_id ?>" id="task-<?= $t['id'] ?>" data-due="<?= $t['due_date'] ? strtotime($t['due_date']) : PHP_INT_MAX ?>" data-created="<?= strtotime($t['created_at'] . ' UTC') ?>">
<div class="kanban-task-header" draggable="true" ondragstart="drag(event, <?= $t['id'] ?>)" ondragend="dragEnd(event)" onclick="toggleTaskExpand(this)">
<div style="display:flex; justify-content:space-between; align-items:flex-start; width:100%; min-width:0; gap:0.5rem;">
<span class="kanban-task-title"><?= htmlspecialchars($t['title']) ?></span>
<div style="display:flex; align-items:center; gap:0.5rem; flex-shrink:0;">
<?php if(!empty($_SESSION['is_admin']) || ($t['assignor_type']=='user' && $t['assignor_id']==$user_id) || ($t['assignor_type']=='group' && in_array($t['assignor_group_id'], $my_group_ids))): ?>
<form method="POST" onsubmit="return confirmCustomDelete(this, 'Permanently delete this task forever?');" style="margin:0; padding:0;" onclick="event.stopPropagation();">
<input type="hidden" name="delete_task_id" value="<?= $t['id'] ?>">
<input type="hidden" name="delete_task" value="1">
<button type="submit" style="background:none; border:none; color:var(--text-light); cursor:pointer; padding:2px; display:flex; align-items:center; justify-content:center; border-radius:4px; transition:color 0.15s;" title="Delete Task" onmouseover="this.style.color='#EF4444'" onmouseout="this.style.color='var(--text-light)'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
<path d="M10 11v6"></path>
<path d="M14 11v6"></path>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"></path>
</svg>
</button>
</form>
<?php endif; ?>
<span class="chevron" style="line-height:1;">▼</span>
</div>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:0.75rem; margin-top:0.5rem;">
<?php
// Determine assignor info
$from_name = '';
if($t['assignor_type'] == 'group') $from_name = $t['assignor_group_name'];
else $from_name = $t['assignor_user_name'] ?? 'System';
$from_pic = ($t['assignor_type'] == 'user') ? ($user_pics[$from_name] ?? '') : '';
$from_initial = strtoupper(mb_substr($from_name, 0, 1));
$bg_colors = ['#4F46E5','#10B981','#F59E0B','#EF4444','#8B5CF6','#06B6D4','#EC4899','#84CC16'];
$from_bg = $bg_colors[crc32($from_name) % 8];
$to_names_raw = '';
if($t['assignee_type'] == 'custom') $to_names_raw = (string)$t['custom_assignee_name'];
elseif($t['assignee_type'] == 'group') $to_names_raw = htmlspecialchars($t['assignee_group_name']);
else $to_names_raw = htmlspecialchars($t['assignee_user_name'] ?? '');
$to_names = array_filter(array_map('trim', explode(',', $to_names_raw)));
$to_names_str = implode(', ', $to_names);
$assignment_title = "Task Assigned from {$from_name} to {$to_names_str}";
// Collapsed View: {assignor} => {assignee}
echo '<div class="avatar-collapsed-only" style="display:flex; align-items:center; gap:0.4rem;" title="'.htmlspecialchars($assignment_title).'">';
// From Avatar
if ($from_pic && file_exists($from_pic)) {
echo '<img src="'.htmlspecialchars($from_pic).'" style="width:24px; height:24px; border-radius:50%; object-fit:cover; border:2px solid var(--surface);">';
} else {
echo '<div style="width:24px; height:24px; border-radius:50%; background:'.$from_bg.'; color:white; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:0.75rem; border:2px solid var(--surface);">'.$from_initial.'</div>';
}
// Arrow style separator
echo '<i class="fas fa-chevron-right" style="font-size:0.6rem; color:var(--text-light); opacity:0.6;"></i>';
// To Avatar(s) stack
echo '<div style="display:flex; align-items:center;">';
foreach($to_names as $idx => $name) {
$initial = strtoupper(mb_substr($name, 0, 1));
$col_idx = crc32($name) % 8;
$av_bg = $bg_colors[$col_idx];
$pic = $user_pics[$name] ?? '';
$margin = ($idx === 0) ? '0' : '-8px';
if ($pic && file_exists($pic)) {
echo '<img src="'.htmlspecialchars($pic).'" style="width:24px; height:24px; border-radius:50%; object-fit:cover; border:2px solid var(--surface); margin-left:'.$margin.'; z-index:'.(10 - $idx).';">';
} else {
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-left:'.$margin.'; z-index:'.(10 - $idx).';">'.$initial.'</div>';
}
}
echo '</div>';
echo '</div>';
// Expanded View Badges
echo '<div class="expanded-only" style="display:flex; flex-wrap:wrap; gap:0.4rem;">';
foreach($to_names as $name) {
$initial = strtoupper(mb_substr($name, 0, 1));
$col_idx = crc32($name) % 8;
$av_bg = $bg_colors[$col_idx];
$pic = $user_pics[$name] ?? '';
echo '<span class="badge-role role-to" style="padding:2px 8px; align-items:center; gap:0.4rem; display:inline-flex;">';
if ($pic && file_exists($pic)) {
echo '<img src="'.htmlspecialchars($pic).'" style="width:18px; height:18px; border-radius:50%; object-fit:cover;">';
} else {
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;">'.$initial.'</div>';
}
echo '<span>'.htmlspecialchars($name).'</span>';
echo '</span>';
}
echo '</div>';
echo '<div style="display:flex; align-items:center; gap:0.5rem; margin-left:auto;">';
$tag_count = !empty($t['tags']) ? count(array_filter(array_map('trim', explode(',', $t['tags'])))) : 0;
if($tag_count > 0) {
echo '<span style="color:var(--text-light); font-weight:600; display:flex; align-items:center; gap:0.15rem;" title="'.$tag_count.' tag(s)"><i class="fas fa-tag" style="font-size:0.75rem;"></i> '.$tag_count.'</span>';
}
$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 comments count too on collapsed state
$comment_count = empty($comments[$t['id']]) ? 0 : count($comments[$t['id']]);
if($comment_count > 0) {
echo '<span style="color:var(--text-light); font-weight:600; display:flex; align-items:center; gap:0.15rem;" title="'.$comment_count.' comment(s)"><i class="fas fa-comment-dots" style="font-size:0.75rem;"></i> '.$comment_count.'</span>';
}
if($t['due_date']) {
$days_diff = (int)round((strtotime($t['due_date']) - strtotime(date('Y-m-d'))) / 86400);
if($days_diff < 0) {
echo '<span class="badge" style="background:#EF4444; color:white; padding:2px 8px; border-radius:12px; font-weight:600;">Overdue '.abs($days_diff).'d ago</span>';
} elseif($days_diff == 0) {
echo '<span class="badge" style="background:#F59E0B; color:white; padding:2px 8px; border-radius:12px; font-weight:600;">Due Today</span>';
} else {
echo '<span class="badge" style="background:var(--border); color:var(--text); padding:2px 8px; border-radius:12px; font-weight:600;">'.$days_diff.'d left</span>';
}
}
echo '</div>';
?>
</div>
</div>
<div class="kanban-task-body">
<?php if(trim((string)$t['description'])): ?>
<div class="kanban-task-desc"><?= nl2br(htmlspecialchars((string)$t['description'])) ?></div>
<?php endif; ?>
<div style="font-size:0.72rem; color:var(--text-light); margin-bottom:0.75rem; display:flex; justify-content:space-between; align-items:center;">
<div title="Created At"><i class="fas fa-calendar-plus" style="opacity:0.7; margin-right:4px;"></i> <?= date('M j, Y H:i', strtotime($t['created_at'] . ' UTC')) ?></div>
<?php if($t['due_date']): ?>
<div title="Due Date"><i class="fas fa-clock" style="color:#EF4444; margin-right:4px;"></i> <strong style="color:#EF4444;"><?= date('M j, Y H:i', strtotime($t['due_date'])) ?></strong></div>
<?php endif; ?>
</div>
<div style="display:flex; flex-direction:column; gap:0.5rem;">
<?php
// Calculate FROM and TO names
$from_name = '';
if($t['assignor_type'] == 'group') $from_name = $t['assignor_group_name'];
else $from_name = $t['assignor_user_name'] ?? 'System';
if (!function_exists('get_avatar_html_local')) {
function get_avatar_html_local($name, $profile_pic = '') {
if (!$name) return '';
if($profile_pic && file_exists($profile_pic)) {
return '<img src="'.htmlspecialchars($profile_pic).'" style="width:24px; height:24px; border-radius:50%; object-fit:cover; border:2px solid var(--surface); cursor:help;" title="'.htmlspecialchars($name).'">';
}
$initial = strtoupper(mb_substr($name, 0, 1));
$col_idx = crc32($name) % 8;
$bg_colors = ['#4F46E5','#10B981','#F59E0B','#EF4444','#8B5CF6','#06B6D4','#EC4899','#84CC16'];
$av_bg = $bg_colors[$col_idx];
return '<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); cursor:help;" title="'.htmlspecialchars($name).'">'.$initial.'</div>';
}
}
$from_profile_pic = $user_pics[$from_name] ?? '';
?>
<div style="font-size:0.8rem; display:flex; justify-content:space-between; align-items:center;">
<span style="color:var(--text-light);">Assignments:</span>
<div style="display:flex; align-items:center; gap:0.5rem;">
<div style="display:flex; align-items:center; gap:0.4rem;">
<span style="font-size:0.65rem; color:var(--text-light);">From</span>
<?= get_avatar_html_local($from_name, $from_profile_pic) ?>
</div>
<div style="color:var(--border);">|</div>
<div style="display:flex; align-items:center; gap:0.4rem; flex-wrap:wrap; justify-content:flex-end; max-width:200px;">
<span style="font-size:0.65rem; color:var(--text-light);">To</span>
<?php
foreach($to_names as $name) {
$p = $user_pics[$name] ?? '';
echo get_avatar_html_local($name, $p);
}
?>
</div>
</div>
</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>
<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;">Comments</div>
<div style="display:flex; flex-direction:column; gap:0.5rem; margin-bottom:0.5rem;" id="comments-container-<?= $t['id'] ?>">
<?php if(!empty($comments[$t['id']])): ?>
<?php foreach($comments[$t['id']] as $cm):
$commenter_name = $cm['created_by_username'] ?? 'User';
$commenter_pic = $user_pics[$commenter_name] ?? '';
?>
<div style="display:flex; gap:0.4rem; align-items:flex-start;">
<?= get_avatar_html_local($commenter_name, $commenter_pic) ?>
<div style="background:var(--bg); border:1px solid var(--border); border-radius:12px; border-top-left-radius:0; padding:0.4rem 0.6rem; font-size:0.75rem; color:var(--text); flex:1;">
<div style="display:flex; justify-content:space-between; margin-bottom:0.2rem;">
<strong style="font-size:0.65rem; color:var(--primary);"><?= htmlspecialchars($cm['created_by_username'] ?? 'User') ?></strong>
<span style="font-size:0.6rem; color:var(--text-light);"><?= date('M j, Y H:i', strtotime($cm['created_at'] . ' UTC')) ?>
<?php if($cm['created_by'] == $user_id || !empty($_SESSION['is_admin'])): ?>
&nbsp;<i class="fas fa-trash" style="cursor:pointer; color:#EF4444;" onclick="deleteComment(<?= $cm['id'] ?>)"></i>
<?php endif; ?>
</span>
</div>
<?= nl2br(htmlspecialchars($cm['comment'])) ?>
</div>
</div>
<?php endforeach; ?>
<?php else: ?>
<div style="font-size:0.75rem; color:var(--text-light); font-style:italic;">No comments yet.</div>
<?php endif; ?>
</div>
<form method="POST" onsubmit="event.preventDefault(); addComment(<?= $t['id'] ?>, this);" style="display:flex; gap:0.4rem;">
<input type="text" name="comment" placeholder="Write a comment..." required style="flex:1; border:1px solid var(--border); background:var(--bg); color:var(--text); border-radius:20px; padding:0.4rem 0.75rem; font-size:0.75rem; min-width:0;">
<button type="submit" style="background:var(--primary); color:white; border:none; border-radius:20px; padding:0.4rem 0.8rem; font-size:0.75rem; cursor:pointer;" title="Send"><i class="fas fa-paper-plane"></i></button>
</form>
</div>
<div style="margin-top:0.75rem; border-top:1px dashed var(--border); padding-top:0.75rem;">
<div style="position:absolute; bottom:-0.25rem; right:-0.25rem; display:flex; align-items:center; gap:0.5rem; padding:0.5rem;">
<!-- Tag icon styled like attachment icon -->
<?php $task_tags = !empty($t['tags']) ? array_filter(array_map('trim', explode(',', $t['tags']))) : []; ?>
<button type="button" onclick="showAddTagPopup(<?= $t['id'] ?>, '<?= htmlspecialchars(implode(',', $task_tags), ENT_QUOTES) ?>')" style="cursor:pointer; display:flex; align-items:center; justify-content:center; width:22px; height:22px; border-radius:4px; background:var(--border); color:var(--text); border:none; transition:background 0.2s; position:relative;" title="Manage Tags">
<i class="fas fa-tag" style="font-size:0.6rem;"></i>
<?php if(!empty($task_tags)): ?>
<span style="position:absolute; top:-4px; right:-4px; background:#EF4444; color:white; border-radius:50%; width:12px; height:12px; font-size:0.5rem; display:flex; align-items:center; justify-content:center; font-weight:700; border:1px solid var(--surface);"><?= count($task_tags) ?></span>
<?php endif; ?>
</button>
<!-- Attachment icon -->
<label title="Attach files" style="cursor:pointer; display:flex; align-items:center; justify-content:center; width:22px; height:22px; border-radius:4px; background:var(--border); color:var(--text); transition:background 0.2s;">
<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>
<input type="file" multiple style="display:none;" onchange="uploadFilesAjax(<?= $t['id'] ?>, this.files)">
</label>
</div>
<div class="progress-bar-container" id="progress-container-<?= $t['id'] ?>" style="display:none; height:4px; background:var(--border); border-radius:2px; margin-bottom:0.5rem; overflow:hidden;">
<div class="progress-bar-fill" id="progress-fill-<?= $t['id'] ?>" style="width:0%; height:100%; background:var(--primary); transition:width 0.2s;"></div>
</div>
<?php if(!empty($attachments[$t['id']])): ?>
<div style="display:flex; flex-wrap:wrap; gap:0.4rem;">
<?php foreach($attachments[$t['id']] as $att):
$ext = strtolower(pathinfo($att['file_name'], PATHINFO_EXTENSION));
$is_img = in_array($ext, ['jpg','jpeg','png','gif','webp']);
$is_txt = in_array($ext, ['txt','md','csv','php','json','sql']);
$initial = strtoupper(mb_substr($att['owner_name'] ?? 'U', 0, 1));
$can_del = ($att['owner_id'] == $user_id || !empty($_SESSION['is_admin']));
?>
<div class="att-item" style="width:72px; height:72px; border:1px solid var(--border); border-radius:0.4rem; overflow:hidden; background:var(--bg); position:relative; display:flex; flex-direction:column; box-shadow:0 1px 2px rgba(0,0,0,0.05);">
<div style="flex:1; display:flex; align-items:center; justify-content:center; overflow:hidden; position:relative;">
<?php if($is_img): ?>
<img src="<?= htmlspecialchars((string)$att['file_path']) ?>" style="width:100%; height:100%; object-fit:cover; display:block;">
<?php elseif($is_txt): ?>
<div style="width:100%; height:100%; padding:0.25rem; font-size:0.45rem; font-family:monospace; background:var(--surface); color:var(--text); overflow:hidden; white-space:pre-wrap; opacity:0.8;">
<?= htmlspecialchars(mb_substr(@file_get_contents($att['file_path']), 0, 100)) ?>
</div>
<?php else: ?>
<div style="display:flex; align-items:center; justify-content:center; width:100%; height:100%; background:var(--surface); color:var(--text-light); font-size:1.2rem; text-decoration:none;">📄</div>
<?php endif; ?>
<div class="att-overlay">
<button type="button" class="att-btn" title="Preview" onclick="openAttachmentPreview('<?= htmlspecialchars(addslashes((string)$att['file_path'])) ?>', '<?= htmlspecialchars(addslashes((string)$att['file_name'])) ?>', <?= $is_img ? 'true' : 'false' ?>, <?= $is_txt ? 'true' : 'false' ?>)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
</button>
<a href="<?= htmlspecialchars((string)$att['file_path']) ?>" download class="att-btn" title="Download">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
</a>
</div>
</div>
<?php if($can_del): ?>
<form method="POST" style="position:absolute; top:2px; right:2px; margin:0; z-index:10;" onsubmit="return deleteAttachmentAjax(this, <?= $att['id'] ?>, '<?= htmlspecialchars(addslashes((string)$att['file_name'])) ?>');">
<input type="hidden" name="attachment_id" value="<?= $att['id'] ?>">
<button type="submit" style="background:rgba(239,68,68,0.9); color:white; border:none; border-radius:50%; width:16px; height:16px; font-size:8px; cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0;" title="Delete">✖</button>
</form>
<?php endif; ?>
<?php
$att_owner_pic = $user_pics[$att['owner_name']] ?? '';
if($att_owner_pic && file_exists($att_owner_pic)): ?>
<img src="<?= htmlspecialchars($att_owner_pic) ?>" title="Uploaded by <?= htmlspecialchars((string)$att['owner_name']) ?>" style="position:absolute; top:2px; left:2px; width:16px; height:16px; border-radius:50%; object-fit:cover; z-index:10; box-shadow:0 1px 2px rgba(0,0,0,0.3); border:1px solid rgba(255,255,255,0.2);">
<?php else: ?>
<div title="Uploaded by <?= htmlspecialchars((string)$att['owner_name']) ?>" style="position:absolute; top:2px; left:2px; width:16px; height:16px; border-radius:50%; background:var(--primary); color:white; display:flex; align-items:center; justify-content:center; font-size:0.5rem; font-weight:700; z-index:10; box-shadow:0 1px 2px rgba(0,0,0,0.3); border:1px solid rgba(255,255,255,0.2);"><?= $initial ?></div>
<?php endif; ?>
<a href="<?= htmlspecialchars((string)$att['file_path']) ?>" target="_blank" style="background:rgba(0,0,0,0.6); color:white; font-size:0.5rem; padding:2px 3px; text-decoration:none; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; position:absolute; bottom:0; left:0; right:0; text-align:center;" title="<?= htmlspecialchars((string)$att['file_name']) ?>"><?= htmlspecialchars((string)$att['file_name']) ?></a>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<script>
const toSources = [
<?php foreach($all_u as $u): ?> {name:"<?= htmlspecialchars(addslashes($u['username'])) ?>", type:"User"}, <?php endforeach; ?>
<?php foreach($all_g as $g): ?> {name:"<?= htmlspecialchars(addslashes($g['name'])) ?>", type:"Group"}, <?php endforeach; ?>
];
const toInput = document.getElementById('to-input');
const toDropdown = document.getElementById('to-dropdown');
function renderDropdown(filterText) {
toDropdown.innerHTML = '';
const q = filterText.toLowerCase();
const matches = toSources.filter(s => s.name.toLowerCase().includes(q));
if(q !== '' && !matches.find(m => m.name.toLowerCase() === q)) {
let div = document.createElement('div');
div.className = 'autocomplete-item';
div.style.color = 'var(--primary)';
div.innerHTML = `<span>Assign to: <strong>${filterText}</strong> (Custom)</span>`;
div.onmousedown = (e) => {
e.preventDefault();
handleToSelection(filterText);
document.getElementById('to-dropdown-container').style.display = 'none';
};
toDropdown.appendChild(div);
}
if(matches.length === 0 && q === '') {
toDropdown.style.display = 'none';
return;
}
matches.forEach(m => {
let div = document.createElement('div');
div.className = 'autocomplete-item';
div.innerHTML = `<span>${m.name}</span> <span class="autocomplete-badge">${m.type}</span>`;
div.onmousedown = (e) => {
e.preventDefault();
handleToSelection(m.name);
document.getElementById('to-dropdown-container').style.display = 'none';
};
toDropdown.appendChild(div);
});
toDropdown.style.display = 'block';
}
if(toInput) {
toInput.addEventListener('input', () => renderDropdown(toInput.value));
toInput.addEventListener('focus', () => renderDropdown(toInput.value));
toInput.addEventListener('blur', () => {
handleToSelection(toInput.value);
toDropdown.style.display = 'none';
});
}
function allowDrop(ev) {
ev.preventDefault();
}
function drag(ev, taskId) {
ev.dataTransfer.setData("text", taskId);
// Get the task element from the header
let taskEl = ev.target.closest('.kanban-task');
setTimeout(() => taskEl.classList.add('dragging'), 0);
}
function dragEnd(ev) {
let taskEl = ev.target.closest('.kanban-task');
if(taskEl) taskEl.classList.remove('dragging');
}
function sortPendingTasks() {
const pendingCol = document.getElementById('col-pending');
if(!pendingCol) return;
let cards = Array.from(pendingCol.querySelectorAll('.kanban-task'));
cards.sort((a, b) => {
let da = parseInt(a.getAttribute('data-due'));
let db = parseInt(b.getAttribute('data-due'));
if (da === db) {
let ca = parseInt(a.getAttribute('data-created'));
let cb = parseInt(b.getAttribute('data-created'));
return cb - ca;
}
return da - db;
});
cards.forEach(c => pendingCol.appendChild(c));
}
function updateColumnCounts() {
document.querySelectorAll('.kanban-col').forEach(col => {
const count = col.querySelectorAll('.kanban-task').length;
col.querySelector('.kanban-col-header .badge').innerText = count;
});
}
function drop(ev, newStatus) {
ev.preventDefault();
const taskId = ev.dataTransfer.getData("text");
const taskEl = document.getElementById("task-" + taskId);
if(!taskEl || taskEl.classList.contains('status-' + newStatus)) {
return; // Prevent dropping into identically classed columns (disabled moving same to same)
}
let target = ev.target;
while(target && !target.classList.contains('kanban-col')) {
target = target.parentNode;
}
if(target) {
target.querySelector('.kanban-tasks').appendChild(taskEl);
taskEl.classList.remove('status-pending', 'status-in_progress', 'status-completed', 'status-archived');
taskEl.classList.add('status-' + newStatus);
if(newStatus === 'pending') {
sortPendingTasks();
}
updateColumnCounts();
}
fetch('?action=api&api_action=update_task_status', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `task_id=${taskId}&status=${newStatus}`
}).then(() => {
showToast('Task updated.');
// Soft reload to show updated completions/archives visually
setTimeout(() => window.location.reload(), 300);
});
}
function addComment(taskId, form) {
let comment = form.comment.value;
if(!comment) return;
fetch('?action=api&api_action=add_comment', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `task_id=${taskId}&comment=${encodeURIComponent(comment)}`
}).then(() => {
form.reset();
window.location.reload();
});
}
function deleteComment(commentId) {
if(!confirm('Delete this comment?')) return;
fetch('?action=api&api_action=delete_comment', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `comment_id=${commentId}`
}).then(() => {
window.location.reload();
});
}
function toggleTaskExpand(header) {
let taskEl = header.parentElement;
taskEl.classList.toggle('expanded');
let expanded = JSON.parse(sessionStorage.getItem('kanbanExpanded') || '[]');
if(taskEl.classList.contains('expanded')) {
if(!expanded.includes(taskEl.id)) expanded.push(taskEl.id);
} else {
expanded = expanded.filter(id => id !== taskEl.id);
}
sessionStorage.setItem('kanbanExpanded', JSON.stringify(expanded));
}
document.addEventListener("DOMContentLoaded", () => {
let expanded = JSON.parse(sessionStorage.getItem('kanbanExpanded') || '[]');
expanded.forEach(id => {
let el = document.getElementById(id);
if(el) el.classList.add('expanded');
});
// Check URL parameters for flash notifications
const urlParams = new URLSearchParams(window.location.search);
if(urlParams.get('msg_title') && urlParams.get('msg_body')) {
showCustomAlert(urlParams.get('msg_title'), urlParams.get('msg_body'), 'alert');
window.history.replaceState({}, document.title, window.location.pathname + '?action=tasks');
}
});
document.addEventListener('mousedown', function(e) {
let container = document.getElementById('to-dropdown-container');
if(container && !container.contains(e.target) && e.target.id !== 'to-input' && !e.target.closest('#to-icon-btn')) {
container.style.display = 'none';
}
let tagContainer = document.getElementById('tags-dropdown-container');
if(tagContainer && !tagContainer.contains(e.target) && !e.target.closest('#tag-icon-btn') && !e.target.closest('.tagify')) {
tagContainer.style.display = 'none';
}
});
let pendingTaskFiles = [];
let uploadedIdList = [];
function handleTaskFilesSelect(input) {
let newFiles = Array.from(input.files);
if(newFiles.length === 0) return;
newFiles.forEach(f => {
if(!pendingTaskFiles.find(existing => existing.name === f.name)) {
pendingTaskFiles.push(f);
}
});
input.value = '';
renderPendingFiles();
updateCreateRowVisibility();
}
function removePendingFile(name) {
pendingTaskFiles = pendingTaskFiles.filter(f => f.name !== name);
renderPendingFiles();
updateCreateRowVisibility();
}
function renderPendingFiles() {
let uploadsBox = document.getElementById('temp-uploads-container');
uploadsBox.innerHTML = '';
if(pendingTaskFiles.length > 0) {
uploadsBox.style.display = 'flex';
pendingTaskFiles.forEach((f, i) => {
let html = `
<div class="att-item" id="pending-file-${i}" style="display:flex; align-items:center; gap:0.25rem; font-size:0.75rem; background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:2px 8px; color:var(--text-light);">
<i class="fas fa-paperclip" style="font-size:0.6rem;"></i> <span>${f.name}</span>
<span id="pending-progress-text-${i}" style="font-size:0.55rem; font-weight:bold; display:none; color:var(--primary); margin-left:2px;">0%</span>
<div id="pending-progress-circle-${i}" style="display:none; width:12px; height:12px; border-radius:50%; background:conic-gradient(var(--primary) 0%, rgba(0,0,0,0.1) 0%); margin-left:2px;"></div>
<i class="fas fa-times" id="pending-delete-${i}" style="cursor:pointer; margin-left:4px; opacity:0.6;" onclick="removePendingFile('${f.name.replace(/'/g, "\\'")}')" title="Delete"></i>
</div>`;
uploadsBox.insertAdjacentHTML('beforeend', html);
});
let attBtn = document.getElementById('attach-icon-btn');
attBtn.style.color = 'var(--primary)';
attBtn.style.borderColor = 'var(--primary)';
} else {
uploadsBox.style.display = 'none';
let attBtn = document.getElementById('attach-icon-btn');
attBtn.style.color = 'var(--text-light)';
attBtn.style.borderColor = 'var(--border)';
}
}
function submitCreateTaskForm() {
let titleInp = document.querySelector('input[name="title"]');
let toInput = document.getElementById('to-input');
if(!titleInp.value.trim()) { titleInp.focus(); return; }
if(toInput.value.trim() !== '') { handleToSelection(toInput.value); }
if(selectedAssignees.length === 0) {
showToast("Please assign to someone.");
document.getElementById('to-dropdown-container').style.display = 'block';
toInput.focus();
return;
}
let form = document.getElementById('create-task-form');
let formData = new FormData(form);
formData.append('create_task', '1');
formData.append('ajax_submit', '1');
formData.append('to_assignee', selectedAssignees.join(','));
// Explicitly add files from memory array
formData.delete('attachment_file[]');
pendingTaskFiles.forEach(f => formData.append('attachment_file[]', f));
// UI Feedback
let btn = document.querySelector('[onclick="submitCreateTaskForm()"]');
let originalText = btn.textContent;
btn.textContent = "Creating...";
btn.style.opacity = '0.7';
btn.disabled = true;
let uContainer = document.getElementById('temp-uploads-container');
uContainer.style.display = 'flex';
uContainer.innerHTML = `<div style="width:100%; display:flex; flex-direction:column; align-items:center; gap:0.25rem; color:var(--primary); font-size:0.75rem;"><i class="fas fa-spinner fa-spin"></i> Processing... <div style="flex:1; height:4px; background:var(--border); width:100%; border-radius:2px; margin:4px 0; overflow:hidden;"><div id="master-progress" style="width:0%; height:100%; background:var(--primary); transition:width 0.2s;"></div></div></div>`;
let xhr = new XMLHttpRequest();
xhr.open("POST", window.location.href, true);
xhr.upload.onprogress = function(e) {
if(e.lengthComputable) {
let p = Math.round((e.loaded / e.total) * 100);
let bar = document.getElementById('master-progress');
if(bar) bar.style.width = p + '%';
}
};
xhr.onload = function() {
if(xhr.status === 200) {
try {
let res = JSON.parse(xhr.responseText);
if(res.success) { window.location.href = res.redirect; }
else { showToast("Error: " + res.error); resetBtn(); }
} catch(e) { console.error(xhr.responseText); showToast("Server error occurred."); resetBtn(); }
} else { showToast("Network error."); resetBtn(); }
};
xhr.onerror = function() { showToast("Request failed."); resetBtn(); };
xhr.send(formData);
function resetBtn() {
btn.textContent = originalText;
btn.style.opacity = '1';
btn.disabled = false;
renderPendingFiles();
}
}
function updateCreateRowVisibility() {
let row = document.getElementById('create-task-details-row');
let hasTo = typeof selectedAssignees !== 'undefined' && selectedAssignees.length > 0;
let hasDue = document.getElementById('display-due').style.display !== 'none';
let hasAttBox = typeof pendingTaskFiles !== 'undefined' && pendingTaskFiles.length > 0;
if(hasTo || hasDue || hasAttBox) {
row.style.display = 'flex';
} else {
row.style.display = 'none';
}
}
function handleDueDateChange(val) {
let disp = document.getElementById('display-due');
let text = document.getElementById('display-due-text');
let input = document.querySelector('input[name="due_date"]');
if(val) {
text.textContent = val;
disp.style.display = 'flex';
input.previousElementSibling.style.color = 'var(--primary)';
input.previousElementSibling.style.borderColor = 'var(--primary)';
} else {
disp.style.display = 'none';
input.previousElementSibling.style.color = 'var(--text-light)';
input.previousElementSibling.style.borderColor = 'var(--border)';
}
updateCreateRowVisibility();
}
function clearDueDate() {
duePicker.clear();
}
let duePicker;
document.addEventListener('DOMContentLoaded', () => {
duePicker = flatpickr("#due-picker-input", {
enableTime: true,
dateFormat: "Y-m-d H:i",
altInput: false,
disableMobile: "true",
onChange: function(selectedDates, dateStr) {
handleDueDateChange(dateStr);
}
});
});
let selectedAssignees = [];
function handleToSelection(val) {
if(!val || val.trim() === '') return;
let name = val.trim();
if(!selectedAssignees.includes(name)) {
selectedAssignees.push(name);
renderAssigneeBadges();
}
document.getElementById('to-input').value = '';
updateCreateRowVisibility();
}
function removeAssignee(name) {
selectedAssignees = selectedAssignees.filter(n => n !== name);
renderAssigneeBadges();
updateCreateRowVisibility();
}
function renderAssigneeBadges() {
let container = document.getElementById('display-to-container');
container.innerHTML = '';
if(selectedAssignees.length > 0) {
container.style.display = 'flex';
selectedAssignees.forEach(name => {
let div = document.createElement('div');
div.style.cssText = 'display:flex; align-items:center; gap:0.25rem; font-size:0.75rem; background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:2px 8px; color:var(--text-light);';
let escapedName = name.replace(/'/g, "\\'").replace(/"/g, '&quot;');
div.innerHTML = `<i class="fas fa-user" style="font-size:0.6rem;"></i> <span>${name}</span> <i class="fas fa-times" style="cursor:pointer; margin-left:4px; opacity:0.6;" onclick="removeAssignee('${escapedName}')" title="Remove"></i>`;
container.appendChild(div);
});
document.getElementById('to-icon-btn').style.color = 'var(--primary)';
document.getElementById('to-icon-btn').style.borderColor = 'var(--primary)';
} else {
container.style.display = 'none';
document.getElementById('to-icon-btn').style.color = 'var(--text-light)';
document.getElementById('to-icon-btn').style.borderColor = 'var(--border)';
}
}
function deleteAttachmentAjax(form, attId, fileName) {
showCustomAlert("Confirm Deletion", `Are you sure you want to delete ${fileName}?`, 'confirm', function(confirmed) {
if(confirmed) {
let formData = new FormData();
formData.append('delete_attachment_ajax', '1');
formData.append('attachment_id', attId);
fetch(window.location.href, {
method: 'POST',
body: formData
}).then(() => {
showCustomAlert("Deleted", "File successfully deleted.", 'alert', function() {
window.location.href = window.location.pathname + window.location.search;
});
});
}
});
return false;
}
// (removed dead updateNewTaskFileInput – element new-task-files-list does not exist in DOM)
function openAttachmentPreview(url, fileName, isImage, isText) {
let lb = document.getElementById('att-lightbox');
let body = document.getElementById('att-lightbox-body');
let title = document.getElementById('att-lightbox-title');
title.textContent = fileName;
body.innerHTML = '';
if (isImage) {
let img = document.createElement('img');
img.src = url;
img.alt = fileName;
body.appendChild(img);
} else if (isText) {
let pre = document.createElement('pre');
pre.textContent = 'Loading…';
body.appendChild(pre);
fetch(url).then(r => r.text()).then(t => { pre.textContent = t; }).catch(() => { pre.textContent = 'Could not load file.'; });
} else {
let iframe = document.createElement('iframe');
iframe.src = url;
body.appendChild(iframe);
}
lb.classList.add('open');
document.body.style.overflow = 'hidden';
}
function closeAttachmentPreview() {
document.getElementById('att-lightbox').classList.remove('open');
document.getElementById('att-lightbox-body').innerHTML = '';
document.body.style.overflow = '';
}
function lbClickOutside(e) {
if (e.target.id === 'att-lightbox') closeAttachmentPreview();
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeAttachmentPreview();
});
function showCustomAlert(title, msg, type, callback) {
let overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed; top:0; left:0; width:100vw; height:100vh; background:rgba(0,0,0,0.5); display:flex; align-items:center; justify-content:center; z-index:9999; opacity:0; transition:opacity 0.2s;';
let modal = document.createElement('div');
modal.style.cssText = 'background:var(--surface); padding:2rem; border-radius:1rem; text-align:center; min-width:300px; box-shadow:0 10px 25px rgba(0,0,0,0.2); transform:scale(0.9); transition:transform 0.2s;';
let titleHtml = type === 'confirm' ? `<h3 style="margin-bottom:1rem; color:#EF4444;">⚠️ ${title}</h3>` : `<h3 style="margin-bottom:1rem;">✅ ${title}</h3>`;
let btnHtml = type === 'confirm' ? `
<button class="btn btn-secondary cancel-btn">Cancel</button>
<button class="btn confirm-btn" style="background:#EF4444;">Proceed</button>
` : `<button class="btn confirm-btn" style="min-width:100px;">OK</button>`;
modal.innerHTML = `
${titleHtml}
<p style="margin-bottom:1.5rem; color:var(--text-light);">${msg}</p>
<div style="display:flex; justify-content:center; gap:1rem;">${btnHtml}</div>
`;
if(type === 'confirm') {
modal.querySelector('.cancel-btn').onclick = function(e) { e.preventDefault(); closeAlert(); };
}
modal.querySelector('.confirm-btn').onclick = function(e) {
e.preventDefault();
closeAlert();
if(callback) callback(true);
};
function closeAlert() {
overlay.style.opacity = '0';
modal.style.transform = 'scale(0.9)';
setTimeout(() => document.body.removeChild(overlay), 200);
}
overlay.appendChild(modal);
document.body.appendChild(overlay);
setTimeout(() => { overlay.style.opacity = '1'; modal.style.transform = 'scale(1)'; }, 10);
return false;
}
function confirmCustomDelete(form, msg) {
if(form.dataset.confirmed === '1') return true;
showCustomAlert("Confirm Deletion", msg, 'confirm', function(confirmed) {
if(confirmed) {
form.dataset.confirmed = '1';
form.submit();
}
});
return false;
}
function showAddTagPopup(taskId, existingTags) {
let tags = existingTags ? existingTags.split(',').filter(t => t.trim()) : [];
let overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed; top:0; left:0; width:100vw; height:100vh; background:rgba(0,0,0,0.5); display:flex; align-items:center; justify-content:center; z-index:9999; opacity:0; transition:opacity 0.2s;';
let modal = document.createElement('div');
modal.style.cssText = 'background:var(--surface); padding:1.5rem; border-radius:1rem; min-width:300px; max-width:90vw; box-shadow:0 10px 25px rgba(0,0,0,0.2); transform:scale(0.9); transition:transform 0.2s;';
let tagsHtml = tags.map(tag => {
let escaped = tag.replace(/'/g, "\\'");
return `<span style="font-size:0.7rem; padding:3px 10px; border-radius:12px; background:rgba(var(--primary-rgb),0.15); color:var(--primary); font-weight:600; display:inline-flex; align-items:center; gap:0.3rem; margin:0.2rem;">
<i class="fas fa-tag" style="font-size:0.6rem;"></i> ${tag}
<i class="fas fa-times" style="cursor:pointer; opacity:0.6; font-size:0.65rem;" onclick="removeTagFromPopup('${escaped}', ${taskId})" title="Remove tag"></i>
</span>`;
}).join('');
modal.innerHTML = `
<h3 style="margin-bottom:1rem; font-size:1rem; text-align:center;"><i class="fas fa-tags" style="color:var(--primary);"></i> Manage Tags</h3>
<div id="popup-tags-list" style="margin-bottom:1rem; min-height:30px; ${tags.length ? '' : 'display:none;'}">${tagsHtml}</div>
<div style="display:flex; gap:0.5rem; margin-bottom:1rem;">
<input type="text" id="popup-tag-input" placeholder="Add tags (comma separated)..." style="flex:1; border:1px solid var(--border); background:var(--bg); color:var(--text); border-radius:8px; padding:0.5rem; font-size:0.875rem; box-sizing:border-box;" onkeypress="if(event.key==='Enter'){event.preventDefault();addTagsFromPopup(${taskId});">
<button type="button" onclick="addTagsFromPopup(${taskId})" style="background:var(--primary); color:white; border:none; border-radius:8px; padding:0.5rem 1rem; font-size:0.8rem; cursor:pointer;"><i class="fas fa-plus"></i></button>
</div>
<div style="text-align:center;">
<button onclick="closeTagPopup()" style="background:var(--border); color:var(--text); border:none; border-radius:8px; padding:0.4rem 1.5rem; font-size:0.8rem; cursor:pointer;">Close</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
window.currentTagPopup = overlay;
window.currentTaskId = taskId;
setTimeout(() => {
overlay.style.opacity = '1';
modal.style.transform = 'scale(1)';
document.getElementById('popup-tag-input').focus();
}, 10);
overlay.onclick = function(e) {
if(e.target === overlay) closeTagPopup();
};
}
function closeTagPopup() {
if(window.currentTagPopup) {
window.currentTagPopup.style.opacity = '0';
window.currentTagPopup.querySelector('div').style.transform = 'scale(0.9)';
setTimeout(() => {
if(window.currentTagPopup) document.body.removeChild(window.currentTagPopup);
window.currentTagPopup = null;
window.location.reload();
}, 200);
}
}
function removeTagFromPopup(tag, taskId) {
fetch('?action=api&api_action=remove_task_tag', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `task_id=${taskId}&tag=${encodeURIComponent(tag)}`
}).then(r => r.json()).then(res => {
if(res.success) {
// Refresh popup
closeTagPopup();
fetch('?action=api&api_action=get_tasks_status&task_id=' + taskId)
.then(r => r.json())
.then(data => {
if(data && !data.status) {
showAddTagPopup(taskId, data.tags || '');
}
});
}
});
}
function addTagsFromPopup(taskId) {
let input = document.getElementById('popup-tag-input');
let tags = input.value.split(',').map(t => t.trim()).filter(t => t);
if(!tags.length) return;
let promises = tags.map(tag =>
fetch('?action=api&api_action=add_task_tag', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `task_id=${taskId}&tag=${encodeURIComponent(tag)}`
}).then(r => r.json())
);
Promise.all(promises).then(() => {
input.value = '';
// Refresh popup
closeTagPopup();
setTimeout(() => showAddTagPopup(taskId, ''), 250);
});
}
function addTaskTag(taskId) {
showAddTagPopup(taskId, '');
}
function removeTaskTag(taskId, tag) {
fetch('?action=api&api_action=remove_task_tag', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `task_id=${taskId}&tag=${encodeURIComponent(tag)}`
}).then(r => r.json()).then(res => {
if(res.success) {
window.location.reload();
} else {
showToast('Error: ' + (res.error || 'Failed to remove tag'));
}
});
}
function uploadFilesAjax(taskId, files) {
let container = document.getElementById('progress-container-' + taskId);
let fill = document.getElementById('progress-fill-' + taskId);
container.style.display = 'block';
fill.style.width = '0%';
let formData = new FormData();
formData.append('upload_attachment_ajax', '1');
formData.append('task_id', taskId);
for(let i=0; i<files.length; i++) {
formData.append('attachment_file[]', files[i]);
}
let xhr = new XMLHttpRequest();
xhr.open("POST", window.location.href, true);
xhr.upload.onprogress = function(e) {
if(e.lengthComputable) {
let p = (e.loaded / e.total) * 100;
fill.style.width = p + '%';
}
};
xhr.onload = function() {
if(xhr.status === 200) {
setTimeout(() => {
showCustomAlert("Upload Complete", files.length + " file(s) uploaded successfully.", 'alert', function() {
window.location.href = window.location.pathname + window.location.search;
});
}, 500);
}
};
xhr.send(formData);
}
</script>