initial commit
This commit is contained in:
2
config.php
Normal file
2
config.php
Normal file
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
define('DB_FILE', __DIR__ . '/database.sqlite');
|
||||
BIN
database.sqlite
Normal file
BIN
database.sqlite
Normal file
Binary file not shown.
54
db.php
Normal file
54
db.php
Normal 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
75
index.php
Normal 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
10
migrate.php
Normal 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
83
public/css/style.css
Normal 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
81
schema.sql
Normal 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
24
schema_v2.sql
Normal 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
15
setup.php
Normal 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
158
views/admin.php
Normal 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
96
views/dashboard.php
Normal 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 →</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 →</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
39
views/layout_bottom.php
Normal 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
47
views/layout_top.php
Normal 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
30
views/login.php
Normal 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
83
views/notes.php
Normal 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
36
views/notifications.php
Normal 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
165
views/passwords.php
Normal 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
174
views/projects.php
Normal 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
957
views/tasks.php
Normal 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, '"');
|
||||
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>
|
||||
Reference in New Issue
Block a user