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:
94
api.php
94
api.php
@@ -1912,6 +1912,100 @@ switch ($action) {
|
||||
echo json_encode(['success' => true]);
|
||||
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 ----------
|
||||
case 'archived_customers':
|
||||
requireAuth();
|
||||
|
||||
90
db.php
90
db.php
@@ -419,6 +419,32 @@ function initDatabase(): void {
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
|
||||
INDEX idx_company_customer (company_id, customer_id)
|
||||
) 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) {
|
||||
@@ -951,6 +977,70 @@ function dbDeleteIpam(string $id): void {
|
||||
_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 ====================
|
||||
|
||||
function dbLoadLeads(string $companyId): array {
|
||||
|
||||
122
index.html
122
index.html
@@ -81,6 +81,7 @@
|
||||
<button class="tab active" data-tab="customers">Asiakkaat</button>
|
||||
<button class="tab" data-tab="leads">Liidit</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="changelog">Muutosloki</button>
|
||||
<button class="tab" data-tab="settings" id="tab-settings" style="display:none">API</button>
|
||||
@@ -325,6 +326,107 @@
|
||||
</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">⚙ 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">← 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">✎ Muokkaa</button>
|
||||
<button class="btn-link" style="color:#dc2626;margin-left:0.5rem;" id="btn-delete-guide">🗑 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">← 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">• 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">🔗</button>
|
||||
<button type="button" class="guide-tb-btn" data-md="code" title="Koodi"></></button>
|
||||
<button type="button" class="guide-tb-btn" data-md="quote" title="Lainaus">❝</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 -->
|
||||
<div class="tab-content" id="tab-content-archive">
|
||||
<div class="main-container">
|
||||
@@ -779,6 +881,9 @@
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
|
||||
<input type="checkbox" data-module="changelog" checked> Muutosloki
|
||||
</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;">
|
||||
<input type="checkbox" data-module="settings" checked> Asetukset / API
|
||||
</label>
|
||||
@@ -1316,6 +1421,23 @@
|
||||
</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">×</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
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) {
|
||||
|
||||
27
style.css
27
style.css
@@ -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-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 {
|
||||
display: inline-block;
|
||||
|
||||
Reference in New Issue
Block a user