Asiakaspalvelu: alinavi-uudelleenjärjestely + tikettityyppien hallinta

Vastauspohjat, Säännöt ja Asetukset siirretty omiksi alinaveikseen
tikettilistan overlay-napeista. Säännöt-välilehdelle lisätty
tikettityyppien hallinta (lisää/poista). Tyypit tallennetaan
tietokantaan yrityskohtaisesti ja populoidaan dynaamisesti
kaikkiin dropdown-valikoihin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 12:52:54 +02:00
parent 306dc6c5cc
commit 656b5042e4
5 changed files with 377 additions and 214 deletions

57
api.php
View File

@@ -3256,7 +3256,7 @@ switch ($action) {
$input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? '';
$type = $input['type'] ?? '';
$validTypes = ['laskutus', 'tekniikka', 'vika', 'muu'];
$validTypes = array_map(fn($t) => $t['value'], dbLoadTicketTypes($companyId));
if (!in_array($type, $validTypes)) {
http_response_code(400);
echo json_encode(['error' => 'Virheellinen tyyppi']);
@@ -3511,6 +3511,61 @@ switch ($action) {
echo json_encode(['success' => true]);
break;
case 'ticket_types':
requireAuth();
$companyId = requireCompany();
echo json_encode(dbLoadTicketTypes($companyId));
break;
case 'ticket_type_save':
requireAuth();
$companyId = requireCompany();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$value = preg_replace('/[^a-z0-9_-]/', '', strtolower(trim($input['value'] ?? '')));
$label = trim($input['label'] ?? '');
if (!$value || !$label) {
http_response_code(400);
echo json_encode(['error' => 'Tunnus ja nimi vaaditaan']);
break;
}
dbSaveTicketType($companyId, [
'id' => $input['id'] ?? null,
'value' => $value,
'label' => $label,
'color' => $input['color'] ?? '',
'sort_order' => intval($input['sort_order'] ?? 0),
]);
dbAddLog($companyId, currentUser(), 'config_update', '', '', 'Tikettityyppi: ' . $label);
echo json_encode(dbLoadTicketTypes($companyId));
break;
case 'ticket_type_delete':
requireAuth();
$companyId = requireCompany();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$value = $input['value'] ?? '';
if (!$value) {
http_response_code(400);
echo json_encode(['error' => 'Tyyppi puuttuu']);
break;
}
// Tarkista onko käytössä
$tickets = dbLoadTickets($companyId);
$inUse = 0;
foreach ($tickets as $t) {
if (($t['type'] ?? '') === $value) $inUse++;
}
if ($inUse > 0) {
http_response_code(400);
echo json_encode(['error' => "Tyyppiä käytetään {$inUse} tiketissä, ei voi poistaa"]);
break;
}
dbDeleteTicketType($companyId, $value);
echo json_encode(dbLoadTicketTypes($companyId));
break;
case 'ticket_priority':
requireAuth();
$companyId = requireCompanyOrParam();

58
db.php
View File

@@ -346,6 +346,18 @@ function initDatabase(): void {
INDEX idx_company (company_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"CREATE TABLE IF NOT EXISTS ticket_types (
id VARCHAR(20) PRIMARY KEY,
company_id VARCHAR(50) NOT NULL,
value VARCHAR(50) NOT NULL,
label VARCHAR(100) NOT NULL,
color VARCHAR(20) DEFAULT '',
sort_order INT DEFAULT 0,
UNIQUE KEY uk_company_value (company_id, value),
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 customer_priority_emails (
id INT AUTO_INCREMENT PRIMARY KEY,
company_id VARCHAR(50) NOT NULL,
@@ -1582,6 +1594,52 @@ function dbDeleteTicketRule(string $ruleId): void {
_dbExecute("DELETE FROM ticket_rules WHERE id = ?", [$ruleId]);
}
// ==================== TIKETTITYYPIT ====================
function dbLoadTicketTypes(string $companyId): array {
$types = _dbFetchAll("SELECT * FROM ticket_types WHERE company_id = ? ORDER BY sort_order, label", [$companyId]);
// Jos ei tyyppejä, luo oletukset
if (empty($types)) {
$defaults = [
['value' => 'laskutus', 'label' => 'Laskutus', 'sort_order' => 1],
['value' => 'tekniikka', 'label' => 'Tekniikka', 'sort_order' => 2],
['value' => 'vika', 'label' => 'Vika', 'sort_order' => 3],
['value' => 'abuse', 'label' => 'Abuse', 'sort_order' => 4],
['value' => 'muu', 'label' => 'Muu', 'sort_order' => 5],
];
foreach ($defaults as $d) {
dbSaveTicketType($companyId, $d);
}
$types = _dbFetchAll("SELECT * FROM ticket_types WHERE company_id = ? ORDER BY sort_order, label", [$companyId]);
}
foreach ($types as &$t) {
$t['sort_order'] = (int)($t['sort_order'] ?? 0);
unset($t['company_id']);
}
return $types;
}
function dbSaveTicketType(string $companyId, array $type): void {
$id = $type['id'] ?? generateId();
_dbExecute("
INSERT INTO ticket_types (id, company_id, value, label, color, sort_order)
VALUES (:id, :company_id, :value, :label, :color, :sort_order)
ON DUPLICATE KEY UPDATE
label = VALUES(label), color = VALUES(color), sort_order = VALUES(sort_order)
", [
'id' => $id,
'company_id' => $companyId,
'value' => $type['value'] ?? '',
'label' => $type['label'] ?? '',
'color' => $type['color'] ?? '',
'sort_order' => $type['sort_order'] ?? 0,
]);
}
function dbDeleteTicketType(string $companyId, string $value): void {
_dbExecute("DELETE FROM ticket_types WHERE company_id = ? AND value = ?", [$companyId, $value]);
}
// ==================== YRITYKSEN API-ASETUKSET ====================
function dbGetCompanyConfig(string $companyId): array {

View File

@@ -1066,6 +1066,9 @@
<div class="sub-tab-bar" id="support-sub-tab-bar">
<button class="sub-tab active" data-support-subtab="support-tickets">Tiketit</button>
<button class="sub-tab" data-support-subtab="support-ohjeet">Ohjeet</button>
<button class="sub-tab" data-support-subtab="support-saannot">Säännöt</button>
<button class="sub-tab" data-support-subtab="support-vastauspohjat">Vastauspohjat</button>
<button class="sub-tab" data-support-subtab="support-asetukset">Asetukset</button>
</div>
<div id="subtab-support-tickets" class="sub-tab-content active">
<div class="main-container">
@@ -1079,11 +1082,6 @@
</div>
<select id="ticket-type-filter" style="padding:9px 12px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.88rem;">
<option value="">Kaikki tyypit</option>
<option value="laskutus">Laskutus</option>
<option value="tekniikka">Tekniikka</option>
<option value="vika">Vika</option>
<option value="abuse">Abuse</option>
<option value="muu">Muu</option>
</select>
<select id="ticket-status-filter" style="padding:9px 12px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.88rem;">
<option value="">Kaikki avoimet</option>
@@ -1101,9 +1099,6 @@
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.85rem;color:#777;cursor:pointer;white-space:nowrap;">
<input type="checkbox" id="ticket-show-closed"> Suljetut
</label>
<button class="btn-secondary" id="btn-ticket-templates" style="padding:7px 14px;font-size:0.82rem;">&#128221; Vastauspohjat</button>
<button class="btn-secondary" id="btn-ticket-rules" style="padding:7px 14px;font-size:0.82rem;">&#9881; Säännöt</button>
<button class="btn-secondary" id="btn-ticket-settings" style="padding:7px 14px;font-size:0.82rem;">&#9881; Omat asetukset</button>
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;color:#777;cursor:pointer;white-space:nowrap;margin-left:auto;">
<input type="checkbox" id="ticket-auto-refresh" checked> Autopäivitys
<select id="ticket-refresh-interval" style="padding:3px 6px;border:1px solid #ddd;border-radius:5px;font-size:0.8rem;">
@@ -1193,9 +1188,12 @@
</div>
</div>
<!-- Sääntönäkymä -->
<div id="ticket-rules-view" style="display:none;">
<button class="btn-secondary" id="btn-rules-back" style="color:#555;border-color:#ddd;margin-bottom:1rem;">&#8592; Takaisin tiketteihin</button>
</div>
</div><!-- /subtab-support-tickets -->
<!-- Sub-tab: Säännöt -->
<div id="subtab-support-saannot" class="sub-tab-content">
<div class="main-container">
<div class="table-card" style="padding:1.5rem;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
<h3 style="color:#0f3460;margin:0;">Automaattisäännöt</h3>
@@ -1244,11 +1242,6 @@
<label>Aseta tyyppi</label>
<select id="rule-form-type">
<option value="">Ei muuteta</option>
<option value="laskutus">Laskutus</option>
<option value="tekniikka">Tekniikka</option>
<option value="vika">Vika</option>
<option value="abuse">Abuse</option>
<option value="muu">Muu</option>
</select>
</div>
<div class="form-group">
@@ -1275,11 +1268,23 @@
</div>
</div>
<!-- Tikettityyppien hallinta -->
<div class="table-card" style="padding:1.5rem;margin-top:1rem;">
<h4 style="color:#0f3460;margin-bottom:0.5rem;">Tikettityypit</h4>
<p style="color:#888;font-size:0.85rem;margin-bottom:1rem;">Hallitse yrityksen tikettityyppejä. Käytössä olevia tyyppejä ei voi poistaa.</p>
<div id="ticket-types-list"></div>
<div style="display:flex;gap:0.5rem;margin-top:0.75rem;align-items:center;">
<input type="text" id="new-ticket-type-value" placeholder="tunnus (esim. myynti)" style="max-width:160px;">
<input type="text" id="new-ticket-type-label" placeholder="Nimi (esim. Myynti)" style="max-width:160px;">
<button class="btn-primary" id="btn-add-ticket-type" style="font-size:0.85rem;">+ Lisää</button>
</div>
</div>
</div>
</div><!-- /subtab-support-saannot -->
<!-- Vastauspohjien hallinta -->
<div id="ticket-templates-view" style="display:none;">
<button class="btn-secondary" id="btn-templates-back" style="color:#555;border-color:#ddd;margin-bottom:1rem;">&#8592; Takaisin tiketteihin</button>
<!-- Sub-tab: Vastauspohjat -->
<div id="subtab-support-vastauspohjat" class="sub-tab-content">
<div class="main-container">
<div class="table-card" style="padding:1.5rem;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
<h3 style="color:#0f3460;margin:0;">Vastauspohjat</h3>
@@ -1308,14 +1313,11 @@
</div>
</div>
</div>
</div><!-- /subtab-support-vastauspohjat -->
<!-- Omat asetukset -näkymä -->
<div id="ticket-settings-view" style="display:none;">
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
<button class="btn-secondary" onclick="closeTicketSettings()">&#8592; Takaisin</button>
<h2 style="color:#0f3460;font-size:1.15rem;margin:0;">Omat asetukset</h2>
</div>
<!-- Sub-tab: Asetukset -->
<div id="subtab-support-asetukset" class="sub-tab-content">
<div class="main-container">
<div class="table-card" style="padding:1.5rem;margin-bottom:1.5rem;">
<h3 style="color:#0f3460;font-size:1rem;margin-bottom:0.75rem;">Sähköpostiallekirjoitukset</h3>
<p style="color:#888;font-size:0.82rem;margin-bottom:1rem;">Allekirjoitus liitetään automaattisesti sähköpostivastausten loppuun.</p>
@@ -1330,12 +1332,9 @@
<div style="display:flex;gap:0.75rem;">
<button class="btn-primary" id="btn-save-ticket-settings">Tallenna asetukset</button>
<button class="btn-secondary" onclick="closeTicketSettings()">Peruuta</button>
</div>
</div>
</div>
</div><!-- /subtab-support-tickets -->
</div><!-- /subtab-support-asetukset -->
<!-- Sub-tab: Ohjeet -->
<div id="subtab-support-ohjeet" class="sub-tab-content">

162
script.js
View File

@@ -301,11 +301,8 @@ function switchToTab(target, subTab) {
if (target === 'support') {
loadTickets(); showTicketListView();
if (document.getElementById('ticket-auto-refresh').checked) startTicketAutoRefresh();
if (subTab === 'ohjeet') {
switchSupportSubTab('support-ohjeet');
} else {
switchSupportSubTab('support-tickets');
}
const supportSubMap = { ohjeet: 'support-ohjeet', saannot: 'support-saannot', vastauspohjat: 'support-vastauspohjat', asetukset: 'support-asetukset' };
switchSupportSubTab(supportSubMap[subTab] || 'support-tickets');
}
if (target === 'documents') {
if (subTab && subTab !== 'kokoukset') {
@@ -1344,7 +1341,7 @@ const ticketStatusLabels = {
suljettu: 'Suljettu',
};
const ticketTypeLabels = {
let ticketTypeLabels = {
laskutus: 'Laskutus',
tekniikka: 'Tekniikka',
vika: 'Vika',
@@ -1520,10 +1517,9 @@ async function showTicketDetail(id, companyId = '') {
</div>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<select id="ticket-type-select" style="padding:6px 10px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.85rem;">
<option value="muu" ${(ticket.type || 'muu') === 'muu' ? 'selected' : ''}>Muu</option>
<option value="laskutus" ${ticket.type === 'laskutus' ? 'selected' : ''}>Laskutus</option>
<option value="tekniikka" ${ticket.type === 'tekniikka' ? 'selected' : ''}>Tekniikka</option>
<option value="vika" ${ticket.type === 'vika' ? 'selected' : ''}>Vika</option>
${Object.entries(ticketTypeLabels).map(([val, label]) =>
`<option value="${val}" ${(ticket.type || 'muu') === val ? 'selected' : ''}>${esc(label)}</option>`
).join('')}
</select>
<select id="ticket-status-select" style="padding:6px 10px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.85rem;">
<option value="uusi" ${ticket.status === 'uusi' ? 'selected' : ''}>Uusi</option>
@@ -2022,20 +2018,6 @@ function renderRules() {
}).join('');
}
function showRulesView() {
document.getElementById('ticket-list-view').style.display = 'none';
document.getElementById('ticket-detail-view').style.display = 'none';
document.getElementById('ticket-templates-view').style.display = 'none';
document.getElementById('ticket-settings-view').style.display = 'none';
document.getElementById('ticket-rules-view').style.display = 'block';
loadRules();
}
function hideRulesView() {
document.getElementById('ticket-rules-view').style.display = 'none';
document.getElementById('ticket-list-view').style.display = 'block';
}
function showRuleForm(rule) {
document.getElementById('rule-form-container').style.display = '';
document.getElementById('rule-form-title').textContent = rule ? 'Muokkaa sääntöä' : 'Uusi sääntö';
@@ -2057,8 +2039,6 @@ function hideRuleForm() {
editingRuleId = null;
}
document.getElementById('btn-ticket-rules').addEventListener('click', () => showRulesView());
document.getElementById('btn-rules-back').addEventListener('click', () => hideRulesView());
document.getElementById('btn-add-rule').addEventListener('click', () => showRuleForm(null));
document.getElementById('btn-cancel-rule').addEventListener('click', () => hideRuleForm());
@@ -2108,23 +2088,86 @@ async function toggleRule(id, enabled) {
} catch (e) { alert(e.message); }
}
// ==================== TIKETTITYYPIT ====================
async function loadTicketTypes() {
try {
const types = await apiCall('ticket_types');
ticketTypeLabels = {};
types.forEach(t => { ticketTypeLabels[t.value] = t.label; });
renderTicketTypes(types);
populateTypeDropdowns();
} catch (e) { console.error('loadTicketTypes:', e); }
}
function renderTicketTypes(types) {
const container = document.getElementById('ticket-types-list');
if (!container) return;
if (!types || types.length === 0) {
container.innerHTML = '<p style="color:#888;font-size:0.85rem;">Ei tikettityyppejä.</p>';
return;
}
container.innerHTML = types.map(t =>
`<div class="ticket-type-item">
<span class="ticket-type-badge ticket-type-${t.value}">${esc(t.label)}</span>
<span style="color:#888;font-size:0.8rem;">(${esc(t.value)})</span>
<button class="btn-danger" onclick="deleteTicketType('${esc(t.value)}')" style="padding:2px 8px;font-size:0.75rem;margin-left:auto;">Poista</button>
</div>`
).join('');
}
function populateTypeDropdowns() {
const options = Object.entries(ticketTypeLabels).map(
([val, label]) => `<option value="${val}">${esc(label)}</option>`
).join('');
// Tikettilistan suodatin
const filter = document.getElementById('ticket-type-filter');
if (filter) {
const current = filter.value;
filter.innerHTML = '<option value="">Kaikki tyypit</option>' + options;
filter.value = current;
}
// Sääntölomakkeen tyyppi
const ruleType = document.getElementById('rule-form-type');
if (ruleType) {
const current = ruleType.value;
ruleType.innerHTML = '<option value="">— Ei muuteta —</option>' + options;
ruleType.value = current;
}
// Tiketin detail-näkymän tyyppi-select
const detailType = document.getElementById('ticket-detail-type');
if (detailType) {
const current = detailType.value;
detailType.innerHTML = options;
detailType.value = current;
}
}
document.getElementById('btn-add-ticket-type')?.addEventListener('click', async () => {
const value = document.getElementById('new-ticket-type-value').value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '');
const label = document.getElementById('new-ticket-type-label').value.trim();
if (!value || !label) { alert('Täytä tunnus ja nimi'); return; }
try {
await apiCall('ticket_type_save', 'POST', { value, label });
document.getElementById('new-ticket-type-value').value = '';
document.getElementById('new-ticket-type-label').value = '';
await loadTicketTypes();
} catch (e) { alert(e.message); }
});
window.deleteTicketType = async function(value) {
if (!confirm(`Poistetaanko tikettityyppi "${value}"?`)) return;
try {
await apiCall('ticket_type_delete', 'POST', { value });
await loadTicketTypes();
} catch (e) { alert(e.message); }
};
// ==================== VASTAUSPOHJAT (TUKITABISSA) ====================
function showTemplatesView() {
document.getElementById('ticket-list-view').style.display = 'none';
document.getElementById('ticket-detail-view').style.display = 'none';
document.getElementById('ticket-rules-view').style.display = 'none';
document.getElementById('ticket-settings-view').style.display = 'none';
document.getElementById('ticket-templates-view').style.display = 'block';
hideTplForm();
renderTplList();
}
function hideTemplatesView() {
document.getElementById('ticket-templates-view').style.display = 'none';
document.getElementById('ticket-list-view').style.display = 'block';
}
function renderTplList() {
const list = document.getElementById('tpl-list');
if (!list) return;
@@ -2158,11 +2201,6 @@ function hideTplForm() {
document.getElementById('tpl-form-container').style.display = 'none';
}
document.getElementById('btn-ticket-templates').addEventListener('click', async () => {
await loadTemplates();
showTemplatesView();
});
document.getElementById('btn-templates-back').addEventListener('click', () => hideTemplatesView());
document.getElementById('btn-add-tpl').addEventListener('click', () => showTplForm(null));
document.getElementById('btn-cancel-tpl').addEventListener('click', () => hideTplForm());
@@ -2195,14 +2233,7 @@ window.deleteTpl = async function(id) {
// ==================== OMAT ASETUKSET (TIKETTIEN ASETUKSET) ====================
async function openTicketSettings() {
// Piilota muut näkymät, näytä asetukset
document.getElementById('ticket-list-view').style.display = 'none';
document.getElementById('ticket-detail-view').style.display = 'none';
document.getElementById('ticket-rules-view').style.display = 'none';
document.getElementById('ticket-templates-view').style.display = 'none';
document.getElementById('ticket-settings-view').style.display = 'block';
async function initTicketSettings() {
const sigContainer = document.getElementById('ticket-settings-signatures');
const visContainer = document.getElementById('ticket-settings-mailbox-visibility');
sigContainer.innerHTML = '<p style="color:#888;font-size:0.85rem;">Ladataan...</p>';
@@ -2239,13 +2270,6 @@ async function openTicketSettings() {
}
}
function closeTicketSettings() {
document.getElementById('ticket-settings-view').style.display = 'none';
document.getElementById('ticket-list-view').style.display = 'block';
}
document.getElementById('btn-ticket-settings').addEventListener('click', () => openTicketSettings());
document.getElementById('btn-save-ticket-settings').addEventListener('click', async () => {
// Kerää allekirjoitukset
const signatures = {};
@@ -2267,7 +2291,6 @@ document.getElementById('btn-save-ticket-settings').addEventListener('click', as
// Päivitä lokaalit muuttujat
currentUserSignatures = signatures;
currentHiddenMailboxes = hiddenMailboxes;
closeTicketSettings();
// Lataa tiketit uudelleen suodatuksen päivittämiseksi
loadTickets();
alert('Asetukset tallennettu!');
@@ -3052,8 +3075,19 @@ function switchSupportSubTab(target) {
if (btn) btn.classList.add('active');
const content = document.getElementById('subtab-' + target);
if (content) content.classList.add('active');
if (target === 'support-ohjeet') { loadGuides(); window.location.hash = 'support/ohjeet'; }
else { window.location.hash = 'support'; }
// Lataa data tarvittaessa
const hashMap = {
'support-tickets': 'support',
'support-ohjeet': 'support/ohjeet',
'support-saannot': 'support/saannot',
'support-vastauspohjat': 'support/vastauspohjat',
'support-asetukset': 'support/asetukset',
};
if (target === 'support-ohjeet') loadGuides();
if (target === 'support-saannot') { loadRules(); loadTicketTypes(); }
if (target === 'support-vastauspohjat') loadTemplates();
if (target === 'support-asetukset') initTicketSettings();
window.location.hash = hashMap[target] || 'support';
}
document.querySelectorAll('#support-sub-tab-bar .sub-tab').forEach(btn => {

View File

@@ -1355,6 +1355,23 @@ span.empty {
color: #757575;
}
.ticket-type-abuse {
background: #fff3e0;
color: #e65100;
}
.ticket-type-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px solid #f0f2f5;
}
.ticket-type-item:last-child {
border-bottom: none;
}
/* Active (käsittelyssä) ticket row — green tint */
.ticket-row-active {
background: #e8f8e8 !important;