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:
62
api.php
62
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();
|
||||
|
||||
44
db.php
44
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]);
|
||||
}
|
||||
|
||||
10
index.html
10
index.html
@@ -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 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;">☑ 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 -->
|
||||
<div id="task-time-section" style="margin-bottom:1.5rem;">
|
||||
<h3 style="font-size:1rem;margin-bottom:0.75rem;">⏱ Aikakirjaukset <span id="task-time-total" style="font-weight:400;color:#888;"></span></h3>
|
||||
|
||||
49
script.js
49
script.js
@@ -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('');
|
||||
}
|
||||
|
||||
// ---- 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">×</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 ----
|
||||
|
||||
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="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><strong>${esc(t.title)}</strong></td>
|
||||
<td><strong>${esc(t.title)}</strong>${t.subtask_count > 0 ? ` <span class="subtask-progress">☑ ${t.subtask_done}/${t.subtask_count}</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.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.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">🗑</button>` : ''}</td>
|
||||
</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
|
||||
renderTodoComments(t.comments || [], 'task');
|
||||
document.getElementById('task-comment-count').textContent = `(${(t.comments||[]).length})`;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user