Tul xxx Tul
User / IP
:
216.73.216.217
Host / Server
:
45.84.207.204 / aircan.me
System
:
Linux lt-bnk-web1726.main-hosting.eu 5.14.0-611.36.1.el9_7.x86_64 #1 SMP PREEMPT_DYNAMIC Tue Mar 3 11:23:52 EST 2026 x86_64
Command
|
Upload
|
Create
Mass Deface
|
Jumping
|
Symlink
|
Reverse Shell
Ping
|
Port Scan
|
DNS Lookup
|
Whois
|
Header
|
cURL
:
/
home
/
u931257429
/
domains
/
emprendo.com.co
/
public_html
/
hoy
/
Viewing: index.php
<?php ini_set('display_errors',1); error_reporting(E_ALL); date_default_timezone_set('America/Bogota'); $pdo = new PDO( "mysql:host=localhost;dbname=u931257429_hoy;charset=utf8mb4", "u931257429_hoy", "eCoal100%", [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC ] ); try { $pdo->exec("SET time_zone = '".date('P')."'"); } catch (Throwable $e) { } $today = date('Y-m-d'); $selectedUserId = isset($_GET['user']) ? (int)$_GET['user'] : 0; ensure_task_time_column($pdo); ensure_projects_table($pdo); ensure_task_project_column($pdo); ensure_finance_tables($pdo); function status_label(string $status): string { if ($status === 'pending') return 'Pendiente'; if ($status === 'progress') return 'En progreso'; if ($status === 'done') return 'Hecho'; return $status; } function format_time_ampm(?string $time): string { $t = trim((string)$time); if ($t === '') return ''; $ts = strtotime('1970-01-01 '.$t); if ($ts === false) return $t; return date('g:i A', $ts); } function ensure_logger_table(PDO $pdo): void { static $done = false; if ($done) return; try { $pdo->exec("CREATE TABLE IF NOT EXISTS logger ( id INT AUTO_INCREMENT PRIMARY KEY, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, action VARCHAR(60) NOT NULL, entity VARCHAR(60) NOT NULL, entity_id INT NULL, actor_user_id INT NULL, ref_date DATE NULL, ip VARCHAR(45) NULL, user_agent VARCHAR(255) NULL, meta_json TEXT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); } catch (Throwable $e) { } $done = true; } function ensure_finance_tables(PDO $pdo): void { static $done = false; if ($done) return; try { $pdo->exec("CREATE TABLE IF NOT EXISTS finance_accounts ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(80) NOT NULL, type VARCHAR(20) NULL, currency_code VARCHAR(5) NOT NULL, opening_balance DECIMAL(12,2) NOT NULL DEFAULT 0, shared_all TINYINT(1) NOT NULL DEFAULT 0, active TINYINT(1) NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); } catch (Throwable $e) { } try { $pdo->exec("CREATE TABLE IF NOT EXISTS finance_account_users ( account_id INT NOT NULL, user_id INT NOT NULL, role VARCHAR(16) NULL, PRIMARY KEY (account_id, user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); } catch (Throwable $e) { } try { $pdo->exec("CREATE TABLE IF NOT EXISTS finance_categories ( id INT AUTO_INCREMENT PRIMARY KEY, owner_user_id INT NULL, kind ENUM('income','expense','both') NOT NULL DEFAULT 'both', name VARCHAR(80) NOT NULL, active TINYINT(1) NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); } catch (Throwable $e) { } try { $pdo->exec("CREATE TABLE IF NOT EXISTS finance_moves ( id INT AUTO_INCREMENT PRIMARY KEY, move_date DATE NOT NULL, type ENUM('income','expense','transfer') NOT NULL, user_id INT NOT NULL, account_id INT NOT NULL, to_account_id INT NULL, amount DECIMAL(12,2) NOT NULL, currency_code VARCHAR(5) NOT NULL, to_amount DECIMAL(12,2) NULL, to_currency_code VARCHAR(5) NULL, category_id INT NULL, note VARCHAR(255) NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); } catch (Throwable $e) { } try { $pdo->exec("CREATE TABLE IF NOT EXISTS finance_debts ( id INT AUTO_INCREMENT PRIMARY KEY, owner_user_id INT NOT NULL, kind ENUM('i_owe','they_owe_me') NOT NULL, person VARCHAR(140) NOT NULL, currency_code VARCHAR(5) NOT NULL, amount DECIMAL(12,2) NOT NULL, start_date DATE NOT NULL, status ENUM('open','closed') NOT NULL DEFAULT 'open', note VARCHAR(255) NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); } catch (Throwable $e) { } try { $pdo->exec("CREATE TABLE IF NOT EXISTS finance_debt_payments ( id INT AUTO_INCREMENT PRIMARY KEY, debt_id INT NOT NULL, user_id INT NOT NULL, pay_date DATE NOT NULL, account_id INT NOT NULL, amount DECIMAL(12,2) NOT NULL, currency_code VARCHAR(5) NOT NULL, move_id INT NULL, note VARCHAR(255) NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); } catch (Throwable $e) { } try { $ix = $pdo->query("SHOW INDEX FROM finance_moves WHERE Key_name='idx_fin_moves_date'")->fetch(); if (!$ix) $pdo->exec("CREATE INDEX idx_fin_moves_date ON finance_moves(move_date)"); } catch (Throwable $e) { } try { $ix = $pdo->query("SHOW INDEX FROM finance_moves WHERE Key_name='idx_fin_moves_account'")->fetch(); if (!$ix) $pdo->exec("CREATE INDEX idx_fin_moves_account ON finance_moves(account_id)"); } catch (Throwable $e) { } try { $ix = $pdo->query("SHOW INDEX FROM finance_moves WHERE Key_name='idx_fin_moves_to_account'")->fetch(); if (!$ix) $pdo->exec("CREATE INDEX idx_fin_moves_to_account ON finance_moves(to_account_id)"); } catch (Throwable $e) { } try { $ix = $pdo->query("SHOW INDEX FROM finance_moves WHERE Key_name='idx_fin_acc_users_user'")->fetch(); if (!$ix) $pdo->exec("CREATE INDEX idx_fin_acc_users_user ON finance_account_users(user_id)"); } catch (Throwable $e) { } try { $ix = $pdo->query("SHOW INDEX FROM finance_debt_payments WHERE Key_name='idx_fin_debt_pay_debt'")->fetch(); if (!$ix) $pdo->exec("CREATE INDEX idx_fin_debt_pay_debt ON finance_debt_payments(debt_id)"); } catch (Throwable $e) { } try { $ix = $pdo->query("SHOW INDEX FROM finance_debts WHERE Key_name='idx_fin_debts_owner'")->fetch(); if (!$ix) $pdo->exec("CREATE INDEX idx_fin_debts_owner ON finance_debts(owner_user_id)"); } catch (Throwable $e) { } $done = true; } function ensure_task_time_column(PDO $pdo): void { static $done = false; if ($done) return; try { $row = $pdo->query("SHOW COLUMNS FROM tasks LIKE 'task_time'")->fetch(); if (!$row) { $pdo->exec("ALTER TABLE tasks ADD COLUMN task_time TIME NULL AFTER task_date"); } } catch (Throwable $e) { } $done = true; } function ensure_projects_table(PDO $pdo): void { static $done = false; if ($done) return; try { $pdo->exec("CREATE TABLE IF NOT EXISTS projects ( id INT AUTO_INCREMENT PRIMARY KEY, category_id INT NULL, name VARCHAR(140) NOT NULL, color VARCHAR(7) NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); try { $row = $pdo->query("SHOW COLUMNS FROM projects LIKE 'category_id'")->fetch(); if (!$row) { $pdo->exec("ALTER TABLE projects ADD COLUMN category_id INT NULL AFTER id"); } } catch (Throwable $e) { } try { $ix = $pdo->query("SHOW INDEX FROM projects WHERE Key_name='idx_projects_category_id'")->fetch(); if (!$ix) { $pdo->exec("CREATE INDEX idx_projects_category_id ON projects(category_id)"); } } catch (Throwable $e) { } try { $pdo->exec("UPDATE projects p SET category_id = (SELECT t.category_id FROM tasks t WHERE t.project_id = p.id AND t.category_id IS NOT NULL LIMIT 1) WHERE p.category_id IS NULL"); } catch (Throwable $e) { } } catch (Throwable $e) { } $done = true; } function ensure_task_project_column(PDO $pdo): void { static $done = false; if ($done) return; try { $row = $pdo->query("SHOW COLUMNS FROM tasks LIKE 'project_id'")->fetch(); if (!$row) { $pdo->exec("ALTER TABLE tasks ADD COLUMN project_id INT NULL AFTER category_id"); } try { $ix = $pdo->query("SHOW INDEX FROM tasks WHERE Key_name='idx_tasks_project_id'")->fetch(); if (!$ix) { $pdo->exec("CREATE INDEX idx_tasks_project_id ON tasks(project_id)"); } } catch (Throwable $e) { } } catch (Throwable $e) { } $done = true; } function log_event(PDO $pdo, string $action, string $entity, ?int $entityId, ?int $actorUserId, array $meta = [], ?string $refDate = null): void { try { ensure_logger_table($pdo); $stmt = $pdo->prepare("INSERT INTO logger (action, entity, entity_id, actor_user_id, ref_date, ip, user_agent, meta_json) VALUES (?,?,?,?,?,?,?,?)"); $ip = $_SERVER['REMOTE_ADDR'] ?? null; $ua = $_SERVER['HTTP_USER_AGENT'] ?? null; $stmt->execute([ $action, $entity, $entityId, $actorUserId, $refDate, $ip, $ua, $meta ? json_encode($meta, JSON_UNESCAPED_UNICODE) : null ]); } catch (Throwable $e) { } } function upload_user_photo(string $fieldName, ?string $existingPath = null): ?string { if (!isset($_FILES[$fieldName])) return null; $f = $_FILES[$fieldName]; if (!is_array($f) || ($f['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) return null; if (($f['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) return null; if (!is_uploaded_file($f['tmp_name'] ?? '')) return null; $maxBytes = 3 * 1024 * 1024; if (($f['size'] ?? 0) > $maxBytes) return null; $tmp = (string)$f['tmp_name']; $mime = null; if (class_exists('finfo')) { $fi = new finfo(FILEINFO_MIME_TYPE); $mime = $fi->file($tmp); } else { $mime = @mime_content_type($tmp); } $map = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp' ]; if (!$mime || !isset($map[$mime])) return null; $ext = $map[$mime]; $dir = __DIR__.DIRECTORY_SEPARATOR.'uploads'.DIRECTORY_SEPARATOR.'users'; if (!is_dir($dir)) { @mkdir($dir, 0755, true); } if (!is_dir($dir) || !is_writable($dir)) return null; $name = bin2hex(random_bytes(8)).'.'.$ext; $destFs = $dir.DIRECTORY_SEPARATOR.$name; if (!move_uploaded_file($tmp, $destFs)) return null; $webPath = 'uploads/users/'.$name; if ($existingPath && str_starts_with($existingPath, 'uploads/users/')) { $oldFs = __DIR__.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $existingPath); if (is_file($oldFs)) { @unlink($oldFs); } } return $webPath; } function json_response($data, int $code = 200): void { http_response_code($code); header('Content-Type: application/json'); echo json_encode($data); exit; } function fin_norm_currency(string $s): string { return strtoupper(trim($s)); } function fin_valid_currency(string $s): bool { return (bool)preg_match('/^[A-Z0-9]{2,5}$/', $s); } function fin_user_can_use_account(PDO $pdo, int $userId, int $accountId): bool { if ($userId <= 0 || $accountId <= 0) return false; try { $stmt = $pdo->prepare("SELECT shared_all, active FROM finance_accounts WHERE id=?"); $stmt->execute([$accountId]); $row = $stmt->fetch(); if (!$row) return false; if (((int)($row['active'] ?? 1)) !== 1) return false; if (((int)($row['shared_all'] ?? 0)) === 1) return true; $stmt = $pdo->prepare("SELECT 1 FROM finance_account_users WHERE account_id=? AND user_id=? LIMIT 1"); $stmt->execute([$accountId, $userId]); return (bool)$stmt->fetch(); } catch (Throwable $e) { return false; } } function fin_account_currency(PDO $pdo, int $accountId): ?string { try { $stmt = $pdo->prepare("SELECT currency_code FROM finance_accounts WHERE id=? LIMIT 1"); $stmt->execute([$accountId]); $row = $stmt->fetch(); if (!$row) return null; return (string)$row['currency_code']; } catch (Throwable $e) { return null; } } function upsert_task_log(PDO $pdo, int $taskId, string $logDate, string $status): void { $stmt = $pdo->prepare("SELECT id FROM task_logs WHERE task_id=? AND log_date=? LIMIT 1"); $stmt->execute([$taskId, $logDate]); $existing = $stmt->fetch(); if ($existing) { $upd = $pdo->prepare("UPDATE task_logs SET status=? WHERE id=?"); $upd->execute([$status, $existing['id']]); return; } $ins = $pdo->prepare("INSERT INTO task_logs (task_id, log_date, status) VALUES (?,?,?)"); $ins->execute([$taskId, $logDate, $status]); } function replicate_unfinished_tasks(PDO $pdo, string $today): void { ensure_task_time_column($pdo); $yesterday = date('Y-m-d', strtotime($today.' -1 day')); $stmt = $pdo->prepare(" SELECT t.id, t.title, t.category_id, t.project_id, p.category_id AS project_category_id, t.task_time, t.status FROM tasks t LEFT JOIN projects p ON p.id = t.project_id WHERE t.task_date = ? AND t.status <> 'done' AND NOT EXISTS ( SELECT 1 FROM task_logs tl WHERE tl.task_id = t.id AND tl.log_date = ? ) ORDER BY t.position IS NULL, t.position, t.id "); $stmt->execute([$yesterday, $today]); $candidates = $stmt->fetchAll(); if (!$candidates) return; $sourceIds = []; $newIds = []; $pdo->beginTransaction(); try { $n = count($candidates); $shift = $pdo->prepare("UPDATE tasks SET position = COALESCE(position, 0) + ? WHERE task_date = ?"); $shift->execute([$n, $today]); $insTask = $pdo->prepare("INSERT INTO tasks (title, category_id, project_id, task_date, task_time, status, position, created_at) VALUES (?,?,?,?,?,?,?,NOW())"); $getUsers = $pdo->prepare("SELECT user_id FROM task_users WHERE task_id = ?"); $insTU = $pdo->prepare("INSERT INTO task_users (task_id, user_id) VALUES (?,?)"); $mark = $pdo->prepare("INSERT INTO task_logs (task_id, log_date, status) VALUES (?,?,?)"); foreach ($candidates as $i => $t) { $sourceIds[] = (int)$t['id']; $cat = $t['category_id'] !== null ? (int)$t['category_id'] : null; if ($t['project_id'] !== null && $t['project_category_id'] !== null) $cat = (int)$t['project_category_id']; $pid = $t['project_id'] !== null ? (int)$t['project_id'] : null; $insTask->execute([$t['title'], $cat, $pid, $today, $t['task_time'], 'pending', $i]); $newTaskId = (int)$pdo->lastInsertId(); $newIds[] = $newTaskId; $getUsers->execute([(int)$t['id']]); $uids = $getUsers->fetchAll(); foreach ($uids as $row) { $insTU->execute([$newTaskId, (int)$row['user_id']]); } $mark->execute([(int)$t['id'], $today, (string)$t['status']]); } $pdo->commit(); log_event($pdo, 'replicate_tasks', 'system', null, null, ['from'=>$yesterday,'to'=>$today,'count'=>count($newIds),'source_task_ids'=>$sourceIds,'new_task_ids'=>$newIds], $today); } catch (Throwable $e) { $pdo->rollBack(); throw $e; } } if ($_SERVER['REQUEST_METHOD']==='POST') { $action = $_POST['action'] ?? ''; $actorUserId = isset($_POST['actor_user_id']) && $_POST['actor_user_id'] !== '' ? (int)$_POST['actor_user_id'] : null; if ($action === 'reorder' || $action === 'reorder_tasks') { $orderRaw = $_POST['order'] ?? ''; $order = array_values(array_filter(array_map('intval', explode(',', $orderRaw)))); $pdo->beginTransaction(); try { foreach($order as $pos=>$id){ $stmt = $pdo->prepare("UPDATE tasks SET position=? WHERE id=?"); $stmt->execute([$pos,$id]); } $pdo->commit(); } catch (Throwable $e) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'reorder_failed'], 500); } log_event($pdo, 'reorder_tasks', 'task', null, $actorUserId, ['order'=>$order], $today); json_response(['ok'=>true]); } if ($action === 'add_user') { $name = trim((string)($_POST['name'] ?? '')); $color = trim((string)($_POST['color'] ?? '')); if ($name === '') json_response(['ok'=>false,'error'=>'name_required'], 400); if ($color === '') { $color = sprintf('#%06X', random_int(0, 0xFFFFFF)); } if ($color[0] !== '#') $color = '#'.$color; if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) json_response(['ok'=>false,'error'=>'bad_color'], 400); $photoPath = upload_user_photo('photo_file'); try { $ins = $pdo->prepare("INSERT INTO users (name, photo, color) VALUES (?,?,?)"); $ins->execute([$name, $photoPath, $color]); $uid = (int)$pdo->lastInsertId(); log_event($pdo, 'add_user', 'user', $uid, $actorUserId, ['name'=>$name,'color'=>$color,'photo'=>$photoPath], $today); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'add_user_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'get_projects_board') { $categoryId = isset($_POST['category_id']) && $_POST['category_id'] !== '' ? (int)$_POST['category_id'] : null; $status = trim((string)($_POST['status'] ?? '')); $q = trim((string)($_POST['q'] ?? '')); $filterUserId = isset($_POST['user_id']) && $_POST['user_id'] !== '' ? (int)$_POST['user_id'] : ($actorUserId !== null ? (int)$actorUserId : 0); $allowed = ['','pending','progress','done']; if (!in_array($status, $allowed, true)) json_response(['ok'=>false,'error'=>'bad_status'], 400); if ($categoryId !== null && $categoryId <= 0) $categoryId = null; if ($filterUserId < 0) $filterUserId = 0; try { $pSql = "SELECT p.id, p.category_id, p.name, p.color, c.name AS category_name FROM projects p LEFT JOIN categories c ON c.id=p.category_id"; $pParams = []; if ($categoryId !== null) { $pSql .= " WHERE p.category_id = ? "; $pParams[] = $categoryId; } $pSql .= " ORDER BY p.name "; $pStmt = $pdo->prepare($pSql); $pStmt->execute($pParams); $projects = $pStmt->fetchAll(); $sql = " SELECT t.id, t.title, t.status, t.task_date, t.task_time, t.project_id, c.name AS category_name, p.name AS project_name, p.color AS project_color, GROUP_CONCAT(DISTINCT u.id ORDER BY u.id) AS user_ids FROM tasks t LEFT JOIN categories c ON c.id = t.category_id LEFT JOIN projects p ON p.id = t.project_id LEFT JOIN task_users tu ON tu.task_id = t.id LEFT JOIN users u ON u.id = tu.user_id WHERE t.project_id IS NOT NULL "; $params = []; if ($categoryId !== null) { $sql .= " AND p.category_id = ? "; $params[] = $categoryId; } if ($status !== '') { $sql .= " AND t.status = ? "; $params[] = $status; } if ($q !== '') { $sql .= " AND t.title LIKE ? "; $params[] = '%'.$q.'%'; } if ($filterUserId > 0) { $sql .= " AND EXISTS (SELECT 1 FROM task_users tu2 WHERE tu2.task_id=t.id AND tu2.user_id=?) "; $params[] = $filterUserId; } $sql .= " GROUP BY t.id ORDER BY t.task_date DESC, t.task_time DESC, t.id DESC "; $stmt = $pdo->prepare($sql); $stmt->execute($params); $tasks = $stmt->fetchAll(); json_response(['ok'=>true,'projects'=>$projects,'tasks'=>$tasks]); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'load_failed'], 500); } } if ($action === 'update_fin_category') { $id = (int)($_POST['id'] ?? 0); $name = trim((string)($_POST['name'] ?? '')); $kind = trim((string)($_POST['kind'] ?? 'both')); $owner = null; if (isset($_POST['owner_user_id']) && $_POST['owner_user_id'] !== '') { $v = (int)$_POST['owner_user_id']; if ($v > 0) $owner = $v; } $allowed = ['income','expense','both']; if ($id <= 0) json_response(['ok'=>false,'error'=>'id_required'], 400); if ($name === '') json_response(['ok'=>false,'error'=>'name_required'], 400); if (!in_array($kind, $allowed, true)) json_response(['ok'=>false,'error'=>'bad_kind'], 400); try { $stmt = $pdo->prepare("SELECT owner_user_id FROM finance_categories WHERE id=?"); $stmt->execute([$id]); $old = $stmt->fetch(); if (!$old) json_response(['ok'=>false,'error'=>'not_found'], 404); if ($actorUserId !== null && $old['owner_user_id'] !== null && (int)$old['owner_user_id'] !== (int)$actorUserId) { json_response(['ok'=>false,'error'=>'forbidden'], 403); } $upd = $pdo->prepare("UPDATE finance_categories SET owner_user_id=?, kind=?, name=? WHERE id=?"); $upd->execute([$owner, $kind, $name, $id]); log_event($pdo, 'update_fin_category', 'finance_category', $id, $actorUserId, ['owner_user_id'=>$owner,'kind'=>$kind,'name'=>$name], $today); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'update_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'update_fin_account') { $id = (int)($_POST['id'] ?? 0); if ($id <= 0) json_response(['ok'=>false,'error'=>'id_required'], 400); $name = trim((string)($_POST['name'] ?? '')); $type = trim((string)($_POST['type'] ?? '')); $currency = fin_norm_currency((string)($_POST['currency_code'] ?? '')); $openingRaw = trim((string)($_POST['opening_balance'] ?? '0')); $sharedAll = (int)($_POST['shared_all'] ?? 0) === 1 ? 1 : 0; $userIdsRaw = (string)($_POST['user_ids'] ?? ''); $userIds = array_values(array_unique(array_filter(array_map('intval', explode(',', $userIdsRaw))))); if ($name === '') json_response(['ok'=>false,'error'=>'name_required'], 400); if (!fin_valid_currency($currency)) json_response(['ok'=>false,'error'=>'bad_currency'], 400); if ($openingRaw === '' || !is_numeric($openingRaw)) json_response(['ok'=>false,'error'=>'bad_amount'], 400); $opening = round((float)$openingRaw, 2); if (!$sharedAll && !$userIds) json_response(['ok'=>false,'error'=>'user_required'], 400); try { $stmt = $pdo->prepare("SELECT id, currency_code FROM finance_accounts WHERE id=?"); $stmt->execute([$id]); $old = $stmt->fetch(); if (!$old) json_response(['ok'=>false,'error'=>'not_found'], 404); if ($actorUserId !== null && !fin_user_can_use_account($pdo, (int)$actorUserId, $id)) { json_response(['ok'=>false,'error'=>'forbidden'], 403); } $oldCcy = fin_norm_currency((string)$old['currency_code']); if ($currency !== $oldCcy) { $cnt = 0; try { $stmt = $pdo->prepare("SELECT COUNT(*) AS c FROM finance_moves WHERE account_id=? OR to_account_id=?"); $stmt->execute([$id, $id]); $cnt += (int)($stmt->fetch()['c'] ?? 0); } catch (Throwable $e) { } try { $stmt = $pdo->prepare("SELECT COUNT(*) AS c FROM finance_debt_payments WHERE account_id=?"); $stmt->execute([$id]); $cnt += (int)($stmt->fetch()['c'] ?? 0); } catch (Throwable $e) { } if ($cnt > 0) json_response(['ok'=>false,'error'=>'currency_locked'], 400); } $pdo->beginTransaction(); $upd = $pdo->prepare("UPDATE finance_accounts SET name=?, type=?, currency_code=?, opening_balance=?, shared_all=? WHERE id=?"); $upd->execute([$name, $type !== '' ? $type : null, $currency, $opening, $sharedAll, $id]); $pdo->prepare("DELETE FROM finance_account_users WHERE account_id=?")->execute([$id]); if (!$sharedAll) { $insAu = $pdo->prepare("INSERT INTO finance_account_users (account_id, user_id, role) VALUES (?,?,NULL)"); foreach ($userIds as $uid) $insAu->execute([$id, (int)$uid]); } $pdo->commit(); log_event($pdo, 'update_fin_account', 'finance_account', $id, $actorUserId, ['name'=>$name,'type'=>$type,'currency_code'=>$currency,'opening_balance'=>$opening,'shared_all'=>$sharedAll,'user_ids'=>$userIds], $today); } catch (Throwable $e) { if ($pdo->inTransaction()) $pdo->rollBack(); json_response(['ok'=>false,'error'=>'update_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'update_fin_move') { $id = (int)($_POST['id'] ?? 0); $moveDate = trim((string)($_POST['move_date'] ?? $today)); $moveTime = trim((string)($_POST['move_time'] ?? '')); $accountIdIn = isset($_POST['account_id']) && $_POST['account_id'] !== '' ? (int)$_POST['account_id'] : 0; $toAccountIdIn = isset($_POST['to_account_id']) && $_POST['to_account_id'] !== '' ? (int)$_POST['to_account_id'] : null; $amountRaw = trim((string)($_POST['amount'] ?? '')); $toAmountRaw = trim((string)($_POST['to_amount'] ?? '')); $categoryId = isset($_POST['category_id']) && $_POST['category_id'] !== '' ? (int)$_POST['category_id'] : null; $note = trim((string)($_POST['note'] ?? '')); if ($id <= 0) json_response(['ok'=>false,'error'=>'id_required'], 400); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $moveDate)) json_response(['ok'=>false,'error'=>'bad_day'], 400); if ($moveTime !== '') { if (!preg_match('/^\d{2}:\d{2}$/', $moveTime)) json_response(['ok'=>false,'error'=>'bad_time'], 400); $th = (int)substr($moveTime,0,2); $tm = (int)substr($moveTime,3,2); if (!($th>=0 && $th<=23 && $tm>=0 && $tm<=59)) json_response(['ok'=>false,'error'=>'bad_time'], 400); } if ($amountRaw === '' || !is_numeric($amountRaw)) json_response(['ok'=>false,'error'=>'bad_amount'], 400); $amount = round((float)$amountRaw, 2); if (!($amount > 0)) json_response(['ok'=>false,'error'=>'bad_amount'], 400); try { $stmt = $pdo->prepare("SELECT id, type, user_id, account_id, to_account_id, currency_code, to_currency_code, created_at FROM finance_moves WHERE id=?"); $stmt->execute([$id]); $m = $stmt->fetch(); if (!$m) json_response(['ok'=>false,'error'=>'not_found'], 404); $type = (string)$m['type']; $accountId = $accountIdIn > 0 ? $accountIdIn : (int)$m['account_id']; $toAccountId = $type === 'transfer' ? ($toAccountIdIn !== null ? (int)$toAccountIdIn : ($m['to_account_id'] !== null ? (int)$m['to_account_id'] : null)) : null; if ($actorUserId !== null) { $ok = fin_user_can_use_account($pdo, (int)$actorUserId, (int)$accountId); if (!$ok && $toAccountId !== null) $ok = fin_user_can_use_account($pdo, (int)$actorUserId, (int)$toAccountId); if (!$ok) json_response(['ok'=>false,'error'=>'forbidden'], 403); } if ($accountId <= 0) json_response(['ok'=>false,'error'=>'account_required'], 400); $currency = fin_account_currency($pdo, $accountId); if (!$currency) json_response(['ok'=>false,'error'=>'account_not_found'], 404); $toCurrency = null; $toAmount = null; if ($type === 'transfer') { if ($toAccountId === null || $toAccountId <= 0) json_response(['ok'=>false,'error'=>'to_account_required'], 400); if ($toAccountId === $accountId) json_response(['ok'=>false,'error'=>'same_account'], 400); $toCurrency = fin_account_currency($pdo, $toAccountId); if (!$toCurrency) json_response(['ok'=>false,'error'=>'to_account_not_found'], 404); if ($toCurrency === $currency) { $toAmount = null; } else { if ($toAmountRaw === '' || !is_numeric($toAmountRaw)) json_response(['ok'=>false,'error'=>'to_amount_required'], 400); $toAmount = round((float)$toAmountRaw, 2); if (!($toAmount > 0)) json_response(['ok'=>false,'error'=>'to_amount_required'], 400); } $categoryId = null; } else { $toAccountId = null; $toCurrency = null; $toAmount = null; if ($categoryId !== null) { $stmt = $pdo->prepare("SELECT kind, active FROM finance_categories WHERE id=?"); $stmt->execute([$categoryId]); $cat = $stmt->fetch(); if (!$cat || ((int)($cat['active'] ?? 1) !== 1)) json_response(['ok'=>false,'error'=>'category_not_found'], 404); $k = (string)($cat['kind'] ?? 'both'); if ($type === 'income' && !in_array($k, ['income','both'], true)) json_response(['ok'=>false,'error'=>'category_kind_mismatch'], 400); if ($type === 'expense' && !in_array($k, ['expense','both'], true)) json_response(['ok'=>false,'error'=>'category_kind_mismatch'], 400); } } if ($moveTime !== '') { $createdAt = $moveDate.' '.$moveTime.':00'; $upd = $pdo->prepare("UPDATE finance_moves SET move_date=?, account_id=?, to_account_id=?, amount=?, currency_code=?, to_amount=?, to_currency_code=?, category_id=?, note=?, created_at=? WHERE id=?"); $upd->execute([$moveDate, $accountId, $toAccountId, $amount, $currency, $toAmount, $toCurrency, $categoryId, $note !== '' ? $note : null, $createdAt, $id]); } else { $upd = $pdo->prepare("UPDATE finance_moves SET move_date=?, account_id=?, to_account_id=?, amount=?, currency_code=?, to_amount=?, to_currency_code=?, category_id=?, note=? WHERE id=?"); $upd->execute([$moveDate, $accountId, $toAccountId, $amount, $currency, $toAmount, $toCurrency, $categoryId, $note !== '' ? $note : null, $id]); } log_event($pdo, 'update_fin_move', 'finance_move', $id, $actorUserId, ['move_date'=>$moveDate,'move_time'=>$moveTime !== '' ? $moveTime : null,'account_id'=>$accountId,'to_account_id'=>$toAccountId,'amount'=>$amount,'currency_code'=>$currency,'to_amount'=>$toAmount,'to_currency_code'=>$toCurrency,'category_id'=>$categoryId,'note'=>$note !== '' ? $note : null], $today); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'update_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'update_fin_debt') { $id = (int)($_POST['id'] ?? 0); $kind = trim((string)($_POST['kind'] ?? '')); $person = trim((string)($_POST['person'] ?? '')); $currency = fin_norm_currency((string)($_POST['currency_code'] ?? '')); $amountRaw = trim((string)($_POST['amount'] ?? '')); $startDate = trim((string)($_POST['start_date'] ?? $today)); $note = trim((string)($_POST['note'] ?? '')); if ($id <= 0) json_response(['ok'=>false,'error'=>'id_required'], 400); if (!in_array($kind, ['i_owe','they_owe_me'], true)) json_response(['ok'=>false,'error'=>'bad_kind'], 400); if ($person === '') json_response(['ok'=>false,'error'=>'person_required'], 400); if (!fin_valid_currency($currency)) json_response(['ok'=>false,'error'=>'bad_currency'], 400); if ($amountRaw === '' || !is_numeric($amountRaw)) json_response(['ok'=>false,'error'=>'bad_amount'], 400); $amount = round((float)$amountRaw, 2); if (!($amount > 0)) json_response(['ok'=>false,'error'=>'bad_amount'], 400); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) json_response(['ok'=>false,'error'=>'bad_day'], 400); $pdo->beginTransaction(); try { $stmt = $pdo->prepare("SELECT owner_user_id, currency_code FROM finance_debts WHERE id=?"); $stmt->execute([$id]); $d = $stmt->fetch(); if (!$d) json_response(['ok'=>false,'error'=>'not_found'], 404); if ($actorUserId !== null && (int)$d['owner_user_id'] !== (int)$actorUserId) { json_response(['ok'=>false,'error'=>'forbidden'], 403); } $stmt = $pdo->prepare("SELECT COALESCE(SUM(amount),0) AS paid FROM finance_debt_payments WHERE debt_id=?"); $stmt->execute([$id]); $paid = (float)($stmt->fetch()['paid'] ?? 0); if ($amount < $paid) json_response(['ok'=>false,'error'=>'amount_lt_paid'], 400); $oldCcy = fin_norm_currency((string)$d['currency_code']); if ($currency !== $oldCcy && $paid > 0) json_response(['ok'=>false,'error'=>'currency_locked'], 400); $upd = $pdo->prepare("UPDATE finance_debts SET kind=?, person=?, currency_code=?, amount=?, start_date=?, note=? WHERE id=?"); $upd->execute([$kind, $person, $currency, $amount, $startDate, $note !== '' ? $note : null, $id]); $pdo->commit(); log_event($pdo, 'update_fin_debt', 'finance_debt', $id, $actorUserId, ['kind'=>$kind,'person'=>$person,'currency_code'=>$currency,'amount'=>$amount,'start_date'=>$startDate,'note'=>$note !== '' ? $note : null,'paid'=>$paid], $today); } catch (Throwable $e) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'update_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'update_user') { $id = (int)($_POST['id'] ?? 0); $name = trim((string)($_POST['name'] ?? '')); $color = trim((string)($_POST['color'] ?? '')); if ($id <= 0) json_response(['ok'=>false,'error'=>'id_required'], 400); if ($name === '') json_response(['ok'=>false,'error'=>'name_required'], 400); if ($color === '') json_response(['ok'=>false,'error'=>'bad_color'], 400); if ($color[0] !== '#') $color = '#'.$color; if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) json_response(['ok'=>false,'error'=>'bad_color'], 400); try { $stmt = $pdo->prepare("SELECT photo, name, color FROM users WHERE id=?"); $stmt->execute([$id]); $old = $stmt->fetch(); if (!$old) json_response(['ok'=>false,'error'=>'user_not_found'], 404); $oldPhoto = (string)($old['photo'] ?? ''); $newPhoto = upload_user_photo('photo_file', $oldPhoto); $finalPhoto = $newPhoto !== null ? $newPhoto : ($oldPhoto !== '' ? $oldPhoto : null); $upd = $pdo->prepare("UPDATE users SET name=?, photo=?, color=? WHERE id=?"); $upd->execute([$name, $finalPhoto, $color, $id]); log_event($pdo, 'update_user', 'user', $id, $actorUserId, ['from'=>['name'=>$old['name'],'color'=>$old['color'],'photo'=>$oldPhoto],'to'=>['name'=>$name,'color'=>$color,'photo'=>$finalPhoto]], $today); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'update_user_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'delete_user') { $id = (int)($_POST['id'] ?? 0); if ($id <= 0) json_response(['ok'=>false,'error'=>'id_required'], 400); $pdo->beginTransaction(); try { $stmt = $pdo->prepare("SELECT photo FROM users WHERE id=?"); $stmt->execute([$id]); $urow = $stmt->fetch(); $photoPath = $urow ? (string)($urow['photo'] ?? '') : ''; $stmt = $pdo->prepare("SELECT id FROM habits WHERE user_id=?"); $stmt->execute([$id]); $hids = array_map(fn($r) => (int)$r['id'], $stmt->fetchAll()); if ($hids) { $del = $pdo->prepare("DELETE FROM habit_logs WHERE habit_id IN (".implode(',', array_fill(0, count($hids), '?')).")"); $del->execute($hids); } $del = $pdo->prepare("DELETE FROM habits WHERE user_id=?"); $del->execute([$id]); $del = $pdo->prepare("DELETE FROM task_users WHERE user_id=?"); $del->execute([$id]); $del = $pdo->prepare("DELETE FROM users WHERE id=?"); $del->execute([$id]); $pdo->commit(); if ($photoPath && str_starts_with($photoPath, 'uploads/users/')) { $oldFs = __DIR__.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $photoPath); if (is_file($oldFs)) { @unlink($oldFs); } } log_event($pdo, 'delete_user', 'user', $id, $actorUserId, [], $today); } catch (Throwable $e) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'delete_user_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'add_task') { $title = trim((string)($_POST['title'] ?? '')); $taskDateRaw = trim((string)($_POST['task_date'] ?? '')); $taskDate = $today; if ($taskDateRaw !== '') { if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $taskDateRaw)) json_response(['ok'=>false,'error'=>'bad_date'], 400); $ts = strtotime($taskDateRaw); if ($ts === false) json_response(['ok'=>false,'error'=>'bad_date'], 400); $taskDate = date('Y-m-d', $ts); } $categoryId = isset($_POST['category_id']) && $_POST['category_id'] !== '' ? (int)$_POST['category_id'] : null; $projectId = isset($_POST['project_id']) && $_POST['project_id'] !== '' ? (int)$_POST['project_id'] : null; $taskTimeRaw = trim((string)($_POST['task_time'] ?? '')); $taskTime = null; if ($taskTimeRaw !== '') { if (!preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $taskTimeRaw)) json_response(['ok'=>false,'error'=>'bad_time'], 400); $taskTime = $taskTimeRaw; } $userIdsRaw = (string)($_POST['user_ids'] ?? ''); $userIds = array_values(array_unique(array_filter(array_map('intval', explode(',', $userIdsRaw))))); if ($title === '') json_response(['ok'=>false,'error'=>'title_required'], 400); if (!$userIds) { $cnt = (int)$pdo->query("SELECT COUNT(*) AS c FROM users")->fetch()['c']; if ($cnt === 1) { $only = (int)$pdo->query("SELECT id FROM users ORDER BY id LIMIT 1")->fetch()['id']; $userIds = [$only]; } else { json_response(['ok'=>false,'error'=>'user_required'], 400); } } $pdo->beginTransaction(); try { if ($projectId !== null) { if ($categoryId === null) json_response(['ok'=>false,'error'=>'project_requires_category'], 400); $stmt = $pdo->prepare("SELECT category_id FROM projects WHERE id=?"); $stmt->execute([$projectId]); $pc = $stmt->fetch(); if (!$pc) json_response(['ok'=>false,'error'=>'project_not_found'], 404); $pCat = $pc['category_id'] !== null ? (int)$pc['category_id'] : null; if ($pCat === null || $pCat !== $categoryId) json_response(['ok'=>false,'error'=>'project_category_mismatch'], 400); } $stmt = $pdo->prepare("SELECT COALESCE(MAX(position), -1) AS m FROM tasks WHERE task_date = ?"); $stmt->execute([$taskDate]); $maxPos = (int)($stmt->fetch()['m'] ?? -1); $pos = $maxPos + 1; $ins = $pdo->prepare("INSERT INTO tasks (title, category_id, project_id, task_date, task_time, status, position, created_at) VALUES (?,?,?,?,?,?,?,NOW())"); $ins->execute([$title, $categoryId, $projectId, $taskDate, $taskTime, 'pending', $pos]); $taskId = (int)$pdo->lastInsertId(); $insTU = $pdo->prepare("INSERT INTO task_users (task_id, user_id) VALUES (?,?)"); foreach ($userIds as $uid) { $insTU->execute([$taskId, $uid]); } $pdo->commit(); log_event($pdo, 'add_task', 'task', $taskId, $actorUserId, ['title'=>$title,'category_id'=>$categoryId,'project_id'=>$projectId,'task_time'=>$taskTime,'user_ids'=>$userIds,'task_date'=>$taskDate], $today); } catch (Throwable $e) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'add_task_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'update_task') { $taskId = (int)($_POST['task_id'] ?? 0); $title = trim((string)($_POST['title'] ?? '')); $taskDateRaw = trim((string)($_POST['task_date'] ?? '')); $taskDate = null; if ($taskDateRaw !== '') { if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $taskDateRaw)) json_response(['ok'=>false,'error'=>'bad_date'], 400); $taskDate = $taskDateRaw; } $categoryId = isset($_POST['category_id']) && $_POST['category_id'] !== '' ? (int)$_POST['category_id'] : null; $projectId = isset($_POST['project_id']) && $_POST['project_id'] !== '' ? (int)$_POST['project_id'] : null; $taskTimeRaw = trim((string)($_POST['task_time'] ?? '')); $taskTime = null; if ($taskTimeRaw !== '') { if (!preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $taskTimeRaw)) json_response(['ok'=>false,'error'=>'bad_time'], 400); $taskTime = $taskTimeRaw; } $userIdsRaw = (string)($_POST['user_ids'] ?? ''); $userIds = array_values(array_unique(array_filter(array_map('intval', explode(',', $userIdsRaw))))); if ($taskId <= 0) json_response(['ok'=>false,'error'=>'task_required'], 400); if ($title === '') json_response(['ok'=>false,'error'=>'title_required'], 400); if (!$userIds) json_response(['ok'=>false,'error'=>'user_required'], 400); $pdo->beginTransaction(); try { if ($projectId !== null) { if ($categoryId === null) json_response(['ok'=>false,'error'=>'project_requires_category'], 400); $stmt = $pdo->prepare("SELECT category_id FROM projects WHERE id=?"); $stmt->execute([$projectId]); $pc = $stmt->fetch(); if (!$pc) json_response(['ok'=>false,'error'=>'project_not_found'], 404); $pCat = $pc['category_id'] !== null ? (int)$pc['category_id'] : null; if ($pCat === null || $pCat !== $categoryId) json_response(['ok'=>false,'error'=>'project_category_mismatch'], 400); } $stmt = $pdo->prepare("SELECT title, category_id, project_id, task_time, task_date, position FROM tasks WHERE id=?"); $stmt->execute([$taskId]); $old = $stmt->fetch(); if (!$old) json_response(['ok'=>false,'error'=>'task_not_found'], 404); $stmt = $pdo->prepare("SELECT user_id FROM task_users WHERE task_id=? ORDER BY user_id"); $stmt->execute([$taskId]); $oldUserIds = array_map(fn($r) => (int)$r['user_id'], $stmt->fetchAll()); $newTaskDate = $taskDate !== null ? $taskDate : (string)$old['task_date']; $newPos = $old['position']; if ($taskDate !== null && (string)$old['task_date'] !== $newTaskDate) { $stmt = $pdo->prepare("SELECT COALESCE(MAX(position), -1) AS m FROM tasks WHERE task_date = ?"); $stmt->execute([$newTaskDate]); $maxPos = (int)($stmt->fetch()['m'] ?? -1); $newPos = $maxPos + 1; } $upd = $pdo->prepare("UPDATE tasks SET title=?, category_id=?, project_id=?, task_time=?, task_date=?, position=? WHERE id=?"); $upd->execute([$title, $categoryId, $projectId, $taskTime, $newTaskDate, $newPos, $taskId]); $del = $pdo->prepare("DELETE FROM task_users WHERE task_id=?"); $del->execute([$taskId]); $ins = $pdo->prepare("INSERT INTO task_users (task_id, user_id) VALUES (?,?)"); foreach ($userIds as $uid) { $ins->execute([$taskId, $uid]); } $pdo->commit(); log_event($pdo, 'update_task', 'task', $taskId, $actorUserId, [ 'from'=>['title'=>$old['title'],'category_id'=>$old['category_id'],'project_id'=>$old['project_id'],'task_time'=>$old['task_time'],'task_date'=>$old['task_date'],'user_ids'=>$oldUserIds], 'to'=>['title'=>$title,'category_id'=>$categoryId,'project_id'=>$projectId,'task_time'=>$taskTime,'task_date'=>$newTaskDate,'user_ids'=>$userIds] ], $today); } catch (Throwable $e) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'update_task_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'add_project') { $name = trim((string)($_POST['name'] ?? '')); $color = trim((string)($_POST['color'] ?? '')); $categoryId = (int)($_POST['category_id'] ?? 0); if ($name === '') json_response(['ok'=>false,'error'=>'name_required'], 400); if ($categoryId <= 0) json_response(['ok'=>false,'error'=>'category_required'], 400); if ($color !== '') { if ($color[0] !== '#') $color = '#'.$color; if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) json_response(['ok'=>false,'error'=>'bad_color'], 400); } else { $color = null; } try { $ins = $pdo->prepare("INSERT INTO projects (category_id, name, color) VALUES (?,?,?)"); $ins->execute([$categoryId, $name, $color]); $pid = (int)$pdo->lastInsertId(); log_event($pdo, 'add_project', 'project', $pid, $actorUserId, ['name'=>$name,'color'=>$color,'category_id'=>$categoryId], $today); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'add_project_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'update_project') { $id = (int)($_POST['id'] ?? 0); $name = trim((string)($_POST['name'] ?? '')); $color = trim((string)($_POST['color'] ?? '')); $categoryId = (int)($_POST['category_id'] ?? 0); if ($id <= 0) json_response(['ok'=>false,'error'=>'id_required'], 400); if ($name === '') json_response(['ok'=>false,'error'=>'name_required'], 400); if ($categoryId <= 0) json_response(['ok'=>false,'error'=>'category_required'], 400); if ($color !== '') { if ($color[0] !== '#') $color = '#'.$color; if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) json_response(['ok'=>false,'error'=>'bad_color'], 400); } else { $color = null; } try { $stmt = $pdo->prepare("SELECT category_id, name, color FROM projects WHERE id=?"); $stmt->execute([$id]); $old = $stmt->fetch(); if (!$old) json_response(['ok'=>false,'error'=>'project_not_found'], 404); $upd = $pdo->prepare("UPDATE projects SET category_id=?, name=?, color=? WHERE id=?"); $upd->execute([$categoryId, $name, $color, $id]); if (((int)($old['category_id'] ?? 0)) !== $categoryId) { $sync = $pdo->prepare("UPDATE tasks SET category_id=? WHERE project_id=?"); $sync->execute([$categoryId, $id]); } log_event($pdo, 'update_project', 'project', $id, $actorUserId, ['from'=>$old,'to'=>['category_id'=>$categoryId,'name'=>$name,'color'=>$color]], $today); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'update_project_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'delete_project') { $id = (int)($_POST['id'] ?? 0); if ($id <= 0) json_response(['ok'=>false,'error'=>'id_required'], 400); $pdo->beginTransaction(); try { $stmt = $pdo->prepare("SELECT name FROM projects WHERE id=?"); $stmt->execute([$id]); $old = $stmt->fetch(); if (!$old) json_response(['ok'=>false,'error'=>'project_not_found'], 404); $nullIt = $pdo->prepare("UPDATE tasks SET project_id=NULL WHERE project_id=?"); $nullIt->execute([$id]); $del = $pdo->prepare("DELETE FROM projects WHERE id=?"); $del->execute([$id]); $pdo->commit(); log_event($pdo, 'delete_project', 'project', $id, $actorUserId, ['name'=>$old['name']], $today); } catch (Throwable $e) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'delete_project_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'get_achieved_month') { $month = trim((string)($_POST['month'] ?? '')); $projectId = isset($_POST['project_id']) && $_POST['project_id'] !== '' ? (int)$_POST['project_id'] : null; if (!preg_match('/^\d{4}-\d{2}$/', $month)) json_response(['ok'=>false,'error'=>'bad_month'], 400); $start = $month.'-01'; $end = date('Y-m-d', strtotime($start.' +1 month')); try { $sql = " SELECT t.task_date AS d, SUM(t.status='pending') AS pending, SUM(t.status='progress') AS progress, SUM(t.status='done') AS done, COUNT(*) AS total FROM tasks t WHERE t.task_date >= ? AND t.task_date < ? "; $params = [$start, $end]; $filterUserId = $actorUserId !== null ? (int)$actorUserId : 0; if ($filterUserId > 0) { $sql .= " AND EXISTS (SELECT 1 FROM task_users tu WHERE tu.task_id=t.id AND tu.user_id=?) "; $params[] = $filterUserId; } if ($projectId !== null) { $sql .= " AND t.project_id = ? "; $params[] = $projectId; } $sql .= " GROUP BY t.task_date ORDER BY d "; $stmt = $pdo->prepare($sql); $stmt->execute($params); $rows = $stmt->fetchAll(); $map = []; foreach ($rows as $r) { $map[(string)$r['d']] = [ 'pending'=>(int)$r['pending'], 'progress'=>(int)$r['progress'], 'done'=>(int)$r['done'], 'total'=>(int)$r['total'], ]; } json_response(['ok'=>true,'days'=>$map]); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'schema_missing'], 500); } } if ($action === 'get_achieved_day') { $day = trim((string)($_POST['day'] ?? '')); $projectId = isset($_POST['project_id']) && $_POST['project_id'] !== '' ? (int)$_POST['project_id'] : null; if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $day)) json_response(['ok'=>false,'error'=>'bad_day'], 400); try { $sql = " SELECT t.id, t.title, t.category_id, t.project_id, t.status, t.task_date, t.task_time, t.achieved_at, t.closure_outcome, t.closure_notes, t.closure_satisfaction, c.name AS category_name, p.name AS project_name, p.color AS project_color, GROUP_CONCAT(DISTINCT u.id ORDER BY u.id) AS user_ids, GROUP_CONCAT(DISTINCT u.color ORDER BY u.id) AS user_colors FROM tasks t LEFT JOIN categories c ON c.id = t.category_id LEFT JOIN projects p ON p.id = t.project_id LEFT JOIN task_users tu ON tu.task_id = t.id LEFT JOIN users u ON u.id = tu.user_id WHERE t.task_date = ? "; $params = [$day]; $filterUserId = $actorUserId !== null ? (int)$actorUserId : 0; if ($filterUserId > 0) { $sql .= " AND EXISTS (SELECT 1 FROM task_users tu2 WHERE tu2.task_id=t.id AND tu2.user_id=?) "; $params[] = $filterUserId; } if ($projectId !== null) { $sql .= " AND t.project_id = ? "; $params[] = $projectId; } $sql .= " GROUP BY t.id ORDER BY (t.task_time IS NULL), t.task_time, t.id DESC "; $stmt = $pdo->prepare($sql); $stmt->execute($params); $rows = $stmt->fetchAll(); json_response(['ok'=>true,'tasks'=>$rows]); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'schema_missing'], 500); } } if ($action === 'delete_task') { $taskId = (int)($_POST['task_id'] ?? 0); if ($taskId <= 0) json_response(['ok'=>false,'error'=>'task_required'], 400); $pdo->beginTransaction(); try { $stmt = $pdo->prepare("SELECT title FROM tasks WHERE id=?"); $stmt->execute([$taskId]); $old = $stmt->fetch(); if (!$old) json_response(['ok'=>false,'error'=>'task_not_found'], 404); $del = $pdo->prepare("DELETE FROM task_users WHERE task_id=?"); $del->execute([$taskId]); $del = $pdo->prepare("DELETE FROM task_logs WHERE task_id=?"); $del->execute([$taskId]); $del = $pdo->prepare("DELETE FROM tasks WHERE id=?"); $del->execute([$taskId]); $pdo->commit(); log_event($pdo, 'delete_task', 'task', $taskId, $actorUserId, ['title'=>$old['title']], $today); } catch (Throwable $e) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'delete_task_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'update_task_status') { $taskId = (int)($_POST['task_id'] ?? 0); $status = (string)($_POST['status'] ?? ''); $allowed = ['pending','progress','done']; if ($taskId <= 0) json_response(['ok'=>false,'error'=>'task_required'], 400); if (!in_array($status, $allowed, true)) json_response(['ok'=>false,'error'=>'bad_status'], 400); if ($status === 'done') json_response(['ok'=>false,'error'=>'use_close_task'], 400); try { $stmt = $pdo->prepare("SELECT status FROM tasks WHERE id=?"); $stmt->execute([$taskId]); $old = $stmt->fetch(); $oldStatus = $old ? (string)$old['status'] : null; if ($oldStatus === 'done' && $status !== 'done') { try { $upd = $pdo->prepare("UPDATE tasks SET status=?, achieved_at=NULL, closure_outcome=NULL, closure_notes=NULL, closure_satisfaction=NULL, closure_actor_user_id=NULL WHERE id=?"); $upd->execute([$status, $taskId]); } catch (Throwable $e) { $upd = $pdo->prepare("UPDATE tasks SET status=? WHERE id=?"); $upd->execute([$status, $taskId]); } } else { $upd = $pdo->prepare("UPDATE tasks SET status=? WHERE id=?"); $upd->execute([$status, $taskId]); } upsert_task_log($pdo, $taskId, $today, $status); log_event($pdo, 'update_task_status', 'task', $taskId, $actorUserId, ['from'=>$oldStatus,'to'=>$status], $today); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'update_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'close_task') { $taskId = (int)($_POST['task_id'] ?? 0); $outcome = (string)($_POST['outcome'] ?? ''); $rerunWhen = (string)($_POST['rerun_when'] ?? ''); $notes = trim((string)($_POST['notes'] ?? '')); $satisfactionRaw = (string)($_POST['satisfaction'] ?? ''); $satisfaction = $satisfactionRaw !== '' ? (int)$satisfactionRaw : null; if ($taskId <= 0) json_response(['ok'=>false,'error'=>'task_required'], 400); if (!in_array($outcome, ['final','rerun'], true)) json_response(['ok'=>false,'error'=>'bad_outcome'], 400); if ($outcome === 'rerun' && !in_array($rerunWhen, ['today','tomorrow'], true)) json_response(['ok'=>false,'error'=>'bad_rerun_when'], 400); if ($satisfaction !== null && ($satisfaction < 1 || $satisfaction > 5)) json_response(['ok'=>false,'error'=>'bad_satisfaction'], 400); $pdo->beginTransaction(); try { $stmt = $pdo->prepare("SELECT t.id, t.title, t.category_id, t.project_id, p.category_id AS project_category_id, t.task_date, t.task_time, t.status FROM tasks t LEFT JOIN projects p ON p.id=t.project_id WHERE t.id=? LIMIT 1"); $stmt->execute([$taskId]); $t = $stmt->fetch(); if (!$t) json_response(['ok'=>false,'error'=>'task_not_found'], 404); $getUsers = $pdo->prepare("SELECT user_id FROM task_users WHERE task_id=? ORDER BY user_id"); $getUsers->execute([$taskId]); $uids = array_map(fn($r) => (int)$r['user_id'], $getUsers->fetchAll()); try { $achievedAt = date('Y-m-d H:i:s'); $upd = $pdo->prepare("UPDATE tasks SET status='done', achieved_at=?, closure_outcome=?, closure_notes=?, closure_satisfaction=?, closure_actor_user_id=? WHERE id=?"); $upd->execute([$achievedAt, $outcome, $notes !== '' ? $notes : null, $satisfaction, $actorUserId, $taskId]); } catch (Throwable $e) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'schema_missing'], 500); } upsert_task_log($pdo, $taskId, $today, 'done'); $newTaskId = null; $newTaskDate = null; if ($outcome === 'rerun') { $newTaskDate = $rerunWhen === 'tomorrow' ? date('Y-m-d', strtotime($today.' +1 day')) : $today; $catForNew = $t['category_id'] !== null ? (int)$t['category_id'] : null; if ($t['project_id'] !== null && $t['project_category_id'] !== null) $catForNew = (int)$t['project_category_id']; $pidForNew = $t['project_id'] !== null ? (int)$t['project_id'] : null; $stmt = $pdo->prepare("SELECT COALESCE(MAX(position), -1) AS m FROM tasks WHERE task_date = ?"); $stmt->execute([$newTaskDate]); $maxPos = (int)($stmt->fetch()['m'] ?? -1); $pos = $maxPos + 1; try { $insTask = $pdo->prepare("INSERT INTO tasks (title, category_id, project_id, task_date, task_time, status, position, created_at, origin_task_id) VALUES (?,?,?,?,?,?,?,NOW(),?)"); $insTask->execute([(string)$t['title'], $catForNew, $pidForNew, $newTaskDate, $t['task_time'], 'pending', $pos, $taskId]); $newTaskId = (int)$pdo->lastInsertId(); } catch (Throwable $e) { $insTask = $pdo->prepare("INSERT INTO tasks (title, category_id, project_id, task_date, task_time, status, position, created_at) VALUES (?,?,?,?,?,?,?,NOW())"); $insTask->execute([(string)$t['title'], $catForNew, $pidForNew, $newTaskDate, $t['task_time'], 'pending', $pos]); $newTaskId = (int)$pdo->lastInsertId(); } $insTU = $pdo->prepare("INSERT INTO task_users (task_id, user_id) VALUES (?,?)"); foreach ($uids as $uid) { $insTU->execute([$newTaskId, $uid]); } } $pdo->commit(); log_event($pdo, 'close_task', 'task', $taskId, $actorUserId, [ 'outcome'=>$outcome, 'rerun_when'=>$outcome==='rerun' ? $rerunWhen : null, 'new_task_id'=>$newTaskId, 'new_task_date'=>$newTaskDate, 'satisfaction'=>$satisfaction, 'notes'=>$notes !== '' ? $notes : null ], $today); } catch (Throwable $e) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'close_task_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'add_category') { $name = trim((string)($_POST['name'] ?? '')); if ($name === '') json_response(['ok'=>false,'error'=>'name_required'], 400); try { $ins = $pdo->prepare("INSERT INTO categories (name) VALUES (?)"); $ins->execute([$name]); $cid = (int)$pdo->lastInsertId(); log_event($pdo, 'add_category', 'category', $cid, $actorUserId, ['name'=>$name], $today); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'add_category_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'update_category') { $id = (int)($_POST['id'] ?? 0); $name = trim((string)($_POST['name'] ?? '')); if ($id <= 0) json_response(['ok'=>false,'error'=>'id_required'], 400); if ($name === '') json_response(['ok'=>false,'error'=>'name_required'], 400); try { $upd = $pdo->prepare("UPDATE categories SET name=? WHERE id=?"); $upd->execute([$name, $id]); log_event($pdo, 'update_category', 'category', $id, $actorUserId, ['name'=>$name], $today); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'update_category_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'delete_category') { $id = (int)($_POST['id'] ?? 0); if ($id <= 0) json_response(['ok'=>false,'error'=>'id_required'], 400); $pdo->beginTransaction(); try { $stmt = $pdo->prepare("SELECT COUNT(*) AS c FROM projects WHERE category_id=?"); $stmt->execute([$id]); $cnt = (int)($stmt->fetch()['c'] ?? 0); if ($cnt > 0) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'category_has_projects'], 400); } $nul = $pdo->prepare("UPDATE tasks SET category_id=NULL WHERE category_id=?"); $nul->execute([$id]); $del = $pdo->prepare("DELETE FROM categories WHERE id=?"); $del->execute([$id]); $pdo->commit(); log_event($pdo, 'delete_category', 'category', $id, $actorUserId, [], $today); } catch (Throwable $e) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'delete_category_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'add_habit') { $userId = (int)($_POST['user_id'] ?? 0); $name = trim((string)($_POST['name'] ?? '')); $daysRaw = (string)($_POST['days'] ?? ''); $days = array_values(array_unique(array_filter(array_map('intval', explode(',', $daysRaw))))); $days = array_values(array_filter($days, fn($d) => $d >= 1 && $d <= 7)); if ($userId <= 0) json_response(['ok'=>false,'error'=>'user_required'], 400); if ($name === '') json_response(['ok'=>false,'error'=>'name_required'], 400); if (!$days) json_response(['ok'=>false,'error'=>'days_required'], 400); try { $ins = $pdo->prepare("INSERT INTO habits (user_id, name, days) VALUES (?,?,?)"); $ins->execute([$userId, $name, implode(',', $days)]); $hid = (int)$pdo->lastInsertId(); log_event($pdo, 'add_habit', 'habit', $hid, $actorUserId, ['user_id'=>$userId,'name'=>$name,'days'=>$days], $today); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'add_habit_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'update_habit') { $habitId = (int)($_POST['habit_id'] ?? 0); $name = trim((string)($_POST['name'] ?? '')); $daysRaw = (string)($_POST['days'] ?? ''); $days = array_values(array_unique(array_filter(array_map('intval', explode(',', $daysRaw))))); $days = array_values(array_filter($days, fn($d) => $d >= 1 && $d <= 7)); if ($habitId <= 0) json_response(['ok'=>false,'error'=>'habit_required'], 400); if ($name === '') json_response(['ok'=>false,'error'=>'name_required'], 400); if (!$days) json_response(['ok'=>false,'error'=>'days_required'], 400); try { $stmt = $pdo->prepare("SELECT user_id, name, days FROM habits WHERE id=?"); $stmt->execute([$habitId]); $old = $stmt->fetch(); if (!$old) json_response(['ok'=>false,'error'=>'habit_not_found'], 404); $upd = $pdo->prepare("UPDATE habits SET name=?, days=? WHERE id=?"); $upd->execute([$name, implode(',', $days), $habitId]); log_event($pdo, 'update_habit', 'habit', $habitId, $actorUserId, [ 'from'=>['user_id'=>$old['user_id'],'name'=>$old['name'],'days'=>$old['days']], 'to'=>['name'=>$name,'days'=>$days] ], $today); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'update_habit_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'delete_habit') { $habitId = (int)($_POST['habit_id'] ?? 0); if ($habitId <= 0) json_response(['ok'=>false,'error'=>'habit_required'], 400); $pdo->beginTransaction(); try { $stmt = $pdo->prepare("SELECT user_id, name, days FROM habits WHERE id=?"); $stmt->execute([$habitId]); $old = $stmt->fetch(); if (!$old) json_response(['ok'=>false,'error'=>'habit_not_found'], 404); $del = $pdo->prepare("DELETE FROM habit_logs WHERE habit_id=?"); $del->execute([$habitId]); $del = $pdo->prepare("DELETE FROM habits WHERE id=?"); $del->execute([$habitId]); $pdo->commit(); log_event($pdo, 'delete_habit', 'habit', $habitId, $actorUserId, ['user_id'=>$old['user_id'],'name'=>$old['name']], $today); } catch (Throwable $e) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'delete_habit_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'toggle_habit') { $habitId = (int)($_POST['habit_id'] ?? 0); if ($habitId <= 0) json_response(['ok'=>false,'error'=>'habit_required'], 400); $stmt = $pdo->prepare("SELECT id, days FROM habits WHERE id=?"); $stmt->execute([$habitId]); $habit = $stmt->fetch(); if (!$habit) json_response(['ok'=>false,'error'=>'habit_not_found'], 404); $days = array_values(array_filter(array_map('intval', explode(',', (string)$habit['days'])))); $dow = (int)date('N'); if (!in_array($dow, $days, true)) json_response(['ok'=>false,'error'=>'not_active_today'], 400); $stmt = $pdo->prepare("SELECT id, completed FROM habit_logs WHERE habit_id=? AND log_date=? LIMIT 1"); $stmt->execute([$habitId, $today]); $row = $stmt->fetch(); try { if ($row) { $new = ((int)$row['completed'] === 1) ? 0 : 1; $upd = $pdo->prepare("UPDATE habit_logs SET completed=? WHERE id=?"); $upd->execute([$new, (int)$row['id']]); log_event($pdo, 'toggle_habit', 'habit', $habitId, $actorUserId, ['completed'=>$new,'log_date'=>$today], $today); json_response(['ok'=>true,'completed'=>$new]); } $ins = $pdo->prepare("INSERT INTO habit_logs (habit_id, log_date, completed) VALUES (?,?,1)"); $ins->execute([$habitId, $today]); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'toggle_failed'], 500); } log_event($pdo, 'toggle_habit', 'habit', $habitId, $actorUserId, ['completed'=>1,'log_date'=>$today], $today); json_response(['ok'=>true,'completed'=>1]); } if ($action === 'get_finance') { $day = trim((string)($_POST['day'] ?? $today)); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $day)) json_response(['ok'=>false,'error'=>'bad_day'], 400); $userFilter = isset($_POST['user_id']) && $_POST['user_id'] !== '' ? (int)$_POST['user_id'] : 0; if ($userFilter <= 0 && $actorUserId !== null) $userFilter = (int)$actorUserId; $accParams = []; $accWhere = " WHERE a.active=1 "; if ($userFilter > 0) { $accWhere .= " AND (a.shared_all=1 OR EXISTS (SELECT 1 FROM finance_account_users au WHERE au.account_id=a.id AND au.user_id=?)) "; $accParams[] = $userFilter; } $accSql = " SELECT a.id, a.name, a.type, a.currency_code, a.opening_balance, a.shared_all, COALESCE(SUM( CASE WHEN m.type='income' AND m.account_id=a.id THEN m.amount WHEN m.type='expense' AND m.account_id=a.id THEN -m.amount WHEN m.type='transfer' AND m.account_id=a.id THEN -m.amount WHEN m.type='transfer' AND m.to_account_id=a.id THEN COALESCE(m.to_amount, m.amount) ELSE 0 END ),0) AS delta FROM finance_accounts a LEFT JOIN finance_moves m ON (m.account_id=a.id OR m.to_account_id=a.id) $accWhere GROUP BY a.id ORDER BY a.name "; $stmt = $pdo->prepare($accSql); $stmt->execute($accParams); $accounts = $stmt->fetchAll(); $accIds = array_map(fn($r)=> (int)$r['id'], $accounts); $accUsersMap = []; if ($accIds) { $stmt = $pdo->prepare("SELECT account_id, user_id FROM finance_account_users WHERE account_id IN (".implode(',', array_fill(0, count($accIds), '?')).")"); $stmt->execute($accIds); foreach ($stmt->fetchAll() as $r) { $aid = (int)$r['account_id']; if (!isset($accUsersMap[$aid])) $accUsersMap[$aid] = []; $accUsersMap[$aid][] = (int)$r['user_id']; } } foreach ($accounts as &$a) { $a['opening_balance'] = (float)$a['opening_balance']; $a['delta'] = (float)$a['delta']; $a['balance'] = (float)$a['opening_balance'] + (float)$a['delta']; $a['shared_all'] = (int)$a['shared_all']; $a['users'] = $accUsersMap[(int)$a['id']] ?? []; } unset($a); $catParams = []; $catWhere = " WHERE active=1 "; if ($userFilter > 0) { $catWhere .= " AND (owner_user_id IS NULL OR owner_user_id=?) "; $catParams[] = $userFilter; } $stmt = $pdo->prepare("SELECT id, owner_user_id, kind, name FROM finance_categories $catWhere ORDER BY name"); $stmt->execute($catParams); $finCats = $stmt->fetchAll(); $moves = []; if ($accIds) { $mp = [$day]; $where = " WHERE m.move_date=? "; if ($userFilter > 0) { $where .= " AND m.user_id=? "; $mp[] = $userFilter; } $where .= " AND (m.account_id IN (".implode(',', array_fill(0, count($accIds), '?')).") OR (m.to_account_id IS NOT NULL AND m.to_account_id IN (".implode(',', array_fill(0, count($accIds), '?'))."))) "; $mp = array_merge($mp, $accIds, $accIds); $sql = " SELECT m.id, m.move_date, m.type, m.user_id, m.account_id, m.to_account_id, m.amount, m.currency_code, m.to_amount, m.to_currency_code, m.category_id, m.note, m.created_at, a.name AS account_name, ta.name AS to_account_name, c.name AS category_name, u.name AS user_name FROM finance_moves m JOIN finance_accounts a ON a.id=m.account_id LEFT JOIN finance_accounts ta ON ta.id=m.to_account_id LEFT JOIN finance_categories c ON c.id=m.category_id LEFT JOIN users u ON u.id=m.user_id $where ORDER BY m.move_date DESC, m.created_at DESC, m.id DESC "; $stmt = $pdo->prepare($sql); $stmt->execute($mp); $moves = $stmt->fetchAll(); } $debtParams = []; $debtWhere = " WHERE 1=1 "; if ($userFilter > 0) { $debtWhere .= " AND d.owner_user_id=? "; $debtParams[] = $userFilter; } $sql = " SELECT d.id, d.owner_user_id, d.kind, d.person, d.currency_code, d.amount, d.start_date, d.status, d.note, d.created_at, COALESCE(SUM(p.amount),0) AS paid FROM finance_debts d LEFT JOIN finance_debt_payments p ON p.debt_id=d.id $debtWhere GROUP BY d.id ORDER BY (d.status='open') DESC, d.start_date DESC, d.id DESC "; $stmt = $pdo->prepare($sql); $stmt->execute($debtParams); $debts = $stmt->fetchAll(); foreach ($debts as &$d) { $d['amount'] = (float)$d['amount']; $d['paid'] = (float)$d['paid']; $d['outstanding'] = (float)$d['amount'] - (float)$d['paid']; } unset($d); $summary = []; foreach ($accounts as $a) { $ccy = (string)$a['currency_code']; if (!isset($summary[$ccy])) $summary[$ccy] = ['currency'=>$ccy,'balance'=>0.0,'income_today'=>0.0,'expense_today'=>0.0]; $summary[$ccy]['balance'] += (float)$a['balance']; } foreach ($moves as $m) { $t = (string)($m['type'] ?? ''); if ($t !== 'income' && $t !== 'expense') continue; $ccy = (string)$m['currency_code']; if (!isset($summary[$ccy])) $summary[$ccy] = ['currency'=>$ccy,'balance'=>0.0,'income_today'=>0.0,'expense_today'=>0.0]; if ($t === 'income') $summary[$ccy]['income_today'] += (float)$m['amount']; if ($t === 'expense') $summary[$ccy]['expense_today'] += (float)$m['amount']; } $summary = array_values($summary); json_response(['ok'=>true,'day'=>$day,'user_id'=>$userFilter,'summary'=>$summary,'accounts'=>$accounts,'categories'=>$finCats,'moves'=>$moves,'debts'=>$debts]); } if ($action === 'add_fin_account') { $name = trim((string)($_POST['name'] ?? '')); $type = trim((string)($_POST['type'] ?? '')); $currency = fin_norm_currency((string)($_POST['currency_code'] ?? '')); $openingRaw = trim((string)($_POST['opening_balance'] ?? '0')); $sharedAll = (int)($_POST['shared_all'] ?? 0) === 1 ? 1 : 0; $userIdsRaw = (string)($_POST['user_ids'] ?? ''); $userIds = array_values(array_unique(array_filter(array_map('intval', explode(',', $userIdsRaw))))); if ($name === '') json_response(['ok'=>false,'error'=>'name_required'], 400); if (!fin_valid_currency($currency)) json_response(['ok'=>false,'error'=>'bad_currency'], 400); if ($openingRaw === '' || !is_numeric($openingRaw)) json_response(['ok'=>false,'error'=>'bad_amount'], 400); $opening = round((float)$openingRaw, 2); if (!$sharedAll && !$userIds) { if ($actorUserId !== null) $userIds = [(int)$actorUserId]; else json_response(['ok'=>false,'error'=>'user_required'], 400); } $pdo->beginTransaction(); try { $ins = $pdo->prepare("INSERT INTO finance_accounts (name, type, currency_code, opening_balance, shared_all, active) VALUES (?,?,?,?,?,1)"); $ins->execute([$name, $type !== '' ? $type : null, $currency, $opening, $sharedAll]); $aid = (int)$pdo->lastInsertId(); if (!$sharedAll) { $insAu = $pdo->prepare("INSERT INTO finance_account_users (account_id, user_id, role) VALUES (?,?,NULL)"); foreach ($userIds as $uid) { $insAu->execute([$aid, (int)$uid]); } } $pdo->commit(); log_event($pdo, 'add_fin_account', 'finance_account', $aid, $actorUserId, ['name'=>$name,'type'=>$type,'currency_code'=>$currency,'opening_balance'=>$opening,'shared_all'=>$sharedAll,'user_ids'=>$userIds], $today); } catch (Throwable $e) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'add_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'delete_fin_account') { $id = (int)($_POST['id'] ?? 0); if ($id <= 0) json_response(['ok'=>false,'error'=>'id_required'], 400); $pdo->beginTransaction(); try { $stmt = $pdo->prepare("SELECT name FROM finance_accounts WHERE id=?"); $stmt->execute([$id]); $old = $stmt->fetch(); if (!$old) json_response(['ok'=>false,'error'=>'not_found'], 404); $cnt = 0; try { $stmt = $pdo->prepare("SELECT COUNT(*) AS c FROM finance_moves WHERE account_id=? OR to_account_id=?"); $stmt->execute([$id, $id]); $cnt += (int)($stmt->fetch()['c'] ?? 0); } catch (Throwable $e) { } try { $stmt = $pdo->prepare("SELECT COUNT(*) AS c FROM finance_debt_payments WHERE account_id=?"); $stmt->execute([$id]); $cnt += (int)($stmt->fetch()['c'] ?? 0); } catch (Throwable $e) { } $pdo->prepare("DELETE FROM finance_account_users WHERE account_id=?")->execute([$id]); if ($cnt > 0) { $pdo->prepare("UPDATE finance_accounts SET active=0 WHERE id=?")->execute([$id]); } else { $pdo->prepare("DELETE FROM finance_accounts WHERE id=?")->execute([$id]); } $pdo->commit(); log_event($pdo, 'delete_fin_account', 'finance_account', $id, $actorUserId, ['name'=>$old['name'],'archived'=>($cnt>0)], $today); } catch (Throwable $e) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'delete_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'add_fin_category') { $name = trim((string)($_POST['name'] ?? '')); $kind = trim((string)($_POST['kind'] ?? 'both')); $owner = null; if (isset($_POST['owner_user_id']) && $_POST['owner_user_id'] !== '') { $v = (int)$_POST['owner_user_id']; if ($v > 0) $owner = $v; } $allowed = ['income','expense','both']; if ($name === '') json_response(['ok'=>false,'error'=>'name_required'], 400); if (!in_array($kind, $allowed, true)) json_response(['ok'=>false,'error'=>'bad_kind'], 400); try { $ins = $pdo->prepare("INSERT INTO finance_categories (owner_user_id, kind, name, active) VALUES (?,?,?,1)"); $ins->execute([$owner, $kind, $name]); $cid = (int)$pdo->lastInsertId(); log_event($pdo, 'add_fin_category', 'finance_category', $cid, $actorUserId, ['owner_user_id'=>$owner,'kind'=>$kind,'name'=>$name], $today); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'add_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'delete_fin_category') { $id = (int)($_POST['id'] ?? 0); if ($id <= 0) json_response(['ok'=>false,'error'=>'id_required'], 400); $pdo->beginTransaction(); try { $stmt = $pdo->prepare("SELECT name FROM finance_categories WHERE id=?"); $stmt->execute([$id]); $old = $stmt->fetch(); if (!$old) json_response(['ok'=>false,'error'=>'not_found'], 404); $cnt = 0; try { $stmt = $pdo->prepare("SELECT COUNT(*) AS c FROM finance_moves WHERE category_id=?"); $stmt->execute([$id]); $cnt = (int)($stmt->fetch()['c'] ?? 0); } catch (Throwable $e) { } if ($cnt > 0) { $pdo->prepare("UPDATE finance_categories SET active=0 WHERE id=?")->execute([$id]); } else { $pdo->prepare("DELETE FROM finance_categories WHERE id=?")->execute([$id]); } $pdo->commit(); log_event($pdo, 'delete_fin_category', 'finance_category', $id, $actorUserId, ['name'=>$old['name'],'archived'=>($cnt>0)], $today); } catch (Throwable $e) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'delete_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'add_fin_move') { $moveDate = trim((string)($_POST['move_date'] ?? $today)); $moveTime = trim((string)($_POST['move_time'] ?? '')); $type = trim((string)($_POST['type'] ?? '')); $userId = (int)($_POST['user_id'] ?? 0); $accountId = (int)($_POST['account_id'] ?? 0); $toAccountId = isset($_POST['to_account_id']) && $_POST['to_account_id'] !== '' ? (int)$_POST['to_account_id'] : null; $amountRaw = trim((string)($_POST['amount'] ?? '')); $toAmountRaw = trim((string)($_POST['to_amount'] ?? '')); $categoryId = isset($_POST['category_id']) && $_POST['category_id'] !== '' ? (int)$_POST['category_id'] : null; $note = trim((string)($_POST['note'] ?? '')); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $moveDate)) json_response(['ok'=>false,'error'=>'bad_day'], 400); if ($moveTime !== '') { if (!preg_match('/^\d{2}:\d{2}$/', $moveTime)) json_response(['ok'=>false,'error'=>'bad_time'], 400); $th = (int)substr($moveTime,0,2); $tm = (int)substr($moveTime,3,2); if (!($th>=0 && $th<=23 && $tm>=0 && $tm<=59)) json_response(['ok'=>false,'error'=>'bad_time'], 400); } if (!in_array($type, ['income','expense','transfer'], true)) json_response(['ok'=>false,'error'=>'bad_type'], 400); if ($userId <= 0) json_response(['ok'=>false,'error'=>'user_required'], 400); if ($accountId <= 0) json_response(['ok'=>false,'error'=>'account_required'], 400); if ($amountRaw === '' || !is_numeric($amountRaw)) json_response(['ok'=>false,'error'=>'bad_amount'], 400); $amount = round((float)$amountRaw, 2); if (!($amount > 0)) json_response(['ok'=>false,'error'=>'bad_amount'], 400); if (!fin_user_can_use_account($pdo, $userId, $accountId)) json_response(['ok'=>false,'error'=>'forbidden_account'], 403); $currency = fin_account_currency($pdo, $accountId); if (!$currency) json_response(['ok'=>false,'error'=>'account_not_found'], 404); $toCurrency = null; $toAmount = null; if ($type === 'transfer') { if ($toAccountId === null || $toAccountId <= 0) json_response(['ok'=>false,'error'=>'to_account_required'], 400); if ($toAccountId === $accountId) json_response(['ok'=>false,'error'=>'same_account'], 400); if (!fin_user_can_use_account($pdo, $userId, $toAccountId)) json_response(['ok'=>false,'error'=>'forbidden_to_account'], 403); $toCurrency = fin_account_currency($pdo, $toAccountId); if (!$toCurrency) json_response(['ok'=>false,'error'=>'to_account_not_found'], 404); if ($toCurrency === $currency) { $toAmount = null; } else { if ($toAmountRaw === '' || !is_numeric($toAmountRaw)) json_response(['ok'=>false,'error'=>'to_amount_required'], 400); $toAmount = round((float)$toAmountRaw, 2); if (!($toAmount > 0)) json_response(['ok'=>false,'error'=>'to_amount_required'], 400); } } else { $toAccountId = null; } if ($categoryId !== null) { $stmt = $pdo->prepare("SELECT kind, active FROM finance_categories WHERE id=?"); $stmt->execute([$categoryId]); $cat = $stmt->fetch(); if (!$cat || ((int)($cat['active'] ?? 1) !== 1)) json_response(['ok'=>false,'error'=>'category_not_found'], 404); $k = (string)($cat['kind'] ?? 'both'); if ($type === 'income' && !in_array($k, ['income','both'], true)) json_response(['ok'=>false,'error'=>'category_kind_mismatch'], 400); if ($type === 'expense' && !in_array($k, ['expense','both'], true)) json_response(['ok'=>false,'error'=>'category_kind_mismatch'], 400); if ($type === 'transfer') $categoryId = null; } try { if ($moveTime !== '') { $createdAt = $moveDate.' '.$moveTime.':00'; $ins = $pdo->prepare("INSERT INTO finance_moves (move_date, type, user_id, account_id, to_account_id, amount, currency_code, to_amount, to_currency_code, category_id, note, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)"); $ins->execute([$moveDate, $type, $userId, $accountId, $toAccountId, $amount, $currency, $toAmount, $toCurrency, $categoryId, $note !== '' ? $note : null, $createdAt]); } else { $ins = $pdo->prepare("INSERT INTO finance_moves (move_date, type, user_id, account_id, to_account_id, amount, currency_code, to_amount, to_currency_code, category_id, note, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,NOW())"); $ins->execute([$moveDate, $type, $userId, $accountId, $toAccountId, $amount, $currency, $toAmount, $toCurrency, $categoryId, $note !== '' ? $note : null]); } $mid = (int)$pdo->lastInsertId(); log_event($pdo, 'add_fin_move', 'finance_move', $mid, $actorUserId, ['move_date'=>$moveDate,'move_time'=>$moveTime !== '' ? $moveTime : null,'type'=>$type,'user_id'=>$userId,'account_id'=>$accountId,'to_account_id'=>$toAccountId,'amount'=>$amount,'currency_code'=>$currency,'to_amount'=>$toAmount,'to_currency_code'=>$toCurrency,'category_id'=>$categoryId,'note'=>$note !== '' ? $note : null], $today); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'add_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'delete_fin_move') { $id = (int)($_POST['id'] ?? 0); if ($id <= 0) json_response(['ok'=>false,'error'=>'id_required'], 400); try { $stmt = $pdo->prepare("SELECT id, user_id, account_id, to_account_id FROM finance_moves WHERE id=?"); $stmt->execute([$id]); $m = $stmt->fetch(); if (!$m) json_response(['ok'=>false,'error'=>'not_found'], 404); if ($actorUserId !== null) { $ok = fin_user_can_use_account($pdo, (int)$actorUserId, (int)$m['account_id']); if (!$ok && $m['to_account_id'] !== null) $ok = fin_user_can_use_account($pdo, (int)$actorUserId, (int)$m['to_account_id']); if (!$ok) json_response(['ok'=>false,'error'=>'forbidden'], 403); } $pdo->prepare("DELETE FROM finance_moves WHERE id=?")->execute([$id]); log_event($pdo, 'delete_fin_move', 'finance_move', $id, $actorUserId, ['user_id'=>(int)$m['user_id'],'account_id'=>(int)$m['account_id'],'to_account_id'=>$m['to_account_id'] !== null ? (int)$m['to_account_id'] : null], $today); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'delete_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'add_fin_debt') { $ownerUserId = (int)($_POST['owner_user_id'] ?? 0); $kind = trim((string)($_POST['kind'] ?? '')); $person = trim((string)($_POST['person'] ?? '')); $currency = fin_norm_currency((string)($_POST['currency_code'] ?? '')); $amountRaw = trim((string)($_POST['amount'] ?? '')); $startDate = trim((string)($_POST['start_date'] ?? $today)); $note = trim((string)($_POST['note'] ?? '')); if ($ownerUserId <= 0) json_response(['ok'=>false,'error'=>'user_required'], 400); if (!in_array($kind, ['i_owe','they_owe_me'], true)) json_response(['ok'=>false,'error'=>'bad_kind'], 400); if ($person === '') json_response(['ok'=>false,'error'=>'person_required'], 400); if (!fin_valid_currency($currency)) json_response(['ok'=>false,'error'=>'bad_currency'], 400); if ($amountRaw === '' || !is_numeric($amountRaw)) json_response(['ok'=>false,'error'=>'bad_amount'], 400); $amount = round((float)$amountRaw, 2); if (!($amount > 0)) json_response(['ok'=>false,'error'=>'bad_amount'], 400); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) json_response(['ok'=>false,'error'=>'bad_day'], 400); try { $ins = $pdo->prepare("INSERT INTO finance_debts (owner_user_id, kind, person, currency_code, amount, start_date, status, note, created_at) VALUES (?,?,?,?,?,?, 'open', ?, NOW())"); $ins->execute([$ownerUserId, $kind, $person, $currency, $amount, $startDate, $note !== '' ? $note : null]); $did = (int)$pdo->lastInsertId(); log_event($pdo, 'add_fin_debt', 'finance_debt', $did, $actorUserId, ['owner_user_id'=>$ownerUserId,'kind'=>$kind,'person'=>$person,'currency_code'=>$currency,'amount'=>$amount,'start_date'=>$startDate,'note'=>$note !== '' ? $note : null], $today); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'add_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'delete_fin_debt') { $id = (int)($_POST['id'] ?? 0); if ($id <= 0) json_response(['ok'=>false,'error'=>'id_required'], 400); $pdo->beginTransaction(); try { $stmt = $pdo->prepare("SELECT owner_user_id FROM finance_debts WHERE id=?"); $stmt->execute([$id]); $d = $stmt->fetch(); if (!$d) json_response(['ok'=>false,'error'=>'not_found'], 404); if ($actorUserId !== null && (int)$d['owner_user_id'] !== (int)$actorUserId) { json_response(['ok'=>false,'error'=>'forbidden'], 403); } $stmt = $pdo->prepare("SELECT COUNT(*) AS c FROM finance_debt_payments WHERE debt_id=?"); $stmt->execute([$id]); $cnt = (int)($stmt->fetch()['c'] ?? 0); if ($cnt > 0) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'has_payments'], 400); } $pdo->prepare("DELETE FROM finance_debts WHERE id=?")->execute([$id]); $pdo->commit(); log_event($pdo, 'delete_fin_debt', 'finance_debt', $id, $actorUserId, [], $today); } catch (Throwable $e) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'delete_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'set_fin_debt_status') { $id = (int)($_POST['id'] ?? 0); $status = trim((string)($_POST['status'] ?? '')); if ($id <= 0) json_response(['ok'=>false,'error'=>'id_required'], 400); if (!in_array($status, ['open','closed'], true)) json_response(['ok'=>false,'error'=>'bad_status'], 400); try { $stmt = $pdo->prepare("SELECT owner_user_id, status FROM finance_debts WHERE id=?"); $stmt->execute([$id]); $old = $stmt->fetch(); if (!$old) json_response(['ok'=>false,'error'=>'not_found'], 404); if ($actorUserId !== null && (int)$old['owner_user_id'] !== (int)$actorUserId) { json_response(['ok'=>false,'error'=>'forbidden'], 403); } $pdo->prepare("UPDATE finance_debts SET status=? WHERE id=?")->execute([$status, $id]); log_event($pdo, 'set_fin_debt_status', 'finance_debt', $id, $actorUserId, ['from'=>$old['status'],'to'=>$status], $today); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'update_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'get_fin_debt_payments') { $debtId = (int)($_POST['debt_id'] ?? 0); if ($debtId <= 0) json_response(['ok'=>false,'error'=>'id_required'], 400); try { $stmt = $pdo->prepare("SELECT owner_user_id FROM finance_debts WHERE id=?"); $stmt->execute([$debtId]); $d = $stmt->fetch(); if (!$d) json_response(['ok'=>false,'error'=>'not_found'], 404); if ($actorUserId !== null && (int)$d['owner_user_id'] !== (int)$actorUserId) { json_response(['ok'=>false,'error'=>'forbidden'], 403); } $stmt = $pdo->prepare(" SELECT p.id, p.debt_id, p.user_id, p.pay_date, p.account_id, p.amount, p.currency_code, p.move_id, p.note, p.created_at, a.name AS account_name, u.name AS user_name FROM finance_debt_payments p LEFT JOIN finance_accounts a ON a.id=p.account_id LEFT JOIN users u ON u.id=p.user_id WHERE p.debt_id=? ORDER BY p.pay_date DESC, p.id DESC "); $stmt->execute([$debtId]); $rows = $stmt->fetchAll(); json_response(['ok'=>true,'payments'=>$rows]); } catch (Throwable $e) { json_response(['ok'=>false,'error'=>'load_failed'], 500); } } if ($action === 'add_fin_debt_payment') { $debtId = (int)($_POST['debt_id'] ?? 0); $payDate = trim((string)($_POST['pay_date'] ?? $today)); $userId = (int)($_POST['user_id'] ?? 0); $accountId = (int)($_POST['account_id'] ?? 0); $amountRaw = trim((string)($_POST['amount'] ?? '')); $note = trim((string)($_POST['note'] ?? '')); if ($debtId <= 0) json_response(['ok'=>false,'error'=>'id_required'], 400); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $payDate)) json_response(['ok'=>false,'error'=>'bad_day'], 400); if ($userId <= 0) json_response(['ok'=>false,'error'=>'user_required'], 400); if ($accountId <= 0) json_response(['ok'=>false,'error'=>'account_required'], 400); if ($amountRaw === '' || !is_numeric($amountRaw)) json_response(['ok'=>false,'error'=>'bad_amount'], 400); $amount = round((float)$amountRaw, 2); if (!($amount > 0)) json_response(['ok'=>false,'error'=>'bad_amount'], 400); if (!fin_user_can_use_account($pdo, $userId, $accountId)) json_response(['ok'=>false,'error'=>'forbidden_account'], 403); $pdo->beginTransaction(); try { $stmt = $pdo->prepare("SELECT owner_user_id, kind, currency_code, amount, status, person FROM finance_debts WHERE id=?"); $stmt->execute([$debtId]); $d = $stmt->fetch(); if (!$d) json_response(['ok'=>false,'error'=>'not_found'], 404); if ($actorUserId !== null && (int)$d['owner_user_id'] !== (int)$actorUserId) { json_response(['ok'=>false,'error'=>'forbidden'], 403); } if ((string)$d['status'] !== 'open') { json_response(['ok'=>false,'error'=>'debt_closed'], 400); } $accCur = fin_account_currency($pdo, $accountId); if (!$accCur) json_response(['ok'=>false,'error'=>'account_not_found'], 404); if (fin_norm_currency((string)$accCur) !== fin_norm_currency((string)$d['currency_code'])) { json_response(['ok'=>false,'error'=>'currency_mismatch'], 400); } $moveType = ((string)$d['kind'] === 'i_owe') ? 'expense' : 'income'; $moveNote = $note !== '' ? $note : ('Pago deuda: '.(string)$d['person']); $insM = $pdo->prepare("INSERT INTO finance_moves (move_date, type, user_id, account_id, to_account_id, amount, currency_code, to_amount, to_currency_code, category_id, note, created_at) VALUES (?,?,?,?,NULL,?,?,NULL,NULL,NULL,?,NOW())"); $insM->execute([$payDate, $moveType, $userId, $accountId, $amount, fin_norm_currency((string)$accCur), $moveNote]); $moveId = (int)$pdo->lastInsertId(); $insP = $pdo->prepare("INSERT INTO finance_debt_payments (debt_id, user_id, pay_date, account_id, amount, currency_code, move_id, note, created_at) VALUES (?,?,?,?,?,?,?, ?, NOW())"); $insP->execute([$debtId, $userId, $payDate, $accountId, $amount, fin_norm_currency((string)$accCur), $moveId, $note !== '' ? $note : null]); $pid = (int)$pdo->lastInsertId(); $stmt = $pdo->prepare("SELECT COALESCE(SUM(amount),0) AS paid FROM finance_debt_payments WHERE debt_id=?"); $stmt->execute([$debtId]); $paid = (float)($stmt->fetch()['paid'] ?? 0); if ($paid >= (float)$d['amount']) { $pdo->prepare("UPDATE finance_debts SET status='closed' WHERE id=?")->execute([$debtId]); } $pdo->commit(); log_event($pdo, 'add_fin_debt_payment', 'finance_debt_payment', $pid, $actorUserId, ['debt_id'=>$debtId,'pay_date'=>$payDate,'user_id'=>$userId,'account_id'=>$accountId,'amount'=>$amount,'currency_code'=>fin_norm_currency((string)$accCur),'move_id'=>$moveId,'note'=>$note !== '' ? $note : null], $today); } catch (Throwable $e) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'add_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'delete_fin_debt_payment') { $id = (int)($_POST['id'] ?? 0); if ($id <= 0) json_response(['ok'=>false,'error'=>'id_required'], 400); $pdo->beginTransaction(); try { $stmt = $pdo->prepare(" SELECT p.id, p.debt_id, p.user_id, p.account_id, p.move_id, d.owner_user_id FROM finance_debt_payments p JOIN finance_debts d ON d.id=p.debt_id WHERE p.id=? "); $stmt->execute([$id]); $p = $stmt->fetch(); if (!$p) json_response(['ok'=>false,'error'=>'not_found'], 404); if ($actorUserId !== null && (int)$p['owner_user_id'] !== (int)$actorUserId) { json_response(['ok'=>false,'error'=>'forbidden'], 403); } $debtId = (int)$p['debt_id']; $pdo->prepare("DELETE FROM finance_debt_payments WHERE id=?")->execute([$id]); if ($p['move_id'] !== null) { $pdo->prepare("DELETE FROM finance_moves WHERE id=?")->execute([(int)$p['move_id']]); } $stmt = $pdo->prepare("SELECT COALESCE(SUM(amount),0) AS paid FROM finance_debt_payments WHERE debt_id=?"); $stmt->execute([$debtId]); $paid = (float)($stmt->fetch()['paid'] ?? 0); $stmt = $pdo->prepare("SELECT amount FROM finance_debts WHERE id=?"); $stmt->execute([$debtId]); $total = (float)($stmt->fetch()['amount'] ?? 0); if ($paid < $total) { $pdo->prepare("UPDATE finance_debts SET status='open' WHERE id=?")->execute([$debtId]); } $pdo->commit(); log_event($pdo, 'delete_fin_debt_payment', 'finance_debt_payment', $id, $actorUserId, ['debt_id'=>$debtId,'move_id'=>$p['move_id'] !== null ? (int)$p['move_id'] : null], $today); } catch (Throwable $e) { $pdo->rollBack(); json_response(['ok'=>false,'error'=>'delete_failed'], 500); } json_response(['ok'=>true]); } if ($action === 'get_metrics') { $daysBack = 7; $dates = []; for ($i = $daysBack - 1; $i >= 0; $i--) { $dates[] = date('Y-m-d', strtotime($today." -{$i} day")); } $daily = []; $stmt = $pdo->prepare("SELECT task_date, COUNT(*) AS total, SUM(status='done') AS done FROM tasks WHERE task_date IN (".implode(',', array_fill(0, count($dates), '?')).") GROUP BY task_date"); $stmt->execute($dates); $rows = $stmt->fetchAll(); $map = []; foreach ($rows as $r) $map[$r['task_date']] = $r; foreach ($dates as $d) { $total = isset($map[$d]) ? (int)$map[$d]['total'] : 0; $done = isset($map[$d]) ? (int)$map[$d]['done'] : 0; $pct = $total > 0 ? (int)round(($done / $total) * 100) : 0; $daily[] = ['date'=>$d,'total'=>$total,'done'=>$done,'pct'=>$pct]; } $users = $pdo->query("SELECT id, name, color FROM users ORDER BY id")->fetchAll(); $perUser = []; $stmt = $pdo->prepare(" SELECT tu.user_id, COUNT(DISTINCT t.id) AS total, SUM(t.status='done') AS done FROM tasks t JOIN task_users tu ON tu.task_id = t.id WHERE t.task_date = ? GROUP BY tu.user_id "); $stmt->execute([$today]); $rows = $stmt->fetchAll(); $map = []; foreach ($rows as $r) $map[(int)$r['user_id']] = $r; foreach ($users as $u) { $uid = (int)$u['id']; $total = isset($map[$uid]) ? (int)$map[$uid]['total'] : 0; $done = isset($map[$uid]) ? (int)$map[$uid]['done'] : 0; $pct = $total > 0 ? (int)round(($done / $total) * 100) : 0; $perUser[] = ['user_id'=>$uid,'name'=>$u['name'],'color'=>$u['color'],'total'=>$total,'done'=>$done,'pct'=>$pct]; } $dow = (int)date('N'); $habitUser = []; foreach ($users as $u) { $habitUser[(int)$u['id']] = ['user_id'=>(int)$u['id'],'name'=>$u['name'],'color'=>$u['color'],'active'=>0,'completed'=>0,'pct'=>0]; } $habits = $pdo->query("SELECT id, user_id, days FROM habits")->fetchAll(); if ($habits) { $ids = array_map(fn($h) => (int)$h['id'], $habits); $stmt = $pdo->prepare("SELECT habit_id, completed FROM habit_logs WHERE log_date=? AND habit_id IN (".implode(',', array_fill(0, count($ids), '?')).")"); $stmt->execute(array_merge([$today], $ids)); $logs = $stmt->fetchAll(); $doneMap = []; foreach ($logs as $l) $doneMap[(int)$l['habit_id']] = (int)$l['completed']; foreach ($habits as $h) { $uid = (int)$h['user_id']; $days = array_values(array_filter(array_map('intval', explode(',', (string)$h['days'])))); if (!in_array($dow, $days, true)) continue; $habitUser[$uid]['active']++; if (($doneMap[(int)$h['id']] ?? 0) === 1) $habitUser[$uid]['completed']++; } foreach ($habitUser as $uid => $hu) { $pct = $hu['active'] > 0 ? (int)round(($hu['completed'] / $hu['active']) * 100) : 0; $habitUser[$uid]['pct'] = $pct; } } json_response([ 'ok' => true, 'daily' => $daily, 'per_user' => $perUser, 'habits' => array_values($habitUser) ]); } json_response(['ok'=>false,'error'=>'unknown_action'], 400); } replicate_unfinished_tasks($pdo, $today); $users = $pdo->query("SELECT id, name, photo, color FROM users ORDER BY id")->fetchAll(); $userMap = []; foreach ($users as $u) { $userMap[(int)$u['id']] = $u; } $categories = $pdo->query("SELECT id, name FROM categories ORDER BY name")->fetchAll(); $projects = []; try { $projects = $pdo->query("SELECT p.id, p.category_id, p.name, p.color, c.name AS category_name FROM projects p LEFT JOIN categories c ON c.id=p.category_id ORDER BY p.name")->fetchAll(); } catch (Throwable $e) { $projects = []; } $taskSql = " SELECT t.*, c.name AS category_name, p.name AS project_name, p.color AS project_color, GROUP_CONCAT(DISTINCT u.color) AS colors, GROUP_CONCAT(DISTINCT u.id) AS user_ids FROM tasks t LEFT JOIN categories c ON c.id = t.category_id LEFT JOIN projects p ON p.id = t.project_id LEFT JOIN task_users tu ON tu.task_id = t.id LEFT JOIN users u ON u.id = tu.user_id WHERE t.task_date = ? "; $taskParams = [$today]; $taskSql .= " GROUP BY t.id ORDER BY t.position IS NULL, t.position, t.id "; $stmt = $pdo->prepare($taskSql); $stmt->execute($taskParams); $tasks = $stmt->fetchAll(); $habitParams = []; $habitSql = "SELECT h.*, u.name AS user_name, u.color AS user_color FROM habits h JOIN users u ON u.id=h.user_id"; $habitSql .= " ORDER BY h.user_id, h.id"; $stmt = $pdo->prepare($habitSql); $stmt->execute($habitParams); $habits = $stmt->fetchAll(); $habitIds = array_map(fn($h) => (int)$h['id'], $habits); $habitTodayMap = []; if ($habitIds) { $stmt = $pdo->prepare("SELECT habit_id, completed FROM habit_logs WHERE log_date=? AND habit_id IN (".implode(',', array_fill(0, count($habitIds), '?')).")"); $stmt->execute(array_merge([$today], $habitIds)); foreach ($stmt->fetchAll() as $r) $habitTodayMap[(int)$r['habit_id']] = (int)$r['completed']; } function habit_streaks(array $daysActive, array $completedMap, string $today): array { $dow = (int)date('N', strtotime($today)); $current = 0; $max = 0; $earliest = $today; if ($completedMap) { $dates = array_keys($completedMap); sort($dates); $earliest = $dates[0]; } $d = $today; while (strtotime($d) >= strtotime($earliest." -30 day")) { $n = (int)date('N', strtotime($d)); if (in_array($n, $daysActive, true)) { $ok = (($completedMap[$d] ?? 0) === 1); if ($ok) $current++; else break; } $d = date('Y-m-d', strtotime($d.' -1 day')); } $start = date('Y-m-d', strtotime($earliest)); $end = $today; $run = 0; $cursor = $start; while (strtotime($cursor) <= strtotime($end)) { $n = (int)date('N', strtotime($cursor)); if (in_array($n, $daysActive, true)) { $ok = (($completedMap[$cursor] ?? 0) === 1); if ($ok) { $run++; if ($run > $max) $max = $run; } else { $run = 0; } } $cursor = date('Y-m-d', strtotime($cursor.' +1 day')); } return [$current, $max, $dow]; } ?> <!DOCTYPE html> <html lang="es"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <title>HOY</title> <link rel="icon" type="image/png" href="assets/img/favicon.png"> <link rel="apple-touch-icon" href="assets/img/favicon.png"> <link rel="manifest" href="manifest.webmanifest"> <meta name="theme-color" content="#111827"> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="default"> <meta name="apple-mobile-web-app-title" content="HOY"> <style> *{box-sizing:border-box} :root{--bg:#f7f8fb;--surface:#fff;--text:#111827;--muted:rgba(17,24,39,.58);--border:rgba(17,24,39,.10);--border2:rgba(17,24,39,.14);--shadow:0 1px 2px rgba(17,24,39,.05);--shadow2:0 0 0 rgba(17,24,39,0);--radius:16px;--radius2:12px;--accent:#111827;--focus:rgba(59,130,246,.35);--c-info:rgba(59,130,246,.92);--c-info-bg:rgba(59,130,246,.07);--c-info-bd:rgba(59,130,246,.18);--c-info-hv:rgba(59,130,246,.11);--c-ok:rgba(34,197,94,.92);--c-ok-bg:rgba(34,197,94,.07);--c-ok-bd:rgba(34,197,94,.18);--c-ok-hv:rgba(34,197,94,.11);--c-warn:rgba(245,158,11,.92);--c-warn-bg:rgba(245,158,11,.08);--c-warn-bd:rgba(245,158,11,.20);--c-warn-hv:rgba(245,158,11,.12);--c-danger:rgba(239,68,68,.92);--c-danger-bg:rgba(239,68,68,.07);--c-danger-bd:rgba(239,68,68,.20);--c-danger-hv:rgba(239,68,68,.11)} :root{--bottompad:92px;--toastpad:88px} body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;background:var(--bg);color:var(--text);padding:0;margin:0;line-height:1.35} button,a{-webkit-tap-highlight-color:transparent;touch-action:manipulation} .wrap{max-width:1100px;margin:0 auto;padding:16px calc(16px + env(safe-area-inset-right)) calc(var(--bottompad) + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left))} .topbar{position:sticky;top:0;z-index:30;display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 calc(-16px - env(safe-area-inset-right)) 14px calc(-16px - env(safe-area-inset-left));padding:calc(10px + env(safe-area-inset-top)) calc(16px + env(safe-area-inset-right)) 10px calc(16px + env(safe-area-inset-left));background:rgba(247,248,251,.82);backdrop-filter:blur(12px);border-bottom:1px solid var(--border)} .lefthead{display:flex;align-items:center;gap:10px;min-width:160px} .iconbtn{border:1px solid var(--border2);background:var(--surface);border-radius:12px;padding:10px 12px;font-size:16px;cursor:pointer;line-height:1;color:var(--text);transition:transform .12s ease, box-shadow .12s ease, border-color .12s ease, background .12s ease, color .12s ease} .iconbtn:hover{border-color:rgba(17,24,39,.22);background:rgba(17,24,39,.03)} .iconbtn:active{transform:scale(.99)} .brand{font-size:14px;font-weight:800;letter-spacing:.35px;color:var(--text);display:flex;align-items:center;gap:8px} .brandmark{width:22px;height:22px;display:block} .brand .topdate{color:var(--muted);font-weight:850;font-size:12px;letter-spacing:.2px} .topnav{display:none;gap:8px;align-items:center;justify-content:center;flex:1} .topnavbtn{border:1px solid var(--border);background:rgba(255,255,255,.7);border-radius:999px;padding:8px 12px;font-size:13px;font-weight:800;cursor:pointer;user-select:none;color:var(--text);transition:transform .12s ease, box-shadow .12s ease, background .12s ease, border-color .12s ease} .topnavbtn:hover{border-color:rgba(17,24,39,.22)} .topnavbtn.active{background:var(--accent);color:#fff;border-color:var(--accent);box-shadow:var(--shadow2)} .filters{display:flex;gap:8px;align-items:center;overflow:auto;max-width:100%} .tasktoolbar{display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:nowrap;margin-bottom:10px;overflow-x:auto;-webkit-overflow-scrolling:touch} .tasktoolbar-left{display:flex;gap:10px;align-items:center;flex-wrap:nowrap;min-width:0;flex:1 1 auto} .tasktoolbar-right{display:flex;gap:10px;align-items:center;flex-wrap:nowrap;justify-content:flex-end;flex:0 0 auto} .tasktoolbar .filters{flex:1 1 260px} .tasktoolbar .select{height:40px} .tasktoolbar .btn{height:40px} .tfilter{width:190px;flex:0 0 190px} .chip{border:1px solid var(--border);background:rgba(255,255,255,.7);border-radius:999px;padding:8px 12px;font-size:13px;font-weight:800;cursor:pointer;user-select:none;transition:transform .12s ease, box-shadow .12s ease, background .12s ease, border-color .12s ease;color:var(--text);text-decoration:none;white-space:nowrap} .chip:hover{border-color:rgba(17,24,39,.22)} .chip.active{background:var(--surface);border-color:rgba(17,24,39,.26);box-shadow:var(--shadow2)} .chip.userchip{padding:0;width:42px;height:42px;display:inline-flex;align-items:center;justify-content:center;border-radius:999px;border:none !important;background:transparent !important;box-shadow:none !important;--ring:rgba(17,24,39,.22)} .chip.userchip .chipava{width:42px;height:42px;border-radius:50%;background:#eee center/cover no-repeat;border:none !important;filter:grayscale(1);opacity:.35;transition:transform .12s ease, opacity .12s ease, filter .12s ease;position:relative} .chip.userchip .chipava::after{content:"";position:absolute;inset:0;border-radius:50%;pointer-events:none;box-shadow:inset 0 0 0 2px var(--ring);opacity:.35;transition:opacity .12s ease, box-shadow .12s ease} .chip.userchip.active .chipava::after{opacity:1} .chip.userchip.active .chipava{filter:none;opacity:1;transform:scale(1.02)} .chip.userchip:hover .chipava{opacity:.6} .views{display:block} .view{display:none} .view.active{display:block} #habitsComposerMount{display:none} .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px;box-shadow:0 1px 0 rgba(17,24,39,.04)} .card h3{margin:0 0 10px 0;font-size:12px;letter-spacing:.36px;text-transform:uppercase;color:var(--muted)} .row{display:flex;gap:10px;align-items:center} .row.wrap{flex-wrap:wrap} .input{flex:1;border:1px solid var(--border2);border-radius:12px;padding:10px 12px;font-size:14px;outline:none;background:rgba(255,255,255,.92);color:var(--text);transition:border-color .12s ease, box-shadow .12s ease, background .12s ease} .select{border:1px solid var(--border2);border-radius:12px;padding:10px 12px;font-size:14px;background:rgba(255,255,255,.92);outline:none;color:var(--text);transition:border-color .12s ease, box-shadow .12s ease, background .12s ease} .file{flex:1;border:1px solid var(--border2);border-radius:12px;padding:10px 12px;font-size:14px;background:rgba(255,255,255,.92);outline:none;color:var(--text)} .btn{border:1px solid rgba(17,24,39,.18);background:var(--accent);color:#fff;border-radius:12px;padding:10px 12px;font-size:14px;font-weight:800;cursor:pointer;transition:transform .12s ease, box-shadow .12s ease, opacity .12s ease, filter .12s ease} .btn:hover{filter:brightness(1.02);box-shadow:0 1px 0 rgba(17,24,39,.06),0 10px 22px rgba(17,24,39,.10)} .btn:active{transform:scale(.98)} .btn.ghost{background:rgba(255,255,255,.92);color:var(--text);border-color:var(--border2)} .btn.ghost:hover{border-color:rgba(17,24,39,.22);background:rgba(17,24,39,.03)} .btn.ghost:active{transform:scale(.98)} .btn.ghost[data-role="pay"]{background:var(--c-ok-bg);border-color:var(--c-ok-bd);color:var(--c-ok)} .btn.ghost[data-role="pay"]:hover{background:var(--c-ok-hv);border-color:rgba(34,197,94,.28)} .btn.ghost[data-role="toggle"]{background:var(--c-warn-bg);border-color:var(--c-warn-bd);color:var(--c-warn)} .btn.ghost[data-role="toggle"]:hover{background:var(--c-warn-hv);border-color:rgba(245,158,11,.30)} .btn.ghost[data-role="save"]{background:var(--c-ok-bg);border-color:var(--c-ok-bd);color:var(--c-ok)} .btn.ghost[data-role="save"]:hover{background:var(--c-ok-hv);border-color:rgba(34,197,94,.28)} .btn.ghost[data-role="del"]{background:var(--c-danger-bg);border-color:var(--c-danger-bd);color:var(--c-danger)} .btn.ghost[data-role="del"]:hover{background:var(--c-danger-hv);border-color:rgba(239,68,68,.30)} .muted{color:var(--muted);font-size:12px} .input:focus,.select:focus,.file:focus,.textarea:focus{border-color:rgba(59,130,246,.55);box-shadow:0 0 0 4px var(--focus);background:#fff} .btn:focus-visible,.iconbtn:focus-visible,.topnavbtn:focus-visible,.chip:focus-visible,.statusbtn:focus-visible,.tbtn:focus-visible,.toggle:focus-visible,.draweritem:focus-visible,.choice:focus-visible,.star:focus-visible,.uopt:focus-visible,.navbtn:focus-visible{outline:none;box-shadow:0 0 0 4px var(--focus)} .task{background:rgba(255,255,255,.94);padding:12px;border-radius:var(--radius2);margin:8px 0;border:1px solid var(--border);display:flex;gap:12px;align-items:center;transition:transform .12s ease, box-shadow .12s ease, border-color .12s ease} .task:hover{border-color:rgba(17,24,39,.18)} .task.pending{background:linear-gradient(0deg, rgba(239,68,68,.08), rgba(239,68,68,.08)), rgba(255,255,255,.92)} .task.progress{background:linear-gradient(0deg, rgba(245,158,11,.10), rgba(245,158,11,.10)), rgba(255,255,255,.92)} .task.done{background:linear-gradient(0deg, rgba(16,185,129,.10), rgba(16,185,129,.10)), rgba(255,255,255,.92)} .handle{width:10px;height:26px;border-radius:99px;background:rgba(17,24,39,.14)} .title{flex:1;font-size:14px} .badge{font-size:11px;border:1px solid var(--border);background:rgba(255,255,255,.72);padding:4px 8px;border-radius:999px;color:rgba(17,24,39,.76)} .task .viewbox .muted{display:flex;gap:6px;flex-wrap:wrap;align-items:center} .pavatars{display:flex;gap:6px;align-items:center} .pava{width:20px;height:20px;border-radius:50%;background:#eee center/cover no-repeat;border:2px solid rgba(17,24,39,.14);flex:0 0 auto} .task .pava{width:26px;height:26px} .pavatars.editable .pava{cursor:pointer} .pava.off{filter:grayscale(1);opacity:.35} .statusbtn{border:1px solid var(--border2);background:rgba(255,255,255,.80);border-radius:999px;padding:6px 10px;font-size:12px;font-weight:800;cursor:pointer;color:var(--text);transition:transform .12s ease, box-shadow .12s ease, background .12s ease, border-color .12s ease} .statusbtn:hover{border-color:rgba(17,24,39,.22);background:rgba(17,24,39,.03)} .task.pending .statusbtn{background:var(--c-danger-bg);border-color:var(--c-danger-bd);color:var(--c-danger)} .task.pending .statusbtn:hover{background:var(--c-danger-hv);border-color:rgba(239,68,68,.30)} .task.progress .statusbtn{background:var(--c-warn-bg);border-color:var(--c-warn-bd);color:var(--c-warn)} .task.progress .statusbtn:hover{background:var(--c-warn-hv);border-color:rgba(245,158,11,.30)} .task.done .statusbtn{background:var(--c-ok-bg);border-color:var(--c-ok-bd);color:var(--c-ok)} .task.done .statusbtn:hover{background:var(--c-ok-hv);border-color:rgba(34,197,94,.28)} .tbtn{border:1px solid var(--border2);background:rgba(255,255,255,.80);border-radius:12px;padding:6px 10px;font-size:12px;font-weight:800;cursor:pointer;color:var(--text);transition:transform .12s ease, box-shadow .12s ease, background .12s ease, border-color .12s ease} .tbtn:hover{border-color:rgba(17,24,39,.22);background:rgba(17,24,39,.03)} .tbtn[data-role="edit"],.iconbtn[data-role="edit"]{background:var(--c-info-bg);border-color:var(--c-info-bd);color:var(--c-info)} .tbtn[data-role="edit"]:hover,.iconbtn[data-role="edit"]:hover{background:var(--c-info-hv);border-color:rgba(59,130,246,.30)} .tbtn[data-role="save"],.iconbtn[data-role="save"]{background:var(--c-ok-bg);border-color:var(--c-ok-bd);color:var(--c-ok)} .tbtn[data-role="save"]:hover,.iconbtn[data-role="save"]:hover{background:var(--c-ok-hv);border-color:rgba(34,197,94,.28)} .tbtn[data-role="del"],.iconbtn[data-role="del"]{background:var(--c-danger-bg);border-color:var(--c-danger-bd);color:var(--c-danger)} .tbtn[data-role="del"]:hover,.iconbtn[data-role="del"]:hover{background:var(--c-danger-hv);border-color:rgba(239,68,68,.30)} .tbtn:active{transform:scale(.98)} .tbtn.icon{padding:6px 9px;font-size:14px;line-height:1;min-width:34px} .taskops{display:flex;gap:8px;align-items:center} .task.editing{flex-wrap:wrap} .editbox{display:flex;gap:8px;align-items:center;flex-wrap:wrap} .editbox .input{min-width:180px} .editbox .select{height:40px} .taskuserpick .uopt{padding:5px 8px} .taskuserpick .uava{width:18px;height:18px} .time12{display:flex;gap:8px;align-items:center} .time12 .select{height:40px} .userpick{display:flex;flex-wrap:wrap;gap:8px} .uopt{display:flex;align-items:center;gap:8px;border:1px solid var(--border);border-radius:999px;padding:6px 10px;cursor:pointer;background:rgba(255,255,255,.92);transition:transform .12s ease, box-shadow .12s ease, border-color .12s ease} .uopt:hover{border-color:rgba(17,24,39,.22)} .uava{width:22px;height:22px;border-radius:50%;background:#eee center/cover no-repeat;border:2px solid rgba(17,24,39,.14);flex:0 0 auto} .uopt input{display:none} .uopt.on{border-color:rgba(17,24,39,.26);box-shadow:var(--shadow2)} .uopt:not(.on) .uava{filter:grayscale(1);opacity:.35} .avapick{gap:10px} .avapick .uopt{padding:0;gap:0;border:none;background:transparent;border-radius:0;box-shadow:none} .avapick .uopt:hover{border:none;box-shadow:none} .avapick .uopt > span:nth-child(2){display:none} .avapick .uava{width:34px;height:34px;border-width:2px;filter:grayscale(1);opacity:.35} .avapick .uopt.on .uava{border-width:3px;filter:none;opacity:1} .list{display:flex;flex-direction:column;gap:8px} #habitList.list{gap:14px} .catitem{display:flex;gap:8px;align-items:center;flex-wrap:wrap} .catitem input{flex:1} .avatar{width:38px;height:38px;border-radius:50%;background:#eee center/cover no-repeat;border:1px solid rgba(0,0,0,.10);flex:0 0 auto} .habit{display:flex;align-items:center;gap:12px;padding:14px;border:1px solid var(--border);border-radius:var(--radius2);background:rgba(255,255,255,.88)} .habit .name{flex:1} .pill{border:1px solid var(--border);border-radius:999px;padding:4px 8px;font-size:12px;background:rgba(255,255,255,.92);color:rgba(17,24,39,.76);font-weight:800} .toggle{border:1px solid var(--border2);background:rgba(255,255,255,.92);border-radius:12px;padding:8px 10px;cursor:pointer;font-weight:900;color:var(--text);transition:transform .12s ease, box-shadow .12s ease, border-color .12s ease, background .12s ease} .toggle:hover{border-color:rgba(17,24,39,.22);background:rgba(17,24,39,.03)} .toggle.on{background:var(--accent);color:#fff;border-color:var(--accent)} .toggle.on:hover{filter:brightness(1.02)} .toggle[disabled]{opacity:.45;cursor:not-allowed} .habit.editing{align-items:flex-start} .habitops{display:flex;gap:8px;align-items:center} .habitedit{margin-top:8px} .habit [data-role="hview"] .muted{margin-top:8px !important} .weekbadges{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px} .weekbadges .pill{background:rgba(0,0,0,.05);border-color:rgba(0,0,0,.10);color:rgba(0,0,0,.55)} .weekbadges .pill.on{background:var(--accent,#111);border-color:var(--accent,#111);color:#fff} .dayspick .pill{background:rgba(0,0,0,.05);border-color:rgba(0,0,0,.10);color:rgba(0,0,0,.55)} .dayspick .uopt.on .pill{background:var(--accent,#111);border-color:var(--accent,#111);color:#fff} .kpis{display:grid;grid-template-columns:repeat(2, minmax(0,1fr));gap:10px;margin-bottom:10px} .kpi{border:1px solid var(--border);border-radius:var(--radius2);padding:10px;background:rgba(255,255,255,.92)} .kpi .l{font-size:12px;color:var(--muted);font-weight:900;letter-spacing:.2px} .kpi .v{font-size:22px;font-weight:950;letter-spacing:.2px;margin-top:2px} .kpi .s{font-size:12px;color:var(--muted);margin-top:4px} .kpi.acckpi{padding:10px 10px 9px} .acchead{display:flex;align-items:flex-start;justify-content:space-between;gap:10px} .accops{display:flex;gap:6px;align-items:center} .accttl{font-weight:950;letter-spacing:.2px;font-size:13px;line-height:1.2;display:flex;gap:6px;align-items:center;flex-wrap:wrap} .accttl .badge{font-size:11px;padding:3px 7px} .accbal{font-size:22px;font-weight:950;letter-spacing:.2px;margin-top:4px} .accbal.neg{color:rgba(185,28,28,.95)} .accbal.pos{color:rgba(21,128,61,.92)} .accmeta{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:6px} .accmeta-left{display:flex;align-items:center;gap:6px;flex-wrap:wrap} .acckpi .pavatars{margin-top:0} .acckpi .tbtn.icon{width:34px;height:34px;border-radius:999px} .acckpi .tbtn.icon{opacity:.85} .acckpi .tbtn.icon:hover{opacity:1} .chartblock{border:1px solid var(--border);border-radius:var(--radius2);padding:10px;background:rgba(255,255,255,.92)} .chartttl{font-weight:950;letter-spacing:.2px;margin-bottom:8px} .navico{width:20px;height:20px;display:block} .navico svg{width:20px;height:20px;display:block} .navlbl{display:block;font-size:11px;font-weight:900;letter-spacing:.2px;margin-top:4px;white-space:nowrap} .drawerico{width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center} .drawerico svg{width:18px;height:18px;display:block} .canwrap{display:grid;grid-template-columns:1fr;gap:10px} canvas{width:100%;height:160px;background:rgba(255,255,255,.92);border:1px solid var(--border);border-radius:var(--radius2)} .bottomnav{position:fixed;left:50%;transform:translateX(-50%);bottom:0;z-index:40;background:rgba(255,255,255,.92);backdrop-filter:blur(12px);border-top:1px solid var(--border);display:flex;gap:10px;padding:10px;padding-bottom:calc(10px + env(safe-area-inset-bottom));padding-left:calc(10px + env(safe-area-inset-left));padding-right:calc(10px + env(safe-area-inset-right));width:min(1100px, 100%)} .bottomnav{box-shadow:0 -10px 30px rgba(17,24,39,.06)} .navbtn{flex:1;border:1px solid var(--border);background:rgba(255,255,255,.96);border-radius:16px;padding:12px 8px;cursor:pointer;color:var(--text);transition:transform .12s ease, box-shadow .12s ease, border-color .12s ease;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:60px;user-select:none;-webkit-user-select:none} .navbtn:hover{border-color:rgba(17,24,39,.22)} .navbtn.active{background:var(--accent);color:#fff;border-color:var(--accent);box-shadow:var(--shadow2)} .drawerbackdrop{position:fixed;inset:0;background:rgba(17,24,39,.32);z-index:60;display:none} .drawer{position:fixed;top:0;bottom:0;left:0;width:min(320px, 86vw);background:#fff;z-index:70;transform:translateX(-102%);transition:transform .18s ease;border-right:1px solid rgba(0,0,0,.12);padding:12px;padding-top:calc(12px + env(safe-area-inset-top));padding-left:calc(12px + env(safe-area-inset-left));padding-right:calc(12px + env(safe-area-inset-right))} .drawer.open{transform:translateX(0)} .drawerbackdrop.open{display:block} .drawerhead{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px} .drawerhead .ttl{font-weight:800;letter-spacing:.3px} .drawer{background:rgba(255,255,255,.96);border-right:1px solid var(--border)} .draweritem{width:100%;text-align:left;border:1px solid var(--border);background:rgba(255,255,255,.92);border-radius:14px;padding:14px 12px;font-size:15px;font-weight:900;cursor:pointer;margin:8px 0;color:var(--text);transition:transform .12s ease, box-shadow .12s ease, border-color .12s ease;display:flex;align-items:center;gap:10px} .draweritem:hover{border-color:rgba(17,24,39,.22)} .splash{position:fixed;inset:0;z-index:120;background:#000;display:flex;align-items:center;justify-content:center;transition:opacity .2s ease, visibility .2s ease} .splash.hide{opacity:0;visibility:hidden;pointer-events:none} .splashvid{width:100%;height:100%;object-fit:cover} @keyframes splashkill{to{opacity:0;visibility:hidden;pointer-events:none}} .splash{animation:splashkill 0s 12s forwards} .skip-splash #splash{display:none !important} @keyframes fadeIn{from{opacity:0}to{opacity:1}} @keyframes modalPopIn{from{opacity:0;transform:translate(-50%,-50%) scale(.98)}to{opacity:1;transform:translate(-50%,-50%) scale(1)}} @keyframes sheetSlideIn{from{opacity:0;transform:translateX(-50%) translateY(18px)}to{opacity:1;transform:translateX(-50%) translateY(0)}} .modalbackdrop{position:fixed;inset:0;background:rgba(17,24,39,.36);z-index:80;display:none} .modalbackdrop.open{display:block;animation:fadeIn .14s ease-out} .modal{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);width:min(560px, 94vw);background:rgba(255,255,255,.98);z-index:90;border:1px solid var(--border);border-radius:18px;padding:14px;display:none;box-shadow:var(--shadow);max-height:calc(90vh - env(safe-area-inset-bottom));overflow:auto;-webkit-overflow-scrolling:touch} .modal.open{display:block;animation:modalPopIn .16s ease-out} .modalbackdrop#appConfirmBackdrop,.modalbackdrop#appPromptBackdrop{z-index:200} .modal#appConfirmModal,.modal#appPromptModal{z-index:210} .modal.sheet{top:auto;bottom:0;transform:translateX(-50%);width:min(560px, 96vw);border-bottom-left-radius:0;border-bottom-right-radius:0;max-height:calc(86vh - env(safe-area-inset-bottom));overflow:auto;padding-bottom:calc(14px + env(safe-area-inset-bottom))} .modal.sheet.open{animation:sheetSlideIn .18s ease-out} #taskDrawer{width:min(720px, 94vw);max-height:calc(86vh - env(safe-area-inset-bottom));overflow:auto} #taskDrawer .todaycomposer{border-bottom:none;margin-bottom:0;padding-bottom:0} #habitModal{max-height:calc(86vh - env(safe-area-inset-bottom));overflow:auto} #habitModal .todaycomposer{border-bottom:none;margin-bottom:0;padding-bottom:0} .toasthost{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(var(--toastpad) + env(safe-area-inset-bottom));z-index:120;display:flex;flex-direction:column;gap:8px;align-items:stretch;width:min(560px, 96vw);pointer-events:none} .toast{pointer-events:auto;border:1px solid var(--border);border-radius:16px;background:rgba(255,255,255,.96);box-shadow:var(--shadow);padding:10px 12px;display:flex;gap:10px;align-items:flex-start} .toast .ico{width:20px;height:20px;border-radius:10px;display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;margin-top:1px;font-weight:950} .toast .msg{flex:1;font-size:13px;font-weight:800;line-height:1.25;color:var(--text)} .toast .x{border:1px solid var(--border2);background:rgba(255,255,255,.92);border-radius:12px;width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;color:var(--text);flex:0 0 auto} .toast.info .ico{background:rgba(59,130,246,.12);color:rgba(59,130,246,.95);border:1px solid rgba(59,130,246,.18)} .toast.ok .ico{background:rgba(34,197,94,.12);color:rgba(34,197,94,.95);border:1px solid rgba(34,197,94,.18)} .toast.warn .ico{background:rgba(245,158,11,.12);color:rgba(245,158,11,.95);border:1px solid rgba(245,158,11,.18)} .toast.err .ico{background:rgba(239,68,68,.12);color:rgba(239,68,68,.95);border:1px solid rgba(239,68,68,.18)} .btn.danger{background:rgba(239,68,68,.94);border-color:rgba(239,68,68,.25);color:#fff} .btn.danger:hover{filter:brightness(1.03)} .btn.danger.ghost{background:rgba(239,68,68,.08);border-color:rgba(239,68,68,.18);color:rgba(239,68,68,.95)} .btn.danger.ghost:hover{border-color:rgba(239,68,68,.28)} .modalhead{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px;position:sticky;top:0;background:rgba(255,255,255,.98);padding-top:2px;z-index:2} .modalhead .ttl{font-weight:950;letter-spacing:.3px} .finhead{display:flex;gap:10px;align-items:center;justify-content:space-between;margin-bottom:10px;flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch} .finhead-left{display:flex;gap:10px;align-items:center;flex:1;min-width:0;flex-wrap:nowrap} .finhead-right{display:flex;gap:10px;align-items:center;justify-content:flex-end;flex:0 0 auto} .finmovesummary{display:flex;gap:8px;align-items:center;justify-content:flex-end;flex-wrap:wrap} .finmovesummary .sum{border:1px solid var(--border);border-radius:999px;padding:7px 10px;font-size:12px;font-weight:950;background:rgba(255,255,255,.92)} .finmovesummary .sum.in{border-color:rgba(34,197,94,.18);background:rgba(34,197,94,.08);color:rgba(21,128,61,.95)} .finmovesummary .sum.out{border-color:rgba(239,68,68,.18);background:rgba(239,68,68,.08);color:rgba(185,28,28,.95)} .movecard{border:1px solid var(--border);background:rgba(255,255,255,.92);border-radius:14px;padding:10px;display:flex;gap:10px;align-items:flex-start;flex-wrap:wrap} .movecard .amt{font-weight:950;font-size:16px;letter-spacing:.2px} .movecard .meta{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px} .movecard.income{border-left:5px solid rgba(34,197,94,.60)} .movecard.expense{border-left:5px solid rgba(239,68,68,.60)} .movecard.transfer{border-left:5px solid rgba(59,130,246,.55)} .movecard .ops{display:flex;gap:8px;align-items:center;justify-content:flex-end;margin-left:auto} .stars{display:flex;gap:6px;align-items:center} .star{width:34px;height:34px;border-radius:12px;border:1px solid var(--border2);background:rgba(255,255,255,.92);cursor:pointer;font-size:18px;line-height:1;display:flex;align-items:center;justify-content:center;user-select:none;transition:transform .12s ease, box-shadow .12s ease, border-color .12s ease} .star:hover{border-color:rgba(17,24,39,.22)} .star.on{background:var(--accent);color:#fff;border-color:var(--accent)} .choices{display:flex;gap:8px;flex-wrap:wrap} .choice{border:1px solid var(--border2);background:rgba(255,255,255,.92);border-radius:999px;padding:8px 12px;font-size:13px;font-weight:900;cursor:pointer;user-select:none;color:var(--text);transition:transform .12s ease, box-shadow .12s ease, border-color .12s ease} .choice:hover{border-color:rgba(17,24,39,.22)} .choice.on{background:var(--accent);color:#fff;border-color:var(--accent)} .choice.typepill{display:flex;align-items:center;gap:8px} .choice.typepill .ico{width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center} .choice.typepill .ico svg{width:18px;height:18px;display:block} .choice.typepill.on{background:rgba(17,24,39,.92);border-color:rgba(17,24,39,.35);color:#fff} .choice.typepill.on.income{background:rgba(34,197,94,.12);border-color:rgba(34,197,94,.35);color:rgba(21,128,61,.95)} .choice.typepill.on.expense{background:rgba(239,68,68,.12);border-color:rgba(239,68,68,.35);color:rgba(185,28,28,.95)} .choice.typepill.on.transfer{background:rgba(37,99,235,.12);border-color:rgba(37,99,235,.35);color:rgba(37,99,235,.95)} .choice.debtpill.on{background:rgba(17,24,39,.92);border-color:rgba(17,24,39,.35);color:#fff} .choice.debtpill.on.iowe{background:rgba(239,68,68,.12);border-color:rgba(239,68,68,.35);color:rgba(185,28,28,.95)} .choice.debtpill.on.theyowe{background:rgba(59,130,246,.12);border-color:rgba(59,130,246,.35);color:rgba(37,99,235,.95)} .debtstatus{display:flex;gap:8px;align-items:center} .circlebtn{border:1px solid var(--border2);background:rgba(255,255,255,.92);border-radius:999px;width:38px;height:38px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;font-weight:950;line-height:1;transition:transform .12s ease, box-shadow .12s ease, border-color .12s ease, background .12s ease, color .12s ease;color:var(--text)} .circlebtn:hover{border-color:rgba(17,24,39,.22);background:rgba(17,24,39,.03)} .circlebtn:active{transform:scale(.98)} .circlebtn.on{background:var(--accent);color:#fff;border-color:var(--accent)} .movecard.debtcard.iowe{border-left:5px solid rgba(239,68,68,.60)} .movecard.debtcard.theyowe{border-left:5px solid rgba(59,130,246,.55)} .movecard.debtcard.closed{opacity:.72} .deck{display:grid;grid-template-columns:repeat(2, minmax(0,1fr));gap:10px} @media (max-width: 520px){.deck{grid-template-columns:1fr}} .catcard{border:1px solid var(--border);background:rgba(255,255,255,.92);border-radius:14px;padding:10px;display:flex;flex-direction:column;gap:8px} .catcard .ttl{font-weight:950} .catcard .meta{display:flex;gap:6px;flex-wrap:wrap;align-items:center} .catcard.income{border-left:5px solid rgba(34,197,94,.60)} .catcard.expense{border-left:5px solid rgba(239,68,68,.60)} .catcard.both{border-left:5px solid rgba(59,130,246,.55)} .textarea{width:100%;min-height:80px;border:1px solid var(--border2);border-radius:14px;padding:10px 12px;font-size:14px;outline:none;resize:vertical;box-sizing:border-box;display:block;background:rgba(255,255,255,.92);color:var(--text);transition:border-color .12s ease, box-shadow .12s ease, background .12s ease} .projcard{border:1px solid var(--border);background:rgba(255,255,255,.92);border-radius:14px;padding:10px;display:flex;flex-direction:column;gap:10px;min-height:140px} .projcard .head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px} .projcard .ttl{font-weight:950;letter-spacing:.2px;font-size:14px;line-height:1.2} .projcard .ops{display:flex;gap:6px;align-items:center} .projcard .ops .tbtn.icon{width:34px;height:34px;border-radius:999px} .projcard .meta{display:flex;gap:6px;flex-wrap:wrap;align-items:center} .projcard .summary{display:flex;gap:6px;flex-wrap:wrap;align-items:center} .projcard .summary .pill{padding:3px 7px;font-size:11px} .ptasks{display:flex;flex-direction:column;gap:8px} .ptask{border:1px solid var(--border);border-radius:12px;padding:8px 10px;display:flex;gap:10px;align-items:flex-start;background:rgba(255,255,255,.92)} .ptask.pending{background:linear-gradient(0deg, rgba(239,68,68,.08), rgba(239,68,68,.08)), rgba(255,255,255,.92);border-left:5px solid rgba(239,68,68,.55)} .ptask.progress{background:linear-gradient(0deg, rgba(245,158,11,.10), rgba(245,158,11,.10)), rgba(255,255,255,.92);border-left:5px solid rgba(245,158,11,.55)} .ptask.done{background:linear-gradient(0deg, rgba(16,185,129,.10), rgba(16,185,129,.10)), rgba(255,255,255,.92);border-left:5px solid rgba(16,185,129,.55)} .ptask .main{flex:1;min-width:0} .ptask .t{font-weight:900} .ptask .m{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px} .ptask .pavatars{margin-left:auto} .calwrap{display:flex;gap:12px;align-items:flex-start;flex-wrap:wrap} .calcard{flex:1;min-width:min(520px, 100%)} .calpanel{flex:1;min-width:min(380px, 100%)} .calhead{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px;flex-wrap:nowrap} .calpanel .calhead{flex-direction:column;align-items:stretch;justify-content:flex-start;gap:8px} .calhead .ttl{font-weight:900;letter-spacing:.3px} #calDayTop{display:flex;align-items:center;justify-content:space-between;gap:10px} #calDayUserTop{flex:1 1 auto;min-width:0} #calDayUserTop #calDayFilterUserChips{max-width:none !important} #calDayUserTop .calday-users{overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;flex-wrap:nowrap;min-width:0} #calDayLabel{white-space:nowrap;flex:0 0 auto;margin-left:auto;text-align:right} #calDayFilters{width:100%;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:nowrap} #calDayFilters #calDayFilterStatus{flex:0 0 auto} #calDayFilters #calDayFilterSearch{flex:0 0 auto;min-width:220px} .calgrid{display:grid;grid-template-columns:repeat(7, 1fr);gap:8px} .caldow{font-size:11px;color:rgba(0,0,0,.55);text-align:center;font-weight:800} .calcell{border:1px solid var(--border);background:rgba(255,255,255,.92);border-radius:14px;padding:10px;min-height:72px;cursor:pointer;display:flex;flex-direction:column;gap:6px;justify-content:space-between;transition:transform .12s ease, box-shadow .12s ease, border-color .12s ease} .calcell:hover{border-color:rgba(17,24,39,.18)} .calcell.muted{opacity:.45;cursor:default} .calcell.on{border-color:var(--accent);box-shadow:var(--shadow)} .calnum{font-weight:900} .calcnt{font-size:11px;color:rgba(0,0,0,.55)} .achlist{display:flex;flex-direction:column;gap:8px} .achitem{border:1px solid var(--border);background:rgba(255,255,255,.92);border-radius:14px;padding:10px} .achhead{display:flex;gap:10px;align-items:flex-start;justify-content:space-between} .achhead .tbtn.icon{width:34px;height:34px;border-radius:999px} .achitem.pending{background:linear-gradient(0deg, rgba(239,68,68,.08), rgba(239,68,68,.08)), rgba(255,255,255,.92);border-left:5px solid rgba(239,68,68,.55)} .achitem.progress{background:linear-gradient(0deg, rgba(245,158,11,.10), rgba(245,158,11,.10)), rgba(255,255,255,.92);border-left:5px solid rgba(245,158,11,.55)} .achitem.done{background:linear-gradient(0deg, rgba(16,185,129,.10), rgba(16,185,129,.10)), rgba(255,255,255,.92);border-left:5px solid rgba(16,185,129,.55)} .achitem .stbadge{border:1px solid var(--border2);border-radius:999px;padding:4px 8px;font-size:11px;font-weight:950;letter-spacing:.2px} .achitem.pending .stbadge{background:var(--c-danger-bg);border-color:var(--c-danger-bd);color:var(--c-danger)} .achitem.progress .stbadge{background:var(--c-warn-bg);border-color:var(--c-warn-bd);color:var(--c-warn)} .achitem.done .stbadge{background:var(--c-ok-bg);border-color:var(--c-ok-bd);color:var(--c-ok)} .calcntrow{display:flex;gap:6px;flex-wrap:wrap;align-items:center} .calb{border:1px solid var(--border);background:rgba(255,255,255,.72);border-radius:999px;padding:3px 7px;font-size:11px;font-weight:900;letter-spacing:.2px;color:rgba(17,24,39,.76)} .calb.pending{background:var(--c-danger-bg);border-color:var(--c-danger-bd);color:var(--c-danger)} .calb.progress{background:var(--c-warn-bg);border-color:var(--c-warn-bd);color:var(--c-warn)} .calb.done{background:var(--c-ok-bg);border-color:var(--c-ok-bd);color:var(--c-ok)} .achmeta{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px} .achusers{margin-top:8px} @media (max-width: 420px){ .kpis{grid-template-columns:1fr} .habit{flex-wrap:wrap;align-items:flex-start} .habit .name{min-width:0} .habitops{width:100%;justify-content:flex-end;flex-wrap:wrap} .habit [data-role="hview"] > .muted{display:flex;flex-wrap:wrap;gap:6px;align-items:center} .weekbadges .pill{padding:3px 7px;font-size:11px} .habitops .toggle,.habitops .tbtn:not(.icon){padding:8px 10px;font-size:12px} .todaycomposer{padding-bottom:12px;border-bottom:1px solid var(--border);margin-bottom:12px} .task{display:grid;grid-template-columns:10px 1fr auto;grid-template-rows:auto auto;gap:8px 10px;align-items:start} .task .handle{grid-column:1;grid-row:1 / span 2} .task .title{grid-column:2;grid-row:1;min-width:0} .task .viewbox .muted{display:flex;flex-wrap:wrap;gap:6px;align-items:center} .task .taskops{grid-column:3;grid-row:1;justify-self:end;align-self:start;flex-wrap:wrap;gap:6px;justify-content:flex-end} .task .pavatars{grid-column:2 / span 2;grid-row:2;justify-content:flex-start} .ptask{flex-wrap:wrap} .ptask .pavatars{margin-left:0;width:100%;justify-content:flex-start} .calgrid{gap:6px} .caldow{font-size:10px} .calcell{padding:8px;min-height:64px;border-radius:12px} .editbox .input{min-width:140px} canvas{height:140px} .bottomnav{gap:8px;padding:8px;padding-bottom:calc(8px + env(safe-area-inset-bottom));padding-left:calc(8px + env(safe-area-inset-left));padding-right:calc(8px + env(safe-area-inset-right))} .navlbl{font-size:10px} } @media (max-width: 380px){ .navlbl{display:none} .navbtn{min-height:54px;padding:10px 6px} } @media (max-width: 980px){ #menuBtn{display:none} #view-today #openTaskDrawerBtn{display:none !important} #view-today #openMoveModalBtn{display:none !important} #view-habits #openHabitModalBtn{display:none !important} #view-projects #openProjectModalBtn{display:none !important} #view-finances #openAccModalBtn{display:none !important} #view-finances #openDebtModalBtn{display:none !important} #view-config #openFinCatModalBtn{display:none !important} #view-projects .card > .row.wrap{flex-direction:column;align-items:stretch;gap:10px} #view-projects .card > .row.wrap > .row.wrap{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;align-items:center} #view-projects #projFilterUserChips{max-width:none !important} #view-projects #projFilterEmpty{flex-wrap:nowrap} #view-projects .card > .row.wrap > .row{justify-content:flex-end} .calwrap{flex-direction:column} .calcard,.calpanel{min-width:100%} .row.wrap > .input,.row.wrap > .select,.row.wrap > .file,.row.wrap > .btn,.row.wrap > .toggle,.row.wrap > .tbtn{flex:1 1 180px} .time12{flex:1 1 220px} #todayComposerMount{display:none} #habitsComposerMount{display:none} .fab{position:fixed;right:calc(16px + env(safe-area-inset-right));bottom:calc(var(--bottompad) + env(safe-area-inset-bottom));z-index:75;width:58px;height:58px;border-radius:18px;border:1px solid rgba(17,24,39,.18);background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:28px;line-height:1;font-weight:900;cursor:pointer;box-shadow:var(--shadow);user-select:none;-webkit-user-select:none} .fab:active{transform:scale(.98)} } .fabmenubackdrop{position:fixed;inset:0;z-index:74;display:none;background:transparent} .fabmenubackdrop.open{display:block} .fabmenu{position:fixed;right:calc(16px + env(safe-area-inset-right));bottom:calc(var(--bottompad) + env(safe-area-inset-bottom) + 72px);z-index:76;display:none;flex-direction:column;gap:10px;align-items:flex-end} .fabmenu.open{display:flex;animation:fadeIn .12s ease-out} .fabitem{border:1px solid var(--border);background:rgba(255,255,255,.96);backdrop-filter:blur(12px);border-radius:999px;padding:10px 14px;font-size:13px;font-weight:950;letter-spacing:.2px;cursor:pointer;color:var(--text);box-shadow:var(--shadow);transition:transform .12s ease, box-shadow .12s ease, border-color .12s ease} .fabitem:hover{border-color:rgba(17,24,39,.22)} .fabitem:active{transform:scale(.98)} @media (hover:none){ .task:active,.habit:active,.movecard:active,.projcard:active,.calcell:active{transform:scale(.99)} .task,.habit,.movecard,.projcard,.calcell{transition:transform .12s ease} .btn:active,.tbtn:active,.choice:active,.chip:active,.navbtn:active,.draweritem:active{transform:scale(.99)} .btn,.tbtn,.choice,.chip,.navbtn,.draweritem{transition:transform .12s ease} } @media (prefers-reduced-motion: reduce){ .modal.open,.modalbackdrop.open{animation:none !important} } @media (min-width: 981px){ .wrap{padding-bottom:16px} .topbar{gap:14px} .topnav{display:flex} #menuBtn{display:none} #drawer,#drawerBackdrop{display:none !important} .bottomnav{display:none} } @media (max-width: 520px){ .modal:not(#appConfirmModal):not(#appPromptModal){ top:auto; bottom:0; transform:translateX(-50%); width:min(720px, 96vw); border-bottom-left-radius:0; border-bottom-right-radius:0; max-height:calc(90vh - env(safe-area-inset-bottom)); overflow:auto; padding-bottom:calc(14px + env(safe-area-inset-bottom)) } .modal:not(#appConfirmModal):not(#appPromptModal).open{animation:sheetSlideIn .18s ease-out} .input,.select,.textarea{font-size:16px} #view-today .tasktoolbar{flex-direction:column;align-items:stretch;gap:12px;overflow:visible} #view-today .tasktoolbar-left{width:100%} #view-today .tasktoolbar-left .filters{width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;padding-bottom:4px} #view-today .tasktoolbar-right{width:100%;display:grid;grid-template-columns:1fr;gap:8px} #view-today .tasktoolbar-right .select{width:100%;min-width:0} #view-today .tasktoolbar-right .tfilter{width:100%;flex:1 1 auto} #view-today .tasktoolbar-right #taskFilterProject{grid-column:1 / -1} #view-today .tasktoolbar-right #openTaskDrawerBtn{grid-column:1 / -1;width:100%} #view-today .finhead{flex-direction:column;align-items:stretch;gap:10px;overflow:visible} #view-today .finhead-left{width:100%;flex-direction:column;align-items:stretch;gap:10px} #view-today #finMoveDate{max-width:none !important;width:100%} #view-today #finMoveListUser{max-width:none !important;width:100%} #view-today #finMoveUserChips{width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;flex-wrap:nowrap} #view-today #finMoveTypeFilter{width:100%;display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:8px} #view-today #finMoveTypeFilter .choice{width:100%;justify-content:center} #view-today .finhead-right{width:100%;justify-content:flex-start} #view-today #finMoveDaySummary{justify-content:flex-start} #view-habits .tasktoolbar{flex-direction:column;align-items:stretch;gap:12px;overflow:visible} #view-habits .tasktoolbar-left{width:100%} #view-habits .tasktoolbar-left .filters{width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;padding-bottom:4px} #view-habits .tasktoolbar-right{width:100%;justify-content:flex-end} #view-habits #openHabitModalBtn{width:100%} #view-calendar #calProject{max-width:none !important;width:100%} #view-calendar #calDayTop{flex-direction:column;align-items:stretch;gap:8px} #view-calendar #calDayUserTop{width:100%} #view-calendar #calDayUserTop .calday-users{width:100%} #view-calendar #calDayLabel{margin-left:0;text-align:left;width:100%} #view-calendar #calDayFilters{gap:8px} #view-calendar #calDayFilterStatus{width:100%;max-width:none !important} #view-calendar #calDayFilterSearch{width:100%;min-width:0} #view-finances .finhead{flex-direction:column;align-items:stretch;gap:10px;overflow:visible} #view-finances .finhead-left{width:100%;flex-direction:column;align-items:stretch;gap:10px} #view-finances .finhead-right{width:100%;justify-content:flex-start} #view-finances .finhead-right .btn{width:100%} #view-finances #finAccListUser{max-width:none !important;width:100%} #view-finances #finAccUserChips{width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;flex-wrap:nowrap} #view-finances #finAccTypeFilter{width:100%;display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:8px} #view-finances #finAccTypeFilter .choice{width:100%;justify-content:center} #view-finances #finAccCurrencyFilter{max-width:none !important;width:100%} #view-finances #finDebtUserChips{width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;flex-wrap:nowrap} #view-finances #finDebtKindPills{width:100%;display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:8px} #view-finances #finDebtKindPills .choice{width:100%;justify-content:center} #view-finances #finDebtStatusPills{width:100%;justify-content:flex-start;flex-wrap:wrap} #view-finances #finDebtFilterPerson{min-width:0;width:100%} #view-config .finhead{flex-direction:column;align-items:stretch;gap:10px;overflow:visible} #view-config .finhead-left{width:100%;flex-direction:column;align-items:stretch;gap:10px} #view-config .finhead-right{width:100%;justify-content:flex-start} #view-config .finhead-right .btn{width:100%} #view-config #finCatOwnerChips{width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;flex-wrap:nowrap} #view-config #finCatFilterKind{max-width:none !important;width:100%} #view-config #finCatFilterName{min-width:0;width:100%} #view-projects .card > .row.wrap{gap:6px !important;margin-bottom:0 !important;padding-bottom:2px !important} #view-projects #projectsBoard{margin-top:-60px !important;padding-top:0 !important} #view-projects .card > .row.wrap > .row.wrap{display:grid;grid-template-columns:1fr;gap:10px;flex-wrap:wrap;overflow:visible} #view-projects #projFilterCategory{max-width:none !important;width:100%} #view-projects #projFilterStatus{max-width:none !important;width:100%} #view-projects #projFilterSearch{min-width:0 !important;width:100%} #view-projects #projFilterUserChips{width:100%;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;flex-wrap:nowrap} #view-projects #projFilterEmpty{width:100%;display:grid;grid-template-columns:repeat(2, minmax(0, 1fr));gap:8px} #view-projects #projFilterEmpty .choice{width:100%;justify-content:center} #projectModal .row.wrap{display:grid;grid-template-columns:1fr;gap:10px} #projectModal #projModalName{min-width:0 !important;width:100%} #projectModal #projModalColor{max-width:none !important;width:100%;height:44px} #projectModal #projModalSaveBtn{width:100%} #projectModal #projModalDelBtn{width:100%} #taskDrawer #todayComposer{display:flex;flex-direction:column;gap:10px} #taskDrawer #todayComposer > .row.wrap{display:grid;grid-template-columns:1fr;gap:10px;margin-bottom:10px !important} #taskDrawer #taskDate{max-width:none !important;width:100%} #taskDrawer #taskTime12{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:8px} #taskDrawer #taskUserPickRow{flex-direction:column;align-items:center;justify-content:flex-start;text-align:center;gap:8px;margin-top:0 !important;margin-bottom:10px !important} #taskDrawer #taskEditExtra{width:100%} #taskDrawer #taskEditExtra .row{justify-content:center !important} #taskDrawer #userPick{justify-content:center;gap:12px;margin-top:0 !important;margin-bottom:10px !important} #taskDrawer #userPick .uava{width:48px;height:48px;border-width:3px} #taskDrawer #userPick .uopt.on .uava{border-width:4px} #habitModal #addHabitBtn{width:100%} #accModal #finAddAccBtn{width:100%} #accModal #finAccUsersLabel{text-align:center} #accModal #finAccUsers{justify-content:center;gap:12px;margin-top:0 !important;margin-bottom:10px !important} #accModal #finAccUsers .uava{width:48px;height:48px;border-width:3px} #accModal #finAccUsers .uopt.on .uava{border-width:4px} .calcard .calhead{flex-direction:column;align-items:stretch} .calcard .calhead > .row{justify-content:space-between} .calcard .calhead > .select{max-width:100% !important} #calDayFilters{flex-direction:column;align-items:stretch} #calDayFilters #calDayFilterStatus{max-width:100% !important} #calDayFilters #calDayFilterSearch{min-width:0;width:100%} } </style> </head> <body> <script>try{if(sessionStorage.getItem('skipSplashOnce')==='1'){document.documentElement.classList.add('skip-splash');sessionStorage.removeItem('skipSplashOnce');}}catch(e){}</script> <div id="splash" class="splash" aria-hidden="false"> <video id="splashVid" class="splashvid" autoplay muted playsinline loop preload="auto" src="assets/img/spinnerV.mp4"></video> </div> <div class="wrap"> <div class="topbar"> <div class="lefthead"> <button id="menuBtn" class="iconbtn" type="button" aria-label="Menú">☰</button> <div class="brand"><img class="brandmark" src="assets/img/isotipo.png" alt=""><span id="topbarTitle">HOY</span><span id="topbarDate" class="topdate"><?= htmlspecialchars(date('d-m-Y', strtotime($today))) ?></span></div> </div> <div class="topnav" id="topNav"> <button class="topnavbtn" type="button" data-view="today">Hoy</button> <button class="topnavbtn" type="button" data-view="habits">Hábitos</button> <button class="topnavbtn" type="button" data-view="finances">Finanzas</button> <button class="topnavbtn" type="button" data-view="calendar">Bitácora</button> <button class="topnavbtn" type="button" data-view="metrics">Métricas</button> <button class="topnavbtn" type="button" data-view="projects">Proyectos</button> <button class="topnavbtn" type="button" data-view="config">Configuración</button> </div> </div> <div class="views"> <section id="view-today" class="view"> <div class="card"> <div class="tasktoolbar"> <div class="tasktoolbar-left"> <div class="filters"> <a class="chip <?= $selectedUserId===0?'active':'' ?>" href="?" data-user="0" data-keep-hash="1">Todos</a> <?php foreach ($users as $u): ?> <a class="chip userchip <?= $selectedUserId===(int)$u['id']?'active':'' ?>" href="?user=<?= (int)$u['id'] ?>" data-user="<?= (int)$u['id'] ?>" data-keep-hash="1" title="<?= htmlspecialchars($u['name']) ?>" aria-label="<?= htmlspecialchars($u['name']) ?>" style="--ring:<?= htmlspecialchars($u['color']) ?>"> <span class="chipava" style="background-image:url('<?= htmlspecialchars((string)($u['photo'] ?? '')) ?>')"></span> </a> <?php endforeach; ?> </div> </div> <div class="tasktoolbar-right"> <select id="taskFilterStatus" class="select tfilter" aria-label="Filtrar por estado"> <option value="">Todos los estados</option> <option value="pending">Pendiente</option> <option value="progress">En progreso</option> <option value="done">Hecho</option> </select> <select id="taskFilterCategory" class="select tfilter" aria-label="Filtrar por categoría"> <option value="">Todas las categorías</option> <?php foreach ($categories as $c): ?> <option value="<?= (int)$c['id'] ?>"><?= htmlspecialchars($c['name']) ?></option> <?php endforeach; ?> </select> <select id="taskFilterProject" class="select tfilter" aria-label="Filtrar por proyecto"> <option value="">Todos los proyectos</option> <?php foreach ($projects as $p): ?> <option value="<?= (int)$p['id'] ?>"><?= htmlspecialchars($p['name']) ?></option> <?php endforeach; ?> </select> <button id="openTaskDrawerBtn" class="btn ghost" type="button">Nueva tarea</button> </div> </div> <div id="tasks"> <?php foreach($tasks as $t): ?> <?php $colors = array_values(array_filter(explode(',', (string)($t['colors'] ?? '')))); $cat = (string)($t['category_name'] ?? ''); $proj = (string)($t['project_name'] ?? ''); $projColor = (string)($t['project_color'] ?? ''); $tt = (string)($t['task_time'] ?? ''); $ttShort = $tt !== '' ? substr($tt, 0, 5) : ''; $ttView = $ttShort !== '' ? format_time_ampm($ttShort) : ''; $uids = array_values(array_unique(array_filter(array_map('intval', explode(',', (string)($t['user_ids'] ?? '')))))); ?> <div class="task <?= htmlspecialchars($t['status']) ?>" draggable="true" data-id="<?= (int)$t['id'] ?>" data-status="<?= htmlspecialchars($t['status']) ?>" data-user-ids="<?= htmlspecialchars(implode(',', $uids)) ?>" data-time="<?= htmlspecialchars($ttShort) ?>" data-date="<?= htmlspecialchars((string)($t['task_date'] ?? $today)) ?>" data-project-id="<?= (int)($t['project_id'] ?? 0) ?>" data-category-id="<?= (int)($t['category_id'] ?? 0) ?>"> <div class="handle"></div> <div class="title"> <div class="viewbox" data-role="viewbox"> <div data-role="title_view"><?= htmlspecialchars($t['title']) ?></div> <div class="muted" style="margin-top:6px"> <?php if ($ttView !== ''): ?><span class="badge" data-role="time_view" data-time="<?= htmlspecialchars($ttShort) ?>"><?= htmlspecialchars($ttView) ?></span><?php endif; ?> <?php if ($cat !== ''): ?><span class="badge" data-role="cat_view"><?= htmlspecialchars($cat) ?></span><?php endif; ?> <?php if ($proj !== ''): ?><span class="badge" data-role="proj_view" style="border-color:<?= htmlspecialchars($projColor !== '' ? $projColor : '#111111') ?>"><?= htmlspecialchars($proj) ?></span><?php endif; ?> <div class="pavatars" data-role="user_views_view"> <?php foreach ($uids as $uid): ?> <?php $uu = $userMap[$uid] ?? null; if (!$uu) continue; ?> <span class="pava" title="<?= htmlspecialchars((string)$uu['name']) ?>" style="background-image:url('<?= htmlspecialchars((string)($uu['photo'] ?? '')) ?>');border-color:<?= htmlspecialchars((string)($uu['color'] ?? '#111111')) ?>"></span> <?php endforeach; ?> </div> </div> </div> <div class="editbox" data-role="editbox" style="display:none"> <input class="input" data-role="title_edit" value="<?= htmlspecialchars($t['title']) ?>" /> <select class="select" data-role="cat_edit"> <option value="">Sin categoría</option> <?php foreach ($categories as $c): ?> <option value="<?= (int)$c['id'] ?>" <?= ((int)($t['category_id'] ?? 0) === (int)$c['id']) ? 'selected' : '' ?>><?= htmlspecialchars($c['name']) ?></option> <?php endforeach; ?> </select> <select class="select" data-role="proj_edit"> <option value="">Sin proyecto</option> <?php foreach ($projects as $p): ?> <option value="<?= (int)$p['id'] ?>" <?= ((int)($t['project_id'] ?? 0) === (int)$p['id']) ? 'selected' : '' ?>><?= htmlspecialchars($p['name']) ?></option> <?php endforeach; ?> </select> <input class="select" type="date" data-role="date_edit" style="max-width:180px" value="<?= htmlspecialchars((string)($t['task_date'] ?? $today)) ?>" /> <div class="time12" data-role="time_edit"> <select class="select" data-role="h"> <option value="">—</option> <?php for ($hh=1; $hh<=12; $hh++): ?> <option value="<?= $hh ?>"><?= $hh ?></option> <?php endfor; ?> </select> <select class="select" data-role="m"> <?php for ($mm=0; $mm<=59; $mm++): $v=str_pad((string)$mm,2,'0',STR_PAD_LEFT); ?> <option value="<?= $v ?>"><?= $v ?></option> <?php endfor; ?> </select> <select class="select" data-role="ap"> <option value="AM">AM</option> <option value="PM">PM</option> </select> </div> </div> </div> <div class="pavatars" data-role="user_views_edit" style="display:none"> <?php foreach ($uids as $uid): ?> <?php $uu = $userMap[$uid] ?? null; if (!$uu) continue; ?> <span class="pava" title="<?= htmlspecialchars((string)$uu['name']) ?>" style="background-image:url('<?= htmlspecialchars((string)($uu['photo'] ?? '')) ?>');border-color:<?= htmlspecialchars((string)($uu['color'] ?? '#111111')) ?>"></span> <?php endforeach; ?> </div> <div class="taskops"> <button class="tbtn icon" data-role="edit" type="button" aria-label="Editar">✎</button> <button class="tbtn icon" data-role="del" type="button" aria-label="Eliminar" style="display:none">✕</button> <button class="statusbtn" data-role="status"><?= htmlspecialchars(status_label((string)$t['status'])) ?></button> <button class="tbtn" data-role="save" type="button" style="display:none">Guardar</button> <button class="tbtn" data-role="cancel" type="button" style="display:none">Cancelar</button> </div> </div> <?php endforeach ?> </div> </div> <div class="card" style="margin-top:12px"> <div class="row" style="justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px"> <h3 style="margin:0">Movimientos</h3> <button id="openMoveModalBtn" class="btn" type="button">Nuevo movimiento</button> </div> <div class="finhead"> <div class="finhead-left"> <input id="finMoveDate" class="input" type="date" value="<?= htmlspecialchars($today) ?>" style="max-width:160px" /> <select id="finMoveListUser" class="select" style="max-width:200px;display:none"> <option value="">Todos</option> <?php foreach ($users as $u): ?> <option value="<?= (int)$u['id'] ?>"><?= htmlspecialchars($u['name']) ?></option> <?php endforeach; ?> </select> <div id="finMoveUserChips" class="filters" style="max-width:260px"></div> <div class="choices" id="finMoveTypeFilter"> <button class="choice on" type="button" data-type="">Todos</button> <button class="choice" type="button" data-type="income">Ingresos</button> <button class="choice" type="button" data-type="expense">Egresos</button> <button class="choice" type="button" data-type="transfer">Transferencias</button> </div> </div> <div class="finhead-right"> <div id="finMoveDaySummary" class="finmovesummary"></div> </div> </div> <div id="finMoveList" class="list"></div> </div> </section> <section id="view-projects" class="view"> <div class="card"> <div class="row wrap" style="margin-bottom:10px;justify-content:space-between;align-items:center"> <div class="row wrap" style="flex:1;min-width:0"> <select id="projFilterCategory" class="select" style="max-width:220px"> <option value="">Todas las categorías</option> <?php foreach ($categories as $c): ?> <option value="<?= (int)$c['id'] ?>"><?= htmlspecialchars($c['name']) ?></option> <?php endforeach; ?> </select> <select id="projFilterUser" class="select" style="max-width:200px;display:none"> <option value="">Todos</option> <?php foreach ($users as $u): ?> <option value="<?= (int)$u['id'] ?>"><?= htmlspecialchars($u['name']) ?></option> <?php endforeach; ?> </select> <div id="projFilterUserChips" class="filters" style="max-width:260px"></div> <select id="projFilterStatus" class="select" style="max-width:180px"> <option value="">Todos los estados</option> <option value="pending">Pendiente</option> <option value="progress">En progreso</option> <option value="done">Hecho</option> </select> <input id="projFilterSearch" class="input" placeholder="Buscar" style="min-width:220px" /> <div class="choices" id="projFilterEmpty"> <button class="choice on" type="button" data-empty="1">Todos</button> <button class="choice" type="button" data-empty="0">Con tareas</button> </div> </div> <div class="row" style="flex:0 0 auto"> <button id="openProjectModalBtn" class="btn" type="button">Nuevo proyecto</button> </div> </div> <div id="projectsBoard" class="deck"></div> </div> </section> <section id="view-calendar" class="view"> <div class="card"> <div class="calwrap"> <div class="calcard"> <div class="calhead"> <div class="row" style="gap:10px"> <div class="ttl" id="calMonthLabel"></div> <div class="row"> <button id="calPrev" class="btn ghost" type="button">◀</button> <button id="calNext" class="btn ghost" type="button">▶</button> </div> </div> <select id="calProject" class="select" style="max-width:260px"> <option value="">Todos los proyectos</option> <?php foreach ($projects as $p): ?> <option value="<?= (int)$p['id'] ?>"><?= htmlspecialchars($p['name']) ?></option> <?php endforeach; ?> </select> </div> <div class="calgrid" id="calDows"> <div class="caldow">L</div><div class="caldow">M</div><div class="caldow">X</div><div class="caldow">J</div><div class="caldow">V</div><div class="caldow">S</div><div class="caldow">D</div> </div> <div class="calgrid" id="calGrid" style="margin-top:8px"></div> </div> <div class="calpanel"> <div class="calhead"> <div class="row" id="calDayTop"> <div id="calDayUserTop" style="display:none"> <select id="calDayFilterUser" class="select" style="max-width:200px;display:none"> <option value="">Todos</option> <?php foreach ($users as $u): ?> <option value="<?= (int)$u['id'] ?>"><?= htmlspecialchars($u['name']) ?></option> <?php endforeach; ?> </select> <div id="calDayFilterUserChips" class="filters calday-users" style="max-width:260px"></div> </div> <div class="ttl" id="calDayLabel">Selecciona un día</div> </div> <div class="row" id="calDayFilters" style="display:none"> <select id="calDayFilterStatus" class="select" style="max-width:190px"> <option value="">Todos los estados</option> <option value="pending">Pendiente</option> <option value="progress">En progreso</option> <option value="done">Hecho</option> </select> <input id="calDayFilterSearch" class="input" placeholder="Buscar" style="min-width:200px" /> </div> </div> <div id="calTasks" class="achlist"></div> </div> </div> </div> </section> <section id="view-config" class="view"> <div class="card"> <h3>Usuarios</h3> <div class="row wrap" style="margin-bottom:10px"> <input id="userName" class="input" placeholder="Nombre" /> <input id="userPhoto" class="file" type="file" accept="image/png,image/jpeg,image/webp" /> <input id="userColor" class="select" type="color" value="#111111" style="padding:6px;height:40px" /> <button id="addUserBtn" class="btn">Crear</button> </div> <div id="userList" class="list"> <?php foreach ($users as $u): ?> <?php $uphoto = (string)($u['photo'] ?? ''); ?> <div class="catitem" data-id="<?= (int)$u['id'] ?>"> <div class="avatar" style="background-image:url('<?= htmlspecialchars($uphoto) ?>');border-color:<?= htmlspecialchars($u['color']) ?>"></div> <input class="input" data-role="name" value="<?= htmlspecialchars($u['name']) ?>" /> <input class="file" data-role="photo_file" type="file" accept="image/png,image/jpeg,image/webp" /> <input class="select" data-role="color" type="color" value="<?= htmlspecialchars($u['color']) ?>" style="padding:6px;height:40px" /> <button class="btn ghost" data-role="save">Guardar</button> <button class="btn ghost" data-role="del">Eliminar</button> </div> <?php endforeach; ?> </div> </div> <div class="card" style="margin-top:12px"> <h3>Categorías (Tareas)</h3> <div class="row" style="margin-bottom:10px"> <input id="catName" class="input" placeholder="Nueva categoría" /> <button id="addCatBtn" class="btn">Crear</button> </div> <div id="catList" class="list"> <?php foreach ($categories as $c): ?> <div class="catitem" data-id="<?= (int)$c['id'] ?>"> <input class="input" value="<?= htmlspecialchars($c['name']) ?>" /> <button class="btn ghost" data-role="save">Guardar</button> <button class="btn ghost" data-role="del">Eliminar</button> </div> <?php endforeach; ?> </div> </div> <div class="card" style="margin-top:12px"> <h3>Categorías (Finanzas)</h3> <div class="finhead"> <div class="finhead-left"> <select id="finCatFilterOwner" class="select" style="max-width:220px;display:none"> <option value="">Todas</option> <option value="shared">Compartidas</option> <?php foreach ($users as $u): ?> <option value="<?= (int)$u['id'] ?>"><?= htmlspecialchars($u['name']) ?></option> <?php endforeach; ?> </select> <div id="finCatOwnerChips" class="filters" style="max-width:340px"></div> <select id="finCatFilterKind" class="select" style="max-width:220px"> <option value="">Tipo</option> <option value="both">Ingreso y egreso</option> <option value="income">Solo ingreso</option> <option value="expense">Solo egreso</option> </select> <input id="finCatFilterName" class="input" placeholder="Buscar" style="min-width:220px" /> </div> <div class="finhead-right"> <button id="openFinCatModalBtn" class="btn" type="button">Nueva categoría</button> </div> </div> <div id="finCatList" class="deck" style="margin-top:10px"></div> </div> </section> <section id="view-habits" class="view"> <div class="card"> <div class="tasktoolbar"> <div class="tasktoolbar-left"> <div class="filters"> <a class="chip <?= $selectedUserId===0?'active':'' ?>" href="?" data-user="0" data-keep-hash="1">Todos</a> <?php foreach ($users as $u): ?> <a class="chip userchip <?= $selectedUserId===(int)$u['id']?'active':'' ?>" href="?user=<?= (int)$u['id'] ?>" data-user="<?= (int)$u['id'] ?>" data-keep-hash="1" title="<?= htmlspecialchars($u['name']) ?>" aria-label="<?= htmlspecialchars($u['name']) ?>" style="--ring:<?= htmlspecialchars($u['color']) ?>"> <span class="chipava" style="background-image:url('<?= htmlspecialchars((string)($u['photo'] ?? '')) ?>')"></span> </a> <?php endforeach; ?> </div> </div> <div class="tasktoolbar-right"> <button id="openHabitModalBtn" class="btn ghost" type="button">Nuevo hábito</button> </div> </div> <div id="habitsComposerMount"> <div id="habitsComposer" class="todaycomposer"> <div class="row wrap" style="margin-bottom:10px"> <select id="habitUser" class="select" style="display:none"> <?php foreach ($users as $u): ?> <option value="<?= (int)$u['id'] ?>" <?= $selectedUserId===(int)$u['id']?'selected':'' ?>><?= htmlspecialchars($u['name']) ?></option> <?php endforeach; ?> </select> <div id="habitUserChips" class="filters" style="max-width:260px"></div> <input id="habitName" class="input" placeholder="Nuevo hábito" /> </div> <div class="muted" style="margin-bottom:8px">Días activos</div> <div id="habitDays" class="userpick dayspick" style="margin-bottom:12px;--accent:#111"> <?php $dnames = [1=>'Lun',2=>'Mar',3=>'Mié',4=>'Jue',5=>'Vie',6=>'Sáb',7=>'Dom']; $dow = (int)date('N'); foreach ($dnames as $i => $name): ?> <label class="uopt <?= $i===$dow?'on':'' ?>" data-day="<?= $i ?>" style="opacity:<?= $i===$dow?1:.85 ?>"> <span class="pill"><?= htmlspecialchars($name) ?></span> <input type="checkbox" <?= $i===$dow?'checked':'' ?> /> </label> <?php endforeach; ?> </div> <div class="row" style="justify-content:flex-end"> <button id="addHabitBtn" class="btn">Crear</button> </div> </div> </div> <div id="habitList" class="list"> <?php $allHabitIds = array_map(fn($h) => (int)$h['id'], $habits); $habitLogMap = []; if ($allHabitIds) { $stmt = $pdo->prepare("SELECT habit_id, log_date, completed FROM habit_logs WHERE habit_id IN (".implode(',', array_fill(0, count($allHabitIds), '?')).")"); $stmt->execute($allHabitIds); foreach ($stmt->fetchAll() as $r) { $hid = (int)$r['habit_id']; $d = (string)$r['log_date']; if (!isset($habitLogMap[$hid])) $habitLogMap[$hid] = []; $habitLogMap[$hid][$d] = (int)$r['completed']; } } $todayDow = (int)date('N', strtotime($today)); $weekStart = date('Y-m-d', strtotime($today.' -'.($todayDow-1).' day')); $weekDates = []; for ($i=1; $i<=7; $i++) { $weekDates[$i] = date('Y-m-d', strtotime($weekStart.' +'.($i-1).' day')); } foreach ($habits as $h): $daysActive = array_values(array_filter(array_map('intval', explode(',', (string)$h['days'])))); $map = $habitLogMap[(int)$h['id']] ?? []; [$cur, $max, $curDow] = habit_streaks($daysActive, $map, $today); $activeToday = in_array($curDow, $daysActive, true); $completedToday = ($habitTodayMap[(int)$h['id']] ?? 0) === 1; $weekAchievedAllDays = []; for ($i=1; $i<=7; $i++) { $dt = $weekDates[$i] ?? null; if ($dt && (($map[$dt] ?? 0) === 1)) $weekAchievedAllDays[] = (int)$i; } ?> <div class="habit" data-id="<?= (int)$h['id'] ?>" data-user-id="<?= (int)$h['user_id'] ?>" data-days="<?= htmlspecialchars(implode(',', $daysActive)) ?>" data-week-achieved-all="<?= htmlspecialchars(implode(',', $weekAchievedAllDays)) ?>" style="border-left:6px solid <?= htmlspecialchars($h['user_color']) ?>;--accent:<?= htmlspecialchars($h['user_color']) ?>"> <div class="name"> <div data-role="hview"> <div data-role="hname_view"><?= htmlspecialchars($h['name']) ?></div> <div class="muted" style="margin-top:2px"> <?= htmlspecialchars($h['user_name']) ?> <span class="pill">Racha <?= (int)$cur ?></span> <span class="pill">Máx <?= (int)$max ?></span> </div> <div class="weekbadges" style="--accent:<?= htmlspecialchars($h['user_color']) ?>"> <?php foreach ($daysActive as $i): $dn = $dnames[(int)$i] ?? ''; ?> <span class="pill <?= in_array((int)$i, $weekAchievedAllDays, true) ? 'on' : '' ?>" data-day="<?= (int)$i ?>"><?= htmlspecialchars($dn) ?></span> <?php endforeach; ?> </div> </div> <div class="habitedit" data-role="hedit" style="display:none"> <div class="row wrap" style="margin-top:8px"> <input class="input" data-role="hname_edit" value="<?= htmlspecialchars($h['name']) ?>" /> </div> <div class="muted" style="margin-top:8px;margin-bottom:6px">Días activos</div> <div class="userpick dayspick" data-role="hdays" style="--accent:<?= htmlspecialchars($h['user_color']) ?>"> <?php foreach ($dnames as $i => $dn): ?> <label class="uopt <?= in_array((int)$i, $daysActive, true) ? 'on' : '' ?>" data-day="<?= (int)$i ?>"> <span class="pill"><?= htmlspecialchars($dn) ?></span> <input type="checkbox" <?= in_array((int)$i, $daysActive, true) ? 'checked' : '' ?> /> </label> <?php endforeach; ?> </div> </div> </div> <div class="habitops"> <button class="tbtn icon" data-role="edit" type="button" aria-label="Editar">✎</button> <button class="tbtn icon" data-role="del" type="button" aria-label="Eliminar" style="display:none">✕</button> <button class="toggle <?= $completedToday?'on':'' ?>" data-role="toggle" <?= $activeToday?'':'disabled' ?>><?= $completedToday?'Logrado':'Marcar' ?></button> <button class="tbtn" type="button" data-role="save" style="display:none">Guardar</button> <button class="tbtn" type="button" data-role="cancel" style="display:none">Cancelar</button> </div> </div> <?php endforeach; ?> </div> </div> </section> <section id="view-metrics" class="view"> <div class="card"> <h3>Métricas</h3> <div id="kpis" class="kpis"></div> <div class="canwrap"> <div class="chartblock"> <div class="chartttl">Últimos 7 días</div> <canvas id="cvDaily" width="900" height="220"></canvas> </div> <div class="chartblock"> <div class="chartttl">Hoy por usuario</div> <canvas id="cvUser" width="900" height="220"></canvas> </div> <div class="chartblock"> <div class="chartttl">Hábitos (hoy)</div> <canvas id="cvHabit" width="900" height="220"></canvas> </div> </div> </div> </section> <section id="view-finances" class="view"> <div class="card"> <h3>Finanzas</h3> <div id="finSummary" class="kpis"></div> </div> <div class="card" style="margin-top:12px"> <div class="finhead"> <div class="finhead-left"> <select id="finAccListUser" class="select" style="max-width:200px;display:none"> <option value="">Todos</option> <?php foreach ($users as $u): ?> <option value="<?= (int)$u['id'] ?>"><?= htmlspecialchars($u['name']) ?></option> <?php endforeach; ?> </select> <div id="finAccUserChips" class="filters" style="max-width:260px"></div> <div class="choices" id="finAccTypeFilter"> <button class="choice on" type="button" data-type="">Todos</button> <button class="choice" type="button" data-type="cash">Efectivo</button> <button class="choice" type="button" data-type="bank">Banco</button> <button class="choice" type="button" data-type="wallet">Billetera</button> <button class="choice" type="button" data-type="card">Tarjeta</button> <button class="choice" type="button" data-type="other">Otro</button> </div> <select id="finAccCurrencyFilter" class="select" style="max-width:140px"> <option value="">Moneda</option> </select> </div> <div class="finhead-right"> <button id="openAccModalBtn" class="btn" type="button">Nueva cuenta</button> </div> </div> <div id="finAccountList" class="kpis"></div> </div> <div class="card" style="margin-top:12px"> <div class="finhead"> <div class="finhead-left"> <div id="finDebtUserChips" class="filters"></div> <div class="choices" id="finDebtKindPills"> <button class="choice debtpill on" type="button" data-kind="">Todos</button> <button class="choice debtpill iowe" type="button" data-kind="i_owe">Yo debo</button> <button class="choice debtpill theyowe" type="button" data-kind="they_owe_me">Me deben</button> </div> <div class="debtstatus" id="finDebtStatusPills"> <button class="circlebtn on" type="button" data-status="" aria-label="Todas">⧉</button> <button class="circlebtn" type="button" data-status="open" aria-label="Abiertas">○</button> <button class="circlebtn" type="button" data-status="closed" aria-label="Cerradas">✓</button> </div> <input id="finDebtFilterPerson" class="input" placeholder="Persona" style="min-width:220px" /> </div> <div class="finhead-right"> <button id="openDebtModalBtn" class="btn" type="button">Nueva deuda</button> </div> </div> <div id="finDebtList" class="list"></div> </div> </section> </div> </div> <div id="projectModalBackdrop" class="modalbackdrop"></div> <div id="projectModal" class="modal" aria-hidden="true"> <div class="modalhead"> <div id="projectModalTitle" class="ttl">Nuevo proyecto</div> <button id="projectModalX" class="iconbtn" type="button" aria-label="Cerrar">✕</button> </div> <div class="row wrap" style="margin-bottom:10px"> <select id="projModalCategory" class="select"> <option value="">Categoría…</option> <?php foreach ($categories as $c): ?> <option value="<?= (int)$c['id'] ?>"><?= htmlspecialchars($c['name']) ?></option> <?php endforeach; ?> </select> <input id="projModalName" class="input" placeholder="Nombre del proyecto" style="min-width:220px" /> <input id="projModalColor" class="select" type="color" value="#111111" style="padding:6px;height:40px;max-width:120px" /> <button id="projModalSaveBtn" class="btn" type="button">Crear</button> <button id="projModalDelBtn" class="btn danger ghost" type="button" style="display:none">Eliminar</button> </div> </div> <div id="moveModalBackdrop" class="modalbackdrop"></div> <div id="moveModal" class="modal" aria-hidden="true"> <div class="modalhead"> <div class="ttl">Nuevo movimiento</div> <button id="moveModalX" class="iconbtn" type="button" aria-label="Cerrar">✕</button> </div> <div class="row wrap" style="margin-bottom:10px"> <input id="finMoveDateModal" class="select" type="date" style="max-width:180px" /> <div id="finMoveTime12" class="time12"> <select class="select" data-role="h"> <option value="">—</option> <?php for ($hh=1; $hh<=12; $hh++): ?> <option value="<?= $hh ?>"><?= $hh ?></option> <?php endfor; ?> </select> <select class="select" data-role="m"> <?php for ($mm=0; $mm<=59; $mm++): $v=str_pad((string)$mm,2,'0',STR_PAD_LEFT); ?> <option value="<?= $v ?>"><?= $v ?></option> <?php endfor; ?> </select> <select class="select" data-role="ap"> <option value="AM">AM</option> <option value="PM">PM</option> </select> </div> <select id="finMoveType" class="select" style="max-width:160px;display:none"> <option value="income">Ingreso</option> <option value="expense">Egreso</option> <option value="transfer">Transferencia</option> </select> <div class="choices" id="finMoveTypePills"> <button class="choice typepill income on" type="button" data-value="income" aria-label="Ingreso"> <span class="ico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17l10-10"/><path d="M9 7h8v8"/></svg></span> <span>Ingreso</span> </button> <button class="choice typepill expense" type="button" data-value="expense" aria-label="Egreso"> <span class="ico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7l10 10"/><path d="M9 17h8V9"/></svg></span> <span>Egreso</span> </button> <button class="choice typepill transfer" type="button" data-value="transfer" aria-label="Transferencia"> <span class="ico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 8h14"/><path d="M13 4l4 4-4 4"/><path d="M17 16H3"/><path d="M11 12l-4 4 4 4"/></svg></span> <span>Transferencia</span> </button> </div> <select id="finMoveUser" class="select" style="max-width:180px;display:none"> <?php foreach ($users as $u): ?> <option value="<?= (int)$u['id'] ?>" <?= $selectedUserId===(int)$u['id']?'selected':'' ?>><?= htmlspecialchars($u['name']) ?></option> <?php endforeach; ?> </select> <div id="finMoveUserChipsModal" class="filters" style="max-width:260px"></div> <select id="finMoveAccount" class="select"></select> <select id="finMoveToAccount" class="select" style="display:none"></select> <input id="finMoveAmount" class="input" placeholder="Monto" inputmode="decimal" style="max-width:160px" /> <input id="finMoveToAmount" class="input" placeholder="Monto destino" inputmode="decimal" style="max-width:160px;display:none" /> <select id="finMoveCategory" class="select" style="max-width:220px"></select> <input id="finMoveNote" class="input" placeholder="Nota" style="min-width:220px" /> <button id="finAddMoveBtn" class="btn" type="button">Registrar</button> </div> </div> <div id="accModalBackdrop" class="modalbackdrop"></div> <div id="accModal" class="modal" aria-hidden="true"> <div class="modalhead"> <div id="accModalTitle" class="ttl">Nueva cuenta</div> <button id="accModalX" class="iconbtn" type="button" aria-label="Cerrar">✕</button> </div> <div class="row wrap" style="margin-bottom:10px"> <input id="finAccName" class="input" placeholder="Nombre de cuenta" /> <select id="finAccType" class="select"> <option value="cash">Efectivo</option> <option value="bank">Banco</option> <option value="wallet">Billetera</option> <option value="card">Tarjeta</option> <option value="other">Otro</option> </select> <input id="finAccCurrency" class="input" placeholder="Moneda (ej: COP)" style="max-width:140px" /> <input id="finAccOpening" class="input" placeholder="Saldo inicial" inputmode="decimal" style="max-width:160px" /> <label class="chip" style="display:flex;align-items:center;gap:8px"><input id="finAccSharedAll" type="checkbox" /> Compartida</label> </div> <div id="finAccUsersLabel" class="muted" style="margin-bottom:6px">Usuarios de la cuenta (si no es compartida)</div> <div id="finAccUsers" class="userpick avapick"></div> <div class="row" style="justify-content:flex-end"> <button id="finAddAccBtn" class="btn" type="button">Crear</button> <button id="finDelAccBtn" class="btn danger ghost" type="button" style="display:none">Eliminar</button> </div> </div> <div id="accAdjustModalBackdrop" class="modalbackdrop"></div> <div id="accAdjustModal" class="modal" aria-hidden="true"> <div class="modalhead"> <div id="accAdjustTitle" class="ttl">Ajuste</div> <button id="accAdjustModalX" class="iconbtn" type="button" aria-label="Cerrar">✕</button> </div> <div id="accAdjustSub" class="muted" style="margin-bottom:10px;font-size:13px"></div> <div class="row wrap" style="margin-bottom:10px"> <input id="accAdjustExpected" class="input" placeholder="Saldo esperado" inputmode="decimal" style="max-width:200px" /> <input id="accAdjustDelta" class="input" placeholder="Diferencia (ajuste)" inputmode="decimal" style="max-width:220px" /> <button id="accAdjustApplyBtn" class="btn" type="button">Aplicar ajuste</button> </div> </div> <div id="debtModalBackdrop" class="modalbackdrop"></div> <div id="debtModal" class="modal" aria-hidden="true"> <div class="modalhead"> <div class="ttl">Nueva deuda</div> <button id="debtModalX" class="iconbtn" type="button" aria-label="Cerrar">✕</button> </div> <div class="row wrap" style="margin-bottom:10px"> <select id="finDebtUserModal" class="select" style="max-width:200px;display:none"> <?php foreach ($users as $u): ?> <option value="<?= (int)$u['id'] ?>" <?= $selectedUserId===(int)$u['id']?'selected':'' ?>><?= htmlspecialchars($u['name']) ?></option> <?php endforeach; ?> </select> <div id="finDebtUserChipsModal" class="filters" style="max-width:260px"></div> <select id="finDebtKindModal" class="select" style="max-width:180px"> <option value="i_owe">Yo debo</option> <option value="they_owe_me">Me deben</option> </select> <input id="finDebtStartDateModal" class="select" type="date" style="max-width:180px" /> <input id="finDebtPersonModal" class="input" placeholder="Persona" style="min-width:220px" /> <input id="finDebtCurrencyModal" class="input" placeholder="Moneda" style="max-width:140px" /> <input id="finDebtAmountModal" class="input" placeholder="Monto" inputmode="decimal" style="max-width:160px" /> <input id="finDebtNoteModal" class="input" placeholder="Nota" style="min-width:220px" /> <button id="finAddDebtModalBtn" class="btn" type="button">Crear</button> </div> </div> <div id="finCatModalBackdrop" class="modalbackdrop"></div> <div id="finCatModal" class="modal" aria-hidden="true"> <div class="modalhead"> <div class="ttl">Nueva categoría</div> <button id="finCatModalX" class="iconbtn" type="button" aria-label="Cerrar">✕</button> </div> <div class="row wrap" style="margin-bottom:10px"> <select id="finCatOwnerModal" class="select" style="max-width:220px;display:none"> <option value="">Compartida</option> <?php foreach ($users as $u): ?> <option value="<?= (int)$u['id'] ?>" <?= $selectedUserId===(int)$u['id']?'selected':'' ?>><?= htmlspecialchars($u['name']) ?></option> <?php endforeach; ?> </select> <div id="finCatOwnerChipsModal" class="filters" style="max-width:340px"></div> <select id="finCatKindModal" class="select" style="max-width:220px"> <option value="both">Ingreso y egreso</option> <option value="income">Solo ingreso</option> <option value="expense">Solo egreso</option> </select> <input id="finCatNameModal" class="input" placeholder="Nombre" style="min-width:240px" /> <button id="finAddFinCatModalBtn" class="btn" type="button">Crear</button> </div> </div> <div id="drawerBackdrop" class="drawerbackdrop"></div> <div id="drawer" class="drawer" aria-hidden="true"> <div class="drawerhead"> <div class="ttl">Menú</div> <button id="drawerClose" class="iconbtn" type="button" aria-label="Cerrar">✕</button> </div> <button class="draweritem" type="button" data-view="projects"><span class="drawerico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7h6l2 2h10v11H3z"/></svg></span>Proyectos</button> <button class="draweritem" type="button" data-view="metrics"><span class="drawerico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20V10"/><path d="M10 20V4"/><path d="M16 20v-8"/><path d="M2 20h20"/></svg></span>Métricas</button> <button class="draweritem" type="button" data-view="config"><span class="drawerico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 21v-7"/><path d="M4 10V3"/><path d="M12 21v-9"/><path d="M12 8V3"/><path d="M20 21v-5"/><path d="M20 12V3"/><path d="M2 14h4"/><path d="M10 10h4"/><path d="M18 16h4"/></svg></span>Configuración</button> <button id="installAppBtn" class="draweritem" type="button" style="display:none"><span class="drawerico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M5 21h14"/></svg></span>Instalar app</button> </div> <div class="bottomnav" id="bottomNav"> <button class="navbtn" type="button" data-view="today"><span class="navico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10.5L12 3l9 7.5"/><path d="M5 10v10h14V10"/></svg></span><span class="navlbl">Hoy</span></button> <button class="navbtn" type="button" data-view="calendar"><span class="navico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h18"/></svg></span><span class="navlbl">Bitácora</span></button> <button class="navbtn" type="button" data-view="habits"><span class="navico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6h12"/><path d="M9 12h12"/><path d="M9 18h12"/><path d="M3 6l1.5 1.5L7 5"/><path d="M3 12l1.5 1.5L7 11"/><path d="M3 18l1.5 1.5L7 17"/></svg></span><span class="navlbl">Hábitos</span></button> <button class="navbtn" type="button" data-view="finances"><span class="navico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7h18"/><path d="M5 7v14h14V7"/><path d="M7 11h10"/><path d="M7 15h6"/></svg></span><span class="navlbl">Fin</span></button> <button id="bottomMoreBtn" class="navbtn" type="button"><span class="navico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></svg></span><span class="navlbl">Más</span></button> </div> <div id="fabMenuBackdrop" class="fabmenubackdrop" aria-hidden="true"></div> <div id="fabMenu" class="fabmenu" aria-hidden="true"></div> <button id="fabAddTask" class="fab" type="button" aria-label="Nueva tarea" style="display:none">+</button> <button id="fabAddHabit" class="fab" type="button" aria-label="Nuevo hábito" style="display:none">+</button> <div id="toastHost" class="toasthost"></div> <div id="appConfirmBackdrop" class="modalbackdrop"></div> <div id="appConfirmModal" class="modal" aria-hidden="true"> <div class="modalhead"> <div id="appConfirmTitle" class="ttl">Confirmar</div> <button id="appConfirmX" class="iconbtn" type="button" aria-label="Cerrar">✕</button> </div> <div id="appConfirmMsg" class="muted" style="font-size:13px;margin-bottom:12px"></div> <div class="row" style="justify-content:flex-end;gap:10px"> <button id="appConfirmCancel" class="btn ghost" type="button">Cancelar</button> <button id="appConfirmOk" class="btn danger" type="button">Confirmar</button> </div> </div> <div id="appPromptBackdrop" class="modalbackdrop"></div> <div id="appPromptModal" class="modal" aria-hidden="true"> <div class="modalhead"> <div id="appPromptTitle" class="ttl">Ingresar</div> <button id="appPromptX" class="iconbtn" type="button" aria-label="Cerrar">✕</button> </div> <div id="appPromptMsg" class="muted" style="font-size:13px;margin-bottom:10px"></div> <input id="appPromptInput" class="input" /> <div class="row" style="justify-content:flex-end;gap:10px;margin-top:12px"> <button id="appPromptCancel" class="btn ghost" type="button">Cancelar</button> <button id="appPromptOk" class="btn" type="button">Aceptar</button> </div> </div> <div id="quickAddBackdrop" class="modalbackdrop"></div> <div id="quickAddModal" class="modal sheet" aria-hidden="true"> <div class="modalhead"> <div class="ttl">Agregar</div> <button id="quickAddX" class="iconbtn" type="button" aria-label="Cerrar">✕</button> </div> <div class="muted" style="margin-bottom:10px">¿Qué quieres añadir?</div> <div class="choices" style="flex-direction:column;align-items:stretch"> <button id="quickAddTask" class="choice" type="button" style="justify-content:center;padding:12px 14px;font-size:14px">Tarea</button> <button id="quickAddMove" class="choice" type="button" style="justify-content:center;padding:12px 14px;font-size:14px">Movimiento</button> </div> </div> <div id="taskDrawerBackdrop" class="modalbackdrop"></div> <div id="taskDrawer" class="modal" aria-hidden="true"> <div class="modalhead"> <div id="taskDrawerTitle" class="ttl">Nueva tarea</div> <button id="taskDrawerClose" class="iconbtn" type="button" aria-label="Cerrar">✕</button> </div> <div id="taskDrawerHost"> <div id="todayComposer" class="todaycomposer"> <div class="row wrap" style="margin-bottom:6px"> <input id="taskTitle" class="input" placeholder="Nueva tarea" /> <input id="taskDate" class="select" type="date" style="max-width:190px" /> <div id="taskTime12" class="time12"> <select class="select" data-role="h"> <option value="">—</option> <?php for ($hh=1; $hh<=12; $hh++): ?> <option value="<?= $hh ?>"><?= $hh ?></option> <?php endfor; ?> </select> <select class="select" data-role="m"> <?php for ($mm=0; $mm<=59; $mm++): $v=str_pad((string)$mm,2,'0',STR_PAD_LEFT); ?> <option value="<?= $v ?>"><?= $v ?></option> <?php endfor; ?> </select> <select class="select" data-role="ap"> <option value="AM">AM</option> <option value="PM">PM</option> </select> </div> <select id="taskCategory" class="select"> <option value="">Sin categoría</option> <?php foreach ($categories as $c): ?> <option value="<?= (int)$c['id'] ?>"><?= htmlspecialchars($c['name']) ?></option> <?php endforeach; ?> </select> <select id="taskProject" class="select"> <option value="">Sin proyecto</option> <?php foreach ($projects as $p): ?> <option value="<?= (int)$p['id'] ?>"><?= htmlspecialchars($p['name']) ?></option> <?php endforeach; ?> </select> </div> <div id="taskUserPickRow" class="row" style="justify-content:space-between;align-items:flex-end;margin-bottom:6px"> <div class="muted">Asignar usuarios (mínimo 1)</div> <div id="taskEditExtra" style="display:none"> <div class="row" style="gap:10px;align-items:center;justify-content:flex-end"> <div class="muted">Estado</div> <div class="choices" style="flex-wrap:nowrap"> <button id="taskStatusPending" class="choice" type="button">Pendiente</button> <button id="taskStatusProgress" class="choice" type="button">En progreso</button> <button id="taskStatusDone" class="choice" type="button">Logrado</button> </div> </div> </div> </div> <div id="userPick" class="userpick avapick" style="margin-bottom:16px"> <?php foreach ($users as $u): ?> <label class="uopt <?= ($selectedUserId > 0 && $selectedUserId === (int)$u['id']) ? 'on' : '' ?>" data-uid="<?= (int)$u['id'] ?>"> <span class="uava" style="background-image:url('<?= htmlspecialchars((string)($u['photo'] ?? '')) ?>');border-color:<?= htmlspecialchars($u['color']) ?>"></span> <span><?= htmlspecialchars($u['name']) ?></span> <input type="checkbox" /> </label> <?php endforeach; ?> </div> <div id="taskCloseBox" style="display:none;margin-bottom:14px"> <div class="muted" style="margin-bottom:6px">¿La tarea se culminó definitivamente o necesita una nueva ronda?</div> <div class="choices" style="margin-bottom:10px"> <button id="taskCloseOutcomeFinal" class="choice on" type="button">Culminada</button> <button id="taskCloseOutcomeRerun" class="choice" type="button">Nueva ronda</button> </div> <div id="taskCloseRerunBox" style="display:none;margin-bottom:10px"> <div class="muted" style="margin-bottom:6px">¿Cuándo crear la nueva ronda?</div> <div class="choices"> <button id="taskCloseRerunToday" class="choice on" type="button">HOY</button> <button id="taskCloseRerunTomorrow" class="choice" type="button">MAÑANA</button> </div> </div> <div class="muted" style="margin-bottom:6px">Observaciones del cierre</div> <textarea id="taskCloseNotes" class="textarea" placeholder="¿Qué pasó? ¿Qué aprendiste? ¿Qué queda pendiente?"></textarea> <div class="muted" style="margin-top:10px;margin-bottom:6px">Satisfacción del logro</div> <div id="taskCloseStars" class="stars"></div> </div> <div class="row" style="justify-content:flex-end"> <button id="delTaskBtn" class="btn danger ghost" type="button" style="display:none">Eliminar</button> <button id="addTaskBtn" class="btn">Agregar</button> </div> </div> </div> </div> <div id="habitModalBackdrop" class="modalbackdrop"></div> <div id="habitModal" class="modal" aria-hidden="true"> <div class="modalhead"> <div class="ttl">Nuevo hábito</div> <button id="habitModalX" class="iconbtn" type="button" aria-label="Cerrar">✕</button> </div> <div id="habitComposerHost"></div> </div> <div id="closeModalBackdrop" class="modalbackdrop"></div> <div id="closeModal" class="modal" aria-hidden="true"> <div class="modalhead"> <div class="ttl">Cierre de tarea</div> <button id="closeModalX" class="iconbtn" type="button" aria-label="Cerrar">✕</button> </div> <div id="closeModalTitle" style="font-weight:800;margin-bottom:10px"></div> <div class="muted" style="margin-bottom:6px">¿La tarea se culminó definitivamente o necesita una nueva ronda?</div> <div class="choices" style="margin-bottom:10px"> <button id="closeOutcomeFinal" class="choice on" type="button">Culminada</button> <button id="closeOutcomeRerun" class="choice" type="button">Nueva ronda</button> </div> <div id="closeRerunBox" style="display:none;margin-bottom:10px"> <div class="muted" style="margin-bottom:6px">¿Cuándo crear la nueva ronda?</div> <div class="choices"> <button id="closeRerunToday" class="choice on" type="button">HOY</button> <button id="closeRerunTomorrow" class="choice" type="button">MAÑANA</button> </div> </div> <div class="muted" style="margin-bottom:6px">Observaciones del cierre</div> <textarea id="closeNotes" class="textarea" placeholder="¿Qué pasó? ¿Qué aprendiste? ¿Qué queda pendiente?"></textarea> <div class="muted" style="margin-top:10px;margin-bottom:6px">Satisfacción del logro</div> <div id="closeStars" class="stars"></div> <div class="row" style="margin-top:12px;justify-content:flex-end"> <button id="closeCancel" class="btn ghost" type="button">Cancelar</button> <button id="closeConfirm" class="btn" type="button">Guardar</button> </div> </div> <script> let ACTOR_USER_ID_DEFAULT = <?= $selectedUserId > 0 ? (int)$selectedUserId : 'null' ?>; let CURRENT_USER_FILTER_ID = <?= (int)$selectedUserId ?>; const STATUS_LABEL = {pending:'Pendiente',progress:'En progreso',done:'Hecho'}; const DOW_LABEL = {1:'Lun',2:'Mar',3:'Mié',4:'Jue',5:'Vie',6:'Sáb',7:'Dom'}; function statusLabel(s){return STATUS_LABEL[s] || s;} const FIN_KIND_LABEL = {both:'Ingreso y egreso', income:'Solo ingreso', expense:'Solo egreso'}; const FIN_MOVE_TYPE_LABEL = {income:'Ingreso', expense:'Egreso', transfer:'Transferencia'}; const USER_PICK_KEY = 'day_task_userpick_v1'; const USERS = <?= json_encode(array_map(fn($u)=>[ 'id'=>(int)$u['id'], 'name'=>(string)$u['name'], 'photo'=>(string)($u['photo'] ?? ''), 'color'=>(string)($u['color'] ?? '#111111') ], $users), JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) ?>; const USER_BY_ID = new Map(USERS.map(u=>[String(u.id), u])); const CATEGORIES = <?= json_encode(array_map(fn($c)=>[ 'id'=>(int)$c['id'], 'name'=>(string)$c['name'] ], $categories), JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) ?>; const CATEGORY_BY_ID = new Map(CATEGORIES.map(c=>[String(c.id), c])); const PROJECTS = <?= json_encode(array_map(fn($p)=>[ 'id'=>(int)$p['id'], 'category_id'=>$p['category_id'] !== null ? (int)$p['category_id'] : null, 'name'=>(string)$p['name'], 'color'=>(string)($p['color'] ?? '#111111') ], $projects), JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) ?>; const PROJECT_BY_ID = new Map(PROJECTS.map(p=>[String(p.id), p])); const VIEWS = ['today','calendar','projects','config','habits','metrics','finances']; function setView(view){ if (!VIEWS.includes(view)) view = 'today'; const viewLabel = {today:'HOY',calendar:'BITÁCORA',habits:'HÁBITOS',finances:'FINANZAS',projects:'PROYECTOS',metrics:'MÉTRICAS',config:'CONFIG'}; const titleEl = document.getElementById('topbarTitle'); if (titleEl) titleEl.textContent = viewLabel[view] || 'HOY'; try { const fab = document.getElementById('fabAddTask'); if (fab) { const fabLabel = {today:'Agregar',calendar:'Agregar',habits:'Nuevo hábito',finances:'Agregar',projects:'Nuevo proyecto',metrics:'Agregar',config:'Ir a…'}; fab.setAttribute('aria-label', fabLabel[view] || 'Agregar'); } } catch (e) {} try { closeFabMenu && closeFabMenu(); } catch (e) {} VIEWS.forEach(v=>{ const el = document.getElementById('view-'+v); if (el) el.classList.toggle('active', v===view); }); document.querySelectorAll('[data-view]').forEach(btn=>{ const on = btn.getAttribute('data-view')===view; if (btn.classList.contains('navbtn') || btn.classList.contains('topnavbtn')) { btn.classList.toggle('active', on); } }); const moreBtn = document.getElementById('bottomMoreBtn'); if (moreBtn) { const inMain = (view === 'today' || view === 'calendar' || view === 'habits' || view === 'finances'); moreBtn.classList.toggle('active', !inMain); } if (view === 'finances' || view === 'today' || view === 'config') setTimeout(loadFinance, 0); if (view === 'projects') setTimeout(loadProjectsBoard, 0); setTimeout(updateFabVisibility, 0); } let PROJECTS_BOARD_STATE = {projects:[], tasks:[]}; let PROJ_EDIT_ID = null; function projDateCmp(a, b){ const ad = String(a.task_date||''); const bd = String(b.task_date||''); const at = String(a.task_time||''); const bt = String(b.task_time||''); if (ad !== bd) return ad < bd ? -1 : 1; if (at !== bt) return at < bt ? -1 : 1; return String(a.id||'') < String(b.id||'') ? -1 : 1; } function projSortUpcomingFirst(tasks){ const today = nowYmd(); const up = []; const past = []; tasks.forEach(t=>{ const d = String(t.task_date||''); if (d && d >= today) up.push(t); else past.push(t); }); up.sort(projDateCmp); past.sort((a,b)=>-projDateCmp(a,b)); return up.concat(past); } async function loadProjectsBoard(){ const view = document.getElementById('view-projects'); const host = document.getElementById('projectsBoard'); if (!view || !view.classList.contains('active') || !host) return; host.innerHTML = '<div class="muted">Cargando…</div>'; const categoryId = String(document.getElementById('projFilterCategory')?.value || '').trim(); const status = String(document.getElementById('projFilterStatus')?.value || '').trim(); const userId = String(document.getElementById('projFilterUser')?.value || '').trim(); const q = String(document.getElementById('projFilterSearch')?.value || '').trim(); const payload = {}; if (categoryId) payload.category_id = categoryId; if (status) payload.status = status; if (userId) payload.user_id = userId; if (q) payload.q = q; const r = await api('get_projects_board', payload); if (!r.ok) { host.innerHTML = '<div class="muted">No se pudo cargar</div>'; return; } PROJECTS_BOARD_STATE = {projects:r.projects||[], tasks:r.tasks||[]}; renderProjectsBoard(); } function renderProjectsBoard(){ const host = document.getElementById('projectsBoard'); if (!host) return; const showEmpty = String(document.querySelector('#projFilterEmpty .choice.on')?.getAttribute('data-empty') || '1') === '1'; const byProj = new Map(); (PROJECTS_BOARD_STATE.tasks||[]).forEach(t=>{ const pid = String(t.project_id||''); if (!pid) return; if (!byProj.has(pid)) byProj.set(pid, []); byProj.get(pid).push(t); }); host.innerHTML = ''; const projects = PROJECTS_BOARD_STATE.projects || []; if (!projects.length) { host.innerHTML = '<div class="muted">No hay proyectos</div>'; return; } projects.forEach(p=>{ const pid = String(p.id||''); const tasks = projSortUpcomingFirst([...(byProj.get(pid) || [])]); if (!showEmpty && !tasks.length) return; const card = document.createElement('div'); card.className = 'projcard'; card.style.borderLeft = '5px solid ' + (p.color || '#111111'); const head = document.createElement('div'); head.className = 'head'; const left = document.createElement('div'); const ttl = document.createElement('div'); ttl.className = 'ttl'; ttl.textContent = p.name || ''; left.appendChild(ttl); const meta = document.createElement('div'); meta.className = 'meta'; const cat = String(p.category_name || '').trim(); if (cat) { const b = document.createElement('span'); b.className = 'badge'; b.textContent = cat; meta.appendChild(b); } left.appendChild(meta); const ops = document.createElement('div'); ops.className = 'ops'; const editBtn = document.createElement('button'); editBtn.className = 'tbtn icon'; editBtn.type = 'button'; editBtn.textContent = '✎'; editBtn.setAttribute('aria-label','Editar'); editBtn.addEventListener('click', ()=>openProjectModal(p)); const delBtn = document.createElement('button'); delBtn.className = 'tbtn icon'; delBtn.type = 'button'; delBtn.textContent = '✕'; delBtn.setAttribute('aria-label','Eliminar'); delBtn.addEventListener('click', async ()=>{ const ok = await openAppConfirm({title:'Eliminar proyecto', message:'¿Seguro que deseas eliminar este proyecto?', okText:'Eliminar', cancelText:'Cancelar', danger:true}); if (!ok) return; const r = await api('delete_project',{id:pid}); if (!r.ok) { toast('No se pudo eliminar', 'err'); return; } location.reload(); }); ops.appendChild(editBtn); ops.appendChild(delBtn); head.appendChild(left); head.appendChild(ops); card.appendChild(head); const summary = document.createElement('div'); summary.className = 'summary'; const counts = {pending:0, progress:0, done:0}; tasks.forEach(t=>{ const s = String(t.status||''); if (s in counts) counts[s]++; }); if (tasks.length) { if (counts.done) { const sp=document.createElement('span'); sp.className='pill'; sp.style.borderColor='rgba(34,197,94,.22)'; sp.style.background='rgba(34,197,94,.08)'; sp.style.color='rgba(21,128,61,.95)'; sp.textContent='✓ '+counts.done; summary.appendChild(sp); } if (counts.progress) { const sp=document.createElement('span'); sp.className='pill'; sp.style.borderColor='rgba(245,158,11,.26)'; sp.style.background='rgba(245,158,11,.10)'; sp.style.color='rgba(180,83,9,.95)'; sp.textContent='◷ '+counts.progress; summary.appendChild(sp); } if (counts.pending) { const sp=document.createElement('span'); sp.className='pill'; sp.style.borderColor='rgba(239,68,68,.26)'; sp.style.background='rgba(239,68,68,.10)'; sp.style.color='rgba(185,28,28,.95)'; sp.textContent='○ '+counts.pending; summary.appendChild(sp); } const sp=document.createElement('span'); sp.className='pill'; sp.textContent=tasks.length+' tarea'+(tasks.length===1?'':'s'); summary.appendChild(sp); } else { const sp=document.createElement('span'); sp.className='pill'; sp.textContent='Sin tareas'; summary.appendChild(sp); } card.appendChild(summary); const list = document.createElement('div'); list.className = 'ptasks'; const shown = tasks.slice(0, 8); shown.forEach(t=>{ const row = document.createElement('div'); const st = String(t.status||'').trim(); row.className = 'ptask' + (st ? (' ' + st) : ''); const main = document.createElement('div'); main.className = 'main'; const title = document.createElement('div'); title.className = 't'; title.textContent = t.title || ''; main.appendChild(title); const meta = document.createElement('div'); meta.className = 'm'; if (st) { const b = document.createElement('span'); b.className = 'badge'; b.textContent = statusLabel(st); meta.appendChild(b); } const d = String(t.task_date || '').trim(); if (d) { const b=document.createElement('span'); b.className='badge'; b.textContent=d; meta.appendChild(b); } const tt = String(t.task_time || '').slice(0,5); const tv = formatTimeAmPm(tt); if (tv) { const b=document.createElement('span'); b.className='badge'; b.textContent=tv; meta.appendChild(b); } const tc = String(t.category_name || '').trim(); if (tc) { const b=document.createElement('span'); b.className='badge'; b.textContent=tc; meta.appendChild(b); } main.appendChild(meta); row.appendChild(main); const ids = String(t.user_ids||'').split(',').filter(Boolean); if (ids.length) { const users = document.createElement('div'); users.className = 'pavatars'; renderTaskUsers(users, ids, false); row.appendChild(users); } list.appendChild(row); }); card.appendChild(list); if (tasks.length > 8) { const more = document.createElement('div'); more.className = 'muted'; more.textContent = 'y ' + (tasks.length - 8) + ' más…'; card.appendChild(more); } host.appendChild(card); }); if (!host.children.length) host.innerHTML = '<div class="muted">Sin resultados</div>'; } function openProjectModal(project){ const isEdit = !!project; PROJ_EDIT_ID = isEdit ? String(project.id||'') : null; const title = document.getElementById('projectModalTitle'); const cat = document.getElementById('projModalCategory'); const name = document.getElementById('projModalName'); const color = document.getElementById('projModalColor'); const saveBtn = document.getElementById('projModalSaveBtn'); const delBtn = document.getElementById('projModalDelBtn'); if (title) title.textContent = isEdit ? 'Editar proyecto' : 'Nuevo proyecto'; if (saveBtn) saveBtn.textContent = isEdit ? 'Guardar' : 'Crear'; if (delBtn) delBtn.style.display = isEdit ? '' : 'none'; if (cat) cat.value = isEdit ? String(project.category_id||'') : ''; if (name) name.value = isEdit ? String(project.name||'') : ''; if (color) color.value = isEdit ? String(project.color||'#111111') : '#111111'; document.getElementById('projectModalBackdrop')?.classList.add('open'); document.getElementById('projectModal')?.classList.add('open'); document.getElementById('projectModal')?.setAttribute('aria-hidden','false'); } function closeProjectModal(){ PROJ_EDIT_ID = null; document.getElementById('projectModalBackdrop')?.classList.remove('open'); document.getElementById('projectModal')?.classList.remove('open'); document.getElementById('projectModal')?.setAttribute('aria-hidden','true'); } document.getElementById('openProjectModalBtn')?.addEventListener('click', ()=>openProjectModal(null)); document.getElementById('projectModalBackdrop')?.addEventListener('click', closeProjectModal); document.getElementById('projectModalX')?.addEventListener('click', closeProjectModal); document.getElementById('projModalSaveBtn')?.addEventListener('click', async ()=>{ const name = String(document.getElementById('projModalName')?.value || '').trim(); const color = String(document.getElementById('projModalColor')?.value || '').trim(); const categoryId = String(document.getElementById('projModalCategory')?.value || '').trim(); if (!name) { toast('Escribe un nombre para el proyecto.', 'warn'); return; } if (!categoryId) { toast('Selecciona una categoría para el proyecto.', 'warn'); return; } const isEdit = !!PROJ_EDIT_ID; const payload = isEdit ? {id:PROJ_EDIT_ID, name, color, category_id:categoryId} : {name, color, category_id:categoryId}; const r = await api(isEdit ? 'update_project' : 'add_project', payload); if (!r.ok) { if (r.error === 'category_required') toast('Selecciona una categoría para el proyecto.', 'warn'); else toast('No se pudo guardar', 'err'); return; } closeProjectModal(); location.reload(); }); document.getElementById('projModalDelBtn')?.addEventListener('click', async ()=>{ if (!PROJ_EDIT_ID) return; const ok = await openAppConfirm({title:'Eliminar proyecto', message:'¿Seguro que deseas eliminar este proyecto?', okText:'Eliminar', cancelText:'Cancelar', danger:true}); if (!ok) return; const r = await api('delete_project', {id:PROJ_EDIT_ID}); if (!r.ok) { toast('No se pudo eliminar', 'err'); return; } closeProjectModal(); location.reload(); }); function bindProjectsBoardFilters(){ const ids = ['projFilterCategory','projFilterUser','projFilterStatus','projFilterSearch']; ids.forEach(id=>{ const el = document.getElementById(id); if (!el) return; el.addEventListener('change', loadProjectsBoard); el.addEventListener('input', ()=>{ clearTimeout(el._t); el._t = setTimeout(loadProjectsBoard, 180); }); }); document.querySelectorAll('#projFilterEmpty .choice').forEach(btn=>{ btn.addEventListener('click', ()=>{ document.querySelectorAll('#projFilterEmpty .choice').forEach(x=>x.classList.remove('on')); btn.classList.add('on'); renderProjectsBoard(); }); }); } function bindUserSelectChips(hostId, selectId, opts){ const host = document.getElementById(hostId); const sel = document.getElementById(selectId); if (!host || !sel) return; if (host.getAttribute('data-bound') === '1') return; host.setAttribute('data-bound', '1'); const allLabel = String(opts?.allLabel || 'Todos'); const extra = Array.isArray(opts?.extra) ? opts.extra : []; const showAll = opts?.showAll !== false; function setValue(v){ sel.value = String(v ?? ''); try { sel.dispatchEvent(new Event('change')); } catch(e) {} render(); } function render(){ host.innerHTML = ''; const cur = String(sel.value || '').trim(); if (showAll) { const allBtn = document.createElement('button'); allBtn.type = 'button'; allBtn.className = 'chip' + (!cur ? ' active' : ''); allBtn.textContent = allLabel; allBtn.addEventListener('click', ()=>setValue('')); host.appendChild(allBtn); } extra.forEach(it=>{ const v = String(it?.value || ''); const label = String(it?.label || ''); const b = document.createElement('button'); b.type = 'button'; b.className = 'chip' + (cur === v ? ' active' : ''); b.textContent = label; b.addEventListener('click', ()=>setValue(v)); host.appendChild(b); }); USERS.forEach(u=>{ const b = document.createElement('button'); b.type = 'button'; b.className = 'chip userchip' + (cur === String(u.id) ? ' active' : ''); b.title = String(u.name||''); b.setAttribute('aria-label', String(u.name||'')); b.style.setProperty('--ring', String(u.color||'#111111')); const ava = document.createElement('span'); ava.className = 'chipava'; ava.style.backgroundImage = "url('" + String(u.photo||'') + "')"; b.appendChild(ava); b.addEventListener('click', ()=>setValue(String(u.id))); host.appendChild(b); }); } sel.addEventListener('change', render); render(); } bindProjectsBoardFilters(); const closeModalBackdrop = document.getElementById('closeModalBackdrop'); const closeModal = document.getElementById('closeModal'); const closeModalX = document.getElementById('closeModalX'); const closeModalTitle = document.getElementById('closeModalTitle'); const closeOutcomeFinal = document.getElementById('closeOutcomeFinal'); const closeOutcomeRerun = document.getElementById('closeOutcomeRerun'); const closeRerunBox = document.getElementById('closeRerunBox'); const closeRerunToday = document.getElementById('closeRerunToday'); const closeRerunTomorrow = document.getElementById('closeRerunTomorrow'); const closeNotes = document.getElementById('closeNotes'); const closeStars = document.getElementById('closeStars'); const closeCancel = document.getElementById('closeCancel'); const closeConfirm = document.getElementById('closeConfirm'); let closeCtx = null; let closeOutcome = 'final'; let closeRerunWhen = 'today'; let closeSatisfaction = 3; function setOutcome(o){ closeOutcome = o; closeOutcomeFinal?.classList.toggle('on', o==='final'); closeOutcomeRerun?.classList.toggle('on', o==='rerun'); if (closeRerunBox) closeRerunBox.style.display = o==='rerun' ? '' : 'none'; } function setRerunWhen(w){ closeRerunWhen = w; closeRerunToday?.classList.toggle('on', w==='today'); closeRerunTomorrow?.classList.toggle('on', w==='tomorrow'); } function renderStars(){ if (!closeStars) return; closeStars.innerHTML = ''; for (let i=1;i<=5;i++){ const b = document.createElement('button'); b.type = 'button'; b.className = 'star' + (i<=closeSatisfaction ? ' on' : ''); b.textContent = '★'; b.setAttribute('aria-label', String(i)); b.addEventListener('click', ()=>{ closeSatisfaction = i; renderStars(); }); closeStars.appendChild(b); } } renderStars(); function openCloseModal(taskEl){ const id = taskEl?.getAttribute('data-id'); if (!id) return; closeCtx = {taskEl, id}; if (closeModalTitle) { const title = taskEl.querySelector('[data-role="title_view"]')?.textContent || ''; closeModalTitle.textContent = title; } if (closeNotes) closeNotes.value = ''; closeSatisfaction = 3; renderStars(); setOutcome('final'); setRerunWhen('today'); closeModalBackdrop?.classList.add('open'); closeModal?.classList.add('open'); closeModal?.setAttribute('aria-hidden','false'); } function closeCloseModal(){ closeCtx = null; closeModalBackdrop?.classList.remove('open'); closeModal?.classList.remove('open'); closeModal?.setAttribute('aria-hidden','true'); } closeModalBackdrop?.addEventListener('click', closeCloseModal); closeModalX?.addEventListener('click', closeCloseModal); closeCancel?.addEventListener('click', closeCloseModal); closeOutcomeFinal?.addEventListener('click', ()=>setOutcome('final')); closeOutcomeRerun?.addEventListener('click', ()=>setOutcome('rerun')); closeRerunToday?.addEventListener('click', ()=>setRerunWhen('today')); closeRerunTomorrow?.addEventListener('click', ()=>setRerunWhen('tomorrow')); closeConfirm?.addEventListener('click', async ()=>{ if (!closeCtx) return; const taskEl = closeCtx.taskEl; const id = closeCtx.id; closeConfirm.disabled = true; closeCancel.disabled = true; try { const notes = (closeNotes?.value || '').trim(); const payload = {task_id:id, outcome:closeOutcome, notes, satisfaction:String(closeSatisfaction)}; if (closeOutcome === 'rerun') payload.rerun_when = closeRerunWhen; const r = await api('close_task', payload); if (!r.ok) { if (r.error === 'schema_missing') toast('Faltan columnas en la tabla tasks. Ejecuta el script SQL de migración.', 'err'); else toast('No se pudo cerrar la tarea', 'err'); return; } closeCloseModal(); const cur = taskEl.getAttribute('data-status'); taskEl.setAttribute('data-status', 'done'); if (cur) taskEl.classList.remove(cur); taskEl.classList.add('done'); const statusBtn = taskEl.querySelector('[data-role="status"]'); if (statusBtn) statusBtn.textContent = statusLabel('done'); if (closeOutcome === 'rerun') { location.reload(); } } finally { closeConfirm.disabled = false; closeCancel.disabled = false; } }); function openDrawer(on){ const d = document.getElementById('drawer'); const b = document.getElementById('drawerBackdrop'); if (on) { d.classList.add('open'); b.classList.add('open'); d.setAttribute('aria-hidden','false'); } else { d.classList.remove('open'); b.classList.remove('open'); d.setAttribute('aria-hidden','true'); } } function viewFromHash(){ const h = (location.hash || '').replace('#',''); if (h === 'users' || h === 'categories') { setView('config'); return; } if (h === 'bitacora') { setView('calendar'); return; } setView(h || 'today'); } document.getElementById('menuBtn').addEventListener('click', ()=>openDrawer(true)); document.getElementById('drawerClose').addEventListener('click', ()=>openDrawer(false)); document.getElementById('drawerBackdrop').addEventListener('click', ()=>openDrawer(false)); document.getElementById('bottomMoreBtn')?.addEventListener('click', ()=>openDrawer(true)); document.querySelectorAll('[data-view]').forEach(btn=>{ btn.addEventListener('click', ()=>{ const v = btn.getAttribute('data-view'); if (btn.classList.contains('draweritem')) openDrawer(false); location.hash = '#'+v; }); }); window.addEventListener('hashchange', viewFromHash); viewFromHash(); function isMobileLayout(){ return !!window.matchMedia && window.matchMedia('(max-width: 980px)').matches; } function nowYmd(){ const d = new Date(); const pad2 = (n)=>String(n).padStart(2,'0'); return d.getFullYear()+'-'+pad2(d.getMonth()+1)+'-'+pad2(d.getDate()); } function nowTime24(){ const d = new Date(); const pad2 = (n)=>String(n).padStart(2,'0'); return pad2(d.getHours())+':'+pad2(d.getMinutes()); } const splash = document.getElementById('splash'); const splashVid = document.getElementById('splashVid'); const splashStart = Date.now(); function setSplashSrc(){ if (!splashVid) return; const src = isMobileLayout() ? 'assets/img/spinnerV.mp4' : 'assets/img/spinnerH.mp4'; if ((splashVid.getAttribute('src') || '') !== src) { splashVid.setAttribute('src', src); try { splashVid.load(); } catch(e) {} } try { splashVid.play(); } catch(e) {} } function hideSplash(){ if (!splash) return; splash.classList.add('hide'); splash.setAttribute('aria-hidden','true'); } setSplashSrc(); window.addEventListener('resize', setSplashSrc); window.addEventListener('orientationchange', setSplashSrc); window.addEventListener('load', ()=>{ const elapsed = Date.now() - splashStart; const wait = Math.max(0, 900 - elapsed); setTimeout(hideSplash, wait); }); setTimeout(hideSplash, 2500); const installAppBtn = document.getElementById('installAppBtn'); let deferredInstall = null; function isStandalone(){ return (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || (window.navigator && window.navigator.standalone === true); } function isIOS(){ return /iphone|ipad|ipod/i.test(navigator.userAgent || ''); } function showInstallButton(){ if (!installAppBtn) return; if (isStandalone()) { installAppBtn.style.display = 'none'; return; } installAppBtn.style.display = ''; } window.addEventListener('beforeinstallprompt', (e)=>{ e.preventDefault(); deferredInstall = e; showInstallButton(); }); installAppBtn?.addEventListener('click', async ()=>{ if (isStandalone()) return; if (deferredInstall) { deferredInstall.prompt(); try { await deferredInstall.userChoice; } catch(e) {} deferredInstall = null; installAppBtn.style.display = 'none'; return; } if (isIOS()) { await openAppConfirm({title:'Instalar app', message:'En iPhone/iPad: toca Compartir y luego “Agregar a pantalla de inicio”.', okText:'Entendido', cancelText:'Cerrar'}); openDrawer(false); } }); if (isIOS() && !isStandalone()) { showInstallButton(); } if ('serviceWorker' in navigator) { window.addEventListener('load', ()=>{ navigator.serviceWorker.register('service-worker.js').catch(()=>{}); }); } const taskDrawerBackdrop = document.getElementById('taskDrawerBackdrop'); const taskDrawer = document.getElementById('taskDrawer'); const taskDrawerClose = document.getElementById('taskDrawerClose'); const openTaskDrawerBtn = document.getElementById('openTaskDrawerBtn'); const fabAddTask = document.getElementById('fabAddTask'); const fabAddHabit = document.getElementById('fabAddHabit'); const fabMenuBackdrop = document.getElementById('fabMenuBackdrop'); const fabMenu = document.getElementById('fabMenu'); const todayComposer = document.getElementById('todayComposer'); const todayComposerMount = document.getElementById('todayComposerMount'); const taskDrawerHost = document.getElementById('taskDrawerHost'); const toastHost = document.getElementById('toastHost'); const appConfirmBackdrop = document.getElementById('appConfirmBackdrop'); const appConfirmModal = document.getElementById('appConfirmModal'); const appConfirmTitle = document.getElementById('appConfirmTitle'); const appConfirmMsg = document.getElementById('appConfirmMsg'); const appConfirmX = document.getElementById('appConfirmX'); const appConfirmCancel = document.getElementById('appConfirmCancel'); const appConfirmOk = document.getElementById('appConfirmOk'); let APP_CONFIRM_RESOLVE = null; const appPromptBackdrop = document.getElementById('appPromptBackdrop'); const appPromptModal = document.getElementById('appPromptModal'); const appPromptTitle = document.getElementById('appPromptTitle'); const appPromptMsg = document.getElementById('appPromptMsg'); const appPromptX = document.getElementById('appPromptX'); const appPromptCancel = document.getElementById('appPromptCancel'); const appPromptOk = document.getElementById('appPromptOk'); const appPromptInput = document.getElementById('appPromptInput'); let APP_PROMPT_RESOLVE = null; const quickAddBackdrop = document.getElementById('quickAddBackdrop'); const quickAddModal = document.getElementById('quickAddModal'); const quickAddX = document.getElementById('quickAddX'); const quickAddTask = document.getElementById('quickAddTask'); const quickAddMove = document.getElementById('quickAddMove'); let FIN_QUICK_ADD = null; const habitModalBackdrop = document.getElementById('habitModalBackdrop'); const habitModal = document.getElementById('habitModal'); const habitModalX = document.getElementById('habitModalX'); const habitsComposer = document.getElementById('habitsComposer'); const habitsComposerMount = document.getElementById('habitsComposerMount'); const habitComposerHost = document.getElementById('habitComposerHost'); const openHabitModalBtn = document.getElementById('openHabitModalBtn'); const moveModalBackdrop = document.getElementById('moveModalBackdrop'); const moveModal = document.getElementById('moveModal'); const moveModalX = document.getElementById('moveModalX'); const openMoveModalBtn = document.getElementById('openMoveModalBtn'); const accModalBackdrop = document.getElementById('accModalBackdrop'); const accModal = document.getElementById('accModal'); const accModalX = document.getElementById('accModalX'); const openAccModalBtn = document.getElementById('openAccModalBtn'); const accAdjustModalBackdrop = document.getElementById('accAdjustModalBackdrop'); const accAdjustModal = document.getElementById('accAdjustModal'); const accAdjustModalX = document.getElementById('accAdjustModalX'); const debtModalBackdrop = document.getElementById('debtModalBackdrop'); const debtModal = document.getElementById('debtModal'); const debtModalX = document.getElementById('debtModalX'); const openDebtModalBtn = document.getElementById('openDebtModalBtn'); const finCatModalBackdrop = document.getElementById('finCatModalBackdrop'); const finCatModal = document.getElementById('finCatModal'); const finCatModalX = document.getElementById('finCatModalX'); const openFinCatModalBtn = document.getElementById('openFinCatModalBtn'); const taskDrawerTitle = document.getElementById('taskDrawerTitle'); const delTaskBtn = document.getElementById('delTaskBtn'); const taskEditExtra = document.getElementById('taskEditExtra'); const taskStatusPending = document.getElementById('taskStatusPending'); const taskStatusProgress = document.getElementById('taskStatusProgress'); const taskStatusDone = document.getElementById('taskStatusDone'); const taskCloseBox = document.getElementById('taskCloseBox'); const taskCloseOutcomeFinal = document.getElementById('taskCloseOutcomeFinal'); const taskCloseOutcomeRerun = document.getElementById('taskCloseOutcomeRerun'); const taskCloseRerunBox = document.getElementById('taskCloseRerunBox'); const taskCloseRerunToday = document.getElementById('taskCloseRerunToday'); const taskCloseRerunTomorrow = document.getElementById('taskCloseRerunTomorrow'); const taskCloseNotes = document.getElementById('taskCloseNotes'); const taskCloseStars = document.getElementById('taskCloseStars'); let TASK_EDIT_ID = null; let TASK_DRAWER_PREV_USER_IDS = null; let TASK_DRAWER_STATUS = 'pending'; let TASK_DRAWER_STATUS_ORIG = null; let TASK_DRAWER_CLOSE_OUTCOME = 'final'; let TASK_DRAWER_CLOSE_RERUN_WHEN = 'today'; let TASK_DRAWER_CLOSE_SATISFACTION = 3; function fmtDmy(iso){ const m = String(iso||'').match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!m) return String(iso||''); return m[3]+'-'+m[2]+'-'+m[1]; } function setTaskDrawerMode(isEdit){ const btn = document.getElementById('addTaskBtn'); if (taskDrawerTitle) taskDrawerTitle.textContent = isEdit ? 'Editar tarea' : 'Nueva tarea'; if (btn) btn.textContent = isEdit ? 'Guardar' : 'Agregar'; if (delTaskBtn) delTaskBtn.style.display = isEdit ? '' : 'none'; if (taskEditExtra) taskEditExtra.style.display = isEdit ? '' : 'none'; } function setTaskDrawerCloseDisabled(on){ const d = !!on; if (taskCloseOutcomeFinal) taskCloseOutcomeFinal.disabled = d; if (taskCloseOutcomeRerun) taskCloseOutcomeRerun.disabled = d; if (taskCloseRerunToday) taskCloseRerunToday.disabled = d; if (taskCloseRerunTomorrow) taskCloseRerunTomorrow.disabled = d; if (taskCloseNotes) taskCloseNotes.disabled = d; if (taskCloseStars) { taskCloseStars.querySelectorAll('button').forEach(b=>{ b.disabled = d; }); } } function setTaskDrawerCloseOutcome(o){ TASK_DRAWER_CLOSE_OUTCOME = o; taskCloseOutcomeFinal?.classList.toggle('on', o==='final'); taskCloseOutcomeRerun?.classList.toggle('on', o==='rerun'); if (taskCloseRerunBox) taskCloseRerunBox.style.display = o==='rerun' ? '' : 'none'; } function setTaskDrawerCloseRerunWhen(w){ TASK_DRAWER_CLOSE_RERUN_WHEN = w; taskCloseRerunToday?.classList.toggle('on', w==='today'); taskCloseRerunTomorrow?.classList.toggle('on', w==='tomorrow'); } function renderTaskDrawerCloseStars(){ if (!taskCloseStars) return; taskCloseStars.innerHTML = ''; for (let i=1;i<=5;i++){ const b = document.createElement('button'); b.type = 'button'; b.className = 'star' + (i<=TASK_DRAWER_CLOSE_SATISFACTION ? ' on' : ''); b.textContent = '★'; b.setAttribute('aria-label', String(i)); b.addEventListener('click', ()=>{ TASK_DRAWER_CLOSE_SATISFACTION = i; renderTaskDrawerCloseStars(); }); taskCloseStars.appendChild(b); } } renderTaskDrawerCloseStars(); function setTaskDrawerStatus(s){ const st = (s==='pending' || s==='progress' || s==='done') ? s : 'pending'; TASK_DRAWER_STATUS = st; taskStatusPending?.classList.toggle('on', st==='pending'); taskStatusProgress?.classList.toggle('on', st==='progress'); taskStatusDone?.classList.toggle('on', st==='done'); if (taskCloseBox) taskCloseBox.style.display = st==='done' ? '' : 'none'; setTaskDrawerCloseDisabled(st==='done' && String(TASK_DRAWER_STATUS_ORIG||'')==='done'); } taskStatusPending?.addEventListener('click', ()=>setTaskDrawerStatus('pending')); taskStatusProgress?.addEventListener('click', ()=>setTaskDrawerStatus('progress')); taskStatusDone?.addEventListener('click', ()=>setTaskDrawerStatus('done')); taskCloseOutcomeFinal?.addEventListener('click', ()=>setTaskDrawerCloseOutcome('final')); taskCloseOutcomeRerun?.addEventListener('click', ()=>setTaskDrawerCloseOutcome('rerun')); taskCloseRerunToday?.addEventListener('click', ()=>setTaskDrawerCloseRerunWhen('today')); taskCloseRerunTomorrow?.addEventListener('click', ()=>setTaskDrawerCloseRerunWhen('tomorrow')); function setUserPickSelected(ids){ const set = new Set((ids||[]).map(String)); document.querySelectorAll('#userPick .uopt').forEach(el=>{ const uid = String(el.getAttribute('data-uid') || ''); el.classList.toggle('on', set.has(uid)); }); } function resetTaskDrawer(){ if (TASK_DRAWER_PREV_USER_IDS) { setUserPickSelected(TASK_DRAWER_PREV_USER_IDS); saveUserPick(); } TASK_DRAWER_PREV_USER_IDS = null; TASK_EDIT_ID = null; TASK_DRAWER_STATUS_ORIG = null; TASK_DRAWER_STATUS = 'pending'; TASK_DRAWER_CLOSE_OUTCOME = 'final'; TASK_DRAWER_CLOSE_RERUN_WHEN = 'today'; TASK_DRAWER_CLOSE_SATISFACTION = 3; if (taskCloseNotes) taskCloseNotes.value = ''; setTaskDrawerCloseOutcome('final'); setTaskDrawerCloseRerunWhen('today'); renderTaskDrawerCloseStars(); setTaskDrawerStatus('pending'); setTaskDrawerMode(false); } delTaskBtn?.addEventListener('click', async ()=>{ const id = String(TASK_EDIT_ID || '').trim(); if (!id) return; const ok = await openAppConfirm({title:'Eliminar tarea', message:'¿Seguro que deseas eliminar esta tarea?', okText:'Eliminar', cancelText:'Cancelar', danger:true}); if (!ok) return; const r = await api('delete_task', {task_id:id}); if (!r.ok) { toast('No se pudo eliminar', 'err'); return; } closeTaskModal(true); location.reload(); }); function openTaskModal(){ if (todayComposer && taskDrawerHost && todayComposer.parentElement !== taskDrawerHost) { taskDrawerHost.appendChild(todayComposer); } try { const titleEl = document.getElementById('taskTitle'); const isNew = !String(titleEl?.value || '').trim(); if (isNew) { const dEl = document.getElementById('taskDate'); if (dEl) dEl.value = nowYmd(); const tEl = document.getElementById('taskTime12'); setTime12Value(tEl, nowTime24()); } } catch (e) {} taskDrawerBackdrop?.classList.add('open'); taskDrawer?.classList.add('open'); taskDrawer?.setAttribute('aria-hidden','false'); setTimeout(()=>document.getElementById('taskTitle')?.focus(), 50); } function openTaskEditModal(t){ if (!t || !t.id) return; TASK_DRAWER_PREV_USER_IDS = getSelectedUserIds(); TASK_EDIT_ID = String(t.id); TASK_DRAWER_STATUS_ORIG = String(t.status || 'pending'); const titleEl = document.getElementById('taskTitle'); if (titleEl) titleEl.value = String(t.title || ''); const dateEl = document.getElementById('taskDate'); if (dateEl) dateEl.value = String(t.task_date || nowYmd()); try { setTime12Value(document.getElementById('taskTime12'), String(t.task_time || '').slice(0,5)); } catch (e) {} const catSel = document.getElementById('taskCategory'); const projSel = document.getElementById('taskProject'); if (catSel) catSel.value = (t.category_id !== null && t.category_id !== undefined) ? String(t.category_id) : ''; renderProjectOptions(projSel, catSel?.value || '', (t.project_id !== null && t.project_id !== undefined) ? String(t.project_id) : ''); if (projSel) projSel.value = (t.project_id !== null && t.project_id !== undefined) ? String(t.project_id) : ''; const ids = String(t.user_ids || '').split(',').filter(Boolean); setUserPickSelected(ids); if (taskCloseNotes) taskCloseNotes.value = String(t.closure_notes || ''); TASK_DRAWER_CLOSE_SATISFACTION = t.closure_satisfaction ? (parseInt(t.closure_satisfaction,10) || 3) : 3; renderTaskDrawerCloseStars(); const oc = String(t.closure_outcome || '').trim(); setTaskDrawerCloseOutcome(oc === 'rerun' ? 'rerun' : 'final'); setTaskDrawerCloseRerunWhen('today'); setTaskDrawerStatus(TASK_DRAWER_STATUS_ORIG); setTaskDrawerMode(true); openTaskModal(); } function openQuickAddModal(){ if (!isMobileLayout()) { openTaskModal(); return; } quickAddBackdrop?.classList.add('open'); quickAddModal?.classList.add('open'); quickAddModal?.setAttribute('aria-hidden','false'); setTimeout(()=>quickAddTask?.focus(), 50); } function closeQuickAddModal(){ quickAddBackdrop?.classList.remove('open'); quickAddModal?.classList.remove('open'); quickAddModal?.setAttribute('aria-hidden','true'); } function closeTaskModal(skipMove){ try { const a = document.activeElement; if (a && taskDrawer && taskDrawer.contains(a)) a.blur(); } catch(e) {} taskDrawerBackdrop?.classList.remove('open'); taskDrawer?.classList.remove('open'); taskDrawer?.setAttribute('aria-hidden','true'); resetTaskDrawer(); if (skipMove) return; if (todayComposer && todayComposerMount && todayComposer.parentElement !== todayComposerMount) { todayComposerMount.appendChild(todayComposer); } } function toast(msg, kind){ const k = (kind==='ok'||kind==='warn'||kind==='err') ? kind : 'info'; if (!toastHost) return; const el = document.createElement('div'); el.className = 'toast '+k; const ico = (k==='ok') ? '✓' : (k==='warn' ? '!' : (k==='err' ? '!' : 'i')); el.innerHTML = '<div class="ico" aria-hidden="true">'+ico+'</div><div class="msg"></div><button class="x" type="button" aria-label="Cerrar">✕</button>'; el.querySelector('.msg').textContent = String(msg||''); el.querySelector('.x').addEventListener('click', ()=>el.remove()); toastHost.appendChild(el); setTimeout(()=>{ try { el.remove(); } catch(e) {} }, 3600); } function openAppConfirm(opts){ const o = opts || {}; if (!appConfirmBackdrop || !appConfirmModal) return Promise.resolve(false); if (appConfirmTitle) appConfirmTitle.textContent = String(o.title || 'Confirmar'); if (appConfirmMsg) appConfirmMsg.textContent = String(o.message || ''); if (appConfirmOk) appConfirmOk.textContent = String(o.okText || 'Confirmar'); if (appConfirmCancel) appConfirmCancel.textContent = String(o.cancelText || 'Cancelar'); if (appConfirmOk) { appConfirmOk.classList.toggle('danger', !!o.danger); appConfirmOk.classList.toggle('ghost', !!o.ghostOk); } appConfirmBackdrop.classList.add('open'); appConfirmModal.classList.add('open'); appConfirmModal.setAttribute('aria-hidden','false'); return new Promise(res=>{ APP_CONFIRM_RESOLVE = res; setTimeout(()=>appConfirmOk?.focus(), 30); }); } function closeAppConfirm(v){ try { document.activeElement?.blur?.(); } catch(e) {} appConfirmBackdrop?.classList.remove('open'); appConfirmModal?.classList.remove('open'); appConfirmModal?.setAttribute('aria-hidden','true'); const r = APP_CONFIRM_RESOLVE; APP_CONFIRM_RESOLVE = null; if (r) r(!!v); } appConfirmBackdrop?.addEventListener('click', ()=>closeAppConfirm(false)); appConfirmX?.addEventListener('click', ()=>closeAppConfirm(false)); appConfirmCancel?.addEventListener('click', ()=>closeAppConfirm(false)); appConfirmOk?.addEventListener('click', ()=>closeAppConfirm(true)); function openAppPrompt(opts){ const o = opts || {}; if (!appPromptBackdrop || !appPromptModal) return Promise.resolve(null); if (appPromptTitle) appPromptTitle.textContent = String(o.title || 'Ingresar'); if (appPromptMsg) appPromptMsg.textContent = String(o.message || ''); if (appPromptInput) { appPromptInput.value = String(o.value || ''); appPromptInput.placeholder = String(o.placeholder || ''); appPromptInput.inputMode = String(o.inputMode || 'text'); } if (appPromptOk) appPromptOk.textContent = String(o.okText || 'Aceptar'); if (appPromptCancel) appPromptCancel.textContent = String(o.cancelText || 'Cancelar'); appPromptBackdrop.classList.add('open'); appPromptModal.classList.add('open'); appPromptModal.setAttribute('aria-hidden','false'); return new Promise(res=>{ APP_PROMPT_RESOLVE = res; setTimeout(()=>appPromptInput?.focus(), 30); }); } function closeAppPrompt(v){ appPromptBackdrop?.classList.remove('open'); appPromptModal?.classList.remove('open'); appPromptModal?.setAttribute('aria-hidden','true'); const r = APP_PROMPT_RESOLVE; APP_PROMPT_RESOLVE = null; if (r) r(v); } appPromptBackdrop?.addEventListener('click', ()=>closeAppPrompt(null)); appPromptX?.addEventListener('click', ()=>closeAppPrompt(null)); appPromptCancel?.addEventListener('click', ()=>closeAppPrompt(null)); appPromptOk?.addEventListener('click', ()=>closeAppPrompt(String(appPromptInput?.value || '').trim() || null)); appPromptInput?.addEventListener('keydown', (e)=>{ if (e.key === 'Enter') { e.preventDefault(); closeAppPrompt(String(appPromptInput?.value || '').trim() || null); } if (e.key === 'Escape') { e.preventDefault(); closeAppPrompt(null); } }); taskDrawerBackdrop?.addEventListener('click', ()=>closeTaskModal()); taskDrawerClose?.addEventListener('click', ()=>closeTaskModal()); openTaskDrawerBtn?.addEventListener('click', openTaskModal); quickAddBackdrop?.addEventListener('click', closeQuickAddModal); quickAddX?.addEventListener('click', closeQuickAddModal); quickAddTask?.addEventListener('click', ()=>{ closeQuickAddModal(); openTaskModal(); }); quickAddMove?.addEventListener('click', ()=>{ closeQuickAddModal(); const d = (document.getElementById('finMoveDate')?.value || '').trim(); FIN_QUICK_ADD = {date: d || null}; if ((location.hash || '') === '#today') { setTimeout(loadFinance, 0); } else { location.hash = '#today'; setTimeout(loadFinance, 120); } }); function closeFabMenu(){ fabMenuBackdrop?.classList.remove('open'); fabMenu?.classList.remove('open'); fabMenu?.setAttribute('aria-hidden','true'); if (fabMenu) fabMenu.innerHTML = ''; } function openFabMenu(items){ if (!fabMenu || !fabMenuBackdrop) return; fabMenu.innerHTML = ''; (items || []).forEach(it=>{ const b = document.createElement('button'); b.type = 'button'; b.className = 'fabitem'; b.textContent = String(it.label || ''); b.addEventListener('click', ()=>{ closeFabMenu(); try { it.onClick && it.onClick(); } catch (e) {} }); fabMenu.appendChild(b); }); fabMenuBackdrop.classList.add('open'); fabMenu.classList.add('open'); fabMenu.setAttribute('aria-hidden','false'); } function toggleFabMenu(items){ if (fabMenu?.classList.contains('open')) closeFabMenu(); else openFabMenu(items); } function activeView(){ const el = document.querySelector('.view.active'); const id = String(el?.id || '').replace('view-',''); return id || 'today'; } function scrollToAndFocus(id){ const el = document.getElementById(id); if (!el) return; const box = el.closest('.card') || el; try { box.scrollIntoView({behavior:'smooth', block:'start'}); } catch (e) {} setTimeout(()=>{ try { el.focus(); } catch (e) {} }, 220); } fabMenuBackdrop?.addEventListener('click', closeFabMenu); window.addEventListener('keydown', (e)=>{ if (e.key === 'Escape') closeFabMenu(); }); function onFabClick(){ const v = activeView(); if (v === 'habits') { closeFabMenu(); openHabitModal(); return; } if (v === 'projects') { closeFabMenu(); openProjectModal(null); return; } if (v === 'today') { toggleFabMenu([ {label:'Tarea', onClick:()=>openTaskModal()}, {label:'Movimiento', onClick:()=>openMoveModal()} ]); return; } if (v === 'finances') { toggleFabMenu([ {label:'Movimiento', onClick:()=>openMoveModal()}, {label:'Cuenta', onClick:()=>openAccModal()}, {label:'Deuda', onClick:()=>openDebtModal()} ]); return; } if (v === 'config') { toggleFabMenu([ {label:'Usuarios', onClick:()=>scrollToAndFocus('userName')}, {label:'Categorías (Tareas)', onClick:()=>scrollToAndFocus('catName')}, {label:'Categorías (Finanzas)', onClick:()=>scrollToAndFocus('finCatFilterName')} ]); return; } closeFabMenu(); } fabAddTask?.addEventListener('click', onFabClick); function openHabitModal(){ if (habitsComposer && habitComposerHost && habitsComposer.parentElement !== habitComposerHost) { habitComposerHost.appendChild(habitsComposer); } habitModalBackdrop?.classList.add('open'); habitModal?.classList.add('open'); habitModal?.setAttribute('aria-hidden','false'); setTimeout(()=>document.getElementById('habitName')?.focus(), 50); } function closeHabitModal(skipMove){ habitModalBackdrop?.classList.remove('open'); habitModal?.classList.remove('open'); habitModal?.setAttribute('aria-hidden','true'); if (skipMove) return; if (habitsComposer && habitsComposerMount && habitsComposer.parentElement !== habitsComposerMount) { habitsComposerMount.appendChild(habitsComposer); } } habitModalBackdrop?.addEventListener('click', ()=>closeHabitModal()); habitModalX?.addEventListener('click', ()=>closeHabitModal()); openHabitModalBtn?.addEventListener('click', openHabitModal); fabAddHabit?.addEventListener('click', openHabitModal); function openMoveModal(){ finInitMoveTypePills(); try { const day = (document.getElementById('finMoveDate')?.value || '').trim() || nowYmd(); const dm = document.getElementById('finMoveDateModal'); if (dm) dm.value = day; } catch (e) {} try { setTime12Value(document.getElementById('finMoveTime12'), nowTime24()); } catch (e) {} moveModalBackdrop?.classList.add('open'); moveModal?.classList.add('open'); moveModal?.setAttribute('aria-hidden','false'); setTimeout(()=>document.getElementById('finMoveAmount')?.focus(), 50); } function closeMoveModal(){ moveModalBackdrop?.classList.remove('open'); moveModal?.classList.remove('open'); moveModal?.setAttribute('aria-hidden','true'); } moveModalBackdrop?.addEventListener('click', closeMoveModal); moveModalX?.addEventListener('click', closeMoveModal); openMoveModalBtn?.addEventListener('click', openMoveModal); function openAccModal(){ try { finResetAccountForm(); } catch (e) {} try { finInitAccSharedToggle(); } catch (e) {} FIN_ACC_EDIT_ID = null; FIN_ACC_EDIT_OPENING = null; try { finSyncAccModalMode(); } catch (e) {} accModalBackdrop?.classList.add('open'); accModal?.classList.add('open'); accModal?.setAttribute('aria-hidden','false'); setTimeout(()=>document.getElementById('finAccName')?.focus(), 50); } function closeAccAdjustModal(){ accAdjustModalBackdrop?.classList.remove('open'); accAdjustModal?.classList.remove('open'); accAdjustModal?.setAttribute('aria-hidden','true'); FIN_ACC_ADJ = {id:'', name:'', currency:'', current:0}; } accAdjustModalBackdrop?.addEventListener('click', closeAccAdjustModal); accAdjustModalX?.addEventListener('click', closeAccAdjustModal); function finParseNum(x){ const s = String(x||'').trim().replace(',', '.'); if (!s) return null; const n = parseFloat(s); return isFinite(n) ? n : null; } function finSyncAccAdjustFields(from){ if (FIN_ACC_ADJ_LOCK) return; FIN_ACC_ADJ_LOCK = true; const expEl = document.getElementById('accAdjustExpected'); const delEl = document.getElementById('accAdjustDelta'); const cur = parseFloat(FIN_ACC_ADJ.current||0); if (from === 'expected') { const exp = finParseNum(expEl?.value); if (exp !== null) { const d = exp - cur; if (delEl) delEl.value = (Math.round(d*100)/100).toFixed(2); } } else { const d = finParseNum(delEl?.value); if (d !== null) { const exp = cur + d; if (expEl) expEl.value = (Math.round(exp*100)/100).toFixed(2); } } FIN_ACC_ADJ_LOCK = false; } function openAccAdjustModal(acc){ const title = document.getElementById('accAdjustTitle'); const sub = document.getElementById('accAdjustSub'); const expEl = document.getElementById('accAdjustExpected'); const delEl = document.getElementById('accAdjustDelta'); FIN_ACC_ADJ = { id: String(acc.id||''), name: String(acc.name||''), currency: String(acc.currency_code||''), current: parseFloat(acc.balance||0) }; if (title) title.textContent = 'Ajuste · ' + FIN_ACC_ADJ.name; if (sub) sub.textContent = 'Saldo actual: ' + finMoney(FIN_ACC_ADJ.current||0) + ' ' + FIN_ACC_ADJ.currency; if (expEl) expEl.value = (Math.round((FIN_ACC_ADJ.current||0)*100)/100).toFixed(2); if (delEl) delEl.value = '0.00'; FIN_ACC_ADJ_LAST = 'expected'; try { if (expEl && expEl.getAttribute('data-bound') !== '1') { expEl.setAttribute('data-bound','1'); expEl.addEventListener('input', ()=>{ FIN_ACC_ADJ_LAST = 'expected'; finSyncAccAdjustFields('expected'); }, {passive:true}); } if (delEl && delEl.getAttribute('data-bound') !== '1') { delEl.setAttribute('data-bound','1'); delEl.addEventListener('input', ()=>{ FIN_ACC_ADJ_LAST = 'delta'; finSyncAccAdjustFields('delta'); }, {passive:true}); } } catch (e) {} accAdjustModalBackdrop?.classList.add('open'); accAdjustModal?.classList.add('open'); accAdjustModal?.setAttribute('aria-hidden','false'); setTimeout(()=>expEl?.focus(), 50); } function closeAccModal(){ accModalBackdrop?.classList.remove('open'); accModal?.classList.remove('open'); accModal?.setAttribute('aria-hidden','true'); FIN_ACC_EDIT_ID = null; FIN_ACC_EDIT_OPENING = null; try { finSyncAccModalMode(); } catch (e) {} } accModalBackdrop?.addEventListener('click', closeAccModal); accModalX?.addEventListener('click', closeAccModal); openAccModalBtn?.addEventListener('click', openAccModal); function finSyncAccModalMode(){ const ttl = document.getElementById('accModalTitle'); const saveBtn = document.getElementById('finAddAccBtn'); const delBtn = document.getElementById('finDelAccBtn'); const opening = document.getElementById('finAccOpening'); const isEdit = FIN_ACC_EDIT_ID !== null && FIN_ACC_EDIT_ID !== undefined && String(FIN_ACC_EDIT_ID) !== ''; if (ttl) ttl.textContent = isEdit ? 'Editar cuenta' : 'Nueva cuenta'; if (saveBtn) saveBtn.textContent = isEdit ? 'Guardar' : 'Crear'; if (delBtn) delBtn.style.display = isEdit ? '' : 'none'; if (opening) opening.style.display = isEdit ? 'none' : ''; } function openAccEditModal(acc){ try { finInitAccSharedToggle(); } catch (e) {} FIN_ACC_EDIT_ID = String(acc.id||''); FIN_ACC_EDIT_OPENING = (acc.opening_balance !== null && acc.opening_balance !== undefined) ? parseFloat(acc.opening_balance||0) : 0; finSyncAccModalMode(); const n = document.getElementById('finAccName'); const t = document.getElementById('finAccType'); const c = document.getElementById('finAccCurrency'); const o = document.getElementById('finAccOpening'); const sh = document.getElementById('finAccSharedAll'); if (n) n.value = String(acc.name||''); if (t) t.value = String(acc.type||'cash') || 'cash'; if (c) c.value = String(acc.currency_code||''); if (o) o.value = String(FIN_ACC_EDIT_OPENING ?? (acc.opening_balance ?? '')); if (sh) sh.checked = (acc.shared_all === 1); finAccSelected.clear(); if (!(acc.shared_all === 1)) { (acc.users || []).forEach(uid=>finAccSelected.add(String(uid))); } finRenderUserPick(finAccUsersHost, finAccSelected); if (finAccUsersHost) finAccUsersHost.style.display = (sh && sh.checked) ? 'none' : ''; accModalBackdrop?.classList.add('open'); accModal?.classList.add('open'); accModal?.setAttribute('aria-hidden','false'); setTimeout(()=>document.getElementById('finAccName')?.focus(), 50); } function openDebtModal(){ try { finResetDebtForm(); } catch (e) {} try { const d = (document.getElementById('finMoveDate')?.value || FIN_STATE.day || nowYmd()).trim(); const sd = document.getElementById('finDebtStartDateModal'); if (sd) sd.value = d; } catch (e) {} debtModalBackdrop?.classList.add('open'); debtModal?.classList.add('open'); debtModal?.setAttribute('aria-hidden','false'); setTimeout(()=>document.getElementById('finDebtPersonModal')?.focus(), 50); } function closeDebtModal(){ debtModalBackdrop?.classList.remove('open'); debtModal?.classList.remove('open'); debtModal?.setAttribute('aria-hidden','true'); } debtModalBackdrop?.addEventListener('click', closeDebtModal); debtModalX?.addEventListener('click', closeDebtModal); openDebtModalBtn?.addEventListener('click', openDebtModal); function openFinCatModal(){ try { finResetCategoryForm(); } catch (e) {} finCatModalBackdrop?.classList.add('open'); finCatModal?.classList.add('open'); finCatModal?.setAttribute('aria-hidden','false'); setTimeout(()=>document.getElementById('finCatNameModal')?.focus(), 50); } function closeFinCatModal(){ finCatModalBackdrop?.classList.remove('open'); finCatModal?.classList.remove('open'); finCatModal?.setAttribute('aria-hidden','true'); } finCatModalBackdrop?.addEventListener('click', closeFinCatModal); finCatModalX?.addEventListener('click', closeFinCatModal); openFinCatModalBtn?.addEventListener('click', openFinCatModal); function updateFabVisibility(){ const mobile = isMobileLayout(); const isToday = document.getElementById('view-today')?.classList.contains('active'); const isHabits = document.getElementById('view-habits')?.classList.contains('active'); const isFin = document.getElementById('view-finances')?.classList.contains('active'); const isCfg = document.getElementById('view-config')?.classList.contains('active'); const isProj = document.getElementById('view-projects')?.classList.contains('active'); const showFab = mobile && (!!isToday || !!isHabits || !!isFin || !!isCfg || !!isProj); if (fabAddTask) fabAddTask.style.display = showFab ? '' : 'none'; if (fabAddHabit) fabAddHabit.style.display = 'none'; if (!showFab) closeFabMenu(); if (!isToday) closeQuickAddModal(); if (!isToday) closeTaskModal(true); if (!isToday) closeMoveModal(); if (!isFin) closeAccModal(); if (!isFin) closeDebtModal(); if (!isFin && !isCfg) closeFinCatModal(); if (!isHabits) closeHabitModal(); } function syncBottomSpacing(){ try { const root = document.documentElement; const nav = document.getElementById('bottomNav'); const visible = !!(nav && window.getComputedStyle(nav).display !== 'none'); const h = visible ? Math.ceil(nav.getBoundingClientRect().height) : 0; root.style.setProperty('--bottompad', (visible ? (h + 16) : 16) + 'px'); root.style.setProperty('--toastpad', (visible ? (h + 12) : 16) + 'px'); } catch (e) {} } window.addEventListener('resize', updateFabVisibility); window.addEventListener('resize', syncBottomSpacing); updateFabVisibility(); syncBottomSpacing(); document.querySelectorAll('.chip[data-user]').forEach(a=>{ a.addEventListener('click', (e)=>{ const uid = a.getAttribute('data-user'); if (uid !== null && a.getAttribute('data-keep-hash') === '1') { e.preventDefault(); e.stopPropagation(); setSelectedUserFilter(uid); return; } const href = a.getAttribute('href'); if (!href) return; const h = location.hash || '#today'; e.preventDefault(); location.href = href + h; }); }); function enc(obj){ return Object.keys(obj).map(k=>encodeURIComponent(k)+'='+encodeURIComponent(obj[k])).join('&'); } async function api(action, data){ if (data instanceof FormData) { if (!data.has('action')) data.append('action', action); if (!data.has('actor_user_id') && ACTOR_USER_ID_DEFAULT !== null) data.append('actor_user_id', String(ACTOR_USER_ID_DEFAULT)); const res = await fetch('',{method:'POST',body:data}); return res.json(); } const payload = Object.assign({action}, data||{}); if (payload.actor_user_id === undefined && ACTOR_USER_ID_DEFAULT !== null) payload.actor_user_id = ACTOR_USER_ID_DEFAULT; const res = await fetch('',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8'},body:enc(payload)}); return res.json(); } var FIN_STATE = {day:'', user_id:0, summary:[], accounts:[], categories:[], moves:[], debts:[]}; var FIN_EDIT = {kind:'', id:null}; var FIN_ACC_EDIT_ID = null; var FIN_ACC_EDIT_OPENING = null; var FIN_ACC_ADJ = {id:'', name:'', currency:'', current:0}; var FIN_ACC_ADJ_LOCK = false; var FIN_ACC_ADJ_LAST = 'expected'; var FIN_DEBT_FILTER = {user:'', kind:'', status:''}; function finMoney(n){ const x = typeof n === 'number' ? n : parseFloat(n||0); if (!isFinite(x)) return '0.00'; return (Math.round(x*100)/100).toFixed(2); } function finSetEdit(kind, id){ FIN_EDIT = {kind, id}; finRenderAll(); } function finClearEdit(){ FIN_EDIT = {kind:'', id:null}; finRenderAll(); } function finRenderUserPick(host, selectedSet){ if (!host) return; host.innerHTML = ''; USERS.forEach(u=>{ const lab = document.createElement('label'); lab.className = 'uopt' + (selectedSet.has(String(u.id)) ? ' on' : ''); lab.setAttribute('data-uid', String(u.id)); lab.innerHTML = '<span class="uava" style="background-image:url(\''+String(u.photo||'')+'\');border-color:'+String(u.color||'#111111')+'"></span><span>'+String(u.name||'')+'</span>'; lab.addEventListener('click', (e)=>{ const id = String(u.id); if (selectedSet.has(id)) selectedSet.delete(id); else selectedSet.add(id); finRenderUserPick(host, selectedSet); e.preventDefault(); }); host.appendChild(lab); }); } function finFillAccountSelect(sel, accounts, allowEmpty, emptyLabel){ if (!sel) return; const cur = sel.value; sel.innerHTML = ''; if (allowEmpty) { const o0 = document.createElement('option'); o0.value = ''; o0.textContent = emptyLabel || '—'; sel.appendChild(o0); } accounts.forEach(a=>{ const o = document.createElement('option'); o.value = String(a.id); o.textContent = String(a.name) + ' (' + String(a.currency_code) + ')'; sel.appendChild(o); }); if ([...sel.options].some(o=>o.value===cur)) sel.value = cur; } function finFillCategorySelect(sel, cats, moveType){ if (!sel) return; const cur = sel.value; sel.innerHTML = ''; const o0 = document.createElement('option'); o0.value = ''; o0.textContent = 'Sin categoría'; sel.appendChild(o0); cats.forEach(c=>{ const k = String(c.kind||'both'); if (moveType === 'income' && !(k==='income' || k==='both')) return; if (moveType === 'expense' && !(k==='expense' || k==='both')) return; const o = document.createElement('option'); o.value = String(c.id); const owner = c.owner_user_id ? (' · '+(USER_BY_ID.get(String(c.owner_user_id))?.name || '')) : ''; o.textContent = String(c.name) + owner; sel.appendChild(o); }); if ([...sel.options].some(o=>o.value===cur)) sel.value = cur; else sel.value = ''; } function finRenderSummary(){ const el = document.getElementById('finSummary'); if (!el) return; const rows = FIN_STATE.summary || []; const debts = FIN_STATE.debts || []; const fmtMap = (m)=>{ const entries = Object.entries(m).filter(([,v])=> isFinite(v) && Math.abs(v) > 0.000001); if (!entries.length) return '0.00'; return entries.map(([ccy, amt])=> String(ccy||'')+' '+finMoney(amt)).join(' · '); }; const pay = {openCount:0, closedCount:0, openOut:{}, closedAmt:{}}; const collect = {openCount:0, closedCount:0, openOut:{}, closedAmt:{}}; debts.forEach(d=>{ const st = String(d.status||'open'); const kind = String(d.kind||''); const ccy = String(d.currency_code||''); const out = parseFloat(d.outstanding ?? 0); const amt = parseFloat(d.amount ?? 0); const bucket = (kind === 'i_owe') ? pay : ((kind === 'they_owe_me') ? collect : null); if (!bucket) return; if (st === 'open') { bucket.openCount++; if (isFinite(out)) bucket.openOut[ccy] = (bucket.openOut[ccy] || 0) + out; } if (st === 'closed') { bucket.closedCount++; if (isFinite(amt)) bucket.closedAmt[ccy] = (bucket.closedAmt[ccy] || 0) + amt; } }); const kpiPay = '<div class="kpi" style="border-left:4px solid rgba(239,68,68,.55);background:linear-gradient(180deg, rgba(239,68,68,.07), rgba(255,255,255,.92))">'+ '<div class="l">Por pagar</div>'+ '<div class="v">'+fmtMap(pay.openOut)+'</div>'+ '<div class="s">Abiertas: '+String(pay.openCount)+' · Cerradas: '+String(pay.closedCount)+' · '+fmtMap(pay.closedAmt)+'</div>'+ '</div>'; const kpiCollect = '<div class="kpi" style="border-left:4px solid rgba(34,197,94,.55);background:linear-gradient(180deg, rgba(34,197,94,.07), rgba(255,255,255,.92))">'+ '<div class="l">Por cobrar</div>'+ '<div class="v">'+fmtMap(collect.openOut)+'</div>'+ '<div class="s">Abiertas: '+String(collect.openCount)+' · Cerradas: '+String(collect.closedCount)+' · '+fmtMap(collect.closedAmt)+'</div>'+ '</div>'; const debtKpis = kpiPay + kpiCollect; if (!rows.length) { el.innerHTML = debtKpis; return; } el.innerHTML = rows.map(r=> '<div class="kpi">'+ '<div class="l">Saldo ('+String(r.currency||'')+')</div>'+ '<div class="v">'+finMoney(r.balance||0)+'</div>'+ '<div class="s">Hoy: Ingreso +'+finMoney(r.income_today||0)+' · Egreso -'+finMoney(r.expense_today||0)+'</div>'+ '</div>' ).join('') + debtKpis; } function finRenderAll(){ finRenderSummary(); finRenderAccounts(); finRenderMoves(); finRenderCategories(); finRenderDebts(); finRenderDebtUserChips(); finSyncDebtKindPills(); finSyncDebtStatusPills(); } function finRenderDebtUserChips(){ const host = document.getElementById('finDebtUserChips'); if (!host) return; host.innerHTML = ''; const mkAll = ()=>{ const b = document.createElement('button'); b.type = 'button'; b.className = 'chip' + (!FIN_DEBT_FILTER.user ? ' active' : ''); b.textContent = 'Todos'; b.addEventListener('click', ()=>{ FIN_DEBT_FILTER.user = ''; finRenderDebtUserChips(); finRenderDebts(); }); return b; }; host.appendChild(mkAll()); USERS.forEach(u=>{ const b = document.createElement('button'); b.type = 'button'; b.className = 'chip userchip' + (String(FIN_DEBT_FILTER.user||'') === String(u.id) ? ' active' : ''); b.title = String(u.name||''); b.setAttribute('aria-label', String(u.name||'')); b.style.setProperty('--ring', String(u.color||'#111111')); const ava = document.createElement('span'); ava.className = 'chipava'; ava.style.backgroundImage = "url('" + String(u.photo||'') + "')"; b.appendChild(ava); b.addEventListener('click', ()=>{ FIN_DEBT_FILTER.user = String(u.id); finRenderDebtUserChips(); finRenderDebts(); }); host.appendChild(b); }); } function finSyncDebtKindPills(){ const host = document.getElementById('finDebtKindPills'); if (!host) return; if (host.getAttribute('data-bound') !== '1') { host.setAttribute('data-bound','1'); host.querySelectorAll('button[data-kind]').forEach(b=>{ b.addEventListener('click', ()=>{ FIN_DEBT_FILTER.kind = String(b.getAttribute('data-kind')||''); finSyncDebtKindPills(); finRenderDebts(); }); }); } host.querySelectorAll('button[data-kind]').forEach(b=>{ const v = String(b.getAttribute('data-kind')||''); const on = String(FIN_DEBT_FILTER.kind||'') === v; b.classList.toggle('on', on); }); } function finSyncDebtStatusPills(){ const host = document.getElementById('finDebtStatusPills'); if (!host) return; if (host.getAttribute('data-bound') !== '1') { host.setAttribute('data-bound','1'); host.querySelectorAll('button[data-status]').forEach(b=>{ b.addEventListener('click', ()=>{ FIN_DEBT_FILTER.status = String(b.getAttribute('data-status')||''); finSyncDebtStatusPills(); finRenderDebts(); }); }); } host.querySelectorAll('button[data-status]').forEach(b=>{ const v = String(b.getAttribute('data-status')||''); const on = String(FIN_DEBT_FILTER.status||'') === v; b.classList.toggle('on', on); }); } function finRenderCategories(){ const list = document.getElementById('finCatList'); if (!list) return; const fOwner = String(document.getElementById('finCatFilterOwner')?.value || '').trim(); const fKind = String(document.getElementById('finCatFilterKind')?.value || '').trim(); const q = String(document.getElementById('finCatFilterName')?.value || '').trim().toLowerCase(); const xs = (FIN_STATE.categories || []).filter(c=>{ const owner = c.owner_user_id !== null && c.owner_user_id !== undefined ? String(c.owner_user_id) : ''; if (fOwner) { if (fOwner === 'shared') { if (owner) return false; } else { if (owner !== String(fOwner)) return false; } } if (fKind && String(c.kind||'') !== fKind) return false; if (q) { const n = String(c.name||'').toLowerCase(); if (!n.includes(q)) return false; } return true; }).slice().sort((a,b)=>String(a.name||'').localeCompare(String(b.name||''))); list.innerHTML = ''; if (!xs.length) { const empty = document.createElement('div'); empty.className = 'muted'; empty.textContent = 'Sin categorías'; list.appendChild(empty); return; } xs.forEach(c=>{ const isEdit = (FIN_EDIT.kind === 'fincat' && String(FIN_EDIT.id) === String(c.id)); const kind = String(c.kind||'both'); const ownerId = c.owner_user_id !== null && c.owner_user_id !== undefined ? String(c.owner_user_id) : ''; const ownerName = ownerId ? (USER_BY_ID.get(ownerId)?.name || 'Usuario') : 'Compartida'; const card = document.createElement('div'); card.className = 'catcard ' + (kind === 'income' ? 'income' : (kind === 'expense' ? 'expense' : 'both')); if (!isEdit) { const ttl = document.createElement('div'); ttl.className = 'ttl'; ttl.textContent = String(c.name||''); const meta = document.createElement('div'); meta.className = 'meta'; const bKind = document.createElement('span'); bKind.className = 'badge'; bKind.textContent = FIN_KIND_LABEL[kind] || kind; const bOwner = document.createElement('span'); bOwner.className = 'badge'; bOwner.textContent = String(ownerName||''); meta.appendChild(bKind); meta.appendChild(bOwner); const ops = document.createElement('div'); ops.style.display = 'flex'; ops.style.justifyContent = 'flex-end'; ops.style.gap = '8px'; const editBtn = document.createElement('button'); editBtn.className = 'tbtn icon'; editBtn.type = 'button'; editBtn.textContent = '✎'; editBtn.setAttribute('aria-label','Editar'); editBtn.addEventListener('click', ()=>finSetEdit('fincat', c.id)); const delBtn = document.createElement('button'); delBtn.className = 'tbtn icon'; delBtn.type = 'button'; delBtn.textContent = '✕'; delBtn.setAttribute('aria-label','Eliminar'); delBtn.addEventListener('click', async ()=>{ const ok = await openAppConfirm({title:'Eliminar categoría', message:'¿Seguro que deseas eliminar esta categoría?', okText:'Eliminar', cancelText:'Cancelar', danger:true}); if (!ok) return; const r = await api('delete_fin_category', {id:String(c.id)}); if (!r.ok) { toast('No se pudo eliminar', 'err'); return; } await loadFinance(); }); ops.appendChild(editBtn); ops.appendChild(delBtn); card.appendChild(ttl); card.appendChild(meta); card.appendChild(ops); list.appendChild(card); return; } const ownerSel = document.createElement('select'); ownerSel.className = 'select'; ownerSel.innerHTML = '<option value="">Compartida</option>' + USERS.map(u=>'<option value="'+String(u.id)+'">'+String(u.name||'')+'</option>').join(''); ownerSel.value = ownerId; const kindSel = document.createElement('select'); kindSel.className = 'select'; kindSel.innerHTML = '<option value="both">Ingreso y egreso</option><option value="income">Solo ingreso</option><option value="expense">Solo egreso</option>'; kindSel.value = kind; const nameIn = document.createElement('input'); nameIn.className = 'input'; nameIn.value = String(c.name||''); nameIn.placeholder = 'Nombre'; const ops = document.createElement('div'); ops.style.display = 'flex'; ops.style.justifyContent = 'flex-end'; ops.style.gap = '8px'; const saveBtn = document.createElement('button'); saveBtn.className = 'btn ghost'; saveBtn.type = 'button'; saveBtn.textContent = 'Guardar'; saveBtn.addEventListener('click', async ()=>{ const payload = { id: String(c.id), owner_user_id: String(ownerSel.value||'').trim(), kind: String(kindSel.value||'').trim(), name: String(nameIn.value||'').trim() }; const r = await api('update_fin_category', payload); if (!r.ok) { toast('No se pudo guardar', 'err'); return; } FIN_EDIT = {kind:'', id:null}; await loadFinance(); }); const cancelBtn = document.createElement('button'); cancelBtn.className = 'btn ghost'; cancelBtn.type = 'button'; cancelBtn.textContent = 'Cancelar'; cancelBtn.addEventListener('click', finClearEdit); ops.appendChild(saveBtn); ops.appendChild(cancelBtn); card.appendChild(nameIn); card.appendChild(ownerSel); card.appendChild(kindSel); card.appendChild(ops); list.appendChild(card); }); } function finRenderAccounts(){ const list = document.getElementById('finAccountList'); if (!list) return; const fu = String(document.getElementById('finAccListUser')?.value || '').trim(); const fc = String(document.getElementById('finAccCurrencyFilter')?.value || '').trim(); const ft = String(document.querySelector('#finAccTypeFilter .choice.on')?.getAttribute('data-type') || '').trim(); const typeMap = {cash:'Efectivo', bank:'Banco', wallet:'Billetera', card:'Tarjeta', other:'Otro'}; const xs = (FIN_STATE.accounts || []).filter(a=>{ if (fu) { const ids = (a.users || []).map(String); if (!(a.shared_all === 1 || ids.includes(String(fu)))) return false; } if (fc && String(a.currency_code||'') !== fc) return false; if (ft && String(a.type||'') !== ft) return false; return true; }); list.innerHTML = ''; xs.forEach(a=>{ const k = document.createElement('div'); k.className = 'kpi acckpi'; const name = String(a.name||'').trim(); const ccy = String(a.currency_code||'').trim(); const typeKey = String(a.type||'').trim(); const typeLabel = typeMap[typeKey] || typeKey; const balNum = parseFloat(a.balance||0); const head = document.createElement('div'); head.className = 'acchead'; const ttl = document.createElement('div'); ttl.className = 'accttl'; ttl.textContent = name; if (ccy) { const bcur = document.createElement('span'); bcur.className = 'badge'; bcur.textContent = ccy; ttl.appendChild(bcur); } const editBtn = document.createElement('button'); editBtn.className = 'tbtn icon'; editBtn.type = 'button'; editBtn.textContent = '✎'; editBtn.setAttribute('aria-label','Editar'); editBtn.addEventListener('click', ()=>{ try { openAccEditModal(a); } catch (e) { toast('No se pudo abrir', 'err'); } }); const adjBtn = document.createElement('button'); adjBtn.className = 'tbtn icon'; adjBtn.type = 'button'; adjBtn.textContent = '⚙'; adjBtn.setAttribute('aria-label','Ajuste'); adjBtn.addEventListener('click', ()=>{ try { openAccAdjustModal(a); } catch (e) { toast('No se pudo abrir', 'err'); } }); const ops = document.createElement('div'); ops.className = 'accops'; ops.appendChild(adjBtn); ops.appendChild(editBtn); head.appendChild(ttl); head.appendChild(ops); const bal = document.createElement('div'); bal.className = 'accbal' + (balNum < 0 ? ' neg' : (balNum > 0 ? ' pos' : '')); bal.textContent = finMoney(balNum); const metaRow = document.createElement('div'); metaRow.className = 'accmeta'; const metaLeft = document.createElement('div'); metaLeft.className = 'accmeta-left'; if (typeLabel) { const bt = document.createElement('span'); bt.className = 'badge'; bt.textContent = typeLabel; metaLeft.appendChild(bt); } const users = document.createElement('div'); users.className = 'pavatars'; if (a.shared_all === 1) { const b = document.createElement('span'); b.className = 'badge'; b.textContent = 'Compartida'; users.appendChild(b); } else { (a.users || []).forEach(uid=>{ const u = USER_BY_ID.get(String(uid)); if (!u) return; const p = document.createElement('span'); p.className = 'pava'; p.title = String(u.name||''); p.style.backgroundImage = "url('" + String(u.photo||'') + "')"; p.style.borderColor = String(u.color||'#111111'); users.appendChild(p); }); } metaRow.appendChild(metaLeft); metaRow.appendChild(users); k.appendChild(head); k.appendChild(bal); k.appendChild(metaRow); list.appendChild(k); }); } function finRenderMoves(){ const list = document.getElementById('finMoveList'); const sumHost = document.getElementById('finMoveDaySummary'); if (!list) return; const fu = String(document.getElementById('finMoveListUser')?.value || '').trim(); const ft = String(document.querySelector('#finMoveTypeFilter .choice.on')?.getAttribute('data-type') || '').trim(); const moves = (FIN_STATE.moves || []).filter(m=>{ if (fu && String(m.user_id||'') !== fu) return false; if (ft && String(m.type||'') !== ft) return false; return true; }); moves.sort((a,b)=>{ const ad = String(a.move_date||''); const bd = String(b.move_date||''); const at = String(a.created_at||'').slice(11,19) || '00:00:00'; const bt = String(b.created_at||'').slice(11,19) || '00:00:00'; const ak = ad ? (ad+' '+at) : String(a.created_at||''); const bk = bd ? (bd+' '+bt) : String(b.created_at||''); return bk.localeCompare(ak); }); if (sumHost) { const map = {}; moves.forEach(m=>{ const t = String(m.type||''); const c = String(m.currency_code||''); if (!c) return; if (!map[c]) map[c] = {in:0,out:0}; if (t === 'income') map[c].in += parseFloat(m.amount||0); if (t === 'expense') map[c].out += parseFloat(m.amount||0); }); const parts = Object.keys(map).sort((a,b)=>a.localeCompare(b)).map(ccy=>{ const x = map[ccy]; return '<span class="sum in">+'+finMoney(x.in||0)+' '+ccy+'</span><span class="sum out">-'+finMoney(x.out||0)+' '+ccy+'</span>'; }); sumHost.innerHTML = parts.join(''); } list.innerHTML = ''; moves.forEach(m=>{ const isEdit = (FIN_EDIT.kind === 'move' && String(FIN_EDIT.id) === String(m.id)); const type = String(m.type||'income'); const row = document.createElement('div'); row.className = 'movecard ' + type; if (!isEdit) { const u = USER_BY_ID.get(String(m.user_id||'')); const uPhoto = u ? String(u.photo||'') : ''; const uColor = u ? String(u.color||'#111111') : '#111111'; const note = String(m.note||'').trim(); const date = String(m.move_date||'') || String(m.created_at||'').slice(0,10); const time24 = String(m.created_at||'').slice(11,16); const time = formatTimeAmPm(time24); const ccy = String(m.currency_code||''); const amt = finMoney(m.amount||0); const sign = type === 'income' ? '+' : (type === 'expense' ? '-' : ''); const catLabel = (type === 'transfer') ? 'Transferencia' : (String(m.category_name||'') || 'Sin categoría'); const titleLeft = note || catLabel; let amtView = sign + amt + ' ' + ccy; if (type === 'transfer') { const toCcy = String(m.to_currency_code||ccy); const toAmt = m.to_amount !== null && m.to_amount !== undefined && String(m.to_amount) !== '' ? finMoney(m.to_amount) : amt; amtView = amt + ' ' + ccy + ' → ' + toAmt + ' ' + toCcy; } row.innerHTML = '<span class="pava" aria-hidden="true" style="width:34px;height:34px;background-image:url(\''+uPhoto+'\');border-color:'+uColor+'"></span>'+ '<div style="flex:1;min-width:240px">'+ '<div style="display:flex;justify-content:space-between;gap:10px;align-items:flex-start">'+ '<div style="font-weight:950">'+titleLeft+'</div>'+ '<div class="amt">'+amtView+'</div>'+ '</div>'+ '<div class="meta">'+ '<span class="badge">'+String(m.account_name||'')+'</span>'+ '<span class="badge">'+catLabel+'</span>'+ (type==='transfer' ? ('<span class="badge">'+String(m.to_account_name||'')+'</span>') : '')+ ((date||time) ? ('<span class="badge">'+String(date||'')+(time ? (' · '+time) : '')+'</span>') : '')+ '</div>'+ '</div>'+ '<div class="ops">'+ '<button class="tbtn icon" data-role="edit" type="button" aria-label="Editar">✎</button>'+ '<button class="tbtn icon" data-role="del" type="button" aria-label="Eliminar">✕</button>'+ '</div>'; row.querySelector('[data-role="edit"]').addEventListener('click', ()=>finSetEdit('move', m.id)); row.querySelector('[data-role="del"]').addEventListener('click', async ()=>{ const ok = await openAppConfirm({title:'Eliminar movimiento', message:'¿Seguro que deseas eliminar este movimiento?', okText:'Eliminar', cancelText:'Cancelar', danger:true}); if (!ok) return; const r = await api('delete_fin_move', {id:String(m.id)}); if (!r.ok) { toast('No se pudo eliminar', 'err'); return; } await loadFinance(); }); list.appendChild(row); return; } row.innerHTML = ''; row.style.alignItems = 'center'; const dateIn = document.createElement('input'); dateIn.className = 'select'; dateIn.type = 'date'; dateIn.value = String(m.move_date||''); dateIn.style.maxWidth = '160px'; const timeBox = document.createElement('div'); timeBox.className = 'time12'; timeBox.innerHTML = '<select class="select" data-role="h"><option value="">—</option>'+Array.from({length:12}).map((_,i)=>'<option value="'+(i+1)+'">'+(i+1)+'</option>').join('')+'</select>'+ '<select class="select" data-role="m">'+Array.from({length:60}).map((_,i)=>{const v=String(i).padStart(2,'0');return '<option value="'+v+'">'+v+'</option>';}).join('')+'</select>'+ '<select class="select" data-role="ap"><option value="AM">AM</option><option value="PM">PM</option></select>'; const time24 = String(m.created_at||'').slice(11,16); setTime12Value(timeBox, time24); const accSel = document.createElement('select'); accSel.className = 'select'; accSel.style.minWidth = '220px'; finFillAccountSelect(accSel, FIN_STATE.accounts||[], true, 'Cuenta'); accSel.value = String(m.account_id||''); const toAccSel = document.createElement('select'); toAccSel.className = 'select'; toAccSel.style.minWidth = '220px'; finFillAccountSelect(toAccSel, FIN_STATE.accounts||[], true, 'Cuenta destino'); toAccSel.value = String(m.to_account_id||''); const amtIn = document.createElement('input'); amtIn.className = 'input'; amtIn.placeholder = 'Monto'; amtIn.inputMode = 'decimal'; amtIn.style.maxWidth = '160px'; amtIn.value = String(m.amount||''); const toAmtIn = document.createElement('input'); toAmtIn.className = 'input'; toAmtIn.placeholder = 'Monto destino'; toAmtIn.inputMode = 'decimal'; toAmtIn.style.maxWidth = '160px'; toAmtIn.value = (m.to_amount !== null && m.to_amount !== undefined) ? String(m.to_amount) : ''; const catSel = document.createElement('select'); catSel.className = 'select'; catSel.style.minWidth = '240px'; finFillCategorySelect(catSel, FIN_STATE.categories||[], type); catSel.value = m.category_id !== null && m.category_id !== undefined ? String(m.category_id) : ''; catSel.disabled = (type === 'transfer'); const noteIn = document.createElement('input'); noteIn.className = 'input'; noteIn.placeholder = 'Nota'; noteIn.style.minWidth = '240px'; noteIn.value = String(m.note||''); const saveBtn = document.createElement('button'); saveBtn.className = 'btn ghost'; saveBtn.setAttribute('data-role','save'); saveBtn.textContent = 'Guardar'; const cancelBtn = document.createElement('button'); cancelBtn.className = 'btn ghost'; cancelBtn.textContent = 'Cancelar'; const delBtn = document.createElement('button'); delBtn.className = 'btn ghost'; delBtn.setAttribute('data-role','del'); delBtn.textContent = 'Eliminar'; function syncTransfer(){ if (type !== 'transfer') { toAccSel.style.display = 'none'; toAmtIn.style.display = 'none'; return; } toAccSel.style.display = ''; const aCur = finAccountCurrencyById(accSel.value); const bCur = finAccountCurrencyById(toAccSel.value); const same = !!(aCur && bCur && aCur === bCur); toAmtIn.style.display = same ? 'none' : ''; if (same) toAmtIn.value = ''; } accSel.addEventListener('change', syncTransfer); toAccSel.addEventListener('change', syncTransfer); syncTransfer(); saveBtn.addEventListener('click', async ()=>{ const payload = { id: String(m.id), move_date: String(dateIn.value||'').trim(), account_id: String(accSel.value||'').trim(), amount: String(amtIn.value||'').trim(), category_id: String(catSel.value||'').trim(), note: String(noteIn.value||'').trim() }; const mt = getTime12Value(timeBox); if (mt) payload.move_time = mt; if (type === 'transfer') { payload.to_account_id = String(toAccSel.value||'').trim(); if (toAmtIn.style.display !== 'none') payload.to_amount = String(toAmtIn.value||'').trim(); } const r = await api('update_fin_move', payload); if (!r.ok) { toast('No se pudo guardar', 'err'); return; } FIN_EDIT = {kind:'', id:null}; await loadFinance(); }); cancelBtn.addEventListener('click', finClearEdit); delBtn.addEventListener('click', async ()=>{ const ok = await openAppConfirm({title:'Eliminar movimiento', message:'¿Seguro que deseas eliminar este movimiento?', okText:'Eliminar', cancelText:'Cancelar', danger:true}); if (!ok) return; const r = await api('delete_fin_move', {id:String(m.id)}); if (!r.ok) { toast('No se pudo eliminar', 'err'); return; } FIN_EDIT = {kind:'', id:null}; await loadFinance(); }); row.appendChild(dateIn); row.appendChild(timeBox); row.appendChild(accSel); row.appendChild(toAccSel); row.appendChild(amtIn); row.appendChild(toAmtIn); row.appendChild(catSel); row.appendChild(noteIn); row.appendChild(saveBtn); row.appendChild(cancelBtn); row.appendChild(delBtn); list.appendChild(row); }); } function finRenderDebts(){ const list = document.getElementById('finDebtList'); if (!list) return; const fp = String(document.getElementById('finDebtFilterPerson')?.value || '').trim().toLowerCase(); const fu = String(FIN_DEBT_FILTER.user||'').trim(); const fk = String(FIN_DEBT_FILTER.kind||'').trim(); const fs = String(FIN_DEBT_FILTER.status||'').trim(); const debts = (FIN_STATE.debts || []).filter(d=>{ if (fu && String(d.owner_user_id||'') !== fu) return false; if (fk && String(d.kind||'') !== fk) return false; if (fs && String(d.status||'') !== fs) return false; if (fp) { const p = String(d.person||'').toLowerCase(); const n = String(d.note||'').toLowerCase(); if (!p.includes(fp) && !n.includes(fp)) return false; } return true; }); list.innerHTML = ''; debts.forEach(d=>{ const isEdit = (FIN_EDIT.kind === 'debt' && String(FIN_EDIT.id) === String(d.id)); const kind = String(d.kind||'i_owe'); const status = String(d.status||'open'); const kindLabel = (kind === 'i_owe') ? 'Yo debo' : 'Me deben'; const out = finMoney(d.outstanding||0); const paid = finMoney(d.paid||0); const total = finMoney(d.amount||0); const ccy = String(d.currency_code||''); const row = document.createElement('div'); row.className = 'movecard debtcard ' + (kind === 'i_owe' ? 'iowe' : 'theyowe') + (status === 'closed' ? ' closed' : ''); if (!isEdit) { const ownerU = USER_BY_ID.get(String(d.owner_user_id||'')); const uPhoto = ownerU ? String(ownerU.photo||'') : ''; const uColor = ownerU ? String(ownerU.color||'#111111') : '#111111'; const uName = ownerU ? String(ownerU.name||'') : ''; const note = String(d.note||'').trim(); const sign = (kind === 'i_owe') ? '-' : '+'; const amtColor = (kind === 'i_owe') ? 'rgba(185,28,28,.95)' : 'rgba(37,99,235,.95)'; row.innerHTML = '<div style="flex:1;min-width:240px">'+ '<div style="display:flex;justify-content:space-between;gap:10px;align-items:flex-start">'+ '<div style="font-weight:950">'+String(d.person||'')+'</div>'+ '<div class="amt" style="color:'+amtColor+'">'+sign+out+' '+ccy+'</div>'+ '</div>'+ '<div class="meta">'+ '<span class="badge">'+kindLabel+'</span>'+ '<span class="badge">'+(status==='open'?'Abierta':'Cerrada')+'</span>'+ '<span class="badge">'+String(d.start_date||'')+'</span>'+ '<span class="badge">Pagado '+paid+' / '+total+'</span>'+ (ownerU ? ('<span class="pava" title="'+uName+'" style="background-image:url(\''+uPhoto+'\');border-color:'+uColor+'"></span>') : '')+ '</div>'+ (note ? ('<div class="muted" style="margin-top:6px">'+note+'</div>') : '')+ '</div>'+ '<div class="ops">'+ '<button class="tbtn icon" data-role="edit" type="button" aria-label="Editar">✎</button>'+ '<button class="tbtn icon" data-role="del" type="button" aria-label="Eliminar">✕</button>'+ '<button class="tbtn" data-role="pay" type="button">Pagar</button>'+ '<button class="tbtn" data-role="toggle" type="button">'+(status==='open'?'Cerrar':'Reabrir')+'</button>'+ '</div>'; row.querySelector('[data-role="edit"]').addEventListener('click', ()=>finSetEdit('debt', d.id)); row.querySelector('[data-role="del"]').addEventListener('click', async ()=>{ const ok = await openAppConfirm({title:'Eliminar deuda', message:'¿Seguro que deseas eliminar esta deuda?', okText:'Eliminar', cancelText:'Cancelar', danger:true}); if (!ok) return; const r = await api('delete_fin_debt', {id:String(d.id)}); if (!r.ok) { if (r.error === 'has_payments') toast('No puedes eliminar: tiene pagos', 'warn'); else toast('No se pudo eliminar', 'err'); return; } await loadFinance(); }); row.querySelector('[data-role="toggle"]').addEventListener('click', async ()=>{ const next = (status === 'open') ? 'closed' : 'open'; const r = await api('set_fin_debt_status', {id:String(d.id), status:next}); if (!r.ok) { toast('No se pudo actualizar', 'err'); return; } await loadFinance(); }); row.querySelector('[data-role="pay"]').addEventListener('click', async ()=>{ if (status !== 'open') { toast('La deuda está cerrada', 'warn'); return; } const amtStr = await openAppPrompt({title:'Pago', message:'Monto a pagar', okText:'Pagar', cancelText:'Cancelar', inputMode:'decimal'}); if (amtStr === null) return; const amt = parseFloat(String(amtStr||'').replace(',', '.')); if (!(amt > 0)) { toast('Monto inválido', 'warn'); return; } const payDate = String(document.getElementById('finMoveDate')?.value || FIN_STATE.day || nowYmd()).trim(); const userId = (ACTOR_USER_ID_DEFAULT !== null) ? String(ACTOR_USER_ID_DEFAULT) : String(d.owner_user_id||''); const accs = (FIN_STATE.accounts||[]).filter(a=>String(a.currency_code||'') === ccy); if (!accs.length) { toast('No hay cuentas para '+ccy, 'warn'); return; } let accountId = String(accs[0].id); if (accs.length > 1) { const listTxt = accs.map(a=>'#'+String(a.id)+' '+String(a.name||'')).join(' | '); const accStr = await openAppPrompt({title:'Cuenta', message:'Elige cuenta: '+listTxt, okText:'Elegir', cancelText:'Cancelar'}); if (accStr === null) return; accountId = String(accStr||'').trim(); } const r = await api('add_fin_debt_payment', {debt_id:String(d.id), pay_date:payDate, user_id:userId, account_id:accountId, amount:String(amt)}); if (!r.ok) { if (r.error === 'currency_mismatch') toast('Moneda no coincide', 'warn'); else if (r.error === 'forbidden_account') toast('Cuenta no permitida', 'warn'); else toast('No se pudo pagar', 'err'); return; } await loadFinance(); }); list.appendChild(row); return; } row.innerHTML = ''; row.style.alignItems = 'center'; const kindSel = document.createElement('select'); kindSel.className = 'select'; kindSel.innerHTML = '<option value="i_owe">Yo debo</option><option value="they_owe_me">Me deben</option>'; kindSel.value = kind; const personIn = document.createElement('input'); personIn.className = 'input'; personIn.value = String(d.person||''); personIn.placeholder = 'Persona'; personIn.style.minWidth = '200px'; const curIn = document.createElement('input'); curIn.className = 'input'; curIn.value = String(d.currency_code||''); curIn.placeholder = 'Moneda'; curIn.style.maxWidth = '120px'; const amtIn = document.createElement('input'); amtIn.className = 'input'; amtIn.value = String(d.amount||''); amtIn.placeholder = 'Monto'; amtIn.inputMode = 'decimal'; amtIn.style.maxWidth = '140px'; const sdIn = document.createElement('input'); sdIn.className = 'select'; sdIn.type = 'date'; sdIn.value = String(d.start_date||''); sdIn.style.maxWidth = '160px'; const noteIn = document.createElement('input'); noteIn.className = 'input'; noteIn.value = String(d.note||''); noteIn.placeholder = 'Nota'; noteIn.style.minWidth = '220px'; const saveBtn = document.createElement('button'); saveBtn.className = 'btn ghost'; saveBtn.setAttribute('data-role','save'); saveBtn.textContent = 'Guardar'; const cancelBtn = document.createElement('button'); cancelBtn.className = 'btn ghost'; cancelBtn.textContent = 'Cancelar'; const delBtn = document.createElement('button'); delBtn.className = 'btn ghost'; delBtn.setAttribute('data-role','del'); delBtn.textContent = 'Eliminar'; saveBtn.addEventListener('click', async ()=>{ const payload = { id: String(d.id), kind: String(kindSel.value||'').trim(), person: String(personIn.value||'').trim(), currency_code: String(curIn.value||'').trim(), amount: String(amtIn.value||'').trim(), start_date: String(sdIn.value||'').trim(), note: String(noteIn.value||'').trim() }; const r = await api('update_fin_debt', payload); if (!r.ok) { if (r.error === 'currency_locked') toast('No puedes cambiar la moneda: ya tiene pagos', 'warn'); else if (r.error === 'amount_lt_paid') toast('El monto no puede ser menor al total ya pagado', 'warn'); else toast('No se pudo guardar', 'err'); return; } FIN_EDIT = {kind:'', id:null}; await loadFinance(); }); cancelBtn.addEventListener('click', finClearEdit); delBtn.addEventListener('click', async ()=>{ const ok = await openAppConfirm({title:'Eliminar deuda', message:'¿Seguro que deseas eliminar esta deuda?', okText:'Eliminar', cancelText:'Cancelar', danger:true}); if (!ok) return; const r = await api('delete_fin_debt', {id:String(d.id)}); if (!r.ok) { if (r.error === 'has_payments') toast('No puedes eliminar: tiene pagos', 'warn'); else toast('No se pudo eliminar', 'err'); return; } FIN_EDIT = {kind:'', id:null}; await loadFinance(); }); row.appendChild(kindSel); row.appendChild(personIn); row.appendChild(curIn); row.appendChild(amtIn); row.appendChild(sdIn); row.appendChild(noteIn); row.appendChild(saveBtn); row.appendChild(cancelBtn); row.appendChild(delBtn); list.appendChild(row); }); } function finSyncMoveTypeUI(){ const t = document.getElementById('finMoveType')?.value || 'income'; const toSel = document.getElementById('finMoveToAccount'); const toAmt = document.getElementById('finMoveToAmount'); const catSel = document.getElementById('finMoveCategory'); if (toSel) toSel.style.display = (t==='transfer') ? '' : 'none'; if (toAmt) toAmt.style.display = (t==='transfer') ? '' : 'none'; if (catSel) catSel.disabled = (t==='transfer'); finFillCategorySelect(catSel, FIN_STATE.categories||[], t); finMaybeAutofillTransferToAmount(true); } function finSyncMoveTypePills(){ const sel = document.getElementById('finMoveType'); const host = document.getElementById('finMoveTypePills'); if (!sel || !host) return; const v = (sel.value || '').trim() || 'income'; host.querySelectorAll('button[data-value]').forEach(b=>{ const bv = (b.getAttribute('data-value') || '').trim(); b.classList.toggle('on', bv === v); }); } function finInitMoveTypePills(){ const sel = document.getElementById('finMoveType'); const host = document.getElementById('finMoveTypePills'); if (!sel || !host) return; if (host.getAttribute('data-bound') === '1') { finSyncMoveTypePills(); return; } host.setAttribute('data-bound','1'); host.querySelectorAll('button[data-value]').forEach(b=>{ b.addEventListener('click', ()=>{ const v = (b.getAttribute('data-value') || '').trim() || 'income'; sel.value = v; sel.dispatchEvent(new Event('change')); finSyncMoveTypePills(); }); }); sel.addEventListener('change', finSyncMoveTypePills); finSyncMoveTypePills(); } function finAccountCurrencyById(id){ const xs = (FIN_STATE && FIN_STATE.accounts) ? FIN_STATE.accounts : []; const x = xs.find(a=>String(a.id)===String(id)); return x ? String(x.currency_code||'') : ''; } function finIsSameCurrencyTransfer(){ const t = document.getElementById('finMoveType')?.value || 'income'; if (t !== 'transfer') return false; const aId = document.getElementById('finMoveAccount')?.value || ''; const bId = document.getElementById('finMoveToAccount')?.value || ''; if (!aId || !bId) return false; const aCur = finAccountCurrencyById(aId); const bCur = finAccountCurrencyById(bId); return !!(aCur && bCur && aCur === bCur); } function finMaybeAutofillTransferToAmount(force){ const toAmt = document.getElementById('finMoveToAmount'); const amt = document.getElementById('finMoveAmount'); if (!toAmt || !amt) return; if (!finIsSameCurrencyTransfer()) { toAmt.removeAttribute('data-autofill'); return; } const can = !!force || !String(toAmt.value||'').trim() || toAmt.getAttribute('data-autofill') === '1'; if (can) { toAmt.value = String(amt.value||'').trim(); toAmt.setAttribute('data-autofill','1'); } } async function loadFinance(){ const viewFin = document.getElementById('view-finances'); const viewToday = document.getElementById('view-today'); const viewCfg = document.getElementById('view-config'); const active = (!!viewFin && viewFin.classList.contains('active')) || (!!viewToday && viewToday.classList.contains('active')) || (!!viewCfg && viewCfg.classList.contains('active')); if (!active) return; const day = document.getElementById('finMoveDate')?.value || ''; const payload = {}; if (day) payload.day = day; if (ACTOR_USER_ID_DEFAULT !== null) payload.user_id = String(ACTOR_USER_ID_DEFAULT); const r = await api('get_finance', payload); if (!r.ok) return; FIN_STATE = r; finFillAccCurrencyFilter(); finRenderAll(); finFillAccountSelect(document.getElementById('finMoveAccount'), FIN_STATE.accounts||[], true, 'Cuenta'); finFillAccountSelect(document.getElementById('finMoveToAccount'), FIN_STATE.accounts||[], true, 'Cuenta destino'); finSyncMoveTypeUI(); finInitMoveTypePills(); if (FIN_QUICK_ADD) { const dateEl = document.getElementById('finMoveDate'); const amtEl = document.getElementById('finMoveAmount'); const dateModalEl = document.getElementById('finMoveDateModal'); if (dateEl) { const d = (FIN_QUICK_ADD.date || '').trim(); if (d) dateEl.value = d; else { const now = new Date(); dateEl.value = now.getFullYear()+'-'+pad2n(now.getMonth()+1)+'-'+pad2n(now.getDate()); } } if (dateModalEl) dateModalEl.value = (dateEl?.value || nowYmd()); FIN_QUICK_ADD = null; setTimeout(()=>{ try { openMoveModal(); if (amtEl) amtEl.focus(); } catch (e) {} }, 40); } } // Finance form wiring const finAccUsersHost = document.getElementById('finAccUsers'); const finAccSelected = new Set(); finRenderUserPick(finAccUsersHost, finAccSelected); let FIN_ACC_SHARED_BOUND = false; function finInitAccSharedToggle(){ if (FIN_ACC_SHARED_BOUND) return; FIN_ACC_SHARED_BOUND = true; const sh = document.getElementById('finAccSharedAll'); if (!sh || !finAccUsersHost) return; const sync = ()=>{ finAccUsersHost.style.display = sh.checked ? 'none' : ''; }; sh.addEventListener('change', sync); sync(); } function finFillAccCurrencyFilter(){ const sel = document.getElementById('finAccCurrencyFilter'); if (!sel) return; const cur = (sel.value || '').trim(); const set = new Set(); (FIN_STATE.accounts||[]).forEach(a=>{ const c = String(a.currency_code||'').trim(); if (c) set.add(c); }); const xs = [...set].sort((a,b)=>a.localeCompare(b)); sel.innerHTML = '<option value="">Moneda</option>'; xs.forEach(c=>{ const o = document.createElement('option'); o.value = c; o.textContent = c; sel.appendChild(o); }); if ([...sel.options].some(o=>o.value===cur)) sel.value = cur; else sel.value = ''; } function finResetAccountForm(){ const n = document.getElementById('finAccName'); const t = document.getElementById('finAccType'); const c = document.getElementById('finAccCurrency'); const o = document.getElementById('finAccOpening'); const sh = document.getElementById('finAccSharedAll'); if (n) n.value = ''; if (c) c.value = ''; if (o) o.value = ''; if (sh) sh.checked = false; finAccSelected.clear(); finRenderUserPick(finAccUsersHost, finAccSelected); if (finAccUsersHost) finAccUsersHost.style.display = ''; setTimeout(()=>n?.focus(), 30); } function finResetCategoryForm(){ const n = document.getElementById('finCatNameModal'); if (n) n.value = ''; setTimeout(()=>n?.focus(), 30); } function finResetMoveForm(){ const amt = document.getElementById('finMoveAmount'); const toAmt = document.getElementById('finMoveToAmount'); const note = document.getElementById('finMoveNote'); const cat = document.getElementById('finMoveCategory'); const dateModalEl = document.getElementById('finMoveDateModal'); if (amt) amt.value = ''; if (toAmt) toAmt.value = ''; if (note) note.value = ''; if (cat) cat.value = ''; if (dateModalEl) dateModalEl.value = nowYmd(); try { setTime12Value(document.getElementById('finMoveTime12'), nowTime24()); } catch (e) {} finSyncMoveTypeUI(); finInitMoveTypePills(); setTimeout(()=>{ if (moveModal && moveModal.classList.contains('open')) amt?.focus(); }, 30); } function finResetDebtForm(){ const p = document.getElementById('finDebtPersonModal'); const cur = document.getElementById('finDebtCurrencyModal'); const amt = document.getElementById('finDebtAmountModal'); const note = document.getElementById('finDebtNoteModal'); if (p) p.value = ''; if (cur) cur.value = ''; if (amt) amt.value = ''; if (note) note.value = ''; setTimeout(()=>p?.focus(), 30); } document.getElementById('finAddAccBtn')?.addEventListener('click', async ()=>{ const name = (document.getElementById('finAccName')?.value || '').trim(); const type = (document.getElementById('finAccType')?.value || '').trim(); const currency_code = (document.getElementById('finAccCurrency')?.value || '').trim(); const shared_all = document.getElementById('finAccSharedAll')?.checked ? 1 : 0; const user_ids = [...finAccSelected].join(','); const isEdit = FIN_ACC_EDIT_ID !== null && FIN_ACC_EDIT_ID !== undefined && String(FIN_ACC_EDIT_ID) !== ''; let opening_balance = ''; if (isEdit) opening_balance = (FIN_ACC_EDIT_OPENING !== null && FIN_ACC_EDIT_OPENING !== undefined) ? (Math.round(FIN_ACC_EDIT_OPENING*100)/100).toFixed(2) : '0.00'; else opening_balance = (document.getElementById('finAccOpening')?.value || '').trim(); const payload = {name,type,currency_code,opening_balance,shared_all,user_ids}; if (isEdit) payload.id = String(FIN_ACC_EDIT_ID); const r = await api(isEdit ? 'update_fin_account' : 'add_fin_account', payload); if (!r.ok) { if (r.error === 'bad_currency') toast('Moneda inválida', 'warn'); else if (r.error === 'bad_amount') toast('Saldo inválido', 'warn'); else if (r.error === 'user_required') toast('Selecciona usuarios o marca Compartida', 'warn'); else toast(isEdit ? 'No se pudo guardar' : 'No se pudo crear', 'err'); return; } await loadFinance(); finResetAccountForm(); closeAccModal(); toast(isEdit ? 'Cuenta guardada' : 'Cuenta creada', 'ok'); }); document.getElementById('finDelAccBtn')?.addEventListener('click', async ()=>{ if (FIN_ACC_EDIT_ID === null || FIN_ACC_EDIT_ID === undefined || String(FIN_ACC_EDIT_ID) === '') return; const ok = await openAppConfirm({title:'Eliminar cuenta', message:'¿Seguro que deseas eliminar esta cuenta?', okText:'Eliminar', cancelText:'Cancelar', danger:true}); if (!ok) return; const r = await api('delete_fin_account', {id:String(FIN_ACC_EDIT_ID)}); if (!r.ok) { toast('No se pudo eliminar', 'err'); return; } FIN_ACC_EDIT_ID = null; FIN_ACC_EDIT_OPENING = null; await loadFinance(); finResetAccountForm(); closeAccModal(); toast('Cuenta eliminada', 'ok'); }); document.getElementById('accAdjustApplyBtn')?.addEventListener('click', async ()=>{ const expEl = document.getElementById('accAdjustExpected'); const delEl = document.getElementById('accAdjustDelta'); const cur = parseFloat(FIN_ACC_ADJ.current||0); const exp = finParseNum(expEl?.value); const dIn = finParseNum(delEl?.value); let delta = null; if (FIN_ACC_ADJ_LAST === 'delta') delta = dIn; else delta = (exp !== null) ? (exp - cur) : dIn; if (delta === null || !isFinite(delta)) { toast('Monto inválido', 'warn'); return; } delta = Math.round(delta*100)/100; if (Math.abs(delta) < 0.005) { closeAccAdjustModal(); toast('Sin cambios', 'info'); return; } const type = delta > 0 ? 'income' : 'expense'; const amount = Math.abs(delta); const move_date = nowYmd(); const move_time = nowTime24(); const user_id = (ACTOR_USER_ID_DEFAULT !== null) ? String(ACTOR_USER_ID_DEFAULT) : String(FIN_STATE.user_id||''); if (!user_id || String(user_id) === '0') { toast('Usuario requerido', 'warn'); return; } const note = 'Ajuste saldo · '+String(FIN_ACC_ADJ.name||''); const r = await api('add_fin_move', { move_date, move_time, type, user_id, account_id:String(FIN_ACC_ADJ.id), amount:String(amount.toFixed(2)), category_id:'', note }); if (!r.ok) { toast('No se pudo aplicar', 'err'); return; } await loadFinance(); closeAccAdjustModal(); toast('Ajuste aplicado', 'ok'); }); document.getElementById('finAddFinCatModalBtn')?.addEventListener('click', async ()=>{ const owner_user_id = (document.getElementById('finCatOwnerModal')?.value || '').trim(); const kind = (document.getElementById('finCatKindModal')?.value || '').trim(); const name = (document.getElementById('finCatNameModal')?.value || '').trim(); const r = await api('add_fin_category',{owner_user_id,kind,name}); if (!r.ok) { toast('No se pudo crear', 'err'); return; } await loadFinance(); finResetCategoryForm(); closeFinCatModal(); toast('Categoría creada', 'ok'); }); document.getElementById('finMoveType')?.addEventListener('change', finSyncMoveTypeUI); document.getElementById('finMoveAccount')?.addEventListener('change', ()=>finMaybeAutofillTransferToAmount(true)); document.getElementById('finMoveToAccount')?.addEventListener('change', ()=>finMaybeAutofillTransferToAmount(true)); document.getElementById('finMoveAmount')?.addEventListener('input', ()=>finMaybeAutofillTransferToAmount(false)); document.getElementById('finMoveToAmount')?.addEventListener('input', ()=>document.getElementById('finMoveToAmount')?.removeAttribute('data-autofill')); document.getElementById('finMoveDate')?.addEventListener('change', loadFinance); document.getElementById('finAddMoveBtn')?.addEventListener('click', async ()=>{ const move_date = (document.getElementById('finMoveDateModal')?.value || document.getElementById('finMoveDate')?.value || '').trim(); const move_time = getTime12Value(document.getElementById('finMoveTime12')); const type = (document.getElementById('finMoveType')?.value || '').trim(); const user_id = (document.getElementById('finMoveUser')?.value || '').trim(); const account_id = (document.getElementById('finMoveAccount')?.value || '').trim(); const to_account_id = (document.getElementById('finMoveToAccount')?.value || '').trim(); const amount = (document.getElementById('finMoveAmount')?.value || '').trim(); const to_amount = (document.getElementById('finMoveToAmount')?.value || '').trim(); const category_id = (document.getElementById('finMoveCategory')?.value || '').trim(); const note = (document.getElementById('finMoveNote')?.value || '').trim(); const payload = {move_date,type,user_id,account_id,amount,category_id,note}; if (move_time) payload.move_time = move_time; if (type === 'transfer') { payload.to_account_id = to_account_id; payload.to_amount = to_amount; } const r = await api('add_fin_move', payload); if (!r.ok) { if (r.error === 'forbidden_account' || r.error === 'forbidden_to_account') toast('Cuenta no permitida', 'warn'); else if (r.error === 'to_amount_required') toast('Monto destino requerido', 'warn'); else toast('No se pudo registrar', 'err'); return; } try { const dayEl = document.getElementById('finMoveDate'); if (dayEl && move_date) dayEl.value = move_date; } catch (e) {} await loadFinance(); finResetMoveForm(); closeMoveModal(); toast('Movimiento registrado', 'ok'); }); document.getElementById('finMoveListUser')?.addEventListener('change', finRenderMoves); document.querySelectorAll('#finMoveTypeFilter .choice').forEach(b=>{ b.addEventListener('click', ()=>{ document.querySelectorAll('#finMoveTypeFilter .choice').forEach(x=>x.classList.remove('on')); b.classList.add('on'); finRenderMoves(); }); }); document.getElementById('finAccListUser')?.addEventListener('change', finRenderAccounts); document.getElementById('finAccCurrencyFilter')?.addEventListener('change', finRenderAccounts); document.querySelectorAll('#finAccTypeFilter .choice').forEach(b=>{ b.addEventListener('click', ()=>{ document.querySelectorAll('#finAccTypeFilter .choice').forEach(x=>x.classList.remove('on')); b.classList.add('on'); finRenderAccounts(); }); }); document.getElementById('finAddDebtModalBtn')?.addEventListener('click', async ()=>{ const owner_user_id = (document.getElementById('finDebtUserModal')?.value || '').trim(); const kind = (document.getElementById('finDebtKindModal')?.value || '').trim(); const person = (document.getElementById('finDebtPersonModal')?.value || '').trim(); const currency_code = (document.getElementById('finDebtCurrencyModal')?.value || '').trim(); const amount = (document.getElementById('finDebtAmountModal')?.value || '').trim(); const note = (document.getElementById('finDebtNoteModal')?.value || '').trim(); const start_date = (document.getElementById('finDebtStartDateModal')?.value || document.getElementById('finMoveDate')?.value || FIN_STATE.day || '').trim(); const r = await api('add_fin_debt',{owner_user_id,kind,person,currency_code,amount,start_date,note}); if (!r.ok) { toast('No se pudo crear', 'err'); return; } await loadFinance(); finResetDebtForm(); closeDebtModal(); toast('Deuda creada', 'ok'); }); document.getElementById('finDebtFilterUser')?.addEventListener('change', finRenderDebts); document.getElementById('finDebtFilterKind')?.addEventListener('change', finRenderDebts); document.getElementById('finDebtFilterStatus')?.addEventListener('change', finRenderDebts); document.getElementById('finDebtFilterPerson')?.addEventListener('input', finRenderDebts); document.getElementById('finCatFilterOwner')?.addEventListener('change', finRenderCategories); document.getElementById('finCatFilterKind')?.addEventListener('change', finRenderCategories); document.getElementById('finCatFilterName')?.addEventListener('input', finRenderCategories); document.getElementById('addUserBtn').addEventListener('click', async ()=>{ const name = document.getElementById('userName').value.trim(); const color = document.getElementById('userColor').value; const fd = new FormData(); fd.append('name', name); fd.append('color', color); if (ACTOR_USER_ID_DEFAULT !== null) fd.append('actor_user_id', String(ACTOR_USER_ID_DEFAULT)); const f = document.getElementById('userPhoto').files[0]; if (f) fd.append('photo_file', f); const r = await api('add_user', fd); if (!r.ok) { toast('No se pudo crear el usuario', 'err'); return; } location.reload(); }); document.querySelectorAll('#userList .catitem').forEach(row=>{ const id = row.getAttribute('data-id'); row.querySelector('[data-role="save"]').addEventListener('click', async ()=>{ const name = row.querySelector('[data-role="name"]').value.trim(); const color = row.querySelector('[data-role="color"]').value; const fd = new FormData(); fd.append('id', id); fd.append('name', name); fd.append('color', color); if (ACTOR_USER_ID_DEFAULT !== null) fd.append('actor_user_id', String(ACTOR_USER_ID_DEFAULT)); const f = row.querySelector('[data-role="photo_file"]').files[0]; if (f) fd.append('photo_file', f); const r = await api('update_user', fd); if (!r.ok) toast('No se pudo guardar', 'err'); location.reload(); }); row.querySelector('[data-role="del"]').addEventListener('click', async ()=>{ const ok = await openAppConfirm({title:'Eliminar usuario', message:'¿Seguro que deseas eliminar este usuario?', okText:'Eliminar', cancelText:'Cancelar', danger:true}); if (!ok) return; const r = await api('delete_user',{id}); if (!r.ok) { toast('No se pudo eliminar', 'err'); return; } location.reload(); }); }); function getSelectedUserIds(){ return [...document.querySelectorAll('#userPick .uopt')].filter(x=>x.classList.contains('on')).map(x=>x.getAttribute('data-uid')); } document.querySelectorAll('#userPick .uopt').forEach(el=>{ el.addEventListener('click',e=>{ el.classList.toggle('on'); saveUserPick(); e.preventDefault(); }); }); function saveUserPick(){ try { const ids = getSelectedUserIds(); localStorage.setItem(USER_PICK_KEY, JSON.stringify(ids)); } catch(e) {} } function loadUserPick(){ let ids = []; try { ids = JSON.parse(localStorage.getItem(USER_PICK_KEY) || '[]') || []; } catch(e) { ids = []; } const opts = [...document.querySelectorAll('#userPick .uopt')]; opts.forEach(o=>o.classList.remove('on')); if (opts.length === 1) { opts[0].classList.add('on'); return; } ids.forEach(id=>{ const el = document.querySelector('#userPick .uopt[data-uid="'+id+'"]'); if (el) el.classList.add('on'); }); } loadUserPick(); document.getElementById('addTaskBtn').addEventListener('click', async ()=>{ const title = document.getElementById('taskTitle').value.trim(); const taskDate = (document.getElementById('taskDate')?.value || '').trim(); const categoryId = document.getElementById('taskCategory').value; const projectId = document.getElementById('taskProject')?.value || ''; const taskTime = getTime12Value(document.getElementById('taskTime12')); const userIds = getSelectedUserIds(); if (!userIds.length) { toast('Selecciona al menos un usuario', 'warn'); return; } const isEdit = !!TASK_EDIT_ID; const desiredStatus = isEdit ? String(TASK_DRAWER_STATUS || 'pending') : 'pending'; const payload = isEdit ? {task_id:TASK_EDIT_ID,title,task_date:taskDate,category_id:categoryId,project_id:projectId,task_time:taskTime,user_ids:userIds.join(',')} : {title,task_date:taskDate,category_id:categoryId,project_id:projectId,task_time:taskTime,user_ids:userIds.join(',')}; const r = await api(isEdit ? 'update_task' : 'add_task', payload); if (!r.ok) { if (r.error === 'project_requires_category') toast('Para asignar proyecto debes elegir una categoría.', 'warn'); else if (r.error === 'project_category_mismatch') toast('El proyecto elegido no pertenece a la categoría seleccionada.', 'warn'); else toast(isEdit ? 'No se pudo guardar la tarea' : 'No se pudo agregar la tarea', 'err'); return; } if (isEdit) { const orig = String(TASK_DRAWER_STATUS_ORIG || ''); if (desiredStatus === 'done') { if (orig !== 'done') { const notes = (taskCloseNotes?.value || '').trim(); const payloadClose = {task_id:TASK_EDIT_ID, outcome:String(TASK_DRAWER_CLOSE_OUTCOME || 'final'), notes, satisfaction:String(TASK_DRAWER_CLOSE_SATISFACTION || 3)}; if (String(TASK_DRAWER_CLOSE_OUTCOME || 'final') === 'rerun') payloadClose.rerun_when = String(TASK_DRAWER_CLOSE_RERUN_WHEN || 'today'); const rr = await api('close_task', payloadClose); if (!rr.ok) { if (rr.error === 'schema_missing') toast('Faltan columnas en la tabla tasks. Ejecuta el script SQL de migración.', 'err'); else toast('No se pudo cerrar la tarea', 'err'); return; } } } else { if (desiredStatus !== orig) { const rr = await api('update_task_status', {task_id:TASK_EDIT_ID, status:desiredStatus}); if (!rr.ok) { toast('No se pudo actualizar el estado', 'err'); return; } } } } try { document.getElementById('taskTitle').value = ''; } catch (e) {} try { document.getElementById('taskDate').value = nowYmd(); } catch (e) {} try { setTime12Value(document.getElementById('taskTime12'), nowTime24()); } catch (e) {} closeTaskModal(true); try { sessionStorage.setItem('skipSplashOnce','1'); } catch(e) {} location.reload(); }); function renderProjectOptions(selectEl, categoryId, selectedProjectId){ if (!selectEl) return; const cur = selectedProjectId || ''; selectEl.innerHTML = ''; const opt0 = document.createElement('option'); opt0.value = ''; opt0.textContent = 'Sin proyecto'; selectEl.appendChild(opt0); const cat = (categoryId || '').trim(); if (!cat) { selectEl.value = ''; return; } const ps = PROJECTS.filter(p=>String(p.category_id||'') === String(cat)); ps.forEach(p=>{ const o = document.createElement('option'); o.value = String(p.id); o.textContent = p.name; selectEl.appendChild(o); }); selectEl.value = ps.some(p=>String(p.id)===String(cur)) ? String(cur) : ''; } const taskCategorySel = document.getElementById('taskCategory'); const taskProjectSel = document.getElementById('taskProject'); taskCategorySel?.addEventListener('change', ()=>{ renderProjectOptions(taskProjectSel, taskCategorySel.value, ''); }); renderProjectOptions(taskProjectSel, taskCategorySel?.value || '', taskProjectSel?.value || ''); document.getElementById('addProjectBtn')?.addEventListener('click', async ()=>{ const name = (document.getElementById('projectName')?.value || '').trim(); const color = (document.getElementById('projectColor')?.value || '').trim(); const categoryId = (document.getElementById('projectCategory')?.value || '').trim(); const r = await api('add_project', {name, color, category_id:categoryId}); if (!r.ok) { if (r.error === 'category_required') toast('Selecciona una categoría para el proyecto.', 'warn'); else toast('No se pudo crear el proyecto', 'err'); return; } location.reload(); }); document.querySelectorAll('#projectList .catitem').forEach(row=>{ const id = row.getAttribute('data-id'); row.querySelector('[data-role="save"]').addEventListener('click', async ()=>{ const name = row.querySelector('input.input')?.value.trim() || ''; const color = row.querySelector('[data-role="color"]')?.value || ''; const categoryId = row.querySelector('[data-role="cat"]')?.value || ''; const r = await api('update_project',{id,name,color,category_id:categoryId}); if (!r.ok) { if (r.error === 'category_required') toast('Selecciona una categoría para el proyecto.', 'warn'); else toast('No se pudo guardar', 'err'); } }); row.querySelector('[data-role="del"]').addEventListener('click', async ()=>{ const ok = await openAppConfirm({title:'Eliminar proyecto', message:'¿Seguro que deseas eliminar este proyecto?', okText:'Eliminar', cancelText:'Cancelar', danger:true}); if (!ok) return; const r = await api('delete_project',{id}); if (!r.ok) { toast('No se pudo eliminar', 'err'); return; } location.reload(); }); }); document.getElementById('addCatBtn').addEventListener('click', async ()=>{ const name = document.getElementById('catName').value.trim(); const r = await api('add_category',{name}); if (!r.ok) return; location.reload(); }); document.querySelectorAll('#catList .catitem').forEach(row=>{ const id = row.getAttribute('data-id'); row.querySelector('[data-role="save"]').addEventListener('click', async ()=>{ const name = row.querySelector('input').value.trim(); const r = await api('update_category',{id,name}); if (!r.ok) return; }); row.querySelector('[data-role="del"]').addEventListener('click', async ()=>{ const ok = await openAppConfirm({title:'Eliminar categoría', message:'¿Seguro que deseas eliminar esta categoría?', okText:'Eliminar', cancelText:'Cancelar', danger:true}); if (!ok) return; const r = await api('delete_category',{id}); if (!r.ok) { if (r.error === 'category_has_projects') toast('No puedes eliminar esta categoría porque tiene proyectos asociados.', 'warn'); else toast('No se pudo eliminar', 'err'); return; } location.reload(); }); }); document.querySelectorAll('#habitDays .uopt').forEach(el=>{ el.addEventListener('click',e=>{ el.classList.toggle('on'); e.preventDefault(); }); }); const habitDaysEl = document.getElementById('habitDays'); const habitUserEl = document.getElementById('habitUser'); function updateHabitDaysAccent(){ if (!habitDaysEl) return; const uid = habitUserEl?.value ? String(habitUserEl.value) : ''; const u = USER_BY_ID.get(uid); habitDaysEl.style.setProperty('--accent', (u && u.color) ? u.color : '#111'); } habitUserEl?.addEventListener('change', updateHabitDaysAccent); updateHabitDaysAccent(); function getHabitDays(){ return [...document.querySelectorAll('#habitDays .uopt')].filter(x=>x.classList.contains('on')).map(x=>x.getAttribute('data-day')); } document.getElementById('addHabitBtn').addEventListener('click', async ()=>{ const userId = document.getElementById('habitUser').value; const name = document.getElementById('habitName').value.trim(); const days = getHabitDays(); const r = await api('add_habit',{user_id:userId,name,days:days.join(',')}); if (!r.ok) return; try { document.getElementById('habitName').value = ''; } catch (e) {} closeHabitModal(true); location.reload(); }); document.querySelectorAll('#habitList .habit [data-role="toggle"]').forEach(btn=>{ btn.addEventListener('click', async ()=>{ const habitId = btn.closest('.habit').getAttribute('data-id'); const r = await api('toggle_habit',{habit_id:habitId}); if (!r.ok) return; const on = (r.completed ?? 0) === 1; btn.classList.toggle('on', on); btn.textContent = on ? 'Logrado' : 'Marcar'; const habitEl = btn.closest('.habit'); if (habitEl) { const dow = isoDow(new Date()); const set = new Set(parseDayList(habitEl.getAttribute('data-week-achieved-all')).map(String)); if (on) set.add(String(dow)); else set.delete(String(dow)); const next = [...set].map(x=>parseInt(x,10)).filter(n=>Number.isFinite(n)).sort((a,b)=>a-b); habitEl.setAttribute('data-week-achieved-all', next.join(',')); renderHabitWeekBadges(habitEl); } }); }); document.querySelectorAll('#habitList .habit').forEach(habit=>{ const editBtn = habit.querySelector('[data-role="edit"]'); const saveBtn = habit.querySelector('[data-role="save"]'); const cancelBtn = habit.querySelector('[data-role="cancel"]'); const delBtn = habit.querySelector('[data-role="del"]'); const toggleBtn = habit.querySelector('[data-role="toggle"]'); const view = habit.querySelector('[data-role="hview"]'); const edit = habit.querySelector('[data-role="hedit"]'); const nameEdit = habit.querySelector('[data-role="hname_edit"]'); const nameView = habit.querySelector('[data-role="hname_view"]'); const daysBox = habit.querySelector('[data-role="hdays"]'); function getDays(){ if (!daysBox) return []; return [...daysBox.querySelectorAll('.uopt')].filter(x=>x.classList.contains('on')).map(x=>x.getAttribute('data-day')); } function setDays(days){ if (!daysBox) return; const set = new Set((days||[]).map(String)); daysBox.querySelectorAll('.uopt').forEach(x=>{ const d = x.getAttribute('data-day'); const on = set.has(String(d)); x.classList.toggle('on', on); const cb = x.querySelector('input[type="checkbox"]'); if (cb) cb.checked = on; }); } daysBox?.querySelectorAll('.uopt').forEach(el=>{ el.addEventListener('click', (e)=>{ el.classList.toggle('on'); const cb = el.querySelector('input[type="checkbox"]'); if (cb) cb.checked = el.classList.contains('on'); e.preventDefault(); }); }); function enterEdit(){ habit.classList.add('editing'); if (view) view.style.display = 'none'; if (edit) edit.style.display = ''; if (editBtn) editBtn.style.display = 'none'; if (delBtn) delBtn.style.display = ''; if (saveBtn) saveBtn.style.display = ''; if (cancelBtn) cancelBtn.style.display = ''; habit.dataset.origToggleDisabled = toggleBtn ? (toggleBtn.disabled ? '1' : '0') : ''; if (toggleBtn) { toggleBtn.disabled = true; toggleBtn.style.display = 'none'; } habit.dataset.origName = nameEdit ? nameEdit.value : ''; habit.dataset.origDays = getDays().join(','); } function exitEdit(){ habit.classList.remove('editing'); if (view) view.style.display = ''; if (edit) edit.style.display = 'none'; if (editBtn) editBtn.style.display = ''; if (delBtn) delBtn.style.display = 'none'; if (saveBtn) saveBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'none'; if (toggleBtn) { toggleBtn.style.display = ''; toggleBtn.disabled = (habit.dataset.origToggleDisabled || '') === '1'; } } function restore(){ if (nameEdit) nameEdit.value = habit.dataset.origName || ''; const d = (habit.dataset.origDays || '').split(',').filter(Boolean); setDays(d); } editBtn?.addEventListener('click', ()=>{ enterEdit(); }); cancelBtn?.addEventListener('click', ()=>{ restore(); exitEdit(); }); saveBtn?.addEventListener('click', async ()=>{ const habitId = habit.getAttribute('data-id'); const name = (nameEdit?.value || '').trim(); const days = getDays(); const r = await api('update_habit',{habit_id:habitId,name,days:days.join(',')}); if (!r.ok) { toast('No se pudo guardar', 'err'); return; } if (nameView) nameView.textContent = name; if (nameEdit) nameEdit.value = name; habit.setAttribute('data-days', days.join(',')); renderHabitWeekBadges(habit); exitEdit(); toast('Hábito actualizado', 'ok'); }); delBtn?.addEventListener('click', async ()=>{ const ok = await openAppConfirm({title:'Eliminar hábito', message:'¿Seguro que deseas eliminar este hábito?', okText:'Eliminar', cancelText:'Cancelar', danger:true}); if (!ok) return; const habitId = habit.getAttribute('data-id'); const r = await api('delete_habit',{habit_id:habitId}); if (!r.ok) { toast('No se pudo eliminar', 'err'); return; } habit.remove(); }); }); function nextStatus(s){ if (s==='pending') return 'progress'; if (s==='progress') return 'done'; return 'pending'; } function pad2(n){ const s = String(n); return s.length===1 ? '0'+s : s; } function time24ToParts(t){ const s = String(t||'').trim(); const m = s.match(/^(\d{1,2}):(\d{2})$/); if (!m) return {h:'',m:'00',ap:'AM'}; let hh = parseInt(m[1],10); const mm = m[2]; const ap = hh >= 12 ? 'PM' : 'AM'; hh = hh % 12; if (hh === 0) hh = 12; return {h:String(hh), m:mm, ap}; } function formatTimeAmPm(time24){ const p = time24ToParts(time24); if (!p.h) return ''; return p.h + ':' + (p.m || '00') + ' ' + (p.ap || 'AM'); } function partsToTime24(h, m, ap){ const hs = String(h||'').trim(); const ms = String(m||'').trim(); const aps = (String(ap||'AM').toUpperCase()==='PM') ? 'PM' : 'AM'; if (!hs) return ''; let hh = parseInt(hs,10); if (!(hh>=1 && hh<=12)) return ''; let mm = parseInt(ms,10); if (!(mm>=0 && mm<=59)) mm = 0; if (aps==='PM' && hh<12) hh += 12; if (aps==='AM' && hh===12) hh = 0; return pad2(hh)+':'+pad2(mm); } function bindTime12(container){ if (!container) return; const h = container.querySelector('[data-role="h"]'); const m = container.querySelector('[data-role="m"]'); const ap = container.querySelector('[data-role="ap"]'); function sync(){ const on = !!(h && h.value); if (m) m.disabled = !on; if (ap) ap.disabled = !on; } h?.addEventListener('input', sync); sync(); } function getTime12Value(container){ if (!container) return ''; const h = container.querySelector('[data-role="h"]')?.value || ''; const m = container.querySelector('[data-role="m"]')?.value || '00'; const ap = container.querySelector('[data-role="ap"]')?.value || 'AM'; return partsToTime24(h, m, ap); } function setTime12Value(container, time24){ if (!container) return; const p = time24ToParts(time24); const hEl = container.querySelector('[data-role="h"]'); const mEl = container.querySelector('[data-role="m"]'); const apEl = container.querySelector('[data-role="ap"]'); if (hEl) hEl.value = p.h; if (mEl) mEl.value = p.m; if (apEl) apEl.value = p.ap; bindTime12(container); } function isoDow(d){ const g = d.getDay(); return g === 0 ? 7 : g; } function parseDayList(s){ return String(s||'') .split(',') .map(x=>parseInt(String(x).trim(),10)) .filter(n=>Number.isFinite(n) && n>=1 && n<=7); } function renderHabitWeekBadges(habitEl){ if (!habitEl) return; const box = habitEl.querySelector('.weekbadges'); if (!box) return; const days = parseDayList(habitEl.getAttribute('data-days')); const achieved = new Set(parseDayList(habitEl.getAttribute('data-week-achieved-all')).map(String)); box.innerHTML = ''; days.forEach((i)=>{ const s = document.createElement('span'); s.className = 'pill' + (achieved.has(String(i)) ? ' on' : ''); s.setAttribute('data-day', String(i)); s.textContent = DOW_LABEL[i] || String(i); box.appendChild(s); }); } document.querySelectorAll('#habitList .habit').forEach(renderHabitWeekBadges); function renderTaskUsers(userViews, selectedIds, editable, onToggle){ if (!userViews) return; userViews.innerHTML = ''; if (editable) userViews.classList.add('editable'); else userViews.classList.remove('editable'); const set = new Set((selectedIds||[]).map(String)); const list = editable ? USERS.map(u=>String(u.id)) : [...set]; list.forEach(id=>{ const u = USER_BY_ID.get(String(id)); if (!u) return; const s = document.createElement('span'); s.className = 'pava' + (set.has(String(id)) ? '' : ' off'); s.title = u.name || ''; s.style.backgroundImage = u.photo ? `url('${u.photo}')` : ''; s.style.borderColor = u.color || '#111111'; s.setAttribute('data-uid', String(u.id)); if (editable) { s.addEventListener('click', (e)=>{ onToggle && onToggle(String(u.id)); e.preventDefault(); }); } userViews.appendChild(s); }); } document.querySelectorAll('#tasks .task [data-role="status"]').forEach(btn=>{ btn.addEventListener('click', async ()=>{ const task = btn.closest('.task'); const id = task.getAttribute('data-id'); const cur = task.getAttribute('data-status'); const isEditing = task.classList.contains('editing'); const status = isEditing ? (cur === 'pending' ? 'progress' : 'pending') : nextStatus(cur); if (!isEditing && status === 'done') { openCloseModal(task); return; } if (cur === 'done' && status === 'pending') { const ok = await openAppConfirm({ title:'Reabrir tarea', message:'Esta acción reabrirá la tarea y borrará los datos de cierre (observaciones y satisfacción). ¿Seguro que deseas marcarla como pendiente?', okText:'Sí, reabrir', cancelText:'Cancelar', danger:true }); if (!ok) return; } const r = await api('update_task_status',{task_id:id,status}); if (!r.ok) return; task.classList.remove(cur); task.classList.add(status); task.setAttribute('data-status', status); btn.textContent = statusLabel(status); }); }); document.querySelectorAll('#tasks .task').forEach(task=>{ const editBtn = task.querySelector('[data-role="edit"]'); const saveBtn = task.querySelector('[data-role="save"]'); const cancelBtn = task.querySelector('[data-role="cancel"]'); const delBtn = task.querySelector('[data-role="del"]'); const statusBtn = task.querySelector('[data-role="status"]'); const viewbox = task.querySelector('[data-role="viewbox"]'); const editbox = task.querySelector('[data-role="editbox"]'); const titleEdit = task.querySelector('[data-role="title_edit"]'); const dateEdit = task.querySelector('[data-role="date_edit"]'); const catEdit = task.querySelector('[data-role="cat_edit"]'); const projEdit = task.querySelector('[data-role="proj_edit"]'); const timeEdit = task.querySelector('[data-role="time_edit"]'); const userViewsView = task.querySelector('[data-role="user_views_view"]'); const userViewsEdit = task.querySelector('[data-role="user_views_edit"]'); let selectedSet = new Set(String(task.getAttribute('data-user-ids')||'').split(',').filter(Boolean)); function renderUserViews(editable){ renderTaskUsers(userViewsView, [...selectedSet], false); renderTaskUsers(userViewsEdit, [...selectedSet], editable, toggleUser); } if (userViewsEdit) userViewsEdit.style.display = 'none'; renderUserViews(false); bindTime12(timeEdit); setTime12Value(timeEdit, task.getAttribute('data-time')||''); if (projEdit) { const curPid = (projEdit?.value || '').trim(); if (curPid) { const p = PROJECT_BY_ID.get(String(curPid)); if (p && p.category_id && (!catEdit?.value || String(catEdit.value) !== String(p.category_id))) { if (catEdit) catEdit.value = String(p.category_id); } } renderProjectOptions(projEdit, (catEdit?.value || '').trim(), curPid); catEdit?.addEventListener('change', ()=>{ renderProjectOptions(projEdit, (catEdit?.value || '').trim(), ''); }); } function toggleUser(uid){ const k = String(uid); if (selectedSet.has(k)) selectedSet.delete(k); else selectedSet.add(k); renderUserViews(true); } function enterEdit(){ task.classList.add('editing'); if (viewbox) viewbox.style.display = 'none'; if (editbox) editbox.style.display = ''; if (editBtn) editBtn.style.display = 'none'; if (delBtn) delBtn.style.display = ''; if (saveBtn) saveBtn.style.display = ''; if (cancelBtn) cancelBtn.style.display = ''; if (userViewsEdit) userViewsEdit.style.display = ''; task.dataset.origTitle = titleEdit ? titleEdit.value : ''; task.dataset.origDate = dateEdit ? dateEdit.value : (task.getAttribute('data-date') || ''); task.dataset.origCat = catEdit ? catEdit.value : ''; task.dataset.origProj = projEdit ? projEdit.value : ''; task.dataset.origTime = getTime12Value(timeEdit); task.dataset.origUsers = [...selectedSet].join(','); renderUserViews(true); } function exitEdit(){ task.classList.remove('editing'); if (viewbox) viewbox.style.display = ''; if (editbox) editbox.style.display = 'none'; if (editBtn) editBtn.style.display = ''; if (delBtn) delBtn.style.display = 'none'; if (saveBtn) saveBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'none'; if (userViewsEdit) userViewsEdit.style.display = 'none'; renderUserViews(false); } function restoreEditValues(){ if (titleEdit) titleEdit.value = task.dataset.origTitle || ''; if (dateEdit) dateEdit.value = task.dataset.origDate || ''; if (catEdit) catEdit.value = task.dataset.origCat || ''; if (projEdit) projEdit.value = task.dataset.origProj || ''; setTime12Value(timeEdit, task.dataset.origTime || ''); selectedSet = new Set((task.dataset.origUsers || '').split(',').filter(Boolean)); renderUserViews(true); } editBtn?.addEventListener('click', ()=>{ enterEdit(); }); cancelBtn?.addEventListener('click', ()=>{ restoreEditValues(); exitEdit(); }); saveBtn?.addEventListener('click', async ()=>{ const id = task.getAttribute('data-id'); const title = (titleEdit?.value || '').trim(); const taskDate = (dateEdit?.value || task.getAttribute('data-date') || '').trim(); const categoryId = (catEdit?.value || '').trim(); const projectId = (projEdit?.value || '').trim(); const taskTime = getTime12Value(timeEdit); const userIds = [...selectedSet]; if (!userIds.length) { toast('Selecciona al menos un usuario', 'warn'); return; } const r = await api('update_task',{task_id:id,title,task_date:taskDate,category_id:categoryId,project_id:projectId,task_time:taskTime,user_ids:userIds.join(',')}); if (!r.ok) { if (r.error === 'project_requires_category') toast('Para asignar proyecto debes elegir una categoría.', 'warn'); else if (r.error === 'project_category_mismatch') toast('El proyecto elegido no pertenece a la categoría seleccionada.', 'warn'); else toast('No se pudo guardar', 'err'); return; } const oldDate = (task.getAttribute('data-date') || '').trim(); if (taskDate && oldDate && taskDate !== oldDate) { toast('Tarea movida a '+fmtDmy(taskDate), 'ok'); try { sessionStorage.setItem('skipSplashOnce','1'); } catch(e) {} location.reload(); return; } const titleView = task.querySelector('[data-role="title_view"]'); if (titleView) titleView.textContent = title; const meta = task.querySelector('[data-role="viewbox"] .muted'); if (meta) { const tt = taskTime ? taskTime.slice(0,5) : ''; let timeBadge = task.querySelector('[data-role="time_view"]'); if (tt) { if (!timeBadge) { timeBadge = document.createElement('span'); timeBadge.className = 'badge'; timeBadge.setAttribute('data-role','time_view'); meta.prepend(timeBadge); } timeBadge.textContent = formatTimeAmPm(tt); timeBadge.setAttribute('data-time', tt); } else { if (timeBadge) timeBadge.remove(); } const catId = categoryId !== '' ? categoryId : ''; const catName = catId ? (catEdit?.selectedOptions?.[0]?.textContent || '') : ''; let catBadge = task.querySelector('[data-role="cat_view"]'); if (catId && catName && catName !== 'Sin categoría') { if (!catBadge) { catBadge = document.createElement('span'); catBadge.className = 'badge'; catBadge.setAttribute('data-role','cat_view'); meta.appendChild(catBadge); } catBadge.textContent = catName; } else { if (catBadge) catBadge.remove(); } const projId = projectId !== '' ? projectId : ''; const projName = projId ? (projEdit?.selectedOptions?.[0]?.textContent || '') : ''; const proj = PROJECT_BY_ID.get(String(projId)); let projBadge = task.querySelector('[data-role="proj_view"]'); if (projId && projName && projName !== 'Sin proyecto') { if (!projBadge) { projBadge = document.createElement('span'); projBadge.className = 'badge'; projBadge.setAttribute('data-role','proj_view'); meta.appendChild(projBadge); } projBadge.textContent = projName; projBadge.style.borderColor = (proj && proj.color) ? proj.color : '#111111'; } else { if (projBadge) projBadge.remove(); } } if (titleEdit) titleEdit.value = title; if (dateEdit) dateEdit.value = taskDate; if (catEdit) catEdit.value = categoryId; if (projEdit) projEdit.value = projectId; setTime12Value(timeEdit, taskTime); task.setAttribute('data-user-ids', userIds.join(',')); task.setAttribute('data-time', taskTime ? taskTime.slice(0,5) : ''); task.setAttribute('data-date', taskDate ? taskDate : task.getAttribute('data-date') || ''); task.setAttribute('data-category-id', categoryId ? categoryId : '0'); task.setAttribute('data-project-id', projectId ? projectId : '0'); selectedSet = new Set(userIds.map(String)); exitEdit(); toast('Tarea actualizada', 'ok'); applyTodayTaskFilters(); }); delBtn?.addEventListener('click', async ()=>{ const id = String(task.getAttribute('data-id') || '').trim(); if (!id) return; const ok = await openAppConfirm({title:'Eliminar tarea', message:'¿Seguro que deseas eliminar esta tarea?', okText:'Eliminar', cancelText:'Cancelar', danger:true}); if (!ok) return; const r = await api('delete_task',{task_id:id}); if (!r.ok) { toast('No se pudo eliminar', 'err'); return; } task.remove(); toast('Tarea eliminada', 'ok'); applyTodayTaskFilters(); }); }); function syncUserFilterChips(){ const cur = String(CURRENT_USER_FILTER_ID || 0); document.querySelectorAll('a.chip[data-user][data-keep-hash="1"]').forEach(a=>{ const id = String(a.getAttribute('data-user') || '0'); a.classList.toggle('active', id === cur); }); } function setUrlUserFilter(id){ try { const url = new URL(location.href); if (id && parseInt(String(id),10) > 0) url.searchParams.set('user', String(id)); else url.searchParams.delete('user'); history.replaceState({}, '', url.pathname + (url.search ? url.search : '') + (location.hash || '')); } catch(e) {} } function setSelectedUserFilter(id){ const n = parseInt(String(id||0), 10) || 0; CURRENT_USER_FILTER_ID = n; ACTOR_USER_ID_DEFAULT = n > 0 ? n : null; syncUserFilterChips(); setUrlUserFilter(n); const habitUser = document.getElementById('habitUser'); if (habitUser && n > 0) habitUser.value = String(n); if (n > 0) { const opts = [...document.querySelectorAll('#userPick .uopt')]; opts.forEach(o=>o.classList.remove('on')); const el = document.querySelector('#userPick .uopt[data-uid="'+String(n)+'"]'); if (el) el.classList.add('on'); saveUserPick(); } else { loadUserPick(); } applyTodayTaskFilters(); applyHabitFilters(); setTimeout(loadFinance, 0); } document.addEventListener('click', (e)=>{ const a = e.target?.closest?.('a.chip[data-user][data-keep-hash="1"]'); if (!a) return; e.preventDefault(); const id = a.getAttribute('data-user') || '0'; setSelectedUserFilter(id); }); function applyTodayTaskFilters(){ const list = document.getElementById('tasks'); if (!list) return; const userVal = parseInt(String(CURRENT_USER_FILTER_ID||0), 10) || 0; const statusVal = (document.getElementById('taskFilterStatus')?.value || '').trim(); const catVal = (document.getElementById('taskFilterCategory')?.value || '').trim(); const projVal = (document.getElementById('taskFilterProject')?.value || '').trim(); const hasNonUserFilter = !!(statusVal || catVal || projVal); [...list.querySelectorAll('.task')].forEach(t=>{ const s = String(t.getAttribute('data-status') || '').trim(); const c = String(t.getAttribute('data-category-id') || '').trim(); const p = String(t.getAttribute('data-project-id') || '').trim(); const uids = String(t.getAttribute('data-user-ids') || '').split(',').filter(Boolean); const okUser = !userVal || uids.includes(String(userVal)); const okStatus = !statusVal || s === statusVal; const okCat = !catVal || c === catVal; const okProj = !projVal || p === projVal; const on = okUser && okStatus && okCat && okProj; t.style.display = on ? '' : 'none'; t.draggable = !hasNonUserFilter; }); } function applyHabitFilters(){ const list = document.getElementById('habitList'); if (!list) return; const userVal = parseInt(String(CURRENT_USER_FILTER_ID||0), 10) || 0; [...list.querySelectorAll('.habit')].forEach(h=>{ const uid = parseInt(String(h.getAttribute('data-user-id') || '0'), 10) || 0; const on = !userVal || uid === userVal; h.style.display = on ? '' : 'none'; }); } document.getElementById('taskFilterStatus')?.addEventListener('change', applyTodayTaskFilters); document.getElementById('taskFilterCategory')?.addEventListener('change', ()=>{ const catVal = (document.getElementById('taskFilterCategory')?.value || '').trim(); const proj = document.getElementById('taskFilterProject'); const cur = (proj?.value || '').trim(); if (proj) { proj.innerHTML = ''; const opt0 = document.createElement('option'); opt0.value = ''; opt0.textContent = 'Todos los proyectos'; proj.appendChild(opt0); const ps = !catVal ? PROJECTS : PROJECTS.filter(p=>String(p.category_id||'') === String(catVal)); ps.forEach(p=>{ const o = document.createElement('option'); o.value = String(p.id); o.textContent = p.name; proj.appendChild(o); }); proj.value = ps.some(p=>String(p.id)===String(cur)) ? String(cur) : ''; } applyTodayTaskFilters(); }); document.getElementById('taskFilterProject')?.addEventListener('change', applyTodayTaskFilters); setSelectedUserFilter(CURRENT_USER_FILTER_ID); let drag=null; document.querySelectorAll('#tasks .task').forEach(t=>{ t.addEventListener('dragstart',()=>{drag=t;}); t.addEventListener('dragover',e=>e.preventDefault()); t.addEventListener('drop',e=>{ e.preventDefault(); if (!drag || drag===t) return; t.before(drag); saveOrder(); }); }); async function saveOrder(){ const ids=[...document.querySelectorAll('#tasks .task')].map(t=>t.getAttribute('data-id')); await api('reorder_tasks',{order:ids.join(',')}); } function clearCanvas(cv){ const ctx = cv.getContext('2d'); ctx.clearRect(0,0,cv.width,cv.height); return ctx; } function barChart(cv, items, getLabel, getPct, getColor, getSub){ const ctx = clearCanvas(cv); const padX = 28; const padTop = 22; const padBottom = 34; const w = cv.width - padX*2; const h = cv.height - padTop - padBottom; ctx.font = '12px system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif'; const n = items.length; if (!n) return; const grid = [0,25,50,75,100]; ctx.strokeStyle = 'rgba(17,24,39,.08)'; ctx.lineWidth = 1; grid.forEach(v=>{ const y = padTop + (h - (v/100)*h); ctx.beginPath(); ctx.moveTo(padX, y); ctx.lineTo(padX+w, y); ctx.stroke(); }); function rr(x,y,w2,h2,r){ const rr2 = Math.min(r, w2/2, h2/2); ctx.beginPath(); ctx.moveTo(x+rr2, y); ctx.arcTo(x+w2, y, x+w2, y+h2, rr2); ctx.arcTo(x+w2, y+h2, x, y+h2, rr2); ctx.arcTo(x, y+h2, x, y, rr2); ctx.arcTo(x, y, x+w2, y, rr2); ctx.closePath(); } const gap = 10; const bw = Math.max(18, (w - gap*(n-1)) / n); items.forEach((it,i)=>{ const pct = Math.max(0, Math.min(100, getPct(it))); const bh = (pct/100)*h; const x = padX + i*(bw+gap); const y = padTop + (h - bh); ctx.fillStyle = getColor(it); rr(x, y, bw, bh, 8); ctx.fill(); ctx.fillStyle = 'rgba(17,24,39,.78)'; ctx.fillText(pct+'%', x, y-6); const sub = getSub ? (getSub(it) || '') : ''; if (sub) { ctx.fillStyle = 'rgba(17,24,39,.55)'; ctx.fillText(sub, x, y-20); } ctx.fillStyle = 'rgba(17,24,39,.58)'; ctx.fillText(getLabel(it), x, padTop+h+16); }); } (async ()=>{ const r = await api('get_metrics',{}); if (!r.ok) return; const daily = r.daily || []; const perUser = r.per_user || []; const habits = r.habits || []; const today = daily.length ? daily[daily.length-1] : {total:0,done:0,pct:0}; const wTotal = daily.reduce((a,it)=>a+(it.total||0),0); const wDone = daily.reduce((a,it)=>a+(it.done||0),0); const wPct = wTotal>0 ? Math.round((wDone/wTotal)*100) : 0; const usersWith = perUser.filter(u=>(u.total||0)>0).length; const habActive = habits.reduce((a,h)=>a+(h.active||0),0); const habDone = habits.reduce((a,h)=>a+(h.completed||0),0); const habPct = habActive>0 ? Math.round((habDone/habActive)*100) : 0; const k = document.getElementById('kpis'); if (k) { k.innerHTML = '<div class="kpi"><div class="l">Hoy</div><div class="v">'+(today.done||0)+'/'+(today.total||0)+'</div><div class="s">Cumplimiento '+(today.pct||0)+'%</div></div>'+ '<div class="kpi"><div class="l">Semana (7 días)</div><div class="v">'+wDone+'/'+wTotal+'</div><div class="s">Promedio '+wPct+'%</div></div>'+ '<div class="kpi"><div class="l">Hábitos activos (hoy)</div><div class="v">'+habDone+'/'+habActive+'</div><div class="s">Cumplimiento '+habPct+'%</div></div>'+ '<div class="kpi"><div class="l">Usuarios (hoy)</div><div class="v">'+usersWith+'</div><div class="s">Con tareas asignadas</div></div>'; } barChart(document.getElementById('cvDaily'), daily, it=>it.date.slice(5), it=>it.pct, it=>'rgba(17,24,39,.78)', it=>(it.done||0)+'/'+(it.total||0)); barChart(document.getElementById('cvUser'), perUser, it=>(it.name||'').slice(0,8), it=>it.pct, it=>it.color || 'rgba(17,24,39,.65)', it=>(it.done||0)+'/'+(it.total||0)); barChart(document.getElementById('cvHabit'), habits, it=>(it.name||'').slice(0,8), it=>it.pct, it=>it.color || 'rgba(17,24,39,.65)', it=>(it.completed||0)+'/'+(it.active||0)); })(); function monthLabel(ym){ const m = ym.match(/^(\d{4})-(\d{2})$/); if (!m) return ym; const y = parseInt(m[1],10); const mm = parseInt(m[2],10); const names = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre']; return (names[mm-1] || ym)+' '+y; } function pad2n(n){ return String(n).padStart(2,'0'); } function addMonths(ym, delta){ const m = ym.match(/^(\d{4})-(\d{2})$/); if (!m) return ym; let y = parseInt(m[1],10); let mm = parseInt(m[2],10) + delta; while (mm < 1) { mm += 12; y -= 1; } while (mm > 12) { mm -= 12; y += 1; } return y+'-'+pad2n(mm); } function ymNow(){ const d = new Date(); return d.getFullYear()+'-'+pad2n(d.getMonth()+1); } let calYm = ymNow(); let calSelectedDay = ''; async function loadCalendarMonth(){ const grid = document.getElementById('calGrid'); const label = document.getElementById('calMonthLabel'); if (!grid || !label) return; label.textContent = monthLabel(calYm); grid.innerHTML = ''; const projectId = document.getElementById('calProject')?.value || ''; const r = await api('get_achieved_month', {month:calYm, project_id:projectId}); if (!r.ok) { grid.innerHTML = '<div class="muted">No se pudo cargar</div>'; return; } const map = r.days || {}; const start = new Date(calYm+'-01T00:00:00'); const firstDow = (start.getDay() + 6) % 7; const daysInMonth = new Date(start.getFullYear(), start.getMonth()+1, 0).getDate(); const prevMonth = new Date(start.getFullYear(), start.getMonth(), 0); const prevDays = prevMonth.getDate(); const totalCells = 42; for (let i=0;i<totalCells;i++){ const cell = document.createElement('div'); cell.className = 'calcell'; let dayNum = i - firstDow + 1; let dateObj; let inMonth = true; if (dayNum < 1) { inMonth = false; dayNum = prevDays + dayNum; dateObj = new Date(start.getFullYear(), start.getMonth()-1, dayNum); cell.classList.add('muted'); } else if (dayNum > daysInMonth) { inMonth = false; dayNum = dayNum - daysInMonth; dateObj = new Date(start.getFullYear(), start.getMonth()+1, dayNum); cell.classList.add('muted'); } else { dateObj = new Date(start.getFullYear(), start.getMonth(), dayNum); } const iso = dateObj.getFullYear()+'-'+pad2n(dateObj.getMonth()+1)+'-'+pad2n(dateObj.getDate()); const s = map[iso] || null; const p = s ? (parseInt(s.pending||0,10) || 0) : 0; const g = s ? (parseInt(s.progress||0,10) || 0) : 0; const d = s ? (parseInt(s.done||0,10) || 0) : 0; const total = p + g + d; if (!total) { cell.innerHTML = '<div class="calnum">'+dayNum+'</div><div class="calcnt">—</div>'; } else { const parts = []; if (d) parts.push('<span class="calb done">✓ '+d+'</span>'); if (g) parts.push('<span class="calb progress">◷ '+g+'</span>'); if (p) parts.push('<span class="calb pending">○ '+p+'</span>'); cell.innerHTML = '<div class="calnum">'+dayNum+'</div><div class="calcntrow">'+parts.join('')+'</div>'; } if (inMonth) { cell.addEventListener('click', ()=>selectCalendarDay(iso)); if (iso === calSelectedDay) cell.classList.add('on'); } grid.appendChild(cell); } } async function selectCalendarDay(day){ calSelectedDay = day; const label = document.getElementById('calDayLabel'); const list = document.getElementById('calTasks'); if (label) label.textContent = fmtDmy(day); if (!list) return; list.innerHTML = '<div class="muted">Cargando…</div>'; const projectId = document.getElementById('calProject')?.value || ''; const r = await api('get_achieved_day', {day, project_id:projectId}); if (!r.ok) { list.innerHTML = '<div class="muted">No se pudo cargar</div>'; return; } CAL_DAY_TASKS = (r.tasks || []).map(t=>Object.assign({}, t)); const filters = document.getElementById('calDayFilters'); if (filters) filters.style.display = ''; const userTop = document.getElementById('calDayUserTop'); if (userTop) userTop.style.display = ''; renderCalendarDayTasks(); if (!CAL_DAY_TASKS.length) { list.innerHTML = '<div class="muted">Sin tareas este día</div>'; await loadCalendarMonth(); return; } await loadCalendarMonth(); } let CAL_DAY_TASKS = []; function calDayFilteredTasks(){ const q = String(document.getElementById('calDayFilterSearch')?.value || '').trim().toLowerCase(); const st = String(document.getElementById('calDayFilterStatus')?.value || '').trim(); const uid = String(document.getElementById('calDayFilterUser')?.value || '').trim(); return (CAL_DAY_TASKS||[]).filter(t=>{ const ts = String(t.status||'').trim(); if (st && ts !== st) return false; if (uid) { const ids = String(t.user_ids||'').split(',').filter(Boolean); if (!ids.includes(uid)) return false; } if (q) { const a = String(t.title||'').toLowerCase(); const b = String(t.closure_notes||'').toLowerCase(); if (!a.includes(q) && !b.includes(q)) return false; } return true; }); } function renderCalendarDayTasks(){ const list = document.getElementById('calTasks'); if (!list) return; const tasks = calDayFilteredTasks(); if (!tasks.length) { list.innerHTML = '<div class="muted">Sin tareas con esos filtros</div>'; return; } list.innerHTML = ''; tasks.forEach(t=>{ const el = document.createElement('div'); const st = String(t.status||'').trim(); el.className = 'achitem' + (st ? (' ' + st) : ''); const head = document.createElement('div'); head.className = 'achhead'; const left = document.createElement('div'); const title = document.createElement('div'); title.style.fontWeight = '900'; title.textContent = (t.title||''); left.appendChild(title); head.appendChild(left); const editBtn = document.createElement('button'); editBtn.className = 'tbtn icon'; editBtn.type = 'button'; editBtn.textContent = '✎'; editBtn.setAttribute('aria-label','Editar'); editBtn.addEventListener('click', ()=>openTaskEditModal(t)); head.appendChild(editBtn); el.appendChild(head); const projName = t.project_name || ''; const projColor = t.project_color || '#111111'; const cat = t.category_name || ''; const sat = t.closure_satisfaction ? parseInt(t.closure_satisfaction,10) : 0; const stars = sat ? ('★'.repeat(sat) + '☆'.repeat(5-sat)) : ''; const notes = (t.closure_notes || '').trim(); const meta = document.createElement('div'); meta.className = 'achmeta'; if (st) { const b = document.createElement('span'); b.className = 'stbadge'; b.textContent = statusLabel(st); meta.appendChild(b); } const ttime = String(t.task_time || '').slice(0,5); const atime = String(t.achieved_at || '').slice(11,16); const time = formatTimeAmPm(ttime || atime); if (projName) { const b = document.createElement('span'); b.className = 'badge'; b.style.borderColor = projColor; b.textContent = projName; meta.appendChild(b); } if (cat) { const b = document.createElement('span'); b.className = 'badge'; b.textContent = cat; meta.appendChild(b); } if (stars) { const b = document.createElement('span'); b.className = 'badge'; b.textContent = stars; meta.appendChild(b); } if (time) { const b = document.createElement('span'); b.className = 'badge'; b.textContent = time; meta.appendChild(b); } el.appendChild(meta); const ids = String(t.user_ids||'').split(',').filter(Boolean); if (ids.length) { const users = document.createElement('div'); users.className = 'pavatars achusers'; renderTaskUsers(users, ids, false); el.appendChild(users); } if (notes) { const n = document.createElement('div'); n.className = 'muted'; n.style.marginTop = '6px'; n.textContent = notes; el.appendChild(n); } list.appendChild(el); }); } function bindCalendarDayFilters(){ const ids = ['calDayFilterUser','calDayFilterStatus']; ids.forEach(id=>document.getElementById(id)?.addEventListener('change', renderCalendarDayTasks)); document.getElementById('calDayFilterSearch')?.addEventListener('input', ()=>{ clearTimeout(window.__calDayT); window.__calDayT=setTimeout(renderCalendarDayTasks, 160); }); } bindCalendarDayFilters(); bindUserSelectChips('finMoveUserChips','finMoveListUser', {allLabel:'Todos'}); bindUserSelectChips('finAccUserChips','finAccListUser', {allLabel:'Todos'}); bindUserSelectChips('projFilterUserChips','projFilterUser', {allLabel:'Todos'}); bindUserSelectChips('calDayFilterUserChips','calDayFilterUser', {allLabel:'Todos'}); bindUserSelectChips('finCatOwnerChips','finCatFilterOwner', {allLabel:'Todas', extra:[{value:'shared', label:'Compartidas'}]}); bindUserSelectChips('habitUserChips','habitUser', {showAll:false}); bindUserSelectChips('finMoveUserChipsModal','finMoveUser', {showAll:false}); bindUserSelectChips('finDebtUserChipsModal','finDebtUserModal', {showAll:false}); bindUserSelectChips('finCatOwnerChipsModal','finCatOwnerModal', {showAll:false, extra:[{value:'', label:'Compartida'}]}); document.getElementById('calPrev')?.addEventListener('click', async ()=>{ calYm = addMonths(calYm, -1); calSelectedDay = ''; document.getElementById('calDayLabel').textContent = 'Selecciona un día'; document.getElementById('calTasks').innerHTML = ''; const f = document.getElementById('calDayFilters'); if (f) f.style.display = 'none'; const ut = document.getElementById('calDayUserTop'); if (ut) ut.style.display = 'none'; CAL_DAY_TASKS = []; await loadCalendarMonth(); }); document.getElementById('calNext')?.addEventListener('click', async ()=>{ calYm = addMonths(calYm, 1); calSelectedDay = ''; document.getElementById('calDayLabel').textContent = 'Selecciona un día'; document.getElementById('calTasks').innerHTML = ''; const f = document.getElementById('calDayFilters'); if (f) f.style.display = 'none'; const ut = document.getElementById('calDayUserTop'); if (ut) ut.style.display = 'none'; CAL_DAY_TASKS = []; await loadCalendarMonth(); }); document.getElementById('calProject')?.addEventListener('change', async ()=>{ calSelectedDay = ''; document.getElementById('calDayLabel').textContent = 'Selecciona un día'; document.getElementById('calTasks').innerHTML = ''; const f = document.getElementById('calDayFilters'); if (f) f.style.display = 'none'; const ut = document.getElementById('calDayUserTop'); if (ut) ut.style.display = 'none'; CAL_DAY_TASKS = []; await loadCalendarMonth(); }); window.addEventListener('hashchange', ()=>{ if ((location.hash || '') === '#calendar' || (location.hash || '') === '#bitacora') { loadCalendarMonth(); } }); if ((location.hash || '') === '#calendar' || (location.hash || '') === '#bitacora') { loadCalendarMonth(); } </script> </body> </html>
Coded With 💗 by
0x6ick