diff --git a/api.php b/api.php index ddcd610..2f09f3d 100644 --- a/api.php +++ b/api.php @@ -2423,6 +2423,68 @@ switch ($action) { } break; + case 'todo_subtask_add': + requireAuth(); + $companyId = requireCompany(); + if ($method !== 'POST') { echo json_encode(['error' => 'POST required']); break; } + try { + $input = json_decode(file_get_contents('php://input'), true); + $todoId = $input['todo_id'] ?? ''; + $title = trim($input['title'] ?? ''); + if (!$todoId || !$title) { echo json_encode(['error' => 'todo_id ja title vaaditaan']); break; } + $rows = _dbFetchAll("SELECT company_id FROM todos WHERE id = ?", [$todoId]); + if (empty($rows) || $rows[0]['company_id'] !== $companyId) { + http_response_code(404); + echo json_encode(['error' => 'Tehtävää ei löytynyt']); + break; + } + $id = generateId(); + dbAddTodoSubtask($todoId, ['id' => $id, 'title' => $title, 'created_by' => currentUser()]); + echo json_encode(['success' => true, 'id' => $id]); + } catch (\Throwable $e) { + http_response_code(500); + echo json_encode(['error' => 'Virhe: ' . $e->getMessage()]); + } + break; + + case 'todo_subtask_toggle': + requireAuth(); + requireCompany(); + if ($method !== 'POST') { echo json_encode(['error' => 'POST required']); break; } + try { + $input = json_decode(file_get_contents('php://input'), true); + $subtaskId = $input['id'] ?? ''; + if (!$subtaskId) { echo json_encode(['error' => 'id vaaditaan']); break; } + $completed = dbToggleTodoSubtask($subtaskId); + echo json_encode(['success' => true, 'completed' => $completed]); + } catch (\Throwable $e) { + http_response_code(500); + echo json_encode(['error' => 'Virhe: ' . $e->getMessage()]); + } + break; + + case 'todo_subtask_delete': + requireAuth(); + requireCompany(); + if ($method !== 'POST') { echo json_encode(['error' => 'POST required']); break; } + try { + $input = json_decode(file_get_contents('php://input'), true); + $subtaskId = $input['id'] ?? ''; + if (!$subtaskId) { echo json_encode(['error' => 'id vaaditaan']); break; } + $rows = _dbFetchAll("SELECT created_by FROM todo_subtasks WHERE id = ?", [$subtaskId]); + if (!empty($rows) && ($rows[0]['created_by'] === currentUser() || isCompanyAdmin())) { + dbDeleteTodoSubtask($subtaskId); + echo json_encode(['success' => true]); + } else { + http_response_code(403); + echo json_encode(['error' => 'Ei oikeutta']); + } + } catch (\Throwable $e) { + http_response_code(500); + echo json_encode(['error' => 'Virhe: ' . $e->getMessage()]); + } + break; + // ---------- ARCHIVE ---------- case 'archived_customers': requireAuth(); diff --git a/db.php b/db.php index 4f65fbb..e48d0a9 100644 --- a/db.php +++ b/db.php @@ -490,6 +490,18 @@ function initDatabase(): void { INDEX idx_todo (todo_id), INDEX idx_user (user) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + + "CREATE TABLE IF NOT EXISTS todo_subtasks ( + id VARCHAR(20) PRIMARY KEY, + todo_id VARCHAR(20) NOT NULL, + title VARCHAR(500) NOT NULL, + completed TINYINT(1) DEFAULT 0, + sort_order INT DEFAULT 0, + created_by VARCHAR(100) DEFAULT '', + luotu DATETIME, + FOREIGN KEY (todo_id) REFERENCES todos(id) ON DELETE CASCADE, + INDEX idx_todo (todo_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", ]; foreach ($tables as $i => $sql) { @@ -1495,7 +1507,9 @@ function dbLoadTodos(string $companyId): array { $rows = _dbFetchAll(" SELECT t.*, COALESCE((SELECT SUM(te.hours) FROM todo_time_entries te WHERE te.todo_id = t.id), 0) AS total_hours, - (SELECT COUNT(*) FROM todo_comments tc WHERE tc.todo_id = t.id) AS comment_count + (SELECT COUNT(*) FROM todo_comments tc WHERE tc.todo_id = t.id) AS comment_count, + (SELECT COUNT(*) FROM todo_subtasks ts WHERE ts.todo_id = t.id) AS subtask_count, + (SELECT COUNT(*) FROM todo_subtasks ts2 WHERE ts2.todo_id = t.id AND ts2.completed = 1) AS subtask_done FROM todos t WHERE t.company_id = ? ORDER BY @@ -1506,6 +1520,8 @@ function dbLoadTodos(string $companyId): array { foreach ($rows as &$r) { $r['total_hours'] = floatval($r['total_hours']); $r['comment_count'] = intval($r['comment_count']); + $r['subtask_count'] = intval($r['subtask_count']); + $r['subtask_done'] = intval($r['subtask_done']); unset($r['company_id']); } return $rows; @@ -1517,9 +1533,13 @@ function dbLoadTodo(string $todoId): ?array { $todo = $row[0]; $todo['comments'] = _dbFetchAll("SELECT * FROM todo_comments WHERE todo_id = ? ORDER BY luotu", [$todoId]); $todo['time_entries'] = _dbFetchAll("SELECT * FROM todo_time_entries WHERE todo_id = ? ORDER BY work_date DESC, luotu DESC", [$todoId]); + $todo['subtasks'] = _dbFetchAll("SELECT * FROM todo_subtasks WHERE todo_id = ? ORDER BY sort_order, luotu", [$todoId]); foreach ($todo['time_entries'] as &$te) { $te['hours'] = floatval($te['hours']); } + foreach ($todo['subtasks'] as &$st) { + $st['completed'] = (bool)$st['completed']; + } $todo['total_hours'] = array_sum(array_column($todo['time_entries'], 'hours')); return $todo; } @@ -1583,3 +1603,25 @@ function dbAddTodoTimeEntry(string $todoId, array $entry): void { function dbDeleteTodoTimeEntry(string $entryId): void { _dbExecute("DELETE FROM todo_time_entries WHERE id = ?", [$entryId]); } + +function dbAddTodoSubtask(string $todoId, array $subtask): void { + $maxOrder = _dbFetchAll("SELECT COALESCE(MAX(sort_order), 0) + 1 AS next_order FROM todo_subtasks WHERE todo_id = ?", [$todoId]); + _dbExecute("INSERT INTO todo_subtasks (id, todo_id, title, completed, sort_order, created_by, luotu) VALUES (?, ?, ?, 0, ?, ?, ?)", [ + $subtask['id'] ?? generateId(), + $todoId, + $subtask['title'] ?? '', + $maxOrder[0]['next_order'] ?? 0, + $subtask['created_by'] ?? '', + date('Y-m-d H:i:s') + ]); +} + +function dbToggleTodoSubtask(string $subtaskId): bool { + _dbExecute("UPDATE todo_subtasks SET completed = NOT completed WHERE id = ?", [$subtaskId]); + $row = _dbFetchAll("SELECT completed FROM todo_subtasks WHERE id = ?", [$subtaskId]); + return !empty($row) && $row[0]['completed']; +} + +function dbDeleteTodoSubtask(string $subtaskId): void { + _dbExecute("DELETE FROM todo_subtasks WHERE id = ?", [$subtaskId]); +} diff --git a/index.html b/index.html index 7ae9699..dda2849 100644 --- a/index.html +++ b/index.html @@ -505,6 +505,16 @@
+ +
+

