From 4a1dccb6ffe4ef7bcabab7f94da1135fa2ffab50 Mon Sep 17 00:00:00 2001 From: Jukka Lampikoski Date: Wed, 11 Mar 2026 13:14:53 +0200 Subject: [PATCH] =?UTF-8?q?Uusi=20TODO-moduuli:=20Teht=C3=A4v=C3=A4t=20+?= =?UTF-8?q?=20Kehitysehdotukset=20+=20Ajanseuranta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Talon sisäinen tehtävienhallinta kahdella alatabilla: Tehtävät (admin luo): - Prioriteetti (normaali/tärkeä/kiireellinen), status, deadline - Vastuuhenkilö-osoitus, inline-muokkaus lukunäkymässä - Aikakirjaukset: pvm, tunnit, kuvaus - kaikki voivat kirjata - Myöhästyneet = punainen reunus, lähestyvät = keltainen - Kommentointi kaikille käyttäjille Kehitysehdotukset (kaikki voivat luoda): - Status: ehdotettu → harkinnassa → toteutettu/hylätty (admin muuttaa) - Kommentointi kaikille - Ehdottaja voi muokata omia Tietokanta: 3 taulua (todos, todo_comments, todo_time_entries) API: 10 endpointtia oikeustarkistuksineen Frontend: Sub-tab navigointi, kortti-grid, 3-näkymämalli per alatabi Co-Authored-By: Claude Opus 4.6 --- api.php | 200 ++++++++++++++++++++++++++++ db.php | 138 +++++++++++++++++++ index.html | 216 ++++++++++++++++++++++++++++++ script.js | 382 ++++++++++++++++++++++++++++++++++++++++++++++++++++- style.css | 24 ++++ 5 files changed, 958 insertions(+), 2 deletions(-) diff --git a/api.php b/api.php index dc144b8..e5060d8 100644 --- a/api.php +++ b/api.php @@ -2208,6 +2208,206 @@ switch ($action) { readfile($path); exit; + // ---------- TEHTÄVÄT (TODOS) ---------- + case 'todos': + requireAuth(); + $companyId = requireCompany(); + echo json_encode(dbLoadTodos($companyId)); + break; + + case 'todo_detail': + requireAuth(); + $companyId = requireCompany(); + $id = $_GET['id'] ?? ''; + $todo = dbLoadTodo($id); + if (!$todo || $todo['company_id'] !== $companyId) { + http_response_code(404); + echo json_encode(['error' => 'Tehtävää ei löydy']); + break; + } + unset($todo['company_id']); + echo json_encode($todo); + break; + + case 'todo_save': + requireAuth(); + $companyId = requireCompany(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $type = $input['type'] ?? 'task'; + $isNew = empty($input['id']); + // Task: vain admin. Feature request: kaikki voivat luoda, mutta muokata vain omia (tai admin) + if ($type === 'task') { + requireAdmin(); + } elseif (!$isNew) { + $existing = dbLoadTodo($input['id']); + if ($existing && $existing['created_by'] !== currentUser() && !isAdmin()) { + http_response_code(403); + echo json_encode(['error' => 'Voit muokata vain omia ehdotuksiasi']); + break; + } + } + $todo = [ + 'id' => $input['id'] ?? generateId(), + 'type' => $type, + 'title' => trim($input['title'] ?? ''), + 'description' => $input['description'] ?? '', + 'status' => $input['status'] ?? ($type === 'task' ? 'avoin' : 'ehdotettu'), + 'priority' => $input['priority'] ?? 'normaali', + 'assigned_to' => $input['assigned_to'] ?? '', + 'created_by' => $isNew ? currentUser() : ($input['created_by'] ?? currentUser()), + 'deadline' => $input['deadline'] ?? null, + 'luotu' => $isNew ? date('Y-m-d H:i:s') : ($input['luotu'] ?? date('Y-m-d H:i:s')), + 'muokkaaja' => currentUser(), + ]; + if (empty($todo['title'])) { + http_response_code(400); + echo json_encode(['error' => 'Otsikko on pakollinen']); + break; + } + dbSaveTodo($companyId, $todo); + dbAddLog($companyId, currentUser(), $isNew ? 'todo_create' : 'todo_update', $todo['id'], $todo['title'], ''); + echo json_encode($todo); + break; + + case 'todo_delete': + requireAuth(); + requireAdmin(); + $companyId = requireCompany(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? ''; + $todo = dbLoadTodo($id); + if ($todo) { + dbDeleteTodo($id); + dbAddLog($companyId, currentUser(), 'todo_delete', $id, $todo['title'] ?? '', ''); + } + echo json_encode(['success' => true]); + break; + + case 'todo_status': + requireAuth(); + $companyId = requireCompany(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? ''; + $status = $input['status'] ?? ''; + $todo = dbLoadTodo($id); + if (!$todo || $todo['company_id'] !== $companyId) { + http_response_code(404); break; + } + // Feature request status: vain admin + if ($todo['type'] === 'feature_request' && !isAdmin()) { + http_response_code(403); + echo json_encode(['error' => 'Vain admin voi muuttaa ehdotuksen statusta']); + break; + } + // Task status: vain admin + if ($todo['type'] === 'task' && !isAdmin()) { + http_response_code(403); break; + } + $todo['status'] = $status; + $todo['muokkaaja'] = currentUser(); + dbSaveTodo($companyId, $todo); + echo json_encode(['success' => true]); + break; + + case 'todo_assign': + requireAuth(); + requireAdmin(); + $companyId = requireCompany(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? ''; + $todo = dbLoadTodo($id); + if (!$todo || $todo['company_id'] !== $companyId) { + http_response_code(404); break; + } + $todo['assigned_to'] = $input['assigned_to'] ?? ''; + $todo['muokkaaja'] = currentUser(); + dbSaveTodo($companyId, $todo); + echo json_encode(['success' => true]); + break; + + case 'todo_comment': + requireAuth(); + $companyId = requireCompany(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $todoId = $input['todo_id'] ?? ''; + $body = trim($input['body'] ?? ''); + if (empty($body)) { + http_response_code(400); + echo json_encode(['error' => 'Kommentti ei voi olla tyhjä']); + break; + } + $comment = [ + 'id' => generateId(), + 'author' => currentUser(), + 'body' => $body, + 'luotu' => date('Y-m-d H:i:s'), + ]; + dbAddTodoComment($todoId, $comment); + echo json_encode($comment); + break; + + case 'todo_comment_delete': + requireAuth(); + $companyId = requireCompany(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $commentId = $input['id'] ?? ''; + // Tarkista onko oma kommentti tai admin + $rows = _dbFetchAll("SELECT author FROM todo_comments WHERE id = ?", [$commentId]); + if (!empty($rows) && ($rows[0]['author'] === currentUser() || isAdmin())) { + dbDeleteTodoComment($commentId); + echo json_encode(['success' => true]); + } else { + http_response_code(403); + echo json_encode(['error' => 'Ei oikeutta']); + } + break; + + case 'todo_time_add': + requireAuth(); + $companyId = requireCompany(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $todoId = $input['todo_id'] ?? ''; + $hours = floatval($input['hours'] ?? 0); + if ($hours <= 0) { + http_response_code(400); + echo json_encode(['error' => 'Tunnit pitää olla > 0']); + break; + } + $entry = [ + 'id' => generateId(), + 'user' => currentUser(), + 'hours' => $hours, + 'description' => trim($input['description'] ?? ''), + 'work_date' => $input['work_date'] ?? date('Y-m-d'), + 'luotu' => date('Y-m-d H:i:s'), + ]; + dbAddTodoTimeEntry($todoId, $entry); + echo json_encode($entry); + break; + + case 'todo_time_delete': + requireAuth(); + $companyId = requireCompany(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $entryId = $input['id'] ?? ''; + $rows = _dbFetchAll("SELECT user FROM todo_time_entries WHERE id = ?", [$entryId]); + if (!empty($rows) && ($rows[0]['user'] === currentUser() || isAdmin())) { + dbDeleteTodoTimeEntry($entryId); + echo json_encode(['success' => true]); + } else { + http_response_code(403); + echo json_encode(['error' => 'Ei oikeutta']); + } + break; + // ---------- ARCHIVE ---------- case 'archived_customers': requireAuth(); diff --git a/db.php b/db.php index 085a3a4..89b9032 100644 --- a/db.php +++ b/db.php @@ -445,6 +445,50 @@ function initDatabase(): void { INDEX idx_company (company_id), INDEX idx_category (category_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + + "CREATE TABLE IF NOT EXISTS todos ( + id VARCHAR(20) PRIMARY KEY, + company_id VARCHAR(50) NOT NULL, + type VARCHAR(20) NOT NULL DEFAULT 'task', + title VARCHAR(500) NOT NULL, + description TEXT, + status VARCHAR(30) NOT NULL DEFAULT 'avoin', + priority VARCHAR(20) DEFAULT 'normaali', + assigned_to VARCHAR(100) DEFAULT '', + created_by VARCHAR(100) NOT NULL DEFAULT '', + deadline DATE DEFAULT NULL, + luotu DATETIME, + muokattu DATETIME NULL, + muokkaaja VARCHAR(100) DEFAULT '', + FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE, + INDEX idx_company (company_id), + INDEX idx_type (type), + INDEX idx_status (status), + INDEX idx_deadline (deadline) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + + "CREATE TABLE IF NOT EXISTS todo_comments ( + id VARCHAR(20) PRIMARY KEY, + todo_id VARCHAR(20) NOT NULL, + author VARCHAR(100) NOT NULL, + body TEXT NOT NULL, + luotu DATETIME, + FOREIGN KEY (todo_id) REFERENCES todos(id) ON DELETE CASCADE, + INDEX idx_todo (todo_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + + "CREATE TABLE IF NOT EXISTS todo_time_entries ( + id VARCHAR(20) PRIMARY KEY, + todo_id VARCHAR(20) NOT NULL, + user VARCHAR(100) NOT NULL, + hours DECIMAL(6,2) NOT NULL, + description VARCHAR(500) DEFAULT '', + work_date DATE NOT NULL, + luotu DATETIME, + FOREIGN KEY (todo_id) REFERENCES todos(id) ON DELETE CASCADE, + INDEX idx_todo (todo_id), + INDEX idx_user (user) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", ]; foreach ($tables as $i => $sql) { @@ -1442,3 +1486,97 @@ function dbIsPriorityEmail(string $companyId, string $email): bool { } return false; } + +// ==================== TEHTÄVÄT (TODOS) ==================== + +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 + FROM todos t + WHERE t.company_id = ? + ORDER BY + CASE t.priority WHEN 'kiireellinen' THEN 0 WHEN 'tarkea' THEN 1 ELSE 2 END, + t.deadline IS NULL, t.deadline ASC, + t.luotu DESC + ", [$companyId]); + foreach ($rows as &$r) { + $r['total_hours'] = floatval($r['total_hours']); + $r['comment_count'] = intval($r['comment_count']); + unset($r['company_id']); + } + return $rows; +} + +function dbLoadTodo(string $todoId): ?array { + $row = _dbFetchAll("SELECT * FROM todos WHERE id = ?", [$todoId]); + if (empty($row)) return null; + $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]); + foreach ($todo['time_entries'] as &$te) { + $te['hours'] = floatval($te['hours']); + } + $todo['total_hours'] = array_sum(array_column($todo['time_entries'], 'hours')); + return $todo; +} + +function dbSaveTodo(string $companyId, array $todo): void { + _dbExecute(" + INSERT INTO todos (id, company_id, type, title, description, status, priority, assigned_to, created_by, deadline, luotu, muokattu, muokkaaja) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + title = VALUES(title), description = VALUES(description), + status = VALUES(status), priority = VALUES(priority), + assigned_to = VALUES(assigned_to), deadline = VALUES(deadline), + muokattu = VALUES(muokattu), muokkaaja = VALUES(muokkaaja) + ", [ + $todo['id'], $companyId, + $todo['type'] ?? 'task', + $todo['title'] ?? '', + $todo['description'] ?? '', + $todo['status'] ?? 'avoin', + $todo['priority'] ?? 'normaali', + $todo['assigned_to'] ?? '', + $todo['created_by'] ?? '', + !empty($todo['deadline']) ? $todo['deadline'] : null, + $todo['luotu'] ?? date('Y-m-d H:i:s'), + date('Y-m-d H:i:s'), + $todo['muokkaaja'] ?? '' + ]); +} + +function dbDeleteTodo(string $todoId): void { + _dbExecute("DELETE FROM todos WHERE id = ?", [$todoId]); +} + +function dbAddTodoComment(string $todoId, array $comment): void { + _dbExecute("INSERT INTO todo_comments (id, todo_id, author, body, luotu) VALUES (?, ?, ?, ?, ?)", [ + $comment['id'] ?? generateId(), + $todoId, + $comment['author'] ?? '', + $comment['body'] ?? '', + $comment['luotu'] ?? date('Y-m-d H:i:s') + ]); +} + +function dbDeleteTodoComment(string $commentId): void { + _dbExecute("DELETE FROM todo_comments WHERE id = ?", [$commentId]); +} + +function dbAddTodoTimeEntry(string $todoId, array $entry): void { + _dbExecute("INSERT INTO todo_time_entries (id, todo_id, user, hours, description, work_date, luotu) VALUES (?, ?, ?, ?, ?, ?, ?)", [ + $entry['id'] ?? generateId(), + $todoId, + $entry['user'] ?? '', + $entry['hours'] ?? 0, + $entry['description'] ?? '', + $entry['work_date'] ?? date('Y-m-d'), + $entry['luotu'] ?? date('Y-m-d H:i:s') + ]); +} + +function dbDeleteTodoTimeEntry(string $entryId): void { + _dbExecute("DELETE FROM todo_time_entries WHERE id = ?", [$entryId]); +} diff --git a/index.html b/index.html index 90c49c6..d3dd4bc 100644 --- a/index.html +++ b/index.html @@ -82,6 +82,7 @@ + @@ -430,6 +431,218 @@ + +
+
+ +
+ + +
+ + +
+ +
+
+ +
+ + + +
+
+
+ +
+ + + + + + +
+ + + + +
+
+
@@ -887,6 +1100,9 @@ + diff --git a/script.js b/script.js index f4a2a64..4255524 100644 --- a/script.js +++ b/script.js @@ -195,7 +195,7 @@ async function showDashboard() { // Avaa oikea tabi URL-hashin perusteella (tai customers oletuks) const hash = window.location.hash.replace('#', ''); const [mainHash, subHash] = hash.split('/'); - const validTabs = ['customers', 'leads', 'tekniikka', 'ohjeet', 'archive', 'changelog', 'support', 'users', 'settings', 'companies']; + const validTabs = ['customers', 'leads', 'tekniikka', 'ohjeet', 'todo', 'archive', 'changelog', 'support', 'users', 'settings', 'companies']; const startTab = validTabs.includes(mainHash) ? mainHash : 'customers'; switchToTab(startTab, subHash); } @@ -256,6 +256,7 @@ function switchToTab(target, subTab) { if (target === 'archive') loadArchive(); if (target === 'changelog') loadChangelog(); if (target === 'ohjeet') loadGuides(); + if (target === 'todo') { loadTodos(); if (subTab) switchTodoSubTab(subTab); } if (target === 'support') { loadTickets(); showTicketListView(); if (document.getElementById('ticket-auto-refresh').checked) startTicketAutoRefresh(); } if (target === 'users') loadUsers(); if (target === 'settings') loadSettings(); @@ -3889,9 +3890,386 @@ async function deleteGuideCategory(id, name) { } catch (e) { alert(e.message); } } +// ==================== TEHTÄVÄT (TODO) ==================== + +let todosData = []; +let currentTodoId = null; +let currentTodoSubTab = 'tasks'; + +const todoStatusLabels = { avoin:'Avoin', kaynnissa:'Käynnissä', odottaa:'Odottaa', valmis:'Valmis', ehdotettu:'Ehdotettu', harkinnassa:'Harkinnassa', toteutettu:'Toteutettu', hylatty:'Hylätty' }; +const todoPriorityLabels = { normaali:'Normaali', tarkea:'Tärkeä', kiireellinen:'Kiireellinen' }; + +function switchTodoSubTab(target) { + currentTodoSubTab = target; + document.querySelectorAll('[data-todotab]').forEach(b => { + b.classList.toggle('active', b.dataset.todotab === target); + b.style.borderBottomColor = b.dataset.todotab === target ? 'var(--primary-color)' : 'transparent'; + b.style.color = b.dataset.todotab === target ? 'var(--primary-color)' : '#888'; + }); + document.getElementById('todo-subtab-tasks').style.display = target === 'tasks' ? '' : 'none'; + document.getElementById('todo-subtab-features').style.display = target === 'features' ? '' : 'none'; + // Palauta listanäkymään kun vaihdetaan tabia + if (target === 'tasks') showTaskListView(); + if (target === 'features') showFeatureListView(); + window.location.hash = 'todo/' + target; +} + +async function loadTodos() { + try { + todosData = await apiCall('todos'); + renderTasksList(); + renderFeaturesList(); + populateTodoAssignedFilter(); + const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'superadmin'; + const btnTask = document.getElementById('btn-add-task'); + if (btnTask) btnTask.style.display = isAdmin ? '' : 'none'; + } catch (e) { console.error('loadTodos:', e); } +} + +function populateTodoAssignedFilter() { + const sel = document.getElementById('todo-assigned-filter'); + if (!sel) return; + const users = [...new Set(todosData.filter(t => t.assigned_to).map(t => t.assigned_to))].sort(); + sel.innerHTML = '' + users.map(u => ``).join(''); +} + +// ---- Tehtävät ---- + +function renderTasksList() { + const query = (document.getElementById('todo-search-input')?.value || '').toLowerCase().trim(); + const statusF = document.getElementById('todo-status-filter')?.value || ''; + const assignF = document.getElementById('todo-assigned-filter')?.value || ''; + let tasks = todosData.filter(t => t.type === 'task'); + if (query) tasks = tasks.filter(t => (t.title||'').toLowerCase().includes(query) || (t.description||'').toLowerCase().includes(query) || (t.assigned_to||'').toLowerCase().includes(query)); + if (statusF) tasks = tasks.filter(t => t.status === statusF); + if (assignF) tasks = tasks.filter(t => t.assigned_to === assignF); + const grid = document.getElementById('tasks-grid'); + const noEl = document.getElementById('no-tasks'); + if (!grid) return; + if (!tasks.length) { grid.innerHTML = ''; if (noEl) noEl.style.display = ''; return; } + if (noEl) noEl.style.display = 'none'; + const today = new Date().toISOString().slice(0,10); + grid.innerHTML = tasks.map(t => { + const overdue = t.deadline && t.status !== 'valmis' && t.deadline < today; + const soon = t.deadline && t.status !== 'valmis' && !overdue && t.deadline <= new Date(Date.now()+3*86400000).toISOString().slice(0,10); + return `
+
+ ${todoPriorityLabels[t.priority]||t.priority} + ${todoStatusLabels[t.status]||t.status} +
+
${esc(t.title)}
+
+ ${t.assigned_to ? `👤 ${esc(t.assigned_to)}` : ''} + ${t.deadline ? `📅 ${t.deadline}` : ''} + ${t.total_hours > 0 ? `⏱ ${t.total_hours}h` : ''} + ${t.comment_count > 0 ? `💬 ${t.comment_count}` : ''} +
+
`; + }).join(''); +} + +function showTaskListView() { + document.getElementById('tasks-list-view').style.display = ''; + document.getElementById('task-read-view').style.display = 'none'; + document.getElementById('task-edit-view').style.display = 'none'; +} + +async function openTaskRead(id) { + currentTodoId = id; + try { + const t = await apiCall('todo_detail&id=' + id); + const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'superadmin'; + document.getElementById('task-read-title').textContent = t.title; + document.getElementById('task-read-meta').innerHTML = `Luoja: ${esc(t.created_by)}  |  Luotu: ${(t.luotu||'').slice(0,10)} ${t.muokattu ? ' |  Muokattu: ' + t.muokattu.slice(0,10) : ''}`; + document.getElementById('task-read-badges').innerHTML = ` + ${todoPriorityLabels[t.priority]||t.priority} + ${todoStatusLabels[t.status]||t.status}`; + document.getElementById('task-read-fields').innerHTML = ` +
Status
${isAdmin ? `` : (todoStatusLabels[t.status]||t.status)}
+
Vastuuhenkilö
${isAdmin ? `` : esc(t.assigned_to || '—')}
+
Prioriteetti
${todoPriorityLabels[t.priority]||t.priority}
+
Deadline
${t.deadline || '—'}
`; + // Populoi vastuuhenkilö-dropdown + if (isAdmin) { + try { + const users = await apiCall('users'); + const sel = document.getElementById('task-read-assigned-sel'); + if (sel) { users.forEach(u => { const o = document.createElement('option'); o.value = u.username; o.textContent = u.nimi || u.username; if (u.username === t.assigned_to) o.selected = true; sel.appendChild(o); }); } + } catch(e) {} + } + document.getElementById('task-read-description').textContent = t.description || '(Ei kuvausta)'; + // Aikakirjaukset + const entries = t.time_entries || []; + document.getElementById('task-time-total').textContent = `(yhteensä: ${t.total_hours || 0}h)`; + document.getElementById('task-time-tbody').innerHTML = entries.length ? entries.map(e => ` + ${e.work_date}${esc(e.user)}${e.hours}h${esc(e.description||'')} + ${(e.user === currentUser?.username || isAdmin) ? `` : ''} + `).join('') : 'Ei kirjauksia'; + // Kommentit + renderTodoComments(t.comments || [], 'task'); + document.getElementById('task-comment-count').textContent = `(${(t.comments||[]).length})`; + // Actionit + document.getElementById('task-read-actions').innerHTML = isAdmin ? `` : ''; + // Aikakirjaus-lomake valmistelu + document.getElementById('time-form-date').value = new Date().toISOString().slice(0,10); + document.getElementById('time-form-hours').value = ''; + document.getElementById('time-form-desc').value = ''; + document.getElementById('task-time-form').style.display = 'none'; + document.getElementById('tasks-list-view').style.display = 'none'; + document.getElementById('task-edit-view').style.display = 'none'; + document.getElementById('task-read-view').style.display = ''; + } catch (e) { alert('Virhe: ' + e.message); } +} + +async function updateTaskField(id, field, value) { + try { + if (field === 'status') await apiCall('todo_status', 'POST', { id, status: value }); + if (field === 'assigned') await apiCall('todo_assign', 'POST', { id, assigned_to: value }); + await loadTodos(); + } catch (e) { alert(e.message); } +} + +async function openTaskEdit(id) { + const t = id ? todosData.find(x => x.id === id) : null; + currentTodoId = t?.id || null; + document.getElementById('task-form-id').value = t?.id || ''; + document.getElementById('task-form-type').value = 'task'; + document.getElementById('task-form-title').value = t?.title || ''; + document.getElementById('task-form-priority').value = t?.priority || 'normaali'; + document.getElementById('task-form-status').value = t?.status || 'avoin'; + document.getElementById('task-form-deadline').value = t?.deadline || ''; + document.getElementById('task-form-desc').value = t?.description || ''; + document.getElementById('task-edit-title').textContent = t ? 'Muokkaa tehtävää' : 'Uusi tehtävä'; + // Populoi vastuuhenkilö-dropdown + const asel = document.getElementById('task-form-assigned'); + asel.innerHTML = ''; + try { + const users = await apiCall('users'); + users.forEach(u => { + const o = document.createElement('option'); + o.value = u.username; o.textContent = u.nimi || u.username; + if (t && u.username === t.assigned_to) o.selected = true; + asel.appendChild(o); + }); + } catch(e) {} + document.getElementById('tasks-list-view').style.display = 'none'; + document.getElementById('task-read-view').style.display = 'none'; + document.getElementById('task-edit-view').style.display = ''; +} + +document.getElementById('task-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const id = document.getElementById('task-form-id').value; + const existing = id ? todosData.find(t => t.id === id) : null; + const body = { + id: id || undefined, + type: 'task', + title: document.getElementById('task-form-title').value.trim(), + description: document.getElementById('task-form-desc').value.trim(), + priority: document.getElementById('task-form-priority').value, + status: document.getElementById('task-form-status').value, + deadline: document.getElementById('task-form-deadline').value || null, + assigned_to: document.getElementById('task-form-assigned').value, + created_by: existing?.created_by, + luotu: existing?.luotu, + }; + if (!body.title) return; + try { + const saved = await apiCall('todo_save', 'POST', body); + await loadTodos(); + openTaskRead(saved.id); + } catch (e) { alert(e.message); } +}); + +// ---- Kehitysehdotukset ---- + +function renderFeaturesList() { + const query = (document.getElementById('feature-search-input')?.value || '').toLowerCase().trim(); + const statusF = document.getElementById('feature-status-filter')?.value || ''; + let features = todosData.filter(t => t.type === 'feature_request'); + if (query) features = features.filter(t => (t.title||'').toLowerCase().includes(query) || (t.description||'').toLowerCase().includes(query)); + if (statusF) features = features.filter(t => t.status === statusF); + const grid = document.getElementById('features-grid'); + const noEl = document.getElementById('no-features'); + if (!grid) return; + if (!features.length) { grid.innerHTML = ''; if (noEl) noEl.style.display = ''; return; } + if (noEl) noEl.style.display = 'none'; + grid.innerHTML = features.map(t => `
+
${todoStatusLabels[t.status]||t.status}
+
${esc(t.title)}
+
+ 👤 ${esc(t.created_by)} + ${(t.luotu||'').slice(0,10)} + ${t.comment_count > 0 ? `💬 ${t.comment_count}` : ''} +
+
`).join(''); +} + +function showFeatureListView() { + document.getElementById('features-list-view').style.display = ''; + document.getElementById('feature-read-view').style.display = 'none'; + document.getElementById('feature-edit-view').style.display = 'none'; +} + +async function openFeatureRead(id) { + currentTodoId = id; + try { + const t = await apiCall('todo_detail&id=' + id); + const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'superadmin'; + const isOwner = t.created_by === currentUser?.username; + document.getElementById('feature-read-title').textContent = t.title; + document.getElementById('feature-read-meta').innerHTML = `Ehdottaja: ${esc(t.created_by)}  |  ${(t.luotu||'').slice(0,10)}`; + document.getElementById('feature-read-badges').innerHTML = isAdmin + ? `` + : `${todoStatusLabels[t.status]||t.status}`; + document.getElementById('feature-read-description').textContent = t.description || '(Ei kuvausta)'; + renderTodoComments(t.comments || [], 'feature'); + document.getElementById('feature-comment-count').textContent = `(${(t.comments||[]).length})`; + document.getElementById('feature-read-actions').innerHTML = (isAdmin || isOwner) + ? `${isAdmin ? `` : ''}` + : ''; + document.getElementById('features-list-view').style.display = 'none'; + document.getElementById('feature-edit-view').style.display = 'none'; + document.getElementById('feature-read-view').style.display = ''; + } catch (e) { alert('Virhe: ' + e.message); } +} + +async function openFeatureEdit(id) { + const t = id ? todosData.find(x => x.id === id) : null; + currentTodoId = t?.id || null; + document.getElementById('feature-form-id').value = t?.id || ''; + document.getElementById('feature-form-type').value = 'feature_request'; + document.getElementById('feature-form-title').value = t?.title || ''; + document.getElementById('feature-form-desc').value = t?.description || ''; + document.getElementById('feature-edit-title').textContent = t ? 'Muokkaa ehdotusta' : 'Uusi kehitysehdotus'; + document.getElementById('features-list-view').style.display = 'none'; + document.getElementById('feature-read-view').style.display = 'none'; + document.getElementById('feature-edit-view').style.display = ''; +} + +document.getElementById('feature-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const id = document.getElementById('feature-form-id').value; + const existing = id ? todosData.find(t => t.id === id) : null; + const body = { + id: id || undefined, + type: 'feature_request', + title: document.getElementById('feature-form-title').value.trim(), + description: document.getElementById('feature-form-desc').value.trim(), + status: existing?.status || 'ehdotettu', + priority: 'normaali', + created_by: existing?.created_by, + luotu: existing?.luotu, + }; + if (!body.title) return; + try { + const saved = await apiCall('todo_save', 'POST', body); + await loadTodos(); + openFeatureRead(saved.id); + } catch (e) { alert(e.message); } +}); + +// ---- Yhteiset funktiot ---- + +function renderTodoComments(comments, prefix) { + const list = document.getElementById(prefix + '-comments-list'); + if (!list) return; + const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'superadmin'; + list.innerHTML = comments.length ? comments.map(c => `
+
${esc(c.author)}  ·  ${(c.luotu||'').replace('T',' ').slice(0,16)}
+
${esc(c.body)}
+ ${(c.author === currentUser?.username || isAdmin) ? `` : ''} +
`).join('') : '

Ei kommentteja vielä.

'; +} + +async function sendTodoComment(prefix) { + const input = document.getElementById(prefix + '-comment-input'); + const body = input?.value.trim(); + if (!body || !currentTodoId) return; + try { + await apiCall('todo_comment', 'POST', { todo_id: currentTodoId, body }); + input.value = ''; + if (prefix === 'task') await openTaskRead(currentTodoId); + else await openFeatureRead(currentTodoId); + } catch (e) { alert(e.message); } +} + +async function deleteTodoComment(commentId) { + if (!confirm('Poistetaanko kommentti?')) return; + try { + await apiCall('todo_comment_delete', 'POST', { id: commentId }); + if (currentTodoSubTab === 'tasks') await openTaskRead(currentTodoId); + else await openFeatureRead(currentTodoId); + } catch (e) { alert(e.message); } +} + +async function deleteTodo(id) { + if (!confirm('Poistetaanko pysyvästi?')) return; + try { + await apiCall('todo_delete', 'POST', { id }); + await loadTodos(); + if (currentTodoSubTab === 'tasks') showTaskListView(); + else showFeatureListView(); + } catch (e) { alert(e.message); } +} + +async function addTimeEntry() { + if (!currentTodoId) return; + const hours = parseFloat(document.getElementById('time-form-hours').value); + const desc = document.getElementById('time-form-desc').value.trim(); + const date = document.getElementById('time-form-date').value; + if (!hours || hours <= 0) { alert('Syötä tunnit'); return; } + try { + await apiCall('todo_time_add', 'POST', { todo_id: currentTodoId, hours, description: desc, work_date: date }); + await loadTodos(); + await openTaskRead(currentTodoId); + } catch (e) { alert(e.message); } +} + +async function deleteTimeEntry(entryId, todoId) { + if (!confirm('Poistetaanko aikakirjaus?')) return; + try { + await apiCall('todo_time_delete', 'POST', { id: entryId }); + await loadTodos(); + await openTaskRead(todoId); + } catch (e) { alert(e.message); } +} + +// Event listeners +document.getElementById('todo-search-input')?.addEventListener('input', () => renderTasksList()); +document.getElementById('todo-status-filter')?.addEventListener('change', () => renderTasksList()); +document.getElementById('todo-assigned-filter')?.addEventListener('change', () => renderTasksList()); +document.getElementById('feature-search-input')?.addEventListener('input', () => renderFeaturesList()); +document.getElementById('feature-status-filter')?.addEventListener('change', () => renderFeaturesList()); +document.getElementById('btn-add-task')?.addEventListener('click', () => openTaskEdit(null)); +document.getElementById('btn-add-feature')?.addEventListener('click', () => openFeatureEdit(null)); +document.getElementById('btn-task-back')?.addEventListener('click', () => { showTaskListView(); currentTodoId = null; }); +document.getElementById('btn-feature-back')?.addEventListener('click', () => { showFeatureListView(); currentTodoId = null; }); +document.getElementById('btn-task-edit-cancel')?.addEventListener('click', () => showTaskListView()); +document.getElementById('task-form-cancel')?.addEventListener('click', () => showTaskListView()); +document.getElementById('btn-feature-edit-cancel')?.addEventListener('click', () => showFeatureListView()); +document.getElementById('feature-form-cancel')?.addEventListener('click', () => showFeatureListView()); +document.getElementById('btn-task-comment-send')?.addEventListener('click', () => sendTodoComment('task')); +document.getElementById('btn-feature-comment-send')?.addEventListener('click', () => sendTodoComment('feature')); +document.getElementById('btn-add-time')?.addEventListener('click', () => { + document.getElementById('task-time-form').style.display = 'flex'; + document.getElementById('btn-add-time').style.display = 'none'; +}); +document.getElementById('btn-time-cancel')?.addEventListener('click', () => { + document.getElementById('task-time-form').style.display = 'none'; + document.getElementById('btn-add-time').style.display = ''; +}); +document.getElementById('btn-time-save')?.addEventListener('click', () => addTimeEntry()); + // ==================== MODUULIT ==================== -const ALL_MODULES = ['customers', 'support', 'leads', 'tekniikka', 'ohjeet', 'archive', 'changelog', 'settings']; +const ALL_MODULES = ['customers', 'support', 'leads', 'tekniikka', 'ohjeet', 'todo', 'archive', 'changelog', 'settings']; const DEFAULT_MODULES = ['customers', 'support', 'archive', 'changelog', 'settings']; function applyModules(modules) { diff --git a/style.css b/style.css index 5c6d484..8a7eb42 100644 --- a/style.css +++ b/style.css @@ -1124,6 +1124,30 @@ span.empty { .guide-img { max-width:400px; border-radius:8px; margin:0.75rem 0; cursor:zoom-in; transition:box-shadow 0.15s; border:1px solid #e0e0e0; display:block; } .guide-img:hover { box-shadow:0 4px 20px rgba(0,0,0,0.18); border-color:#bbb; } +/* Todo-moduuli */ +.todo-card { background:#fff; border-radius:12px; padding:1.25rem; box-shadow:0 1px 4px rgba(0,0,0,0.06); cursor:pointer; transition:transform 0.15s, box-shadow 0.15s; border:2px solid transparent; display:flex; flex-direction:column; } +.todo-card:hover { transform:translateY(-2px); box-shadow:0 4px 12px rgba(0,0,0,0.1); border-color:var(--primary-color); } +.todo-card.overdue { border-color:#e74c3c; } +.todo-card.deadline-soon { border-color:#f39c12; } +.priority-badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:0.72rem; font-weight:600; } +.priority-normaali { background:#e8ebf0; color:#555; } +.priority-tarkea { background:#fff3cd; color:#856404; } +.priority-kiireellinen { background:#fde8e8; color:#dc2626; } +.todo-status { display:inline-block; padding:2px 8px; border-radius:10px; font-size:0.72rem; font-weight:600; } +.status-avoin { background:#e3f2fd; color:#1565c0; } +.status-kaynnissa { background:#e8f5e9; color:#2e7d32; } +.status-odottaa { background:#fff8e1; color:#f57f17; } +.status-valmis { background:#f5f5f5; color:#888; } +.status-ehdotettu { background:#e3f2fd; color:#1565c0; } +.status-harkinnassa { background:#fff8e1; color:#f57f17; } +.status-toteutettu { background:#e8f5e9; color:#2e7d32; } +.status-hylatty { background:#fde8e8; color:#dc2626; } +.time-entries-table { width:100%; border-collapse:collapse; font-size:0.88rem; } +.time-entries-table th, .time-entries-table td { padding:0.5rem 0.75rem; text-align:left; border-bottom:1px solid #f0f2f5; } +.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; } + /* Role badge */ .role-badge { display: inline-block;