Osatehtävät (subtaskit) TODO-tehtäviin

Uusi todo_subtasks-taulu + 3 API-endpointtia (add/toggle/delete).
Tehtävän lukunäkymässä checkbox-lista osatehtäville, lisäys
Enter-näppäimellä tai Lisää-napilla. Valmiit yliviivataan.
Tehtävälistassa näkyy edistyminen (esim. ☑ 2/5).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 14:45:47 +02:00
parent ad4c5605f6
commit 093f40ac09
5 changed files with 171 additions and 2 deletions

62
api.php
View File

@@ -2423,6 +2423,68 @@ switch ($action) {
} }
break; 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 ---------- // ---------- ARCHIVE ----------
case 'archived_customers': case 'archived_customers':
requireAuth(); requireAuth();

44
db.php
View File

@@ -490,6 +490,18 @@ function initDatabase(): void {
INDEX idx_todo (todo_id), INDEX idx_todo (todo_id),
INDEX idx_user (user) INDEX idx_user (user)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", ) 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) { foreach ($tables as $i => $sql) {
@@ -1495,7 +1507,9 @@ function dbLoadTodos(string $companyId): array {
$rows = _dbFetchAll(" $rows = _dbFetchAll("
SELECT t.*, SELECT t.*,
COALESCE((SELECT SUM(te.hours) FROM todo_time_entries te WHERE te.todo_id = t.id), 0) AS total_hours, 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 FROM todos t
WHERE t.company_id = ? WHERE t.company_id = ?
ORDER BY ORDER BY
@@ -1506,6 +1520,8 @@ function dbLoadTodos(string $companyId): array {
foreach ($rows as &$r) { foreach ($rows as &$r) {
$r['total_hours'] = floatval($r['total_hours']); $r['total_hours'] = floatval($r['total_hours']);
$r['comment_count'] = intval($r['comment_count']); $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']); unset($r['company_id']);
} }
return $rows; return $rows;
@@ -1517,9 +1533,13 @@ function dbLoadTodo(string $todoId): ?array {
$todo = $row[0]; $todo = $row[0];
$todo['comments'] = _dbFetchAll("SELECT * FROM todo_comments WHERE todo_id = ? ORDER BY luotu", [$todoId]); $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['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) { foreach ($todo['time_entries'] as &$te) {
$te['hours'] = floatval($te['hours']); $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')); $todo['total_hours'] = array_sum(array_column($todo['time_entries'], 'hours'));
return $todo; return $todo;
} }
@@ -1583,3 +1603,25 @@ function dbAddTodoTimeEntry(string $todoId, array $entry): void {
function dbDeleteTodoTimeEntry(string $entryId): void { function dbDeleteTodoTimeEntry(string $entryId): void {
_dbExecute("DELETE FROM todo_time_entries WHERE id = ?", [$entryId]); _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]);
}

View File

@@ -505,6 +505,16 @@
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:0.75rem;margin-bottom:1rem;padding:1rem;background:#fafbfc;border-radius:8px;" id="task-read-fields"></div> <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:0.75rem;margin-bottom:1rem;padding:1rem;background:#fafbfc;border-radius:8px;" id="task-read-fields"></div>
<div id="task-read-description" style="margin-bottom:1.5rem;line-height:1.7;white-space:pre-wrap;"></div> <div id="task-read-description" style="margin-bottom:1.5rem;line-height:1.7;white-space:pre-wrap;"></div>
<!-- Osatehtävät -->
<div id="task-subtasks-section" style="margin-bottom:1.5rem;">
<h3 style="font-size:1rem;margin-bottom:0.75rem;">&#9745; Osatehtävät <span id="task-subtask-count" style="font-weight:400;color:#888;"></span></h3>
<div id="task-subtasks-list"></div>
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;">
<input type="text" id="subtask-input" placeholder="Lisää osatehtävä..." style="flex:1;padding:0.4rem 0.6rem;border:1px solid #ddd;border-radius:6px;font-size:0.88rem;">
<button type="button" class="btn-secondary" id="btn-add-subtask" style="padding:0.4rem 0.8rem;font-size:0.85rem;">Lisää</button>
</div>
</div>
<!-- Aikakirjaukset --> <!-- Aikakirjaukset -->
<div id="task-time-section" style="margin-bottom:1.5rem;"> <div id="task-time-section" style="margin-bottom:1.5rem;">
<h3 style="font-size:1rem;margin-bottom:0.75rem;">&#9201; Aikakirjaukset <span id="task-time-total" style="font-weight:400;color:#888;"></span></h3> <h3 style="font-size:1rem;margin-bottom:0.75rem;">&#9201; Aikakirjaukset <span id="task-time-total" style="font-weight:400;color:#888;"></span></h3>

View File

@@ -3966,6 +3966,48 @@ function populateTodoAssignedFilter() {
sel.innerHTML = '<option value="">Kaikki vastuuhenkilöt</option>' + users.map(u => `<option value="${esc(u)}">${esc(u)}</option>`).join(''); sel.innerHTML = '<option value="">Kaikki vastuuhenkilöt</option>' + users.map(u => `<option value="${esc(u)}">${esc(u)}</option>`).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 => `<div class="subtask-item${s.completed ? ' completed' : ''}">
<label><input type="checkbox" ${s.completed ? 'checked' : ''} onchange="toggleSubtask('${s.id}','${todoId}')"> <span>${esc(s.title)}</span></label>
<button class="subtask-delete" onclick="deleteSubtask('${s.id}','${todoId}')" title="Poista">&times;</button>
</div>`).join('') : '<div style="color:#aaa;font-size:0.85rem;">Ei osatehtäviä</div>';
}
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 ---- // ---- Tehtävät ----
function renderTasksList() { function renderTasksList() {
@@ -4013,7 +4055,7 @@ function renderTasksList() {
<td><span class="todo-status status-${t.status}">${todoStatusLabels[t.status]||t.status}</span></td> <td><span class="todo-status status-${t.status}">${todoStatusLabels[t.status]||t.status}</span></td>
<td><span class="priority-badge priority-${t.priority}">${todoPriorityLabels[t.priority]||t.priority}</span></td> <td><span class="priority-badge priority-${t.priority}">${todoPriorityLabels[t.priority]||t.priority}</span></td>
<td>${t.category ? `<span class="todo-category cat-${t.category}">${todoCategoryLabels[t.category]||t.category}</span>` : '<span style="color:#ccc;">—</span>'}</td> <td>${t.category ? `<span class="todo-category cat-${t.category}">${todoCategoryLabels[t.category]||t.category}</span>` : '<span style="color:#ccc;">—</span>'}</td>
<td><strong>${esc(t.title)}</strong></td> <td><strong>${esc(t.title)}</strong>${t.subtask_count > 0 ? ` <span class="subtask-progress">&#9745; ${t.subtask_done}/${t.subtask_count}</span>` : ''}</td>
<td>${t.assigned_to ? esc(t.assigned_to) : '<span style="color:#ccc;">—</span>'}</td> <td>${t.assigned_to ? esc(t.assigned_to) : '<span style="color:#ccc;">—</span>'}</td>
<td style="text-align:center;">${t.total_hours > 0 ? t.total_hours + 'h' : '<span style="color:#ccc;">—</span>'}</td> <td style="text-align:center;">${t.total_hours > 0 ? t.total_hours + 'h' : '<span style="color:#ccc;">—</span>'}</td>
<td style="text-align:center;">${t.comment_count > 0 ? t.comment_count : ''}</td> <td style="text-align:center;">${t.comment_count > 0 ? t.comment_count : ''}</td>
@@ -4063,6 +4105,11 @@ async function openTaskRead(id) {
<td>${e.work_date}</td><td>${esc(e.user)}</td><td>${e.hours}h</td><td>${esc(e.description||'')}</td> <td>${e.work_date}</td><td>${esc(e.user)}</td><td>${e.hours}h</td><td>${esc(e.description||'')}</td>
<td>${(e.user === currentUser?.username || isAdmin) ? `<button onclick="deleteTimeEntry('${e.id}','${t.id}')" style="background:none;border:none;cursor:pointer;color:#ccc;font-size:1rem;" title="Poista">&#128465;</button>` : ''}</td> <td>${(e.user === currentUser?.username || isAdmin) ? `<button onclick="deleteTimeEntry('${e.id}','${t.id}')" style="background:none;border:none;cursor:pointer;color:#ccc;font-size:1rem;" title="Poista">&#128465;</button>` : ''}</td>
</tr>`).join('') : '<tr><td colspan="5" style="color:#aaa;text-align:center;">Ei kirjauksia</td></tr>'; </tr>`).join('') : '<tr><td colspan="5" style="color:#aaa;text-align:center;">Ei kirjauksia</td></tr>';
// 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 // Kommentit
renderTodoComments(t.comments || [], 'task'); renderTodoComments(t.comments || [], 'task');
document.getElementById('task-comment-count').textContent = `(${(t.comments||[]).length})`; document.getElementById('task-comment-count').textContent = `(${(t.comments||[]).length})`;

View File

@@ -1161,6 +1161,14 @@ span.empty {
.time-entries-table th { font-weight:600; color:#888; font-size:0.78rem; text-transform:uppercase; } .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 { 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; } .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 */
.role-badge { .role-badge {