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:
200
api.php
200
api.php
@@ -2208,6 +2208,206 @@ switch ($action) {
|
|||||||
readfile($path);
|
readfile($path);
|
||||||
exit;
|
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 ----------
|
// ---------- ARCHIVE ----------
|
||||||
case 'archived_customers':
|
case 'archived_customers':
|
||||||
requireAuth();
|
requireAuth();
|
||||||
|
|||||||
138
db.php
138
db.php
@@ -445,6 +445,50 @@ function initDatabase(): void {
|
|||||||
INDEX idx_company (company_id),
|
INDEX idx_company (company_id),
|
||||||
INDEX idx_category (category_id)
|
INDEX idx_category (category_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
) 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) {
|
foreach ($tables as $i => $sql) {
|
||||||
@@ -1442,3 +1486,97 @@ function dbIsPriorityEmail(string $companyId, string $email): bool {
|
|||||||
}
|
}
|
||||||
return false;
|
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]);
|
||||||
|
}
|
||||||
|
|||||||
216
index.html
216
index.html
@@ -82,6 +82,7 @@
|
|||||||
<button class="tab" data-tab="leads">Liidit</button>
|
<button class="tab" data-tab="leads">Liidit</button>
|
||||||
<button class="tab" data-tab="tekniikka">Tekniikka</button>
|
<button class="tab" data-tab="tekniikka">Tekniikka</button>
|
||||||
<button class="tab" data-tab="ohjeet">Ohjeet</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="archive">Arkisto</button>
|
||||||
<button class="tab" data-tab="changelog">Muutosloki</button>
|
<button class="tab" data-tab="changelog">Muutosloki</button>
|
||||||
<button class="tab" data-tab="settings" id="tab-settings" style="display:none">API</button>
|
<button class="tab" data-tab="settings" id="tab-settings" style="display:none">API</button>
|
||||||
@@ -430,6 +431,218 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">← 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;">⏱ 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;">💬 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">← 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">← 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;">💬 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">← 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 -->
|
<!-- Tab: Arkisto -->
|
||||||
<div class="tab-content" id="tab-content-archive">
|
<div class="tab-content" id="tab-content-archive">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
@@ -887,6 +1100,9 @@
|
|||||||
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
|
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
|
||||||
<input type="checkbox" data-module="ohjeet"> Ohjeet
|
<input type="checkbox" data-module="ohjeet"> Ohjeet
|
||||||
</label>
|
</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;">
|
<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
|
<input type="checkbox" data-module="settings" checked> Asetukset / API
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
382
script.js
382
script.js
@@ -195,7 +195,7 @@ async function showDashboard() {
|
|||||||
// Avaa oikea tabi URL-hashin perusteella (tai customers oletuks)
|
// Avaa oikea tabi URL-hashin perusteella (tai customers oletuks)
|
||||||
const hash = window.location.hash.replace('#', '');
|
const hash = window.location.hash.replace('#', '');
|
||||||
const [mainHash, subHash] = hash.split('/');
|
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';
|
const startTab = validTabs.includes(mainHash) ? mainHash : 'customers';
|
||||||
switchToTab(startTab, subHash);
|
switchToTab(startTab, subHash);
|
||||||
}
|
}
|
||||||
@@ -256,6 +256,7 @@ function switchToTab(target, subTab) {
|
|||||||
if (target === 'archive') loadArchive();
|
if (target === 'archive') loadArchive();
|
||||||
if (target === 'changelog') loadChangelog();
|
if (target === 'changelog') loadChangelog();
|
||||||
if (target === 'ohjeet') loadGuides();
|
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 === 'support') { loadTickets(); showTicketListView(); if (document.getElementById('ticket-auto-refresh').checked) startTicketAutoRefresh(); }
|
||||||
if (target === 'users') loadUsers();
|
if (target === 'users') loadUsers();
|
||||||
if (target === 'settings') loadSettings();
|
if (target === 'settings') loadSettings();
|
||||||
@@ -3889,9 +3890,386 @@ async function deleteGuideCategory(id, name) {
|
|||||||
} catch (e) { alert(e.message); }
|
} 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>👤 ${esc(t.assigned_to)}</span>` : ''}
|
||||||
|
${t.deadline ? `<span${overdue ? ' style="color:#e74c3c;font-weight:600;"' : ''}>📅 ${t.deadline}</span>` : ''}
|
||||||
|
${t.total_hours > 0 ? `<span>⏱ ${t.total_hours}h</span>` : ''}
|
||||||
|
${t.comment_count > 0 ? `<span>💬 ${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)} | Luotu: ${(t.luotu||'').slice(0,10)} ${t.muokattu ? ' | 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">🗑</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}')">✎ Muokkaa</button><button class="btn-secondary" onclick="deleteTodo('${t.id}')" style="color:#e74c3c;">🗑 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>👤 ${esc(t.created_by)}</span>
|
||||||
|
<span>${(t.luotu||'').slice(0,10)}</span>
|
||||||
|
${t.comment_count > 0 ? `<span>💬 ${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)} | ${(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}')">✎ Muokkaa</button>${isAdmin ? `<button class="btn-secondary" onclick="deleteTodo('${t.id}')" style="color:#e74c3c;">🗑 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)} · ${(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 ====================
|
// ==================== 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'];
|
const DEFAULT_MODULES = ['customers', 'support', 'archive', 'changelog', 'settings'];
|
||||||
|
|
||||||
function applyModules(modules) {
|
function applyModules(modules) {
|
||||||
|
|||||||
24
style.css
24
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 { 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; }
|
.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 */
|
||||||
.role-badge {
|
.role-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
Reference in New Issue
Block a user