Files
Task-Management/views/tasks.php

1118 lines
58 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 DATE"); } 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) {
query("DELETE FROM tasks WHERE id=?", [$del_id]);
}
}
// 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;
}
}
}
$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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)");
if(!$stmt->execute([$title, $desc, $user_id, $assignor_type, $assignor_group_id, $a_type, $a_id, $custom_name, $due_date])) {
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
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");
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");
}
}
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, g.name as assignor_group_name, au.username as assignee_user_name, ag.name as assignee_group_name
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)
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 = [];
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;
}
}
$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']) - strtotime($a['created_at']);
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); cursor: grab; transition: transform 0.2s, box-shadow 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-header { cursor: pointer; 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);
}
.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; }
/* 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; width:30px; height:30px;" title="Due Date">
<div style="cursor:pointer; display:flex; align-items:center; justify-content:center; width:100%; height:100%; border-radius:50%; background:var(--surface); border:1px solid var(--border); color:var(--text-light); transition:background 0.2s; position:absolute; top:0; left:0; z-index:1; pointer-events:none;">
<i class="fas fa-calendar-alt" style="font-size:0.85rem;"></i>
</div>
<input type="date" name="due_date" style="position:absolute; top:0; left:0; width:100%; height:100%; opacity:0; cursor:pointer; z-index:2;" title="Set Due Date" onchange="handleDueDateChange(this.value)">
</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>
<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 ?>" draggable="true" ondragstart="drag(event, <?= $t['id'] ?>)" id="task-<?= $t['id'] ?>" data-due="<?= $t['due_date'] ? strtotime($t['due_date']) : PHP_INT_MAX ?>" data-created="<?= strtotime($t['created_at']) ?>">
<div class="kanban-task-header" 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;">
<?php
echo '<span class="badge-role role-to" style="padding:2px 8px;">';
if($t['assignee_type'] == 'custom') echo '👤 '.htmlspecialchars((string)$t['custom_assignee_name']);
elseif($t['assignee_type'] == 'group') echo '👥 '.htmlspecialchars($t['assignee_group_name']);
else echo '👤 '.htmlspecialchars($t['assignee_user_name'] ?? '');
echo '</span>';
echo '<div style="display:flex; align-items:center; gap:0.5rem;">';
$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>';
}
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 by '.abs($days_diff).'d</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.75rem; color:var(--text-light); margin-bottom:0.75rem;">
<div>📅 Created: <?= date('M j, Y', strtotime($t['created_at'])) ?></div>
<?php if($t['due_date']): ?>
<div style="margin-top:2px;">⏰ Due Date: <strong style="color:#EF4444;"><?= date('M j, Y', strtotime($t['due_date'])) ?></strong></div>
<?php endif; ?>
</div>
<div style="display:flex; flex-direction:column; gap:0.5rem;">
<div style="font-size:0.8rem; display:flex; justify-content:space-between; align-items:center;">
<span style="color:var(--text-light);">From:</span>
<span class="badge-role role-from">
<?php
if($t['assignor_type'] == 'group') echo '👥 '.htmlspecialchars($t['assignor_group_name']);
else echo '👤 '.htmlspecialchars($t['assignor_user_name'] ?? '');
?>
</span>
</div>
<div style="font-size:0.8rem; display:flex; justify-content:space-between; align-items:center;">
<span style="color:var(--text-light);">To:</span>
<span class="badge-role role-to">
<?php
if($t['assignee_type'] == 'custom') echo '👤 '.htmlspecialchars((string)$t['custom_assignee_name']);
elseif($t['assignee_type'] == 'group') echo '👥 '.htmlspecialchars($t['assignee_group_name']);
else echo '👤 '.htmlspecialchars($t['assignee_user_name'] ?? '');
?>
</span>
</div>
</div>
<div style="margin-top:0.75rem; border-top:1px dashed var(--border); padding-top:0.75rem;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.5rem;">
<div style="font-size:0.75rem; font-weight:600; color:var(--text-light);">Attachments</div>
<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; ?>
<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);"><?= $initial ?></div>
<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);
}
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.'));
}
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('label')) {
container.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() {
let input = document.querySelector('input[name="due_date"]');
input.value = '';
handleDueDateChange('');
}
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 uploadFilesAjax(taskId, files) {
if(files.length === 0) return;
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>