initial commit

This commit is contained in:
root
2026-03-26 15:03:38 +07:00
commit 6464c750f2
19 changed files with 2129 additions and 0 deletions

2
config.php Normal file
View File

@@ -0,0 +1,2 @@
<?php
define('DB_FILE', __DIR__ . '/database.sqlite');

BIN
database.sqlite Normal file

Binary file not shown.

54
db.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
require_once 'config.php';
try {
$pdo = new PDO('sqlite:' . DB_FILE);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
} catch (PDOException $e) {
die("Database connection failed: " . $e->getMessage());
}
function query($sql, $params = []) {
global $pdo;
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return $stmt;
}
function get_user($id) {
global $pdo;
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$id]);
return $stmt->fetch();
}
function get_user_groups($user_id) {
global $pdo;
$stmt = $pdo->prepare("SELECT g.* FROM groups g JOIN user_groups ug ON g.id = ug.group_id WHERE ug.user_id = ?");
$stmt->execute([$user_id]);
return $stmt->fetchAll();
}
function notify_user($user_id, $message) {
global $pdo;
$stmt = $pdo->prepare("INSERT INTO notifications (user_id, message) VALUES (?, ?)");
$stmt->execute([$user_id, $message]);
// Virtual hook for Email/Telegram/WhatsApp based on user features
$user = get_user($user_id);
if($user && isset($user['notify_channels'])) {
$channels = explode(',', $user['notify_channels']);
// Here we would actually send the message to the third party API.
// For MVP we just assume it's sent.
}
}
function notify_group($group_id, $message) {
global $pdo;
$stmt = $pdo->prepare("SELECT user_id FROM user_groups WHERE group_id = ?");
$stmt->execute([$group_id]);
$users = $stmt->fetchAll();
foreach ($users as $u) {
notify_user($u['user_id'], $message);
}
}

75
index.php Normal file
View File

@@ -0,0 +1,75 @@
<?php
session_start();
require_once 'db.php';
$action = (string)($_GET['action'] ?? 'dashboard');
// API updates for task kanban drag n drop
if ($action === 'api') {
$api_action = $_GET['api_action'] ?? '';
if(!isset($_SESSION['user_id'])) die(json_encode(['status'=>'error','msg'=>'unauthorized']));
if($api_action === 'update_task_status') {
$task_id = $_POST['task_id'] ?? null;
$status = $_POST['status'] ?? null;
$stmt = $pdo->prepare("UPDATE tasks SET status = ?, status_updated_at = CURRENT_TIMESTAMP WHERE id = ?");
$stmt->execute([$status, $task_id]);
echo json_encode(['status'=>'ok']);
}
exit;
}
if (!isset($_SESSION['user_id']) && $action !== 'login' && $action !== 'setup') {
header('Location: ?action=login');
exit;
}
$user_id = $_SESSION['user_id'] ?? null;
$current_user = null;
$unread_notifications = 0;
if ($user_id) {
$current_user = get_user($user_id);
$stmt = $pdo->prepare("SELECT COUNT(*) FROM notifications WHERE user_id = ? AND is_read = 0");
$stmt->execute([$user_id]);
$unread_notifications = $stmt->fetchColumn();
}
if ($action === 'logout') {
session_destroy();
header('Location: ?action=login');
exit;
}
if ($action === 'setup') {
include 'setup.php';
exit;
}
if ($action === 'login') {
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim((string)($_POST['username'] ?? ''));
$password = trim((string)($_POST['password'] ?? ''));
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
$_SESSION['user_id'] = $user['id'];
$_SESSION['is_admin'] = $user['is_admin'];
header('Location: ?action=dashboard');
exit;
} else {
$error = "Invalid username or password";
}
}
include 'views/login.php';
exit;
}
$view_file = 'views/' . basename($action) . '.php';
if (file_exists($view_file)) {
include 'views/layout_top.php';
include $view_file;
include 'views/layout_bottom.php';
} else {
echo "404 Not Found";
}

10
migrate.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
require_once 'db.php';
$schema = file_get_contents('schema_v2.sql');
try {
$pdo->exec($schema);
echo "Migration successful.";
} catch(Exception $e) {
// If columns already exist, sqlite throws exception. We can ignore it safely for alter table if rerun.
echo "Migration finished (or already applied).";
}

83
public/css/style.css Normal file
View File

