1523 lines
83 KiB
PHP
1523 lines
83 KiB
PHP
<?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' => $_SESSION['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" 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'])): ?>
|
||
<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, '"');
|
||
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>
|