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:
305
script.js
305
script.js
@@ -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(/^> (.+)$/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">📌</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>📁 ${esc(guide.category_name)}</span>` : '',
|
||||
`<span>✎ ${esc(guide.author || 'Tuntematon')}</span>`,
|
||||
`<span>📅 ${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">×</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) {
|
||||
|
||||
Reference in New Issue
Block a user