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

94
api.php
View File

@@ -1912,6 +1912,100 @@ switch ($action) {
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
break; break;
// ---------- OHJEET (GUIDES) ----------
case 'guides':
requireAuth();
$companyId = requireCompany();
echo json_encode(dbLoadGuides($companyId));
break;
case 'guide':
requireAuth();
requireCompany();
$id = $_GET['id'] ?? '';
$guide = dbLoadGuide($id);
if (!$guide) { http_response_code(404); echo json_encode(['error' => 'Ohjetta ei löydy']); exit; }
echo json_encode($guide);
break;
case 'guide_save':
requireAuth();
requireAdmin();
$companyId = requireCompany();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$isNew = empty($input['id']);
$guide = [
'id' => $input['id'] ?? generateId(),
'category_id' => $input['category_id'] ?? null,
'title' => trim($input['title'] ?? ''),
'content' => $input['content'] ?? '',
'tags' => trim($input['tags'] ?? ''),
'author' => $isNew ? currentUser() : ($input['author'] ?? currentUser()),
'pinned' => !empty($input['pinned']),
'luotu' => $isNew ? date('Y-m-d H:i:s') : ($input['luotu'] ?? date('Y-m-d H:i:s')),
'muokattu' => $isNew ? null : date('Y-m-d H:i:s'),
'muokkaaja' => $isNew ? '' : currentUser(),
];
if (empty($guide['title'])) {
http_response_code(400);
echo json_encode(['error' => 'Otsikko vaaditaan']);
exit;
}
dbSaveGuide($companyId, $guide);
dbAddLog($companyId, currentUser(), $isNew ? 'guide_create' : 'guide_update', $guide['id'], $guide['title'], ($isNew ? 'Loi' : 'Muokkasi') . ' ohjeen');
echo json_encode($guide);
break;
case 'guide_delete':
requireAuth();
requireAdmin();
$companyId = requireCompany();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? '';
$guide = dbLoadGuide($id);
dbDeleteGuide($id);
dbAddLog($companyId, currentUser(), 'guide_delete', $id, $guide ? $guide['title'] : '', 'Poisti ohjeen');
echo json_encode(['success' => true]);
break;
case 'guide_categories':
requireAuth();
$companyId = requireCompany();
echo json_encode(dbLoadGuideCategories($companyId));
break;
case 'guide_category_save':
requireAuth();
requireAdmin();
$companyId = requireCompany();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$cat = [
'id' => $input['id'] ?? generateId(),
'nimi' => trim($input['nimi'] ?? ''),
'sort_order' => (int)($input['sort_order'] ?? 0),
];
if (empty($cat['nimi'])) {
http_response_code(400);
echo json_encode(['error' => 'Kategorian nimi vaaditaan']);
exit;
}
dbSaveGuideCategory($companyId, $cat);
echo json_encode($cat);
break;
case 'guide_category_delete':
requireAuth();
requireAdmin();
$companyId = requireCompany();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
dbDeleteGuideCategory($input['id'] ?? '');
echo json_encode(['success' => true]);
break;
// ---------- ARCHIVE ---------- // ---------- ARCHIVE ----------
case 'archived_customers': case 'archived_customers':
requireAuth(); requireAuth();

90
db.php
View File

@@ -419,6 +419,32 @@ function initDatabase(): void {
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE, FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
INDEX idx_company_customer (company_id, customer_id) INDEX idx_company_customer (company_id, customer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"CREATE TABLE IF NOT EXISTS guide_categories (
id VARCHAR(20) PRIMARY KEY,
company_id VARCHAR(50) NOT NULL,
nimi VARCHAR(255) NOT NULL,
sort_order INT DEFAULT 0,
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
INDEX idx_company (company_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"CREATE TABLE IF NOT EXISTS guides (
id VARCHAR(20) PRIMARY KEY,
company_id VARCHAR(50) NOT NULL,
category_id VARCHAR(20) DEFAULT NULL,
title VARCHAR(500) NOT NULL,
content LONGTEXT,
tags VARCHAR(500) DEFAULT '',
author VARCHAR(100) DEFAULT '',
pinned TINYINT(1) DEFAULT 0,
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_category (category_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
]; ];
foreach ($tables as $i => $sql) { foreach ($tables as $i => $sql) {
@@ -951,6 +977,70 @@ function dbDeleteIpam(string $id): void {
_dbExecute("DELETE FROM ipam WHERE id = ?", [$id]); _dbExecute("DELETE FROM ipam WHERE id = ?", [$id]);
} }
// ==================== OHJEET (GUIDES) ====================
function dbLoadGuideCategories(string $companyId): array {
return _dbFetchAll("SELECT * FROM guide_categories WHERE company_id = ? ORDER BY sort_order, nimi", [$companyId]);
}
function dbSaveGuideCategory(string $companyId, array $cat): void {
_dbExecute("
INSERT INTO guide_categories (id, company_id, nimi, sort_order)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE nimi = VALUES(nimi), sort_order = VALUES(sort_order)
", [$cat['id'], $companyId, $cat['nimi'] ?? '', $cat['sort_order'] ?? 0]);
}
function dbDeleteGuideCategory(string $catId): void {
_dbExecute("DELETE FROM guide_categories WHERE id = ?", [$catId]);
}
function dbLoadGuides(string $companyId): array {
$rows = _dbFetchAll("
SELECT g.*, gc.nimi AS category_name
FROM guides g
LEFT JOIN guide_categories gc ON g.category_id = gc.id
WHERE g.company_id = ?
ORDER BY g.pinned DESC, g.muokattu DESC, g.luotu DESC
", [$companyId]);
foreach ($rows as &$r) {
$r['pinned'] = (bool)$r['pinned'];
}
return $rows;
}
function dbLoadGuide(string $guideId): ?array {
$rows = _dbFetchAll("
SELECT g.*, gc.nimi AS category_name
FROM guides g
LEFT JOIN guide_categories gc ON g.category_id = gc.id
WHERE g.id = ?
", [$guideId]);
if (empty($rows)) return null;
$r = $rows[0];
$r['pinned'] = (bool)$r['pinned'];
return $r;
}
function dbSaveGuide(string $companyId, array $g): void {
_dbExecute("
INSERT INTO guides (id, company_id, category_id, title, content, tags, author, pinned, luotu, muokattu, muokkaaja)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
category_id = VALUES(category_id), title = VALUES(title), content = VALUES(content),
tags = VALUES(tags), pinned = VALUES(pinned), muokattu = VALUES(muokattu), muokkaaja = VALUES(muokkaaja)
", [
$g['id'], $companyId, !empty($g['category_id']) ? $g['category_id'] : null,
$g['title'] ?? '', $g['content'] ?? '', $g['tags'] ?? '',
$g['author'] ?? '', $g['pinned'] ? 1 : 0,
$g['luotu'] ?? date('Y-m-d H:i:s'), $g['muokattu'] ?? null, $g['muokkaaja'] ?? ''
]);
}
function dbDeleteGuide(string $guideId): void {
_dbExecute("DELETE FROM guides WHERE id = ?", [$guideId]);
}
// ==================== LIIDIT ==================== // ==================== LIIDIT ====================
function dbLoadLeads(string $companyId): array { function dbLoadLeads(string $companyId): array {

View File

@@ -81,6 +81,7 @@
<button class="tab active" data-tab="customers">Asiakkaat</button> <button class="tab active" data-tab="customers">Asiakkaat</button>
<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="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>
@@ -325,6 +326,107 @@
</div> </div>
</div> </div>
<!-- Tab: Ohjeet -->
<div class="tab-content" id="tab-content-ohjeet">
<div class="main-container">
<!-- Listanäkymä -->
<div id="guides-list-view">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;gap:0.75rem;flex-wrap:wrap;">
<div class="search-bar" style="flex:1;max-width:400px;">
<input type="text" id="guide-search-input" placeholder="Hae ohjeista...">
</div>
<div style="display:flex;gap:0.5rem;align-items:center;">
<select id="guide-category-filter" style="padding:8px 12px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.88rem;">
<option value="">Kaikki kategoriat</option>
</select>
<button class="btn-primary" id="btn-add-guide" style="display:none;">+ Uusi ohje</button>
<button class="btn-secondary" id="btn-manage-guide-cats" style="display:none;" title="Hallinnoi kategorioita">&#9881; Kategoriat</button>
</div>
</div>
<div id="guides-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem;"></div>
<div id="no-guides" class="empty-state" style="display:none;">
<p>Ei ohjeita vielä.</p>
<p class="empty-hint">Ylläpitäjä voi lisätä ohjeita "+ Uusi ohje" -napista.</p>
</div>
</div>
<!-- Lukunäkymä -->
<div id="guide-read-view" style="display:none;">
<div style="margin-bottom:1rem;">
<button class="btn-secondary" id="btn-guide-back">&larr; Takaisin</button>
</div>
<div class="table-card" style="padding:2rem 2.5rem;">
<div style="margin-bottom:1.5rem;padding-bottom:1rem;border-bottom:2px solid #f0f2f5;">
<h1 id="guide-read-title" style="font-size:1.6rem;color:var(--primary-color);margin-bottom:0.5rem;"></h1>
<div id="guide-read-meta" style="font-size:0.82rem;color:#888;display:flex;gap:1rem;flex-wrap:wrap;"></div>
</div>
<div id="guide-read-content" class="guide-content" style="line-height:1.7;font-size:0.95rem;"></div>
<div id="guide-read-tags" style="margin-top:1.5rem;padding-top:1rem;border-top:1px solid #f0f2f5;"></div>
<div id="guide-read-actions" style="margin-top:1rem;display:none;">
<button class="btn-secondary" id="btn-edit-guide">&#9998; Muokkaa</button>
<button class="btn-link" style="color:#dc2626;margin-left:0.5rem;" id="btn-delete-guide">&#128465; Poista</button>
</div>
</div>
</div>
<!-- Muokkausnäkymä -->
<div id="guide-edit-view" style="display:none;">
<div style="margin-bottom:1rem;">
<button class="btn-secondary" id="btn-guide-edit-cancel">&larr; Peruuta</button>
</div>
<div class="table-card" style="padding:1.5rem 2rem;">
<h2 id="guide-edit-title" style="color:var(--primary-color);margin-bottom:1rem;">Uusi ohje</h2>
<form id="guide-form">
<input type="hidden" id="guide-form-id">
<div class="form-group" style="margin-bottom:1rem;">
<label for="guide-form-title">Otsikko *</label>
<input type="text" id="guide-form-title" required placeholder="Ohjeen otsikko">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
<div class="form-group">
<label for="guide-form-category">Kategoria</label>
<select id="guide-form-category">
<option value="">Ei kategoriaa</option>
</select>
</div>
<div class="form-group">
<label for="guide-form-tags">Tagit (pilkulla erotettuna)</label>
<input type="text" id="guide-form-tags" placeholder="vpn, asennus, ohje">
</div>
</div>
<div class="form-group" style="margin-bottom:0.5rem;">
<label>Sisältö (Markdown)</label>
<div id="guide-editor-toolbar" style="display:flex;gap:0.25rem;margin-bottom:0.5rem;flex-wrap:wrap;">
<button type="button" class="guide-tb-btn" data-md="bold" title="Lihavointi"><strong>B</strong></button>
<button type="button" class="guide-tb-btn" data-md="italic" title="Kursiivi"><em>I</em></button>
<button type="button" class="guide-tb-btn" data-md="h2" title="Otsikko">H2</button>
<button type="button" class="guide-tb-btn" data-md="h3" title="Alaotsikko">H3</button>
<button type="button" class="guide-tb-btn" data-md="ul" title="Lista">&#8226; Lista</button>
<button type="button" class="guide-tb-btn" data-md="ol" title="Numeroitu lista">1. Lista</button>
<button type="button" class="guide-tb-btn" data-md="link" title="Linkki">&#128279;</button>
<button type="button" class="guide-tb-btn" data-md="code" title="Koodi">&lt;/&gt;</button>
<button type="button" class="guide-tb-btn" data-md="quote" title="Lainaus">&#10077;</button>
<span style="flex:1;"></span>
<button type="button" class="guide-tb-btn" id="btn-guide-preview-toggle">Esikatselu</button>
</div>
<textarea id="guide-form-content" rows="20" style="font-family:'SF Mono',Monaco,Consolas,monospace;font-size:0.88rem;line-height:1.6;resize:vertical;min-height:300px;" placeholder="Kirjoita Markdown-muodossa..."></textarea>
<div id="guide-preview-pane" class="guide-content" style="display:none;padding:1rem;border:2px solid #e0e0e0;border-radius:8px;min-height:300px;background:#fafbfc;"></div>
</div>
<label style="display:flex;align-items:center;gap:0.5rem;margin:0.75rem 0;font-size:0.9rem;cursor:pointer;">
<input type="checkbox" id="guide-form-pinned"> Kiinnitetty (näkyy aina listauksen alussa)
</label>
<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="guide-form-cancel">Peruuta</button>
</div>
</form>
</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">
@@ -779,6 +881,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="changelog" checked> Muutosloki <input type="checkbox" data-module="changelog" checked> Muutosloki
</label> </label>
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
<input type="checkbox" data-module="ohjeet"> Ohjeet
</label>
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;"> <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>
@@ -1316,6 +1421,23 @@
</div> </div>
</div> </div>
<!-- Ohjeet: Kategorianhallinta-modal -->
<div id="guide-cat-modal" class="modal" style="display:none">
<div class="modal-content" style="max-width:500px;">
<div class="modal-header">
<h2>Ohjekategoriat</h2>
<button class="modal-close" id="guide-cat-modal-close">&times;</button>
</div>
<div style="padding:1.5rem;">
<div id="guide-cat-list" style="margin-bottom:1rem;"></div>
<div style="display:flex;gap:0.5rem;">
<input type="text" id="guide-cat-new-name" placeholder="Uusi kategoria..." style="flex:1;padding:8px 12px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.9rem;">
<button class="btn-primary" id="btn-guide-cat-add">Lisää</button>
</div>
</div>
</div>
</div>
<script src="script.js"></script> <script src="script.js"></script>
</body> </body>
</html> </html>

305
script.js
View File

@@ -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', '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'; const startTab = validTabs.includes(mainHash) ? mainHash : 'customers';
switchToTab(startTab, subHash); switchToTab(startTab, subHash);
} }
@@ -255,6 +255,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 === '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();
@@ -3477,9 +3478,309 @@ document.getElementById('ipam-form')?.addEventListener('submit', async (e) => {
document.getElementById('ipam-search-input')?.addEventListener('input', () => renderIpam()); 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 ==================== // ==================== 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']; const DEFAULT_MODULES = ['customers', 'support', 'archive', 'changelog', 'settings'];
function applyModules(modules) { function applyModules(modules) {

View File

@@ -1095,6 +1095,33 @@ span.empty {
.ipam-type-free { display:inline-block; padding:1px 8px; border-radius:4px; font-size:0.75rem; font-weight:700; background:#d1fae5; color:#065f46; } .ipam-type-free { display:inline-block; padding:1px 8px; border-radius:4px; font-size:0.75rem; font-weight:700; background:#d1fae5; color:#065f46; }
.ipam-network-free { color:#059669; background:#ecfdf5; } .ipam-network-free { color:#059669; background:#ecfdf5; }
/* ==================== OHJEET ==================== */
.guide-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; }
.guide-card:hover { transform:translateY(-2px); box-shadow:0 4px 12px rgba(0,0,0,0.1); border-color:var(--primary-color); }
.guide-card.guide-pinned { border-color:#ffc107; background:#fffef5; }
.guide-card-header { display:flex; align-items:center; gap:0.5rem; margin-bottom:0.5rem; min-height:24px; }
.guide-pin-icon { font-size:0.9rem; }
.guide-category-badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:0.72rem; font-weight:600; background:var(--primary-light); color:var(--primary-color); }
.guide-card-title { font-size:1.05rem; color:#1a1a2e; margin-bottom:0.5rem; line-height:1.3; }
.guide-card-preview { font-size:0.85rem; color:#888; line-height:1.5; flex:1; margin-bottom:0.75rem; display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; }
.guide-card-footer { display:flex; justify-content:space-between; font-size:0.78rem; color:#aaa; }
.guide-card-tags { display:flex; gap:0.3rem; flex-wrap:wrap; margin-top:0.5rem; }
.guide-tag { display:inline-block; padding:2px 8px; border-radius:10px; font-size:0.72rem; font-weight:600; background:#e8ebf0; color:#555; }
.guide-content h1 { font-size:1.5rem; color:var(--primary-color); margin:1.5rem 0 0.75rem; }
.guide-content h2 { font-size:1.25rem; color:var(--primary-color); margin:1.25rem 0 0.5rem; border-bottom:2px solid #f0f2f5; padding-bottom:0.3rem; }
.guide-content h3 { font-size:1.1rem; color:#333; margin:1rem 0 0.5rem; }
.guide-content p { margin-bottom:0.75rem; }
.guide-content ul, .guide-content ol { margin:0.5rem 0 0.75rem 1.5rem; }
.guide-content li { margin-bottom:0.25rem; }
.guide-content code { background:#f0f2f5; padding:2px 6px; border-radius:4px; font-size:0.88rem; font-family:'SF Mono',Monaco,Consolas,monospace; }
.guide-content pre { background:#1a1a2e; color:#e0e0e0; padding:1rem; border-radius:8px; overflow-x:auto; margin:0.75rem 0; }
.guide-content pre code { background:none; padding:0; color:inherit; }
.guide-content blockquote { border-left:4px solid var(--primary-color); margin:0.75rem 0; padding:0.5rem 1rem; background:#f8f9fb; color:#555; border-radius:0 8px 8px 0; }
.guide-content a { color:var(--primary-color); text-decoration:underline; }
.guide-content hr { border:none; border-top:2px solid #f0f2f5; margin:1.5rem 0; }
.guide-tb-btn { background:#f0f2f5; border:1px solid #ddd; padding:4px 10px; border-radius:5px; font-size:0.78rem; font-weight:600; cursor:pointer; color:#555; transition:all 0.15s; }
.guide-tb-btn:hover { background:var(--primary-color); color:#fff; border-color:var(--primary-color); }
/* Role badge */ /* Role badge */
.role-badge { .role-badge {
display: inline-block; display: inline-block;