feat: enhance task tracking with comment metadata, tag support, improved audit logging, and flatpickr integration.

This commit is contained in:
root
2026-03-29 23:34:27 +07:00
parent 97649d9a98
commit 68a764c0df
3 changed files with 205 additions and 86 deletions

View File

@@ -1,5 +1,6 @@
<?php <?php
session_start(); session_start();
date_default_timezone_set('Etc/GMT-7');
require_once 'db.php'; require_once 'db.php';
$action = (string)($_GET['action'] ?? 'dashboard'); $action = (string)($_GET['action'] ?? 'dashboard');
@@ -13,7 +14,7 @@ if ($action === 'api') {
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_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 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 status_updated_at DATETIME"); } catch(Exception $e) {}
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN due_date DATE"); } catch(Exception $e) {} try { $pdo->exec("ALTER TABLE tasks ADD COLUMN due_date DATETIME"); } catch(Exception $e) {}
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN custom_assignee_name TEXT"); } catch(Exception $e) {} try { $pdo->exec("ALTER TABLE tasks ADD COLUMN custom_assignee_name TEXT"); } catch(Exception $e) {}
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN completed_by INTEGER DEFAULT NULL"); } catch(Exception $e) {} try { $pdo->exec("ALTER TABLE tasks ADD COLUMN completed_by INTEGER DEFAULT NULL"); } catch(Exception $e) {}
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN archived_by INTEGER DEFAULT NULL"); } catch(Exception $e) {} try { $pdo->exec("ALTER TABLE tasks ADD COLUMN archived_by INTEGER DEFAULT NULL"); } catch(Exception $e) {}
@@ -99,7 +100,16 @@ if ($action === 'api') {
g.name as assignor_group_name, g.name as assignor_group_name,
au.username as assignee_user_name, au.username as assignee_user_name,
ag.name as assignee_group_name, ag.name as assignee_group_name,
(SELECT COUNT(*) FROM task_attachments ta WHERE ta.task_id = t.id) AS attachments_count (SELECT COUNT(*) FROM task_attachments ta WHERE ta.task_id = t.id) AS attachments_count,
(SELECT COUNT(*) FROM task_comments tc WHERE tc.task_id = t.id) AS comments_count,
(SELECT comment FROM task_comments tc WHERE tc.task_id = t.id ORDER BY tc.created_at DESC LIMIT 1) AS last_comment,
(SELECT created_at FROM task_comments tc WHERE tc.task_id = t.id ORDER BY tc.created_at DESC LIMIT 1) AS last_comment_date,
(SELECT cu.username FROM task_comments tc JOIN users cu ON tc.created_by = cu.id WHERE tc.task_id = t.id ORDER BY tc.created_at DESC LIMIT 1) AS last_commenter,
t.status_updated_at,
t.completed_by,
t.archived_by,
t.tags,
t.title
FROM tasks t FROM tasks t
LEFT JOIN users u ON t.assignor_id = u.id LEFT JOIN users u ON t.assignor_id = u.id
LEFT JOIN groups g ON t.assignor_group_id = g.id LEFT JOIN groups g ON t.assignor_group_id = g.id
@@ -142,15 +152,35 @@ if ($action === 'api') {
$desc = (string)($row['description'] ?? ''); $desc = (string)($row['description'] ?? '');
$desc = trim($desc) !== '' ? $desc : (string)($row['title'] ?? ''); $desc = trim($desc) !== '' ? $desc : (string)($row['title'] ?? '');
$tags = !empty($row['tags']) ? array_filter(array_map('trim', explode(',', $row['tags']))) : [];
// Logic for last_modified_by
// If it's archived, 'Archived by' user; if completed, 'Completed by' user; otherwise latest commenter or creator.
$last_mod_by = 'System';
if ($row['status'] === 'archived' && $row['archived_by']) {
$last_mod_by = query("SELECT username FROM users WHERE id=?", [$row['archived_by']])->fetchColumn() ?: 'System';
} elseif ($row['status'] === 'completed' && $row['completed_by']) {
$last_mod_by = query("SELECT username FROM users WHERE id=?", [$row['completed_by']])->fetchColumn() ?: 'System';
} else {
$last_mod_by = $row['last_commenter'] ?: $row['assignor_user_name'];
}
$payload = [ $payload = [
'task_id' => (string)$row['id'], 'task_id' => (string)$row['id'],
'task_created_date' => $format_dt($row['created_at'] ?? null), 'task_name' => (string)$row['title'],
'task_created_date' => $format_dt($row['created_at'] ? $row['created_at'] . ' UTC' : null),
'from' => $from, 'from' => $from,
'to' => $to, 'to' => $to,
'task_description' => $desc, 'task_description' => $desc,
'due_date' => $format_dt($row['due_date'] ?? null), 'due_date' => $format_dt($row['due_date'] ?? null),
'current_status' => $format_status($row['status'] ?? null), 'current_status' => $format_status($row['status'] ?? null),
'attachments_count' => (int)($row['attachments_count'] ?? 0), 'attachments_count' => (int)($row['attachments_count'] ?? 0),
'tags_count' => count($tags),
'comments_count' => (int)($row['comments_count'] ?? 0),
'last_modified_by' => $last_mod_by,
'last_modified_date' => $format_dt(($row['status_updated_at'] ?: $row['created_at']) . ' UTC'),
'last_comment' => $row['last_comment'] ?: null,
'last_comment_date' => $format_dt($row['last_comment_date'] ? $row['last_comment_date'] . ' UTC' : null),
]; ];
echo json_encode($payload); echo json_encode($payload);
@@ -171,15 +201,33 @@ if ($action === 'api') {
$desc = (string)($row['description'] ?? ''); $desc = (string)($row['description'] ?? '');
$desc = trim($desc) !== '' ? $desc : (string)($row['title'] ?? ''); $desc = trim($desc) !== '' ? $desc : (string)($row['title'] ?? '');
$tags = !empty($row['tags']) ? array_filter(array_map('trim', explode(',', $row['tags']))) : [];
$last_mod_by = 'System';
if ($row['status'] === 'archived' && $row['archived_by']) {
$last_mod_by = query("SELECT username FROM users WHERE id=?", [$row['archived_by']])->fetchColumn() ?: 'System';
} elseif ($row['status'] === 'completed' && $row['completed_by']) {
$last_mod_by = query("SELECT username FROM users WHERE id=?", [$row['completed_by']])->fetchColumn() ?: 'System';
} else {
$last_mod_by = $row['last_commenter'] ?: $row['assignor_user_name'];
}
$result[] = [ $result[] = [
'task_id' => (string)$row['id'], 'task_id' => (string)$row['id'],
'task_created_date' => $format_dt($row['created_at'] ?? null), 'task_name' => (string)$row['title'],
'task_created_date' => $format_dt($row['created_at'] ? $row['created_at'] . ' UTC' : null),
'from' => $from, 'from' => $from,
'to' => $to, 'to' => $to,
'task_description' => $desc, 'task_description' => $desc,
'due_date' => $format_dt($row['due_date'] ?? null), 'due_date' => $format_dt($row['due_date'] ?? null),
'current_status' => $format_status($row['status'] ?? null), 'current_status' => $format_status($row['status'] ?? null),
'attachments_count' => (int)($row['attachments_count'] ?? 0), 'attachments_count' => (int)($row['attachments_count'] ?? 0),
'tags_count' => count($tags),
'comments_count' => (int)($row['comments_count'] ?? 0),
'last_modified_by' => $last_mod_by,
'last_modified_date' => $format_dt(($row['status_updated_at'] ?: $row['created_at']) . ' UTC'),
'last_comment' => $row['last_comment'] ?: null,
'last_comment_date' => $format_dt($row['last_comment_date'] ? $row['last_comment_date'] . ' UTC' : null),
]; ];
} }

View File

@@ -9,12 +9,24 @@
<link rel="stylesheet" href="public/css/style.css"> <link rel="stylesheet" href="public/css/style.css">
<script src="https://cdn.jsdelivr.net/npm/@yaireo/tagify"></script> <script src="https://cdn.jsdelivr.net/npm/@yaireo/tagify"></script>
<link href="https://cdn.jsdelivr.net/npm/@yaireo/tagify/dist/tagify.css" rel="stylesheet" type="text/css" /> <link href="https://cdn.jsdelivr.net/npm/@yaireo/tagify/dist/tagify.css" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css">
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<style> <style>
.tags-look .tagify__dropdown__item{ display: inline-block; border-radius: 3px; padding: .3em .5em; border: 1px solid var(--border); background: var(--surface); margin: .2em; font-size: .85em; transition: 0s; color: var(--text); } .tags-look .tagify__dropdown__item{ display: inline-block; border-radius: 3px; padding: .3em .5em; border: 1px solid var(--border); background: var(--surface); margin: .2em; font-size: .85em; transition: 0s; color: var(--text); }
.tags-look .tagify__dropdown__item--active{ color: black; } .tags-look .tagify__dropdown__item--active{ color: white; background: var(--primary); border-color: var(--primary); }
.tags-look .tagify__dropdown__item:hover{ background: var(--primary); color: white; border-color: var(--primary); } .tags-look .tagify__dropdown__item:hover{ background: var(--primary); color: white; border-color: var(--primary); }
/* Premium Dark Mode Tagify Sync */
.tagify { --tag-bg: rgba(var(--primary-rgb), 0.2); --tag-text-color: var(--text); --tag-hover: rgba(var(--primary-rgb), 0.3); --placeholder-color: var(--text-light); }
body.dark-mode .tagify { --tag-bg: #2d3748; --tag-text-color: #e2e8f0; --tag-hover: #4a5568; }
.tagify__tag > div { background: var(--tag-bg); color: var(--tag-text-color); border-radius: 6px; }
.tagify__tag:hover:not([readonly]) > div::before { background: var(--tag-hover); }
.tagify__dropdown { background: rgba(30, 41, 59, 0.95); backdrop-filter: blur(8px); border: 1px solid var(--border); border-radius: 8px; }
.tagify__dropdown__wrapper { background: transparent; border: none; }
</style> </style>
<?php <?php
date_default_timezone_set('Etc/GMT-7');
$all_app_tags = []; $all_app_tags = [];
try { try {
$rows = $pdo->query("SELECT label_name as tag FROM notes WHERE label_name IS NOT NULL AND label_name != ''")->fetchAll(); $rows = $pdo->query("SELECT label_name as tag FROM notes WHERE label_name IS NOT NULL AND label_name != ''")->fetchAll();

View File

@@ -3,7 +3,7 @@
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_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 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 status_updated_at DATETIME"); } catch(Exception $e) {}
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN due_date DATE"); } catch(Exception $e) {} try { $pdo->exec("ALTER TABLE tasks ADD COLUMN due_date DATETIME"); } catch(Exception $e) {}
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN tags TEXT DEFAULT NULL"); } 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 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 { try {
@@ -237,6 +237,26 @@ if($t_ids) {
} }
} }
// Pre-fetch profile pics for all potential assignees in the view
$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'];
}
}
$kanban = ['pending' => [], 'in_progress' => [], 'completed' => [], 'archived' => []]; $kanban = ['pending' => [], 'in_progress' => [], 'completed' => [], 'archived' => []];
foreach($tasks as $t) { foreach($tasks as $t) {
if(isset($kanban[$t['status']])) $kanban[$t['status']][] = $t; if(isset($kanban[$t['status']])) $kanban[$t['status']][] = $t;
@@ -246,7 +266,7 @@ foreach($tasks as $t) {
usort($kanban['pending'], function($a, $b) { usort($kanban['pending'], function($a, $b) {
$da = $a['due_date'] ? strtotime($a['due_date']) : PHP_INT_MAX; $da = $a['due_date'] ? strtotime($a['due_date']) : PHP_INT_MAX;
$db = $b['due_date'] ? strtotime($b['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']); if($da == $db) return strtotime($b['created_at'] . ' UTC') - strtotime($a['created_at'] . ' UTC');
return $da - $db; return $da - $db;
}); });
?> ?>
@@ -306,7 +326,7 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
-webkit-line-clamp: unset; -webkit-line-clamp: unset;
} }
.kanban-task-body { .kanban-task-body {
display: none; padding-top: 0.75rem; margin-top: 0.5rem; border-top: 1px dashed var(--border); position: relative; 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; } .kanban-task.expanded .kanban-task-body { display: block; }
.chevron { transition: transform 0.2s; font-size: 0.8rem; color: var(--text-light); } .chevron { transition: transform 0.2s; font-size: 0.8rem; color: var(--text-light); }
@@ -445,11 +465,11 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
</div> </div>
</div> </div>
<div style="position:relative; width:30px; height:30px;" title="Due Date"> <div style="position:relative;" title="Due Date & Time">
<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;"> <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> <i class="fas fa-calendar-alt" style="font-size:0.85rem;"></i>
</div> </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)"> <input type="text" name="due_date" id="due-picker-input" style="visibility:hidden; position:absolute; width:1px; height:1px;">
</div> </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;"> <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;">
@@ -501,7 +521,7 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
</div> </div>
<div class="kanban-tasks" id="col-<?= $col_id ?>"> <div class="kanban-tasks" id="col-<?= $col_id ?>">
<?php foreach($kanban[$col_id] as $t): ?> <?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']) ?>"> <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 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;"> <div style="display:flex; justify-content:space-between; align-items:flex-start; width:100%; min-width:0; gap:0.5rem;">
@@ -528,40 +548,80 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
<div style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:0.75rem; margin-top:0.5rem;"> <div style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:0.75rem; margin-top:0.5rem;">
<?php <?php
$to_name = ''; // Determine assignor info
if($t['assignee_type'] == 'custom') $to_name = htmlspecialchars((string)$t['custom_assignee_name']); $from_name = '';
elseif($t['assignee_type'] == 'group') $to_name = htmlspecialchars($t['assignee_group_name']); if($t['assignor_type'] == 'group') $from_name = $t['assignor_group_name'];
else $to_name = htmlspecialchars($t['assignee_user_name'] ?? ''); else $from_name = $t['assignor_user_name'] ?? 'System';
$from_pic = ($t['assignor_type'] == 'user') ? ($user_pics[$from_name] ?? '') : '';
// Get assignee profile pic for user assignments $from_initial = strtoupper(mb_substr($from_name, 0, 1));
$to_profile_pic = ($t['assignee_type'] == 'user') ? ($t['assignee_profile_pic'] ?? '') : '';
$to_initial = strtoupper(mb_substr($to_name, 0, 1));
$col_idx = crc32($to_name) % 8;
$bg_colors = ['#4F46E5','#10B981','#F59E0B','#EF4444','#8B5CF6','#06B6D4','#EC4899','#84CC16']; $bg_colors = ['#4F46E5','#10B981','#F59E0B','#EF4444','#8B5CF6','#06B6D4','#EC4899','#84CC16'];
$av_bg = $bg_colors[$col_idx]; $from_bg = $bg_colors[crc32($from_name) % 8];
echo '<div class="avatar-collapsed-only" style="display:flex; align-items:center;" title="To: ' . $to_name . '">'; $to_names_raw = '';
if($to_profile_pic && file_exists($to_profile_pic)) { if($t['assignee_type'] == 'custom') $to_names_raw = (string)$t['custom_assignee_name'];
echo '<img src="'.htmlspecialchars($to_profile_pic).'" style="width:24px; height:24px; border-radius:50%; object-fit:cover; border:2px solid var(--surface); margin-right:-8px; z-index:1;">'; 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 { } 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-right:-8px; z-index:1;">'.$to_initial.'</div>'; 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>';
echo '</div>';
echo '<span class="badge-role role-to expanded-only" style="padding:2px 8px; align-items:center; gap:0.4rem;">'; // Expanded View Badges
if($to_profile_pic && file_exists($to_profile_pic)) { echo '<div class="expanded-only" style="display:flex; flex-wrap:wrap; gap:0.4rem;">';
echo '<img src="'.htmlspecialchars($to_profile_pic).'" style="width:18px; height:18px; border-radius:50%; object-fit:cover;">'; foreach($to_names as $name) {
} else { $initial = strtoupper(mb_substr($name, 0, 1));
echo '<div style="width:18px; height:18px; border-radius:50%; background:'.$av_bg.'; color:white; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:0.6rem;">'.$to_initial.'</div>'; $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 '<span>'.$to_name.'</span>'; echo '</div>';
echo '</span>';
echo '<div style="display:flex; align-items:center; gap:0.5rem; margin-left:auto;">'; 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; $tag_count = !empty($t['tags']) ? count(array_filter(array_map('trim', explode(',', $t['tags'])))) : 0;
if($tag_count > 0) { if($tag_count > 0) {
echo '<span style="color:var(--primary); 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>'; 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']]); $att_count = empty($attachments[$t['id']]) ? 0 : count($attachments[$t['id']]);
@@ -578,7 +638,7 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
if($t['due_date']) { if($t['due_date']) {
$days_diff = (int)round((strtotime($t['due_date']) - strtotime(date('Y-m-d'))) / 86400); $days_diff = (int)round((strtotime($t['due_date']) - strtotime(date('Y-m-d'))) / 86400);
if($days_diff < 0) { 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>'; 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) { } 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>'; echo '<span class="badge" style="background:#F59E0B; color:white; padding:2px 8px; border-radius:12px; font-weight:600;">Due Today</span>';
} else { } else {
@@ -595,10 +655,10 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
<div class="kanban-task-desc"><?= nl2br(htmlspecialchars((string)$t['description'])) ?></div> <div class="kanban-task-desc"><?= nl2br(htmlspecialchars((string)$t['description'])) ?></div>
<?php endif; ?> <?php endif; ?>
<div style="font-size:0.75rem; color:var(--text-light); margin-bottom:0.75rem; display:flex; justify-content:space-between; align-items:center;"> <div style="font-size:0.72rem; color:var(--text-light); margin-bottom:0.75rem; display:flex; justify-content:space-between; align-items:center;">
<div><i class="fas fa-calendar-plus" style="opacity:0.7; margin-right:4px;"></i> Created: <?= date('M j, Y', strtotime($t['created_at'])) ?></div> <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']): ?> <?php if($t['due_date']): ?>
<div><i class="fas fa-clock" style="color:#EF4444; margin-right:4px;"></i> Due Date: <strong style="color:#EF4444;"><?= date('M j, Y', strtotime($t['due_date'])) ?></strong></div> <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; ?> <?php endif; ?>
</div> </div>
@@ -623,9 +683,7 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
} }
} }
// Get profile pics for expanded view $from_profile_pic = ($t['assignor_type'] == 'user') ? ($user_pics[$from_name] ?? '') : '';
$from_profile_pic = ($t['assignor_type'] == 'user') ? ($t['assignor_profile_pic'] ?? '') : '';
$to_profile_pic_expanded = ($t['assignee_type'] == 'user') ? ($t['assignee_profile_pic'] ?? '') : '';
?> ?>
<div style="font-size:0.8rem; display:flex; justify-content:space-between; align-items:center;"> <div style="font-size:0.8rem; display:flex; justify-content:space-between; align-items:center;">
<span style="color:var(--text-light);">Assignments:</span> <span style="color:var(--text-light);">Assignments:</span>
@@ -635,9 +693,14 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
<?= get_avatar_html_local($from_name, $from_profile_pic) ?> <?= get_avatar_html_local($from_name, $from_profile_pic) ?>
</div> </div>
<div style="color:var(--border);">|</div> <div style="color:var(--border);">|</div>
<div style="display:flex; align-items:center; gap:0.4rem;"> <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> <span style="font-size:0.65rem; color:var(--text-light);">To</span>
<?= get_avatar_html_local($to_name, $to_profile_pic_expanded) ?> <?php
foreach($to_names as $name) {
$p = $user_pics[$name] ?? '';
echo get_avatar_html_local($name, $p);
}
?>
</div> </div>
</div> </div>
</div> </div>
@@ -659,42 +722,20 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
<?php endif; ?> <?php endif; ?>
</div> </div>
<!-- Tags Section for Expanded View -->
<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);">Tags</div>
<?php if(!empty($task_tags)): ?>
<button type="button" onclick="showAddTagPopup(<?= $t['id'] ?>)" style="background:var(--primary); color:white; border:none; border-radius:4px; width:20px; height:20px; display:flex; align-items:center; justify-content:center; cursor:pointer; font-size:0.7rem;" title="Add Tag"><i class="fas fa-plus"></i></button>
<?php endif; ?>
</div>
<div id="tags-container-<?= $t['id'] ?>" style="display:flex; flex-wrap:wrap; gap:0.4rem; margin-bottom:0.5rem; align-items:center;">
<?php
$task_tags = !empty($t['tags']) ? array_filter(array_map('trim', explode(',', $t['tags']))) : [];
foreach($task_tags as $tag):
$tag_escaped = htmlspecialchars(addslashes($tag));
?>
<span class="task-tag" 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:flex; align-items:center; gap:0.3rem;">
<i class="fas fa-tag" style="font-size:0.6rem;"></i> <?= htmlspecialchars($tag) ?>
<i class="fas fa-times" style="cursor:pointer; opacity:0.6; font-size:0.65rem;" onclick="removeTaskTag(<?= $t['id'] ?>, '<?= $tag_escaped ?>')" title="Remove tag"></i>
</span>
<?php endforeach; ?>
<?php if(empty($task_tags)): ?>
<button type="button" onclick="showAddTagPopup(<?= $t['id'] ?>)" style="background:var(--primary); color:white; border:none; border-radius:50%; width:24px; height:24px; display:flex; align-items:center; justify-content:center; cursor:pointer; font-size:0.75rem;" title="Add Tag"><i class="fas fa-plus"></i></button>
<?php endif; ?>
</div>
</div>
<div style="margin-top:0.75rem; border-top:1px dashed var(--border); padding-top:0.75rem;"> <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="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'] ?>"> <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 if(!empty($comments[$t['id']])): ?>
<?php foreach($comments[$t['id']] as $cm): ?> <?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;"> <div style="display:flex; gap:0.4rem; align-items:flex-start;">
<?= get_avatar_html_local($cm['created_by_username'] ?? 'User') ?> <?= 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="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;"> <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> <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, H:i', strtotime($cm['created_at'])) ?> <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'])): ?> <?php if($cm['created_by'] == $user_id || !empty($_SESSION['is_admin'])): ?>
&nbsp;<i class="fas fa-trash" style="cursor:pointer; color:#EF4444;" onclick="deleteComment(<?= $cm['id'] ?>)"></i> &nbsp;<i class="fas fa-trash" style="cursor:pointer; color:#EF4444;" onclick="deleteComment(<?= $cm['id'] ?>)"></i>
<?php endif; ?> <?php endif; ?>
@@ -715,8 +756,17 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
</div> </div>
<div style="margin-top:0.75rem; border-top:1px dashed var(--border); padding-top:0.75rem;"> <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="position:absolute; bottom:-0.25rem; right:-0.25rem; display:flex; align-items:center; gap:0.5rem; padding:0.5rem;">
<div style="font-size:0.75rem; font-weight:600; color:var(--text-light);">Attachments</div> <!-- 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;"> <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> <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)"> <input type="file" multiple style="display:none;" onchange="uploadFilesAjax(<?= $t['id'] ?>, this.files)">
@@ -764,7 +814,13 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
</form> </form>
<?php endif; ?> <?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> <?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> <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> </div>
@@ -773,15 +829,7 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
<?php endif; ?> <?php endif; ?>
</div> </div>
<?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="position:absolute; bottom:0.75rem; right:0.75rem; background:var(--primary); color:white; border:none; border-radius:50%; width:28px; height:28px; display:flex; align-items:center; justify-content:center; cursor:pointer; font-size:0.8rem; box-shadow:0 2px 4px rgba(0,0,0,0.2); z-index:10;" title="Manage Tags">
<i class="fas fa-tag"></i>
<?php if(!empty($task_tags)): ?>
<span style="position:absolute; top:-4px; right:-4px; background:#EF4444; color:white; border-radius:50%; width:14px; height:14px; font-size:0.6rem; display:flex; align-items:center; justify-content:center; font-weight:700;"><?= count($task_tags) ?></span>
<?php endif; ?>
</button>
</div> </div>
</div> </div>
@@ -1126,11 +1174,22 @@ function handleDueDateChange(val) {
} }
function clearDueDate() { function clearDueDate() {
let input = document.querySelector('input[name="due_date"]'); duePicker.clear();
input.value = '';
handleDueDateChange('');
} }
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 = []; let selectedAssignees = [];
function handleToSelection(val) { function handleToSelection(val) {