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 @@ + +
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;