Ohjeet-moduuli: Confluence-tyylinen tietopankki asiakaspalvelijoille

Uusi moduuli "Ohjeet" jossa ylläpitäjä voi kirjoittaa ohjeita
asiakaspalvelijoille miten asioita tehdään.

Ominaisuudet:
- Korttipohjainen listanäkymä (grid) hakutoiminnolla ja kategoriasuodatuksella
- Markdown-editori toolbarilla (B, I, H2, H3, listat, linkit, koodi, lainaukset)
- Esikatselu-toggle muokkausnäkymässä
- Artikkelien lukunäkymä renderoitulla Markdownilla
- Kategorioiden hallinta (lisää/poista)
- Tagit ja kiinnitys (pinned) -toiminto
- Oikeushallinta: kaikki lukevat, admin luo/muokkaa/poistaa
- Moduuli näkyy/piiloutuu yrityskohtaisista asetuksista
- Muutokset kirjautuvat muutoslokiin

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 12:19:00 +02:00
parent f4f11505d2
commit 7c4060bfa8
5 changed files with 636 additions and 2 deletions

305
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', 'archive', 'changelog', 'support', 'users', 'settings', 'companies'];
const validTabs = ['customers', 'leads', 'tekniikka', 'ohjeet', 'archive', 'changelog', 'support', 'users', 'settings', 'companies'];
const startTab = validTabs.includes(mainHash) ? mainHash : 'customers';
switchToTab(startTab, subHash);
}
@@ -255,6 +255,7 @@ function switchToTab(target, subTab) {
}
if (target === 'archive') loadArchive();
if (target === 'changelog') loadChangelog();
if (target === 'ohjeet') loadGuides();
if (target === 'support') { loadTickets(); showTicketListView(); if (document.getElementById('ticket-auto-refresh').checked) startTicketAutoRefresh(); }
if (target === 'users') loadUsers();
if (target === 'settings') loadSettings();
@@ -3477,9 +3478,309 @@ document.getElementById('ipam-form')?.addEventListener('submit', async (e) => {
document.getElementById('ipam-search-input')?.addEventListener('input', () => renderIpam());
// ==================== OHJEET ====================
let guidesData = [];
let guideCategories = [];
let currentGuideId = null;
// Markdown-renderöijä (kevyt, ei ulkoisia kirjastoja)
function renderMarkdown(md) {
if (!md) return '';
let html = esc(md);
// Koodilohkot ``` ... ```
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (m, lang, code) => `<pre><code>${code}</code></pre>`);
// Inline-koodi
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// Otsikot
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Lihavointi + kursiivi
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Linkit
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Lainaukset
html = html.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
// Vaakaviiva
html = html.replace(/^---$/gm, '<hr>');
// Listat: kerätään peräkkäiset lista-rivit yhteen
html = html.replace(/(^[\-\*] .+\n?)+/gm, (match) => {
const items = match.trim().split('\n').map(l => '<li>' + l.replace(/^[\-\*] /, '') + '</li>').join('');
return '<ul>' + items + '</ul>';
});
html = html.replace(/(^\d+\. .+\n?)+/gm, (match) => {
const items = match.trim().split('\n').map(l => '<li>' + l.replace(/^\d+\. /, '') + '</li>').join('');
return '<ol>' + items + '</ol>';
});
// Kappalejaot
html = html.replace(/\n\n/g, '</p><p>');
html = html.replace(/\n/g, '<br>');
return '<p>' + html + '</p>';
}
async function loadGuides() {
try {
[guidesData, guideCategories] = await Promise.all([
apiCall('guides'),
apiCall('guide_categories')
]);
populateGuideCategoryFilter();
renderGuidesList();
showGuideListView();
const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'superadmin';
document.getElementById('btn-add-guide').style.display = isAdmin ? '' : 'none';
document.getElementById('btn-manage-guide-cats').style.display = isAdmin ? '' : 'none';
} catch (e) { console.error(e); }
}
function populateGuideCategoryFilter() {
const sel = document.getElementById('guide-category-filter');
const formSel = document.getElementById('guide-form-category');
const opts = guideCategories.map(c => `<option value="${c.id}">${esc(c.nimi)}</option>`).join('');
if (sel) sel.innerHTML = '<option value="">Kaikki kategoriat</option>' + opts;
if (formSel) formSel.innerHTML = '<option value="">Ei kategoriaa</option>' + opts;
}
function renderGuidesList() {
const query = (document.getElementById('guide-search-input')?.value || '').toLowerCase().trim();
const catFilter = document.getElementById('guide-category-filter')?.value || '';
let filtered = guidesData;
if (catFilter) filtered = filtered.filter(g => g.category_id === catFilter);
if (query) {
filtered = filtered.filter(g =>
(g.title || '').toLowerCase().includes(query) ||
(g.tags || '').toLowerCase().includes(query) ||
(g.category_name || '').toLowerCase().includes(query) ||
(g.content || '').toLowerCase().includes(query)
);
}
const grid = document.getElementById('guides-grid');
const noGuides = document.getElementById('no-guides');
if (!grid) return;
if (filtered.length === 0) {
grid.innerHTML = '';
if (noGuides) noGuides.style.display = 'block';
} else {
if (noGuides) noGuides.style.display = 'none';
grid.innerHTML = filtered.map(g => {
const preview = (g.content || '').substring(0, 150).replace(/[#*`\[\]]/g, '');
const tags = (g.tags || '').split(',').filter(t => t.trim());
return `<div class="guide-card ${g.pinned ? 'guide-pinned' : ''}" onclick="openGuideRead('${g.id}')">
<div class="guide-card-header">
${g.pinned ? '<span class="guide-pin-icon" title="Kiinnitetty">&#128204;</span>' : ''}
${g.category_name ? `<span class="guide-category-badge">${esc(g.category_name)}</span>` : ''}
</div>
<h3 class="guide-card-title">${esc(g.title)}</h3>
<p class="guide-card-preview">${esc(preview)}${(g.content || '').length > 150 ? '...' : ''}</p>
<div class="guide-card-footer">
<span>${esc(g.author || '')}</span>
<span>${timeAgo(g.muokattu || g.luotu)}</span>
</div>
${tags.length > 0 ? `<div class="guide-card-tags">${tags.map(t => `<span class="guide-tag">${esc(t.trim())}</span>`).join('')}</div>` : ''}
</div>`;
}).join('');
}
}
function showGuideListView() {
document.getElementById('guides-list-view').style.display = '';
document.getElementById('guide-read-view').style.display = 'none';
document.getElementById('guide-edit-view').style.display = 'none';
}
function showGuideReadView() {
document.getElementById('guides-list-view').style.display = 'none';
document.getElementById('guide-read-view').style.display = '';
document.getElementById('guide-edit-view').style.display = 'none';
}
function showGuideEditView() {
document.getElementById('guides-list-view').style.display = 'none';
document.getElementById('guide-read-view').style.display = 'none';
document.getElementById('guide-edit-view').style.display = '';
}
async function openGuideRead(id) {
try {
const guide = await apiCall('guide&id=' + encodeURIComponent(id));
currentGuideId = id;
document.getElementById('guide-read-title').textContent = guide.title;
document.getElementById('guide-read-meta').innerHTML = [
guide.category_name ? `<span>&#128193; ${esc(guide.category_name)}</span>` : '',
`<span>&#9998; ${esc(guide.author || 'Tuntematon')}</span>`,
`<span>&#128197; ${esc((guide.luotu || '').substring(0, 10))}</span>`,
guide.muokattu ? `<span>Päivitetty: ${timeAgo(guide.muokattu)} (${esc(guide.muokkaaja || '')})</span>` : ''
].filter(Boolean).join('');
document.getElementById('guide-read-content').innerHTML = renderMarkdown(guide.content);
const tags = (guide.tags || '').split(',').filter(t => t.trim());
document.getElementById('guide-read-tags').innerHTML = tags.length > 0
? tags.map(t => `<span class="guide-tag">${esc(t.trim())}</span>`).join(' ')
: '';
const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'superadmin';
document.getElementById('guide-read-actions').style.display = isAdmin ? 'block' : 'none';
showGuideReadView();
} catch (e) { alert(e.message); }
}
function openGuideEdit(guide) {
document.getElementById('guide-edit-title').textContent = guide ? 'Muokkaa ohjetta' : 'Uusi ohje';
document.getElementById('guide-form-id').value = guide ? guide.id : '';
document.getElementById('guide-form-title').value = guide ? guide.title : '';
document.getElementById('guide-form-content').value = guide ? guide.content : '';
document.getElementById('guide-form-tags').value = guide ? (guide.tags || '') : '';
document.getElementById('guide-form-pinned').checked = guide ? guide.pinned : false;
document.getElementById('guide-form-content').style.display = '';
document.getElementById('guide-preview-pane').style.display = 'none';
populateGuideCategoryFilter();
if (guide) document.getElementById('guide-form-category').value = guide.category_id || '';
showGuideEditView();
document.getElementById('guide-form-title').focus();
}
// Tallenna ohje
document.getElementById('guide-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('guide-form-id').value;
const body = {
title: document.getElementById('guide-form-title').value.trim(),
category_id: document.getElementById('guide-form-category').value || null,
content: document.getElementById('guide-form-content').value,
tags: document.getElementById('guide-form-tags').value.trim(),
pinned: document.getElementById('guide-form-pinned').checked,
};
if (id) {
body.id = id;
const existing = guidesData.find(g => g.id === id);
if (existing) { body.luotu = existing.luotu; body.author = existing.author; }
}
try {
const saved = await apiCall('guide_save', 'POST', body);
await loadGuides();
openGuideRead(saved.id);
} catch (e) { alert(e.message); }
});
async function deleteGuide(id) {
if (!confirm('Haluatko varmasti poistaa tämän ohjeen?')) return;
try {
await apiCall('guide_delete', 'POST', { id });
await loadGuides();
showGuideListView();
} catch (e) { alert(e.message); }
}
// Event listenerit
document.getElementById('guide-search-input')?.addEventListener('input', () => renderGuidesList());
document.getElementById('guide-category-filter')?.addEventListener('change', () => renderGuidesList());
document.getElementById('btn-add-guide')?.addEventListener('click', () => openGuideEdit(null));
document.getElementById('btn-guide-back')?.addEventListener('click', () => { showGuideListView(); currentGuideId = null; });
document.getElementById('btn-guide-edit-cancel')?.addEventListener('click', () => {
if (currentGuideId) openGuideRead(currentGuideId); else showGuideListView();
});
document.getElementById('guide-form-cancel')?.addEventListener('click', () => {
if (currentGuideId) openGuideRead(currentGuideId); else showGuideListView();
});
document.getElementById('btn-edit-guide')?.addEventListener('click', () => {
const guide = guidesData.find(g => g.id === currentGuideId);
if (guide) openGuideEdit(guide);
});
document.getElementById('btn-delete-guide')?.addEventListener('click', () => {
if (currentGuideId) deleteGuide(currentGuideId);
});
// Markdown toolbar
document.querySelectorAll('.guide-tb-btn[data-md]').forEach(btn => {
btn.addEventListener('click', () => {
const ta = document.getElementById('guide-form-content');
const start = ta.selectionStart;
const end = ta.selectionEnd;
const sel = ta.value.substring(start, end);
let ins = '';
switch (btn.dataset.md) {
case 'bold': ins = `**${sel || 'teksti'}**`; break;
case 'italic': ins = `*${sel || 'teksti'}*`; break;
case 'h2': ins = `\n## ${sel || 'Otsikko'}\n`; break;
case 'h3': ins = `\n### ${sel || 'Alaotsikko'}\n`; break;
case 'ul': ins = `\n- ${sel || 'kohta'}\n`; break;
case 'ol': ins = `\n1. ${sel || 'kohta'}\n`; break;
case 'link': ins = `[${sel || 'linkki'}](https://)`; break;
case 'code': ins = sel.includes('\n') ? `\n\`\`\`\n${sel}\n\`\`\`\n` : `\`${sel || 'koodi'}\``; break;
case 'quote': ins = `\n> ${sel || 'lainaus'}\n`; break;
}
ta.value = ta.value.substring(0, start) + ins + ta.value.substring(end);
ta.focus();
ta.selectionStart = ta.selectionEnd = start + ins.length;
});
});
// Esikatselu-toggle
document.getElementById('btn-guide-preview-toggle')?.addEventListener('click', () => {
const ta = document.getElementById('guide-form-content');
const preview = document.getElementById('guide-preview-pane');
if (ta.style.display !== 'none') {
preview.innerHTML = renderMarkdown(ta.value);
ta.style.display = 'none';
preview.style.display = '';
} else {
ta.style.display = '';
preview.style.display = 'none';
}
});
// Kategorianhallinta
document.getElementById('btn-manage-guide-cats')?.addEventListener('click', () => {
renderGuideCatList();
document.getElementById('guide-cat-modal').style.display = 'flex';
});
document.getElementById('guide-cat-modal-close')?.addEventListener('click', () => {
document.getElementById('guide-cat-modal').style.display = 'none';
});
function renderGuideCatList() {
const list = document.getElementById('guide-cat-list');
if (!list) return;
if (guideCategories.length === 0) {
list.innerHTML = '<p style="color:#888;font-size:0.9rem;">Ei kategorioita.</p>';
return;
}
list.innerHTML = guideCategories.map(c => `
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0;border-bottom:1px solid #f0f0f0;">
<span style="flex:1;font-weight:600;">${esc(c.nimi)}</span>
<button onclick="deleteGuideCategory('${c.id}','${esc(c.nimi)}')" class="btn-link" style="color:#dc2626;" title="Poista">&times;</button>
</div>
`).join('');
}
document.getElementById('btn-guide-cat-add')?.addEventListener('click', async () => {
const inp = document.getElementById('guide-cat-new-name');
const nimi = inp.value.trim();
if (!nimi) return;
try {
await apiCall('guide_category_save', 'POST', { nimi, sort_order: guideCategories.length });
inp.value = '';
guideCategories = await apiCall('guide_categories');
renderGuideCatList();
populateGuideCategoryFilter();
} catch (e) { alert(e.message); }
});
async function deleteGuideCategory(id, name) {
if (!confirm(`Poista kategoria "${name}"? Ohjeet siirtyvät kategoriattomiksi.`)) return;
try {
await apiCall('guide_category_delete', 'POST', { id });
guideCategories = await apiCall('guide_categories');
renderGuideCatList();
populateGuideCategoryFilter();
} catch (e) { alert(e.message); }
}
// ==================== MODUULIT ====================
const ALL_MODULES = ['customers', 'support', 'leads', 'tekniikka', 'archive', 'changelog', 'settings'];
const ALL_MODULES = ['customers', 'support', 'leads', 'tekniikka', 'ohjeet', 'archive', 'changelog', 'settings'];
const DEFAULT_MODULES = ['customers', 'support', 'archive', 'changelog', 'settings'];
function applyModules(modules) {