From 7fcf0a3b31510706505d41602d4714539141a89d Mon Sep 17 00:00:00 2001 From: Jukka Lampikoski Date: Tue, 10 Mar 2026 10:18:10 +0200 Subject: [PATCH] Add auto-rules management, bulk actions for tickets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auto-rules JS: load, render, save, edit, delete, toggle rules - Rules UI: Säännöt view with rule list and form - Bulk actions: checkbox selection in ticket list - Bulk close/delete endpoints (ticket_bulk_status, ticket_bulk_delete) - Bulk toolbar with select all, close selected, delete selected Co-Authored-By: Claude Opus 4.6 --- api.php | 128 +++++++++++++++++++++++++++++++++++++ index.html | 69 ++++++++++++++++++++ script.js | 180 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 377 insertions(+) diff --git a/api.php b/api.php index 85996e7..9b96962 100644 --- a/api.php +++ b/api.php @@ -1534,12 +1534,40 @@ switch ($action) { 'from_email' => $email['from_email'], 'from_name' => $email['from_name'], 'status' => 'uusi', + 'type' => 'muu', 'assigned_to' => '', + 'customer_id' => '', + 'customer_name' => '', 'created' => $email['date'], 'updated' => $email['date'], 'message_id' => $email['message_id'], 'messages' => [$msg], ]; + + // Apply auto-rules + $rules = $config['ticket_rules'] ?? []; + foreach ($rules as $rule) { + if (empty($rule['enabled'])) continue; + $match = true; + if (!empty($rule['from_contains'])) { + $needle = strtolower($rule['from_contains']); + if (strpos(strtolower($email['from_email'] . ' ' . $email['from_name']), $needle) === false) { + $match = false; + } + } + if (!empty($rule['subject_contains'])) { + $needle = strtolower($rule['subject_contains']); + if (strpos(strtolower($email['subject'] ?? ''), $needle) === false) { + $match = false; + } + } + if ($match) { + if (!empty($rule['set_status'])) $ticket['status'] = $rule['set_status']; + if (!empty($rule['set_type'])) $ticket['type'] = $rule['set_type']; + break; // First matching rule wins + } + } + $tickets[] = $ticket; $newCount++; } @@ -1802,6 +1830,106 @@ switch ($action) { echo json_encode(['success' => true]); break; + case 'ticket_rules': + requireAuth(); + $config = loadConfig(); + echo json_encode($config['ticket_rules'] ?? []); + break; + + case 'ticket_rule_save': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $config = loadConfig(); + $rules = $config['ticket_rules'] ?? []; + + $rule = [ + 'id' => $input['id'] ?? generateId(), + 'name' => trim($input['name'] ?? ''), + 'from_contains' => trim($input['from_contains'] ?? ''), + 'subject_contains' => trim($input['subject_contains'] ?? ''), + 'set_status' => $input['set_status'] ?? '', + 'set_type' => $input['set_type'] ?? '', + 'enabled' => $input['enabled'] ?? true, + ]; + + if (empty($rule['name'])) { + http_response_code(400); + echo json_encode(['error' => 'Säännön nimi puuttuu']); + break; + } + + // Update existing or add new + $found = false; + foreach ($rules as &$r) { + if ($r['id'] === $rule['id']) { + $r = $rule; + $found = true; + break; + } + } + unset($r); + if (!$found) $rules[] = $rule; + + $config['ticket_rules'] = $rules; + file_put_contents(DATA_DIR . '/config.json', json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + addLog('config_update', '', '', 'Tikettisääntö: ' . $rule['name']); + echo json_encode($rule); + break; + + case 'ticket_bulk_status': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $ids = $input['ids'] ?? []; + $newStatus = $input['status'] ?? ''; + $validStatuses = ['uusi','kasittelyssa','odottaa','ratkaistu','suljettu']; + if (!in_array($newStatus, $validStatuses)) { + http_response_code(400); + echo json_encode(['error' => 'Virheellinen tila']); + break; + } + $tickets = json_decode(file_get_contents(TICKETS_FILE), true) ?: []; + $changed = 0; + foreach ($tickets as &$t) { + if (in_array($t['id'], $ids)) { + $t['status'] = $newStatus; + $t['updated'] = date('Y-m-d H:i:s'); + $changed++; + } + } + unset($t); + file_put_contents(TICKETS_FILE, json_encode($tickets, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + addLog('ticket_status', '', '', "Massapäivitys: $changed tikettiä → $newStatus"); + echo json_encode(['success' => true, 'changed' => $changed]); + break; + + case 'ticket_bulk_delete': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $ids = $input['ids'] ?? []; + $tickets = json_decode(file_get_contents(TICKETS_FILE), true) ?: []; + $before = count($tickets); + $tickets = array_values(array_filter($tickets, fn($t) => !in_array($t['id'], $ids))); + $deleted = $before - count($tickets); + file_put_contents(TICKETS_FILE, json_encode($tickets, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + addLog('ticket_delete', '', '', "Massapoisto: $deleted tikettiä"); + echo json_encode(['success' => true, 'deleted' => $deleted]); + break; + + case 'ticket_rule_delete': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $ruleId = $input['id'] ?? ''; + $config = loadConfig(); + $rules = $config['ticket_rules'] ?? []; + $config['ticket_rules'] = array_values(array_filter($rules, fn($r) => $r['id'] !== $ruleId)); + file_put_contents(DATA_DIR . '/config.json', json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + echo json_encode(['success' => true]); + break; + default: http_response_code(404); echo json_encode(['error' => 'Tuntematon toiminto']); diff --git a/index.html b/index.html index 4160ab8..ba2a939 100644 --- a/index.html +++ b/index.html @@ -264,12 +264,20 @@ + +
+ @@ -312,6 +320,67 @@ + + + diff --git a/script.js b/script.js index 68bc6e8..6630667 100644 --- a/script.js +++ b/script.js @@ -1016,7 +1016,9 @@ function renderTickets() { const lastType = t.last_message_type === 'reply_out' ? '→' : (t.last_message_type === 'note' ? '📝' : '←'); const typeLabel = ticketTypeLabels[t.type] || 'Muu'; const rowClass = t.status === 'kasittelyssa' ? 'ticket-row-active' : ''; + const checked = bulkSelectedIds.has(t.id) ? 'checked' : ''; return ` + @@ -1026,6 +1028,14 @@ function renderTickets() { `; }).join(''); + // Re-attach checkbox listeners + document.querySelectorAll('.ticket-checkbox').forEach(cb => { + cb.addEventListener('change', function() { + if (this.checked) bulkSelectedIds.add(this.dataset.ticketId); + else bulkSelectedIds.delete(this.dataset.ticketId); + updateBulkToolbar(); + }); + }); } const openCount = tickets.filter(t => t.status !== 'suljettu').length; @@ -1045,6 +1055,15 @@ document.getElementById('ticket-search-input').addEventListener('input', () => r document.getElementById('ticket-status-filter').addEventListener('change', () => renderTickets()); document.getElementById('ticket-type-filter').addEventListener('change', () => renderTickets()); document.getElementById('ticket-show-closed').addEventListener('change', () => renderTickets()); +document.getElementById('bulk-select-all').addEventListener('change', function() { + const checkboxes = document.querySelectorAll('.ticket-checkbox'); + checkboxes.forEach(cb => { + cb.checked = this.checked; + if (this.checked) bulkSelectedIds.add(cb.dataset.ticketId); + else bulkSelectedIds.delete(cb.dataset.ticketId); + }); + updateBulkToolbar(); +}); document.getElementById('tickets-tbody').addEventListener('click', (e) => { const row = e.target.closest('tr'); @@ -1187,8 +1206,14 @@ async function showTicketDetail(id) { function showTicketListView() { document.getElementById('ticket-detail-view').style.display = 'none'; + document.getElementById('ticket-rules-view').style.display = 'none'; document.getElementById('ticket-list-view').style.display = 'block'; currentTicketId = null; + // Reset bulk selection + bulkSelectedIds.clear(); + const selectAll = document.getElementById('bulk-select-all'); + if (selectAll) selectAll.checked = false; + updateBulkToolbar(); } document.getElementById('btn-ticket-back').addEventListener('click', () => { @@ -1266,6 +1291,161 @@ document.getElementById('btn-fetch-emails').addEventListener('click', async () = } }); +// ==================== TICKET RULES (AUTOMAATTISÄÄNNÖT) ==================== + +let ticketRules = []; +let editingRuleId = null; + +async function loadRules() { + try { + ticketRules = await apiCall('ticket_rules'); + renderRules(); + } catch (e) { console.error(e); } +} + +function renderRules() { + const list = document.getElementById('rules-list'); + if (ticketRules.length === 0) { + list.innerHTML = '
Ei sääntöjä vielä. Lisää ensimmäinen sääntö.
'; + return; + } + list.innerHTML = ticketRules.map(r => { + const conditions = []; + if (r.from_contains) conditions.push('Lähettäjä: ' + esc(r.from_contains) + ''); + if (r.subject_contains) conditions.push('Otsikko: ' + esc(r.subject_contains) + ''); + const actions = []; + if (r.set_status) actions.push('Tila → ' + (ticketStatusLabels[r.set_status] || r.set_status)); + if (r.set_type) actions.push('Tyyppi → ' + (ticketTypeLabels[r.set_type] || r.set_type)); + return `
+
+
${esc(r.name)}
+
+ ${conditions.length ? 'Ehdot: ' + conditions.join(', ') : 'Ei ehtoja'} → ${actions.length ? actions.join(', ') : 'Ei toimenpiteitä'} +
+
+
+ + + +
+
`; + }).join(''); +} + +function showRulesView() { + document.getElementById('ticket-list-view').style.display = 'none'; + document.getElementById('ticket-detail-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ö'; + document.getElementById('rule-form-id').value = rule ? rule.id : ''; + document.getElementById('rule-form-name').value = rule ? rule.name : ''; + document.getElementById('rule-form-from').value = rule ? rule.from_contains : ''; + document.getElementById('rule-form-subject').value = rule ? rule.subject_contains : ''; + document.getElementById('rule-form-status').value = rule ? (rule.set_status || '') : ''; + document.getElementById('rule-form-type').value = rule ? (rule.set_type || '') : ''; + editingRuleId = rule ? rule.id : null; +} + +function hideRuleForm() { + document.getElementById('rule-form-container').style.display = 'none'; + 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()); + +document.getElementById('btn-save-rule').addEventListener('click', async () => { + const name = document.getElementById('rule-form-name').value.trim(); + if (!name) { alert('Nimi puuttuu'); return; } + const data = { + name, + from_contains: document.getElementById('rule-form-from').value.trim(), + subject_contains: document.getElementById('rule-form-subject').value.trim(), + set_status: document.getElementById('rule-form-status').value, + set_type: document.getElementById('rule-form-type').value, + enabled: true, + }; + const existingId = document.getElementById('rule-form-id').value; + if (existingId) data.id = existingId; + try { + await apiCall('ticket_rule_save', 'POST', data); + hideRuleForm(); + await loadRules(); + } catch (e) { alert(e.message); } +}); + +async function editRule(id) { + const rule = ticketRules.find(r => r.id === id); + if (rule) showRuleForm(rule); +} + +async function deleteRule(id) { + if (!confirm('Poistetaanko sääntö?')) return; + try { + await apiCall('ticket_rule_delete', 'POST', { id }); + await loadRules(); + } catch (e) { alert(e.message); } +} + +async function toggleRule(id, enabled) { + const rule = ticketRules.find(r => r.id === id); + if (!rule) return; + try { + await apiCall('ticket_rule_save', 'POST', { ...rule, enabled }); + await loadRules(); + } catch (e) { alert(e.message); } +} + +// ==================== BULK ACTIONS ==================== + +let bulkSelectedIds = new Set(); + +function updateBulkToolbar() { + const toolbar = document.getElementById('bulk-actions-toolbar'); + if (bulkSelectedIds.size > 0) { + toolbar.style.display = 'flex'; + document.getElementById('bulk-count').textContent = bulkSelectedIds.size + ' valittu'; + } else { + toolbar.style.display = 'none'; + } +} + +async function bulkCloseSelected() { + if (bulkSelectedIds.size === 0) return; + if (!confirm(`Suljetaanko ${bulkSelectedIds.size} tikettiä?`)) return; + try { + await apiCall('ticket_bulk_status', 'POST', { ids: [...bulkSelectedIds], status: 'suljettu' }); + bulkSelectedIds.clear(); + updateBulkToolbar(); + await loadTickets(); + } catch (e) { alert(e.message); } +} + +async function bulkDeleteSelected() { + if (bulkSelectedIds.size === 0) return; + if (!confirm(`Poistetaanko ${bulkSelectedIds.size} tikettiä pysyvästi?`)) return; + try { + await apiCall('ticket_bulk_delete', 'POST', { ids: [...bulkSelectedIds] }); + bulkSelectedIds.clear(); + updateBulkToolbar(); + await loadTickets(); + } catch (e) { alert(e.message); } +} + // ==================== SETTINGS ==================== async function loadSettings() {
Tila Tyyppi Aihe
${ticketStatusLabels[t.status] || t.status} ${typeLabel} ${esc(t.subject)}${esc((t.updated || '').substring(0, 16))}