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

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) {