@@ -0,0 +1,83 @@
:root {
--primary: #4F46E5;
--primary-hover: #4338CA;
--primary-rgb: 79, 70, 229;
--secondary: #10B981;
--bg: #F3F4F6;
--surface: #FFFFFF;
--input-bg: #FFFFFF;
--text: #1F2937;
--text-light: #6B7280;
--border: #E5E7EB;
}
body.dark-mode {
--primary: #818CF8;
--primary-hover: #6366F1;
--primary-rgb: 129, 140, 248;
--bg: #0F172A;
--surface: #1E293B;
--input-bg: #334155;
--text: #F8FAFC;
--text-light: #94A3B8;
--border: #334155;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', sans-serif; background-color: var(--bg); color: var(--text); transition: background-color 0.3s, color 0.3s; }
a { text-decoration: none; color: var(--primary); }
a:hover { text-decoration: underline; }
.app-container { display: flex; min-height: 100vh; }
.sidebar { width: 250px; background: var(--surface); border-right: 1px solid var(--border); padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; }
.sidebar h2 { font-size: 1.2rem; margin-bottom: 2rem; color: var(--primary); }
.nav-link { padding: 0.75rem 1rem; border-radius: 0.5rem; text-decoration: none !important; color: var(--text); font-weight: 500; transition: background 0.2s; display: flex; justify-content: space-between; align-items: center; }
.nav-link:hover, .nav-link.active { background: rgba(var(--primary-rgb), 0.1); color: var(--primary); }
.nav-link .badge { background: #EF4444; color: white; padding: 2px 6px; border-radius: 12px; font-size: 0.75rem; }
.main-content { flex: 1; padding: 2rem; overflow-y: auto; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
.card { background: var(--surface); padding: 1.5rem; border-radius: 1rem; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); margin-bottom: 1.5rem; }
.btn { display: inline-flex; align-items: center; gap: 0.5rem; justify-content:center; padding: 0.5rem 1rem; background: var(--primary); color: white !important; border-radius: 0.5rem; border: none; cursor: pointer; text-decoration: none !important; font-weight: 500; transition: opacity 0.2s; }
.btn:hover { opacity: 0.9; }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.875rem; }
.btn-secondary { background: var(--text-light); }
.btn-danger { background: #EF4444; }
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; }
.form-control { width: 100%; padding: 0.75rem; border: 1px solid var(--border); border-radius: 0.5rem; font-family: inherit; background: var(--input-bg); color: var(--text); }
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid var(--border); }
th { color: var(--text-light); font-weight: 500; }
.kanban-board { display: flex; gap: 1.5rem; overflow-x: auto; padding-bottom: 1rem; }
.kanban-col { background: rgba(0,0,0,0.03); border-radius: 1rem; width: 320px; min-width: 320px; display: flex; flex-direction: column; }
body.dark-mode .kanban-col { background: rgba(255,255,255,0.03); }
.kanban-col-header { padding: 1rem; font-weight: 600; border-bottom: 2px solid var(--border); }
.kanban-tasks { padding: 1rem; flex: 1; min-height: 200px; display: flex; flex-direction: column; gap: 1rem; }
.kanban-task { background: var(--surface); padding: 1rem; border-radius: 0.5rem; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border: 1px solid var(--border); cursor: grab; }
.kanban-task:active { cursor: grabbing; }
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; opacity: 0; pointer-events: none; transition: opacity 0.2s; }
.modal-overlay.active { opacity: 1; pointer-events: auto; }
.modal-content { background: var(--surface); padding: 2rem; border-radius: 1rem; width: 100%; max-width: 400px; transform: translateY(-20px); transition: transform 0.2s; }
.modal-overlay.active .modal-content { transform: translateY(0); }
#toast { position: fixed; bottom: 20px; right: 20px; background: var(--text); color: var(--surface); padding: 1rem; border-radius: 0.5rem; z-index: 9999; transform: translateY(100px); opacity: 0; transition: all 0.3s; }
#toast.show { transform: translateY(0); opacity: 1; }
.dropzone { border: 2px dashed var(--border); padding: 3rem; text-align: center; border-radius: 1rem; cursor: pointer; transition: background 0.2s; }
.dropzone.dragover { background: rgba(var(--primary-rgb), 0.1); border-color: var(--primary); }
.project-item { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem; border-bottom: 1px solid var(--border); }
.project-item:hover { background: rgba(0,0,0,0.02); }
body.dark-mode .project-item:hover { background: rgba(255,255,255,0.02); }
.login-container { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #4F46E5 0%, #10B981 100%); }
.login-card { background: var(--surface); padding: 2.5rem; border-radius: 1rem; width: 100%; max-width: 400px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); }
.login-card h1 { text-align: center; margin-bottom: 1.5rem; color: var(--text); }
.alert { padding: 1rem; border-radius: 0.5rem; margin-bottom: 1rem; background: #FEF2F2; color: #991B1B; }

81
schema.sql Normal file
View File

@@ -0,0 +1,81 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS user_groups (
user_id INTEGER,
group_id INTEGER,
PRIMARY KEY (user_id, group_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
assignor_id INTEGER,
assignee_type TEXT,
assignee_id INTEGER,
status TEXT DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (assignor_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS passwords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
username TEXT,
password TEXT NOT NULL,
url TEXT,
notes TEXT,
owner_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS password_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
password_id INTEGER,
share_type TEXT,
share_id INTEGER,
FOREIGN KEY (password_id) REFERENCES passwords(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
is_folder INTEGER DEFAULT 1,
parent_id INTEGER DEFAULT NULL,
file_path TEXT,
owner_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS project_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER,
share_type TEXT,
share_id INTEGER,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
message TEXT NOT NULL,
is_read INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

24
schema_v2.sql Normal file
View File

@@ -0,0 +1,24 @@
ALTER TABLE tasks ADD COLUMN custom_assignee_name TEXT;
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
owner_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS note_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER,
share_type TEXT,
share_id INTEGER,
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
ALTER TABLE users ADD COLUMN notify_channels TEXT DEFAULT 'native';
ALTER TABLE users ADD COLUMN telegram_id TEXT;
ALTER TABLE users ADD COLUMN email TEXT;
ALTER TABLE users ADD COLUMN whatsapp_number TEXT;

15
setup.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
require_once 'db.php';
$schema = file_get_contents('schema.sql');
$pdo->exec($schema);
$stmt = $pdo->prepare("SELECT id FROM users WHERE username = 'admin'");
$stmt->execute();
if (!$stmt->fetch()) {
$hash = password_hash('admin', PASSWORD_DEFAULT);
$stmt = $pdo->prepare("INSERT INTO users (username, password_hash, is_admin) VALUES ('admin', ?, 1)");
$stmt->execute([$hash]);
echo "Setup complete. Admin user created. <a href='index.php'>Go to Login</a>";
} else {
echo "Setup already completed. <a href='index.php'>Go to Login</a>";
}

158
views/admin.php Normal file
View File

@@ -0,0 +1,158 @@
<?php
if(empty($_SESSION['is_admin'])) die("Access Denied");
if($_SERVER['REQUEST_METHOD'] === 'POST') {
if(isset($_POST['create_user'])) {
$username = $_POST['username'];
$pass = password_hash($_POST['password'], PASSWORD_DEFAULT);
$is_admin = isset($_POST['is_admin']) ? 1 : 0;
$stmt = $pdo->prepare("INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)");
$stmt->execute([$username, $pass, $is_admin]);
}
if(isset($_POST['create_group'])) {
$stmt = $pdo->prepare("INSERT INTO groups (name) VALUES (?)");
$stmt->execute([$_POST['group_name']]);
}
if(isset($_POST['assign_group'])) {
$stmt = $pdo->prepare("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)");
$stmt->execute([$_POST['user_id'], $_POST['group_id']]);
}
if(isset($_POST['update_user_channels'])) {
$ch = $_POST['channels'] ?? [];
$ch_str = is_array($ch) ? implode(',', $ch) : '';
$stmt = $pdo->prepare("UPDATE users SET notify_channels=?, telegram_id=?, email=?, whatsapp_number=? WHERE id=?");
$stmt->execute([$ch_str, $_POST['telegram_id'], $_POST['email'], $_POST['whatsapp_number'], $_POST['user_id']]);
echo "<div class='alert' style='background:#10B981; color:white;'>User preferences updated!</div>";
}
if(isset($_POST['update_sys_config'])) {
$stmt = $pdo->prepare("UPDATE system_config SET v=? WHERE k=?");
$stmt->execute([$_POST['completed_days'], 'completed_days']);
$stmt->execute([$_POST['archived_days'], 'archived_days']);
echo "<div class='alert' style='background:#10B981; color:white;'>System Kanban Configuration updated!</div>";
}
}
$users = query("SELECT id, username, is_admin, notify_channels, telegram_id, email, whatsapp_number FROM users")->fetchAll();
$all_groups = query("SELECT * FROM groups")->fetchAll();
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) {}
$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;
?>
<div class="header">
<h1>Admin Panel</h1>
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:1.5rem; margin-bottom: 2rem;">
<div class="card">
<h3>Create User</h3>
<form method="POST" style="margin-top:1rem;">
<div class="form-group"><input type="text" name="username" class="form-control" placeholder="Username" required></div>
<div class="form-group"><input type="password" name="password" class="form-control" placeholder="Password" required></div>
<div class="form-group">
<label><input type="checkbox" name="is_admin"> Admin Privileges</label>
</div>
<button type="submit" name="create_user" class="btn">Create User</button>
</form>
</div>
<div class="card">
<h3>Create Group & Assign Role</h3>
<form method="POST" style="margin-top:1rem; margin-bottom:2rem;">
<div style="display:flex; gap:0.5rem;">
<input type="text" name="group_name" class="form-control" placeholder="Group Name" required>
<button type="submit" name="create_group" class="btn">Create Group</button>
</div>
</form>
<form method="POST">
<div style="display:flex; gap:0.5rem; align-items:center;">
<select name="user_id" class="form-control" required style="width:130px;">
<option value="">User...</option>
<?php foreach($users as $u): ?><option value="<?= $u['id'] ?>"><?= htmlspecialchars($u['username']) ?></option><?php endforeach; ?>
</select>
<select name="group_id" class="form-control" required style="width:130px;">
<option value="">Group...</option>
<?php foreach($all_groups as $g): ?><option value="<?= $g['id'] ?>"><?= htmlspecialchars($g['name']) ?></option><?php endforeach; ?>
</select>
<button type="submit" name="assign_group" class="btn">Assign</button>
</div>
</form>
</div>
<div class="card" style="grid-column: span 2;">
<h3>System Operations & Configurations</h3>
<p style="margin-top:0.5rem; color:var(--text-light);">Modify the global lifecycle durations for Kanban Auto-Archiving / Auto-Deleting processes.</p>
<form method="POST" style="margin-top:1.5rem; display:flex; gap:1.5rem; align-items:flex-end;">
<div class="form-group" style="margin:0;">
<label>Completed tasks retain for (Days)</label>
<input type="number" name="completed_days" value="<?= htmlspecialchars($conf_comp) ?>" class="form-control" style="width:200px;">
<div style="font-size:0.75rem; color:var(--text-light); margin-top:4px;">Default: 7 Days (Before auto-moving to Archive)</div>
</div>
<div class="form-group" style="margin:0;">
<label>Archived tasks retain for (Days)</label>
<input type="number" name="archived_days" value="<?= htmlspecialchars($conf_arch) ?>" class="form-control" style="width:200px;">
<div style="font-size:0.75rem; color:var(--text-light); margin-top:4px;">Default: 90 Days (Before permanent deletion)</div>
</div>
<button type="submit" name="update_sys_config" class="btn">Save Database Config</button>
</form>
</div>
</div>
<div style="display:grid; grid-template-columns: 1fr; gap:1.5rem;">
<div class="card">
<h3>User Profile & Settings (Channels)</h3>
<p style="margin-bottom:1rem; color:var(--text-light);">Assign specific notification channels (Native, Telegram, Email, WhatsApp) to users. User must be created above first.</p>
<div style="overflow-x:auto;">
<table>
<thead><tr><th>Username</th><th>Active Channels</th><th>Telegram ID</th><th>Email</th><th>WhatsApp</th><th>Action</th></tr></thead>
<tbody>
<?php foreach($users as $u): ?>
<tr>
<form method="POST">
<input type="hidden" name="user_id" value="<?= $u['id'] ?>">
<td><strong><?= htmlspecialchars($u['username']) ?></strong></td>
<td style="display:flex; flex-direction:column; gap:0.25rem;">
<?php $chs = explode(',', $u['notify_channels'] ?? ''); ?>
<label><input type="checkbox" name="channels[]" value="native" <?= in_array('native', $chs)?'checked':'' ?>> Native</label>
<label><input type="checkbox" name="channels[]" value="email" <?= in_array('email', $chs)?'checked':'' ?>> Email</label>
<label><input type="checkbox" name="channels[]" value="telegram" <?= in_array('telegram', $chs)?'checked':'' ?>> TG</label>
<label><input type="checkbox" name="channels[]" value="whatsapp" <?= in_array('whatsapp', $chs)?'checked':'' ?>> WA</label>
</td>
<td><input type="text" name="telegram_id" value="<?= htmlspecialchars($u['telegram_id']??'') ?>" class="form-control" style="width:100px; padding:0.25rem;"></td>
<td><input type="email" name="email" value="<?= htmlspecialchars($u['email']??'') ?>" class="form-control" style="width:150px; padding:0.25rem;"></td>
<td><input type="text" name="whatsapp_number" value="<?= htmlspecialchars($u['whatsapp_number']??'') ?>" class="form-control" style="width:120px; padding:0.25rem;"></td>
<td><button type="submit" name="update_user_channels" class="btn btn-sm">Save</button></td>
</form>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<div class="card">
<h3>Existing Groups Breakdown</h3>
<div style="display:flex; flex-wrap:wrap; gap:1.5rem; margin-top:1rem;">
<?php foreach($all_groups as $g): ?>
<div style="border:1px solid var(--border); border-radius:0.5rem; padding:1rem; width:250px;">
<h4 style="color:var(--primary); font-size:1.1rem; border-bottom:1px solid var(--border); padding-bottom:0.5rem; margin-bottom:0.5rem;"><?= htmlspecialchars($g['name']) ?></h4>
<ul style="padding-left:1.5rem;">
<?php
$stmt = $pdo->prepare("SELECT u.username FROM users u JOIN user_groups ug ON u.id = ug.user_id WHERE ug.group_id=?");
$stmt->execute([$g['id']]);
$g_users = $stmt->fetchAll();
foreach($g_users as $gu) echo "<li>".htmlspecialchars($gu['username'])."</li>";
if(!$g_users) echo "<li style='color:var(--text-light); list-style:none; margin-left:-1.5rem;'>No users in group</li>";
?>
</ul>
</div>
<?php endforeach; ?>
<?php if(!$all_groups): ?><p style="color:var(--text-light);">No groups registered.</p><?php endif; ?>
</div>
</div>
</div>

96
views/dashboard.php Normal file
View File

@@ -0,0 +1,96 @@
<?php
// Calculate Task Counts
$stmt = $pdo->prepare("SELECT status, COUNT(*) as count FROM tasks WHERE (assignee_type='user' AND assignee_id=?) OR (assignee_type='custom' AND assignor_id=?) GROUP BY status");
$stmt->execute([$user_id, $user_id]);
$task_counts = ['pending' => 0, 'in_progress' => 0, 'completed' => 0];
foreach($stmt->fetchAll() as $row) {
if(isset($task_counts[$row['status']])) $task_counts[$row['status']] = $row['count'];
}
$total_tasks = array_sum($task_counts);
// Calculate Passwords count
$stmt = $pdo->prepare("SELECT COUNT(*) FROM passwords p LEFT JOIN password_shares ps ON p.id = ps.password_id WHERE p.owner_id = ? OR (ps.share_type='user' AND ps.share_id=?)");
$stmt->execute([$user_id, $user_id]);
$passwords_count = $stmt->fetchColumn();
// Calculate Projects count
$stmt = $pdo->prepare("SELECT COUNT(*) FROM projects p LEFT JOIN project_shares ps ON p.id = ps.project_id WHERE p.owner_id = ? OR (ps.share_type='user' AND ps.share_id=?)");
$stmt->execute([$user_id, $user_id]);
$projects_count = $stmt->fetchColumn();
?>
<div class="header">
<h1>Dashboard</h1>
</div>
<div class="card" style="display:flex; justify-content:space-between; align-items:center;">
<div>
<h3>Welcome, <?= htmlspecialchars($current_user['username']) ?>!</h3>
<p style="margin-top:0.5rem; color:var(--text-light);"><span id="current-datetime"></span></p>
</div>
<div style="text-align:right">
<div style="font-weight:600; color:var(--primary);">Holiday Info:</div>
<div id="holiday-info" style="color:var(--text-light);">Loading...</div>
</div>
</div>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem;">
<!-- Tasks Card -->
<div class="card">
<h3>My Tasks Overview</h3>
<h1 style="margin: 1rem 0; font-size: 2.5rem; color: var(--primary);"><?= $total_tasks ?> <span style="font-size:1rem; color:var(--text-light);">Total</span></h1>
<div style="display:flex; justify-content:space-between; margin-top:1rem; border-top:1px solid var(--border); padding-top:1rem;">
<div>
<div style="font-size:0.875rem; color:var(--text-light);">Pending</div>
<div style="font-weight:600;"><?= $task_counts['pending'] ?></div>
</div>
<div>
<div style="font-size:0.875rem; color:var(--text-light);">In Progress</div>
<div style="font-weight:600; color:#F59E0B;"><?= $task_counts['in_progress'] ?></div>
</div>
<div>
<div style="font-size:0.875rem; color:var(--text-light);">Completed</div>
<div style="font-weight:600; color:var(--secondary);"><?= $task_counts['completed'] ?></div>
</div>
</div>
</div>
<!-- Passwords Card -->
<div class="card" style="display:flex; flex-direction:column; justify-content:center;">
<h3>My Passwords Vault</h3>
<h1 style="margin: 1rem 0; font-size: 2.5rem; color: var(--primary);"><?= $passwords_count ?> <span style="font-size:1rem; color:var(--text-light);">Items</span></h1>
<a href="?action=passwords" class="btn btn-sm">Go to Vault &rarr;</a>
</div>
<!-- Projects Card -->
<div class="card" style="display:flex; flex-direction:column; justify-content:center;">
<h3>My Projects & Files</h3>
<h1 style="margin: 1rem 0; font-size: 2.5rem; color: var(--primary);"><?= $projects_count ?> <span style="font-size:1rem; color:var(--text-light);">Files/Folders</span></h1>
<a href="?action=projects" class="btn btn-sm">Go to Explorer &rarr;</a>
</div>
</div>
<script>
// Time & Holiday functions
function updateTime() {
const now = new Date();
document.getElementById('current-datetime').textContent = now.toLocaleString();
}
setInterval(updateTime, 1000);
updateTime();
fetch('https://date.nager.at/api/v3/NextPublicHolidaysWorldwide')
.then(r => r.json())
.then(data => {
if(data && data.length > 0) {
// Find upcoming holiday for US, or fallback to first worldwide
let h = data.find(x => x.countryCode === 'US') || data[0];
document.getElementById('holiday-info').textContent = h.date + ' - ' + h.name + ' (' + h.countryCode + ')';
} else {
document.getElementById('holiday-info').textContent = 'No upcoming holidays';
}
}).catch(err => {
document.getElementById('holiday-info').textContent = 'Unable to fetch data';
});
</script>

39
views/layout_bottom.php Normal file
View File

@@ -0,0 +1,39 @@
</main>
</div>
<script>
// Dark Mode Toggle
const themeBtn = document.getElementById('theme-toggle');
const iconLight = document.getElementById('theme-icon-light');
const iconDark = document.getElementById('theme-icon-dark');
function updateThemeIcon() {
if(document.body.classList.contains('dark-mode')) {
iconLight.style.display = 'block';
iconDark.style.display = 'none';
} else {
iconLight.style.display = 'none';
iconDark.style.display = 'block';
}
}
if(themeBtn) {
updateThemeIcon();
themeBtn.addEventListener('click', () => {
document.body.classList.toggle('dark-mode');
const isDark = document.body.classList.contains('dark-mode');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
updateThemeIcon();
});
}
// Toast Notifier
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 3000);
}
</script>
</body>
</html>

47
views/layout_top.php Normal file
View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Task Management</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="public/css/style.css">
</head>
<body>
<script>if(localStorage.getItem('theme') === 'dark') document.body.classList.add('dark-mode');</script>
<div id="toast"></div>
<div class="app-container">
<aside class="sidebar">
<h2 style="display:flex; align-items:center; gap:8px;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>
TaskManager
</h2>
<nav style="display:flex; flex-direction:column; gap:0.5rem;">
<a href="?action=dashboard" class="nav-link <?= $action=='dashboard'?'active':'' ?>"> Dashboard</a>
<a href="?action=tasks" class="nav-link <?= $action=='tasks'?'active':'' ?>"> Tasks</a>
<a href="?action=notes" class="nav-link <?= $action=='notes'?'active':'' ?>"> Notes</a>
<a href="?action=passwords" class="nav-link <?= $action=='passwords'?'active':'' ?>"> Passwords</a>
<a href="?action=projects" class="nav-link <?= $action=='projects'?'active':'' ?>"> Projects</a>
<a href="?action=notifications" class="nav-link <?= $action=='notifications'?'active':'' ?>">
Notifications
<?php if($unread_notifications > 0): ?>
<span class="badge"><?= $unread_notifications ?></span>
<?php endif; ?>
</a>
<?php if(!empty($_SESSION['is_admin'])): ?>
<a href="?action=admin" class="nav-link <?= $action=='admin'?'active':'' ?>"> Admin Panel</a>
<?php endif; ?>
</nav>
<div style="margin-top:auto; padding-top:1rem; border-top:1px solid var(--border);">
<div style="margin-bottom:0.5rem; font-weight:600;"><?= htmlspecialchars($current_user['username']) ?></div>
<a href="?action=logout" class="nav-link" style="padding:0; color:var(--text-light);">Logout</a>
</div>
</aside>
<main class="main-content">
<div style="display:flex; justify-content:flex-end; margin-bottom:1rem;">
<button id="theme-toggle" class="btn btn-secondary" style="border-radius:50%; padding:0.5rem; width:40px; height:40px; display:flex; align-items:center; justify-content:center;">
<span id="theme-icon-light" style="display:none;"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg></span>
<span id="theme-icon-dark" style="display:none;"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg></span>
</button>
</div>

30
views/login.php Normal file
View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - TaskManage</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="public/css/style.css">
</head>
<body>
<div class="login-container">
<div class="login-card">
<h1>Welcome Back</h1>
<?php if($error): ?><div class="alert"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<form method="POST">
<div class="form-group">
<label>Username</label>
<input type="text" name="username" class="form-control" required autofocus>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" class="form-control" required>
</div>
<button type="submit" class="btn" style="width:100%;">Sign In</button>
</form>
<p style="text-align:center; margin-top:1rem; font-size:0.875rem; color:var(--text-light);">First time? Run <a href="?action=setup">setup</a> to install Database.</p>
</div>
</div>
</body>
</html>

83
views/notes.php Normal file
View File

@@ -0,0 +1,83 @@
<?php
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_note'])) {
$stmt = $pdo->prepare("INSERT INTO notes (title, content, owner_id) VALUES (?, ?, ?)");
$stmt->execute([$_POST['title'], $_POST['content'], $user_id]);
}
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['share_note'])) {
$n_id = $_POST['note_id'];
$share = $_POST['share_target']; // 'user_1'
$parts = explode('_', $share);
$stmt = $pdo->prepare("INSERT INTO note_shares (note_id, share_type, share_id) VALUES (?, ?, ?)");
$stmt->execute([$n_id, $parts[0], $parts[1]]);
$n = query("SELECT title FROM notes WHERE id=?", [$n_id])->fetch();
$msg = "A note '{$n['title']}' has been shared with you.";
if($parts[0] === 'user') notify_user($parts[1], $msg);
else notify_group($parts[1], $msg);
}
// Fetch Notes
$user_groups = get_user_groups($user_id);
$my_group_ids = array_column($user_groups, 'id');
$group_in = implode(',', array_fill(0, count($my_group_ids), '?')) ?: '0';
$sql = "SELECT n.*, (n.owner_id = ?) as is_mine FROM notes n
LEFT JOIN note_shares ns ON n.id = ns.note_id
WHERE n.owner_id = ?
OR (ns.share_type='user' AND ns.share_id = ?)
OR (ns.share_type='group' AND ns.share_id IN ($group_in))
GROUP BY n.id ORDER BY n.created_at DESC";
$params = array_merge([$user_id, $user_id, $user_id], $my_group_ids);
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$notes = $stmt->fetchAll();
$all_users = query("SELECT id, username FROM users WHERE id != ?", [$user_id])->fetchAll();
$all_groups = query("SELECT * FROM groups")->fetchAll();
?>
<div class="header">
<h1>Markdown Notes</h1>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<div style="display:grid; grid-template-columns: 1fr 2fr; gap:1.5rem;">
<div class="card">
<h3>Create Note</h3>
<form method="POST" style="margin-top:1rem;">
<div class="form-group"><input type="text" name="title" class="form-control" placeholder="Note Title" required></div>
<div class="form-group">
<textarea name="content" class="form-control" placeholder="Markdown supported..." style="min-height:200px;" required></textarea>
</div>
<button type="submit" name="create_note" class="btn">Save Note</button>
</form>
</div>
<div class="card">
<h3>My Notes</h3>
<?php foreach($notes as $n): ?>
<div style="border:1px solid var(--border); border-radius:1rem; padding:1.5rem; margin-bottom:1.5rem;">
<div style="display:flex; justify-content:space-between; margin-bottom:1rem;">
<h4 style="font-size:1.2rem; color:var(--primary);"><?= htmlspecialchars($n['title']) ?> <?= !$n['is_mine'] ? '<span class="badge" style="background:#10B981; font-size:10px;">Shared</span>' : '' ?></h4>
<?php if($n['is_mine']): ?>
<form method="POST" style="display:inline; display:flex; gap:0.5rem;">
<input type="hidden" name="note_id" value="<?= $n['id'] ?>">
<select name="share_target" class="form-control" style="width:auto; padding:0.25rem;">
<?php foreach($all_users as $u): ?><option value="user_<?= $u['id'] ?>">User: <?= htmlspecialchars($u['username']) ?></option><?php endforeach; ?>
<?php foreach($all_groups as $g): ?><option value="group_<?= $g['id'] ?>">Group: <?= htmlspecialchars($g['name']) ?></option><?php endforeach; ?>
</select>
<button type="submit" name="share_note" class="btn btn-sm">Share</button>
</form>
<?php endif; ?>
</div>
<div class="markdown-body" style="background:var(--bg); padding:1rem; border-radius:0.5rem; font-size:0.95rem;">
<script>document.write(marked.parse(`<?= str_replace(array('\\', '`', '$'), array('\\\\', '\\`', '\\$'), (string)$n['content']) ?>`));</script>
</div>
</div>
<?php endforeach; ?>
<?php if(!$notes): ?><p>No notes written yet.</p><?php endif; ?>
</div>
</div>