☑ Osatehtävät

+
+
+ + +
+
+

⏱ Aikakirjaukset

diff --git a/script.js b/script.js index 0936426..2390955 100644 --- a/script.js +++ b/script.js @@ -3966,6 +3966,48 @@ function populateTodoAssignedFilter() { sel.innerHTML = '' + users.map(u => ``).join(''); } +// ---- Osatehtävät (subtaskit) ---- + +function renderSubtasks(subtasks, todoId) { + const list = document.getElementById('task-subtasks-list'); + const countEl = document.getElementById('task-subtask-count'); + if (!list) return; + const done = subtasks.filter(s => s.completed).length; + const total = subtasks.length; + if (countEl) countEl.textContent = total > 0 ? `(${done}/${total})` : ''; + list.innerHTML = subtasks.length ? subtasks.map(s => `
+ + +
`).join('') : '
Ei osatehtäviä
'; +} + +async function addSubtask(todoId) { + const input = document.getElementById('subtask-input'); + const title = (input?.value || '').trim(); + if (!title) return; + try { + await apiCall('todo_subtask_add', 'POST', { todo_id: todoId, title }); + input.value = ''; + await openTaskRead(todoId); + } catch (e) { alert(e.message); } +} + +async function toggleSubtask(subtaskId, todoId) { + try { + await apiCall('todo_subtask_toggle', 'POST', { id: subtaskId }); + await openTaskRead(todoId); + await loadTodos(); + } catch (e) { alert(e.message); } +} + +async function deleteSubtask(subtaskId, todoId) { + try { + await apiCall('todo_subtask_delete', 'POST', { id: subtaskId }); + await openTaskRead(todoId); + await loadTodos(); + } catch (e) { alert(e.message); } +} + // ---- Tehtävät ---- function renderTasksList() { @@ -4013,7 +4055,7 @@ function renderTasksList() { ${todoStatusLabels[t.status]||t.status} ${todoPriorityLabels[t.priority]||t.priority} ${t.category ? `${todoCategoryLabels[t.category]||t.category}` : ''} - ${esc(t.title)} + ${esc(t.title)}${t.subtask_count > 0 ? ` ☑ ${t.subtask_done}/${t.subtask_count}` : ''} ${t.assigned_to ? esc(t.assigned_to) : ''} ${t.total_hours > 0 ? t.total_hours + 'h' : ''} ${t.comment_count > 0 ? t.comment_count : ''} @@ -4063,6 +4105,11 @@ async function openTaskRead(id) { ${e.work_date}${esc(e.user)}${e.hours}h${esc(e.description||'')} ${(e.user === currentUser?.username || isAdmin) ? `` : ''} `).join('') : 'Ei kirjauksia'; + // Osatehtävät + renderSubtasks(t.subtasks || [], t.id); + document.getElementById('btn-add-subtask')?.replaceWith(document.getElementById('btn-add-subtask')?.cloneNode(true)); + document.getElementById('btn-add-subtask')?.addEventListener('click', () => addSubtask(t.id)); + document.getElementById('subtask-input')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addSubtask(t.id); } }); // Kommentit renderTodoComments(t.comments || [], 'task'); document.getElementById('task-comment-count').textContent = `(${(t.comments||[]).length})`; diff --git a/style.css b/style.css index e7fc931..6da7f4f 100644 --- a/style.css +++ b/style.css @@ -1161,6 +1161,14 @@ span.empty { .time-entries-table th { font-weight:600; color:#888; font-size:0.78rem; text-transform:uppercase; } .todo-comment { padding:0.75rem; margin-bottom:0.5rem; background:#fafbfc; border-radius:8px; border-left:3px solid var(--primary-color); } .todo-comment-meta { font-size:0.78rem; color:#888; margin-bottom:0.25rem; } +.subtask-item { display:flex; align-items:center; gap:0.5rem; padding:0.35rem 0.5rem; border-radius:6px; margin-bottom:2px; } +.subtask-item:hover { background:#f5f7fa; } +.subtask-item label { display:flex; align-items:center; gap:0.4rem; flex:1; cursor:pointer; font-size:0.9rem; } +.subtask-item.completed label span { text-decoration:line-through; color:#aaa; } +.subtask-item input[type="checkbox"] { width:16px; height:16px; cursor:pointer; accent-color:var(--primary-color); } +.subtask-delete { background:none; border:none; cursor:pointer; color:#ccc; font-size:1.1rem; padding:0 0.3rem; line-height:1; } +.subtask-delete:hover { color:#e74c3c; } +.subtask-progress { font-size:0.75rem; color:#888; font-weight:400; margin-left:0.4rem; } /* Role badge */ .role-badge {