Uusi TODO-moduuli: Tehtävät + Kehitysehdotukset + Ajanseuranta

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 13:14:53 +02:00
parent ec86263c5c
commit 4a1dccb6ff
5 changed files with 958 additions and 2 deletions

200
api.php
View File

@@ -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();

138
db.php
View File

@@ -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]);
}

View File

@@ -82,6 +82,7 @@
<button class="tab" data-tab="leads">Liidit</button>
<button class="tab" data-tab="tekniikka">Tekniikka</button>
<button class="tab" data-tab="ohjeet">Ohjeet</button>
<button class="tab" data-tab="todo">Tehtävät</button>
<button class="tab" data-tab="archive">Arkisto</button>
<button class="tab" data-tab="changelog">Muutosloki</button>
<button class="tab" data-tab="settings" id="tab-settings" style="display:none">API</button>
@@ -430,6 +431,218 @@
</div>
</div>
<!-- Tab: Tehtävät / TODO -->
<div class="tab-content" id="tab-content-todo">
<div class="main-container">
<!-- Sub-tab bar -->
<div style="display:flex;gap:0.25rem;margin-bottom:1.25rem;border-bottom:2px solid #f0f2f5;padding-bottom:0;">
<button class="sub-tab active" data-todotab="tasks" onclick="switchTodoSubTab('tasks')" style="padding:0.6rem 1.2rem;border:none;background:none;cursor:pointer;font-weight:600;font-size:0.92rem;color:#888;border-bottom:3px solid transparent;margin-bottom:-2px;">Tehtävät</button>
<button class="sub-tab" data-todotab="features" onclick="switchTodoSubTab('features')" style="padding:0.6rem 1.2rem;border:none;background:none;cursor:pointer;font-weight:600;font-size:0.92rem;color:#888;border-bottom:3px solid transparent;margin-bottom:-2px;">Kehitysehdotukset</button>
</div>
<!-- ===== TEHTÄVÄT ===== -->
<div id="todo-subtab-tasks">
<!-- Listanäkymä -->
<div id="tasks-list-view">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.75rem;margin-bottom:1rem;">
<div class="search-bar" style="flex:1;min-width:200px;max-width:400px;">
<input type="text" id="todo-search-input" placeholder="Hae tehtävistä..." style="width:100%;">
</div>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<select id="todo-status-filter" style="padding:0.4rem 0.6rem;border-radius:6px;border:1px solid #ddd;font-size:0.85rem;">
<option value="">Kaikki statukset</option>
<option value="avoin">Avoin</option>
<option value="kaynnissa">Käynnissä</option>
<option value="odottaa">Odottaa</option>
<option value="valmis">Valmis</option>
</select>
<select id="todo-assigned-filter" style="padding:0.4rem 0.6rem;border-radius:6px;border:1px solid #ddd;font-size:0.85rem;">
<option value="">Kaikki vastuuhenkilöt</option>
</select>
<button class="btn-primary" id="btn-add-task" style="display:none;">+ Uusi tehtävä</button>
</div>
</div>
<div id="tasks-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem;"></div>
<p id="no-tasks" style="display:none;text-align:center;color:#aaa;padding:3rem 0;">Ei tehtäviä.</p>
</div>
<!-- Lukunäkymä -->
<div id="task-read-view" style="display:none;">
<button class="btn-secondary" id="btn-task-back">&larr; Takaisin</button>
<div class="table-card" style="padding:1.5rem 2rem;margin-top:1rem;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:1rem;margin-bottom:1rem;">
<div>
<h2 id="task-read-title" style="color:var(--primary-color);margin:0;"></h2>
<div id="task-read-meta" style="font-size:0.85rem;color:#888;margin-top:0.3rem;"></div>
</div>
<div style="display:flex;gap:0.5rem;align-items:center;" id="task-read-actions"></div>
</div>
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem;" id="task-read-badges"></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>
<!-- Aikakirjaukset -->
<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>
<table class="time-entries-table" id="task-time-table">
<thead><tr><th>Päivämäärä</th><th>Käyttäjä</th><th>Tunnit</th><th>Kuvaus</th><th></th></tr></thead>
<tbody id="task-time-tbody"></tbody>
</table>
<div id="task-time-form-wrap" style="margin-top:0.75rem;">
<button class="btn-secondary" id="btn-add-time" style="font-size:0.85rem;">+ Kirjaa tunteja</button>
<div id="task-time-form" style="display:none;margin-top:0.5rem;display:none;gap:0.5rem;align-items:flex-end;flex-wrap:wrap;">
<div class="form-group" style="margin:0;"><label style="font-size:0.78rem;">Päivämäärä</label><input type="date" id="time-form-date" style="font-size:0.85rem;"></div>
<div class="form-group" style="margin:0;"><label style="font-size:0.78rem;">Tunnit</label><input type="number" id="time-form-hours" step="0.25" min="0.25" placeholder="1.5" style="width:80px;font-size:0.85rem;"></div>
<div class="form-group" style="margin:0;flex:1;min-width:150px;"><label style="font-size:0.78rem;">Kuvaus</label><input type="text" id="time-form-desc" placeholder="Mitä teit" style="font-size:0.85rem;"></div>
<button class="btn-primary" id="btn-time-save" style="font-size:0.85rem;padding:0.4rem 1rem;">Tallenna</button>
<button class="btn-secondary" id="btn-time-cancel" style="font-size:0.85rem;">Peru</button>
</div>
</div>
</div>
<!-- Kommentit -->
<div id="task-comments-section">
<h3 style="font-size:1rem;margin-bottom:0.75rem;">&#128172; Kommentit <span id="task-comment-count" style="font-weight:400;color:#888;"></span></h3>
<div id="task-comments-list"></div>
<div style="margin-top:0.75rem;display:flex;gap:0.5rem;">
<textarea id="task-comment-input" rows="2" placeholder="Kirjoita kommentti..." style="flex:1;font-size:0.88rem;resize:vertical;min-height:50px;"></textarea>
<button class="btn-primary" id="btn-task-comment-send" style="align-self:flex-end;font-size:0.85rem;">Lähetä</button>
</div>
</div>
</div>
</div>
<!-- Muokkausnäkymä -->
<div id="task-edit-view" style="display:none;">
<button class="btn-secondary" id="btn-task-edit-cancel">&larr; Peruuta</button>
<div class="table-card" style="padding:1.5rem 2rem;margin-top:1rem;">
<h2 id="task-edit-title" style="color:var(--primary-color);margin-bottom:1rem;">Uusi tehtävä</h2>
<form id="task-form">
<input type="hidden" id="task-form-id">
<input type="hidden" id="task-form-type" value="task">
<div class="form-group" style="margin-bottom:1rem;">
<label for="task-form-title">Otsikko *</label>
<input type="text" id="task-form-title" required placeholder="Tehtävän otsikko">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div class="form-group">
<label for="task-form-priority">Prioriteetti</label>
<select id="task-form-priority">
<option value="normaali">Normaali</option>
<option value="tarkea">Tärkeä</option>
<option value="kiireellinen">Kiireellinen</option>
</select>
</div>
<div class="form-group">
<label for="task-form-status">Status</label>
<select id="task-form-status">
<option value="avoin">Avoin</option>
<option value="kaynnissa">Käynnissä</option>
<option value="odottaa">Odottaa</option>
<option value="valmis">Valmis</option>
</select>
</div>
<div class="form-group">
<label for="task-form-deadline">Deadline</label>
<input type="date" id="task-form-deadline">
</div>
</div>
<div class="form-group" style="margin-bottom:1rem;">
<label for="task-form-assigned">Vastuuhenkilö</label>
<select id="task-form-assigned">
<option value="">— Ei vastuuhenkilöä —</option>
</select>
</div>
<div class="form-group" style="margin-bottom:1rem;">
<label for="task-form-desc">Kuvaus</label>
<textarea id="task-form-desc" rows="6" style="resize:vertical;" placeholder="Kuvaa tehtävä tarkemmin..."></textarea>
</div>
<div style="padding:1rem 0 0 0;border-top:1px solid #eee;display:flex;gap:0.5rem;">
<button type="submit" class="btn-primary">Tallenna</button>
<button type="button" class="btn-secondary" id="task-form-cancel">Peruuta</button>
</div>
</form>
</div>
</div>
</div>
<!-- ===== KEHITYSEHDOTUKSET ===== -->
<div id="todo-subtab-features" style="display:none;">
<!-- Listanäkymä -->
<div id="features-list-view">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.75rem;margin-bottom:1rem;">
<div class="search-bar" style="flex:1;min-width:200px;max-width:400px;">
<input type="text" id="feature-search-input" placeholder="Hae ehdotuksista..." style="width:100%;">
</div>
<div style="display:flex;gap:0.5rem;align-items:center;">
<select id="feature-status-filter" style="padding:0.4rem 0.6rem;border-radius:6px;border:1px solid #ddd;font-size:0.85rem;">
<option value="">Kaikki statukset</option>
<option value="ehdotettu">Ehdotettu</option>
<option value="harkinnassa">Harkinnassa</option>
<option value="toteutettu">Toteutettu</option>
<option value="hylatty">Hylätty</option>
</select>
<button class="btn-primary" id="btn-add-feature">+ Uusi ehdotus</button>
</div>
</div>
<div id="features-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem;"></div>
<p id="no-features" style="display:none;text-align:center;color:#aaa;padding:3rem 0;">Ei kehitysehdotuksia.</p>
</div>
<!-- Lukunäkymä -->
<div id="feature-read-view" style="display:none;">
<button class="btn-secondary" id="btn-feature-back">&larr; Takaisin</button>
<div class="table-card" style="padding:1.5rem 2rem;margin-top:1rem;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:1rem;margin-bottom:1rem;">
<div>
<h2 id="feature-read-title" style="color:var(--primary-color);margin:0;"></h2>
<div id="feature-read-meta" style="font-size:0.85rem;color:#888;margin-top:0.3rem;"></div>
</div>
<div style="display:flex;gap:0.5rem;align-items:center;" id="feature-read-actions"></div>
</div>
<div style="margin-bottom:1rem;" id="feature-read-badges"></div>
<div id="feature-read-description" style="margin-bottom:1.5rem;line-height:1.7;white-space:pre-wrap;"></div>
<!-- Kommentit -->
<div id="feature-comments-section">
<h3 style="font-size:1rem;margin-bottom:0.75rem;">&#128172; Kommentit <span id="feature-comment-count" style="font-weight:400;color:#888;"></span></h3>
<div id="feature-comments-list"></div>
<div style="margin-top:0.75rem;display:flex;gap:0.5rem;">
<textarea id="feature-comment-input" rows="2" placeholder="Kirjoita kommentti..." style="flex:1;font-size:0.88rem;resize:vertical;min-height:50px;"></textarea>
<button class="btn-primary" id="btn-feature-comment-send" style="align-self:flex-end;font-size:0.85rem;">Lähetä</button>
</div>
</div>
</div>
</div>
<!-- Muokkausnäkymä -->
<div id="feature-edit-view" style="display:none;">
<button class="btn-secondary" id="btn-feature-edit-cancel">&larr; Peruuta</button>
<div class="table-card" style="padding:1.5rem 2rem;margin-top:1rem;">
<h2 id="feature-edit-title" style="color:var(--primary-color);margin-bottom:1rem;">Uusi kehitysehdotus</h2>
<form id="feature-form">
<input type="hidden" id="feature-form-id">
<input type="hidden" id="feature-form-type" value="feature_request">
<div class="form-group" style="margin-bottom:1rem;">
<label for="feature-form-title">Otsikko *</label>
<input type="text" id="feature-form-title" required placeholder="Parannusehdotuksen otsikko">
</div>
<div class="form-group" style="margin-bottom:1rem;">
<label for="feature-form-desc">Kuvaus</label>
<textarea id="feature-form-desc" rows="8" style="resize:vertical;" placeholder="Kuvaa ehdotuksesi tarkemmin: miksi tämä olisi hyödyllinen, miten sen pitäisi toimia..."></textarea>
</div>
<div style="padding:1rem 0 0 0;border-top:1px solid #eee;display:flex;gap:0.5rem;">
<button type="submit" class="btn-primary">Lähetä ehdotus</button>
<button type="button" class="btn-secondary" id="feature-form-cancel">Peruuta</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Tab: Arkisto -->
<div class="tab-content" id="tab-content-archive">
<div class="main-container">
@@ -887,6 +1100,9 @@
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
<input type="checkbox" data-module="ohjeet"> Ohjeet
</label>
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
<input type="checkbox" data-module="todo"> Tehtävät
</label>
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
<input type="checkbox" data-module="settings" checked> Asetukset / API
</label>