36
views/notifications.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['mark_read'])) {
$stmt = $pdo->prepare("UPDATE notifications SET is_read = 1 WHERE user_id = ?");
$stmt->execute([$user_id]);
header("Location: ?action=notifications");
exit;
}
$stmt = $pdo->prepare("SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC");
$stmt->execute([$user_id]);
$notifications = $stmt->fetchAll();
?>
<div class="header">
<h1>Notifications</h1>
<?php if($unread_notifications > 0): ?>
<form method="POST">
<button type="submit" name="mark_read" class="btn">Mark All as Read</button>
</form>
<?php endif; ?>
</div>
<div class="card">
<?php if(!$notifications): ?>
<p>No notifications yet.</p>
<?php else: ?>
<ul style="list-style:none;">
<?php foreach($notifications as $n): ?>
<li style="padding:1rem; border-bottom:1px solid var(--border); <?= !$n['is_read'] ? 'background:#EEF2FF; font-weight:600;' : '' ?>">
<?= htmlspecialchars($n['message']) ?>
<div style="font-size:0.75rem; color:var(--text-light); margin-top:0.25rem;"><?= $n['created_at'] ?></div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>

165
views/passwords.php Normal file
View File

@@ -0,0 +1,165 @@
<?php
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_password'])) {
$stmt = $pdo->prepare("INSERT INTO passwords (title, username, password, url, notes, owner_id) VALUES (?, ?, ?, ?, ?, ?)");
$stmt->execute([$_POST['title'], $_POST['username'], $_POST['password'], $_POST['url'], $_POST['notes'], $user_id]);
}
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['bulk_action'])) {
if(isset($_POST['selected_passwords'])) {
$selected = $_POST['selected_passwords'];
$action = $_POST['bulk_action'];
if($action === 'delete') {
foreach($selected as $p_id) {
// Check if owner
$p = query("SELECT owner_id FROM passwords WHERE id=?", [$p_id])->fetch();
if($p && $p['owner_id']==$user_id) {
query("DELETE FROM passwords WHERE id=?", [$p_id]);
}
}
} elseif($action === 'share') {
$share_target = $_POST['share_target']; // 'user_1'
$parts = explode('_', $share_target);
foreach($selected as $p_id) {
$p = query("SELECT owner_id, title FROM passwords WHERE id=?", [$p_id])->fetch();
if($p && $p['owner_id']==$user_id) {
$stmt = $pdo->prepare("INSERT INTO password_shares (password_id, share_type, share_id) VALUES (?, ?, ?)");
$stmt->execute([$p_id, $parts[0], $parts[1]]);
$msg = "A password '{$p['title']}' has been shared with you.";
if($parts[0] === 'user') notify_user($parts[1], $msg);
else notify_group($parts[1], $msg);
}
}
}
}
}
// Fetch passwords (owned + shared)
$user_groups = get_user_groups($user_id);
$my_group_ids = array_column($user_groups, 'id');
$group_in = implode(',', array_fill(0, count($my_group_ids), '?')) ?: '0';
$params = array_merge([$user_id, $user_id], $my_group_ids);
$sql = "SELECT p.*, (p.owner_id = ?) as is_mine FROM passwords p
LEFT JOIN password_shares ps ON p.id = ps.password_id
WHERE p.owner_id = ?
OR (ps.share_type='user' AND ps.share_id = ?)
OR (ps.share_type='group' AND ps.share_id IN ($group_in))
GROUP BY p.id ORDER BY p.title";
$params = array_merge([$user_id, $user_id, $user_id], $my_group_ids);
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$passwords = $stmt->fetchAll();
$all_users = query("SELECT id, username FROM users WHERE id != ?", [$user_id])->fetchAll();
$all_groups = query("SELECT * FROM groups")->fetchAll();
?>
<div class="header">
<h1>Passwords Vault</h1>
</div>
<div class="modal-overlay" id="password-modal">
<div class="modal-content">
<h3 style="margin-bottom:1rem;">Password View</h3>
<input type="text" id="modal-password" class="form-control" readonly style="margin-bottom:1rem; font-family:monospace; font-size:1.1rem;">
<div style="display:flex; gap:0.5rem; justify-content:flex-end;">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('password-modal').classList.remove('active')">Close</button>
</div>
</div>
</div>
<div style="display:grid; grid-template-columns: 1fr 2fr; gap:1.5rem;">
<div class="card">
<h3>Add Password</h3>
<form method="POST" style="margin-top:1rem;">
<div class="form-group"><input type="text" name="title" class="form-control" placeholder="Title/Service" required></div>
<div class="form-group"><input type="text" name="username" class="form-control" placeholder="Username"></div>
<div class="form-group" style="display:flex; gap:0.5rem;">
<input type="password" name="password" id="new_pw" class="form-control" placeholder="Password" required>
<button type="button" class="btn btn-secondary" onclick="genPassword()">🎲</button>
</div>
<div class="form-group"><input type="text" name="url" class="form-control" placeholder="Website URL"></div>
<div class="form-group"><textarea name="notes" class="form-control" placeholder="Notes"></textarea></div>
<button type="submit" name="create_password" class="btn">Save Password</button>
</form>
</div>
<div class="card">
<h3>My Vault</h3>
<form method="POST" id="bulk-form">
<div style="display:flex; justify-content:space-between; margin-bottom:1rem;">
<div style="display:flex; gap:0.5rem;">
<button type="submit" name="bulk_action" value="delete" class="btn btn-sm btn-danger" onclick="return confirm('Delete selected?')">Delete Selected</button>
<div style="display:flex; align-items:center;">
<select name="share_target" class="form-control" style="width:150px; padding:0.25rem;">
<?php foreach($all_users as $u): ?><option value="user_<?= $u['id'] ?>">User: <?= htmlspecialchars($u['username']) ?></option><?php endforeach; ?>
<?php foreach($all_groups as $g): ?><option value="group_<?= $g['id'] ?>">Group: <?= htmlspecialchars($g['name']) ?></option><?php endforeach; ?>
</select>
<button type="submit" name="bulk_action" value="share" class="btn btn-sm" style="margin-left:0.5rem;">Share Selected</button>
</div>
</div>
<div style="color:var(--text-light);font-size:0.875rem;">Shortcuts:<br>Ctrl+B (Copy User), Ctrl+C (Copy Password)<br>Hover row to activate</div>
</div>
<table>
<thead><tr><th><input type="checkbox" onclick="toggleAll(this)"></th><th>Title</th><th>Username</th><th>Actions</th></tr></thead>
<tbody>
<?php foreach($passwords as $p): ?>
<tr class="pw-row" data-pw="<?= htmlspecialchars($p['password']) ?>" data-usr="<?= htmlspecialchars((string)$p['username']) ?>">
<td><?php if($p['is_mine']): ?><input type="checkbox" name="selected_passwords[]" value="<?= $p['id'] ?>" class="pw-checkbox"><?php endif; ?></td>
<td><?= htmlspecialchars($p['title']) ?> <?= !$p['is_mine'] ? '<span class="badge" style="background:var(--secondary);">Shared</span>' : '' ?></td>
<td><?= htmlspecialchars((string)$p['username']) ?></td>
<td>
<button type="button" onclick="showGw('<?= htmlspecialchars(addslashes($p['password'])) ?>')" class="btn btn-sm btn-secondary">👀 View</button>
<button type="button" onclick="copyText('<?= htmlspecialchars(addslashes($p['password'])) ?>')" class="btn btn-sm">📋 Copy Pw</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</form>
</div>
</div>
<script>
function genPassword() {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+";
let p = "";
for(let i=0; i<16; i++) p += chars[Math.floor(Math.random()*chars.length)];
document.getElementById('new_pw').value = p;
document.getElementById('new_pw').type = 'text'; // show it temporarily
}
function showGw(pw) {
document.getElementById('modal-password').value = pw;
document.getElementById('password-modal').classList.add('active');
}
function toggleAll(chk) {
document.querySelectorAll('.pw-checkbox').forEach(c => c.checked = chk.checked);
}
function copyText(txt) {
navigator.clipboard.writeText(txt);
showToast('Copied to clipboard!');
}
// Hotkeys Ctrl+C and Ctrl+B on hovered row
let hoveredRow = null;
document.querySelectorAll('.pw-row').forEach(row => {
row.addEventListener('mouseenter', () => hoveredRow = row);
row.addEventListener('mouseleave', () => hoveredRow = null);
});
document.addEventListener('keydown', (e) => {
if(hoveredRow && e.ctrlKey) {
if(e.key.toLowerCase() === 'c') {
copyText(hoveredRow.getAttribute('data-pw'));
e.preventDefault();
} else if(e.key.toLowerCase() === 'b') {
copyText(hoveredRow.getAttribute('data-usr'));
e.preventDefault();
}
}
});
</script>

174
views/projects.php Normal file
View File

@@ -0,0 +1,174 @@
<?php
$parent_id = $_GET['folder'] ?? null;
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_folder'])) {
$stmt = $pdo->prepare("INSERT INTO projects (name, is_folder, parent_id, owner_id) VALUES (?, 1, ?, ?)");
$stmt->execute([$_POST['name'], $parent_id, $user_id]);
}
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_item'])) {
$stmt = $pdo->prepare("DELETE FROM projects WHERE id=? AND owner_id=?");
$stmt->execute([$_POST['item_id'], $user_id]);
}
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_item'])) {
$stmt = $pdo->prepare("UPDATE projects SET name=? WHERE id=? AND owner_id=?");
$stmt->execute([$_POST['new_name'], $_POST['item_id'], $user_id]);
}
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['upload_file']) && isset($_FILES['file'])) {
$name = $_FILES['file']['name'];
$tmp_name = $_FILES['file']['tmp_name'];
$path = 'uploads/' . time() . '_' . $name;
move_uploaded_file($tmp_name, $path);
$stmt = $pdo->prepare("INSERT INTO projects (name, is_folder, parent_id, file_path, owner_id) VALUES (?, 0, ?, ?, ?)");
$stmt->execute([$name, $parent_id, $path, $user_id]);
echo "OK"; exit; // For AJAX
}
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['share_project'])) {
$pr_id = $_POST['project_id'];
$share = $_POST['share_target']; // 'user_1'
$parts = explode('_', $share);
$stmt = $pdo->prepare("INSERT INTO project_shares (project_id, share_type, share_id) VALUES (?, ?, ?)");
$stmt->execute([$pr_id, $parts[0], $parts[1]]);
$pr = query("SELECT name FROM projects WHERE id=?", [$pr_id])->fetch();
$msg = "A project/file '{$pr['name']}' has been shared with you.";
if($parts[0] === 'user') notify_user($parts[1], $msg);
else notify_group($parts[1], $msg);
}
// Fetch items in current folder
$user_groups = get_user_groups($user_id);
$my_group_ids = array_column($user_groups, 'id');
$group_in = implode(',', array_fill(0, count($my_group_ids), '?')) ?: '0';
$parent_cond = $parent_id ? "p.parent_id = ?" : "p.parent_id IS NULL";
$sql = "SELECT p.*, (p.owner_id = ?) as is_mine, u.username as owner_name FROM projects p
LEFT JOIN project_shares ps ON p.id = ps.project_id
LEFT JOIN users u ON p.owner_id = u.id
WHERE ($parent_cond) AND (
p.owner_id = ?
OR (ps.share_type='user' AND ps.share_id = ?)
OR (ps.share_type='group' AND ps.share_id IN ($group_in))
) GROUP BY p.id ORDER BY p.is_folder DESC, p.name ASC";
$params = [$user_id];
if($parent_id) $params[] = $parent_id;
$params = array_merge($params, [$user_id, $user_id], $my_group_ids);
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$items = $stmt->fetchAll();
$all_users = query("SELECT id, username FROM users WHERE id != ?", [$user_id])->fetchAll();
$all_groups = query("SELECT * FROM groups")->fetchAll();
?>
<div class="header">
<h1>Project Files Explorer</h1>
</div>
<div class="flex-between">
<?php if($parent_id):
$parent = query("SELECT parent_id FROM projects WHERE id=?", [$parent_id])->fetch();
?>
<a href="?action=projects&folder=<?= $parent['parent_id'] ?>" class="btn btn-secondary">← Back</a>
<?php else: ?>
<div>Root Directory</div>
<?php endif; ?>
<div style="display:flex; gap:1rem;">
<form method="POST" style="display:flex; gap:0.5rem;">
<input type="text" name="name" class="form-control" placeholder="New Folder Name" required>
<button type="submit" name="create_folder" class="btn">New Folder</button>
</form>
</div>
</div>
<div class="card" style="margin-top:1.5rem;">
<!-- Drag Drop Zone -->
<div id="dropzone" class="dropzone" ondragover="this.classList.add('dragover'); return false;" ondragleave="this.classList.remove('dragover');" ondrop="handleDrop(event)">
SVG Drop files here to upload instantly
<br><br><span style="color:var(--text-light); font-size:0.875rem;">(HTML5 Drag & Drop)</span>
</div>
<div style="margin-top:2rem;">
<?php foreach($items as $item): ?>
<div class="project-item">
<div style="display:flex; align-items:center; gap:0.5rem; flex:1;">
<span style="font-size:1.5rem;"><?= $item['is_folder'] ? '📁' : '📄' ?></span>
<?php if($item['is_folder']): ?>
<a href="?action=projects&folder=<?= $item['id'] ?>" style="font-weight:600;"><?= htmlspecialchars($item['name']) ?></a>
<?php else: ?>
<a href="<?= htmlspecialchars($item['file_path']) ?>" target="_blank"><?= htmlspecialchars($item['name']) ?></a>
<?php endif; ?>
</div>
<div style="width:200px; color:var(--text-light); font-size:0.875rem;">
Owner: <?= htmlspecialchars($item['owner_name']) ?>
<?= !$item['is_mine'] ? '<span class="badge" style="background:#10B981; font-size:10px;">Shared</span>' : '' ?>
</div>
<div style="display:flex; gap:0.5rem;">
<?php if($item['is_mine']): ?>
<form method="POST" style="display:inline; display:flex; gap:0.5rem;">
<input type="hidden" name="project_id" value="<?= $item['id'] ?>">
<select name="share_target" class="form-control" style="width:auto; padding:0.25rem;">
<?php foreach($all_users as $u): ?><option value="user_<?= $u['id'] ?>">User: <?= htmlspecialchars($u['username']) ?></option><?php endforeach; ?>
<?php foreach($all_groups as $g): ?><option value="group_<?= $g['id'] ?>">Group: <?= htmlspecialchars($g['name']) ?></option><?php endforeach; ?>
</select>
<button type="submit" name="share_project" class="btn btn-sm" title="Share">🔗</button>
</form>
<form method="POST" style="display:inline; display:flex; gap:0.5rem;" onsubmit="return confirm('Delete this item?')">
<input type="hidden" name="item_id" value="<?= $item['id'] ?>">
<button type="button" class="btn btn-sm btn-secondary" title="Rename" onclick="renameItem(<?= $item['id'] ?>, '<?= htmlspecialchars(addslashes($item['name'])) ?>')">✏️</button>
<button type="submit" name="delete_item" class="btn btn-sm btn-danger" title="Delete">🗑️</button>
</form>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php if(!$items): ?><p style="margin-top:1rem;">Empty folder.</p><?php endif; ?>
</div>
</div>
<!-- Rename form hidden -->
<form method="POST" id="rename-form" style="display:none;">
<input type="hidden" name="edit_item" value="1">
<input type="hidden" name="item_id" id="rn-item_id">
<input type="hidden" name="new_name" id="rn-new_name">
</form>
<script>
function renameItem(id, currName) {
let nn = prompt("Enter new name:", currName);
if(nn && nn.trim()!=='' && nn !== currName) {
document.getElementById('rn-item_id').value = id;
document.getElementById('rn-new_name').value = nn;
document.getElementById('rename-form').submit();
}
}
function handleDrop(e) {
e.preventDefault();
e.currentTarget.classList.remove('dragover');
let dt = e.dataTransfer;
let files = dt.files;
if(files.length > 0) {
let formData = new FormData();
formData.append('upload_file', '1');
formData.append('file', files[0]);
fetch('?action=projects&folder=<?= $parent_id ?>', {
method: 'POST',
body: formData
}).then(res => {
window.location.reload();
});
}
}
</script>

957
views/tasks.php Normal file
View File

@@ -0,0 +1,957 @@
<?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 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';
$params = array_merge([$user_id], $my_group_ids, $my_group_ids, [$user_id]);
$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 = ?)
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);
}
.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; }
</style>
<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 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:var(--text-light); border-radius:12px; padding:2px 8px; font-size:0.8rem;"><?= 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%;">
<span class="kanban-task-title"><?= htmlspecialchars($t['title']) ?></span>
<div style="display:flex; align-items:center; gap:0.5rem;">
<?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; font-size:1.1rem; line-height:1;" title="Delete Task">🗑️</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 = (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">
<a href="<?= htmlspecialchars((string)$att['file_path']) ?>" target="_blank" class="att-btn" title="Preview">
<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>
</a>
<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;
}
function updateNewTaskFileInput(input) {
let list = document.getElementById('new-task-files-list');
if(input.files.length > 0) {
list.innerHTML = `<span style="color:var(--primary); font-weight:600;">${input.files.length} file(s) attached</span>`;
} else {
list.textContent = "";
}
}
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>