382
script.js
View File

@@ -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 = '<option value="">Kaikki vastuuhenkilöt</option>' + users.map(u => `<option value="${esc(u)}">${esc(u)}</option>`).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 `<div class="todo-card${overdue ? ' overdue' : ''}${soon ? ' deadline-soon' : ''}" onclick="openTaskRead('${t.id}')" style="cursor:pointer;">
<div style="display:flex;gap:0.4rem;margin-bottom:0.5rem;flex-wrap:wrap;">
<span class="priority-badge priority-${t.priority}">${todoPriorityLabels[t.priority]||t.priority}</span>
<span class="todo-status status-${t.status}">${todoStatusLabels[t.status]||t.status}</span>
</div>
<div style="font-weight:600;font-size:0.95rem;margin-bottom:0.4rem;">${esc(t.title)}</div>
<div style="font-size:0.82rem;color:#888;display:flex;gap:0.75rem;flex-wrap:wrap;margin-top:auto;">
${t.assigned_to ? `<span>&#128100; ${esc(t.assigned_to)}</span>` : ''}
${t.deadline ? `<span${overdue ? ' style="color:#e74c3c;font-weight:600;"' : ''}>&#128197; ${t.deadline}</span>` : ''}
${t.total_hours > 0 ? `<span>&#9201; ${t.total_hours}h</span>` : ''}
${t.comment_count > 0 ? `<span>&#128172; ${t.comment_count}</span>` : ''}
</div>
</div>`;
}).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)} &nbsp;|&nbsp; Luotu: ${(t.luotu||'').slice(0,10)} ${t.muokattu ? '&nbsp;|&nbsp; Muokattu: ' + t.muokattu.slice(0,10) : ''}`;
document.getElementById('task-read-badges').innerHTML = `
<span class="priority-badge priority-${t.priority}">${todoPriorityLabels[t.priority]||t.priority}</span>
<span class="todo-status status-${t.status}">${todoStatusLabels[t.status]||t.status}</span>`;
document.getElementById('task-read-fields').innerHTML = `
<div><strong style="font-size:0.78rem;color:#888;">Status</strong><br>${isAdmin ? `<select onchange="updateTaskField('${t.id}','status',this.value)" style="font-size:0.88rem;padding:0.25rem 0.5rem;border-radius:6px;border:1px solid #ddd;">
<option value="avoin" ${t.status==='avoin'?'selected':''}>Avoin</option><option value="kaynnissa" ${t.status==='kaynnissa'?'selected':''}>Käynnissä</option><option value="odottaa" ${t.status==='odottaa'?'selected':''}>Odottaa</option><option value="valmis" ${t.status==='valmis'?'selected':''}>Valmis</option>
</select>` : (todoStatusLabels[t.status]||t.status)}</div>
<div><strong style="font-size:0.78rem;color:#888;">Vastuuhenkilö</strong><br>${isAdmin ? `<select onchange="updateTaskField('${t.id}','assigned',this.value)" style="font-size:0.88rem;padding:0.25rem 0.5rem;border-radius:6px;border:1px solid #ddd;" id="task-read-assigned-sel">
<option value="">— Ei —</option>
</select>` : esc(t.assigned_to || '—')}</div>
<div><strong style="font-size:0.78rem;color:#888;">Prioriteetti</strong><br>${todoPriorityLabels[t.priority]||t.priority}</div>
<div><strong style="font-size:0.78rem;color:#888;">Deadline</strong><br>${t.deadline || '—'}</div>`;
// 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 => `<tr>
<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>
</tr>`).join('') : '<tr><td colspan="5" style="color:#aaa;text-align:center;">Ei kirjauksia</td></tr>';
// Kommentit
renderTodoComments(t.comments || [], 'task');
document.getElementById('task-comment-count').textContent = `(${(t.comments||[]).length})`;
// Actionit
document.getElementById('task-read-actions').innerHTML = isAdmin ? `<button class="btn-secondary" onclick="openTaskEdit('${t.id}')">&#9998; Muokkaa</button><button class="btn-secondary" onclick="deleteTodo('${t.id}')" style="color:#e74c3c;">&#128465; Poista</button>` : '';
// 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 = '<option value="">— Ei vastuuhenkilöä —</option>';
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 => `<div class="todo-card" onclick="openFeatureRead('${t.id}')" style="cursor:pointer;">
<div style="margin-bottom:0.5rem;"><span class="todo-status status-${t.status}">${todoStatusLabels[t.status]||t.status}</span></div>
<div style="font-weight:600;font-size:0.95rem;margin-bottom:0.4rem;">${esc(t.title)}</div>
<div style="font-size:0.82rem;color:#888;display:flex;gap:0.75rem;flex-wrap:wrap;margin-top:auto;">
<span>&#128100; ${esc(t.created_by)}</span>
<span>${(t.luotu||'').slice(0,10)}</span>
${t.comment_count > 0 ? `<span>&#128172; ${t.comment_count}</span>` : ''}
</div>
</div>`).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)} &nbsp;|&nbsp; ${(t.luotu||'').slice(0,10)}`;
document.getElementById('feature-read-badges').innerHTML = isAdmin
? `<select onchange="updateTaskField('${t.id}','status',this.value)" style="font-size:0.88rem;padding:0.3rem 0.6rem;border-radius:6px;border:1px solid #ddd;">
<option value="ehdotettu" ${t.status==='ehdotettu'?'selected':''}>Ehdotettu</option><option value="harkinnassa" ${t.status==='harkinnassa'?'selected':''}>Harkinnassa</option><option value="toteutettu" ${t.status==='toteutettu'?'selected':''}>Toteutettu</option><option value="hylatty" ${t.status==='hylatty'?'selected':''}>Hylätty</option>
</select>`
: `<span class="todo-status status-${t.status}">${todoStatusLabels[t.status]||t.status}</span>`;
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)
? `<button class="btn-secondary" onclick="openFeatureEdit('${t.id}')">&#9998; Muokkaa</button>${isAdmin ? `<button class="btn-secondary" onclick="deleteTodo('${t.id}')" style="color:#e74c3c;">&#128465; Poista</button>` : ''}`
: '';
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 => `<div class="todo-comment">
<div class="todo-comment-meta">${esc(c.author)} &nbsp;·&nbsp; ${(c.luotu||'').replace('T',' ').slice(0,16)}</div>
<div style="white-space:pre-wrap;">${esc(c.body)}</div>
${(c.author === currentUser?.username || isAdmin) ? `<button onclick="deleteTodoComment('${c.id}')" style="background:none;border:none;color:#ccc;cursor:pointer;font-size:0.78rem;margin-top:0.25rem;">Poista</button>` : ''}
</div>`).join('') : '<p style="color:#aaa;font-size:0.88rem;">Ei kommentteja vielä.</p>';
}
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) {

View File

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