Add auto-rules management, bulk actions for tickets

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 10:18:10 +02:00
parent ea7c3e0cf7
commit 7fcf0a3b31
3 changed files with 377 additions and 0 deletions

128
api.php
View File

@@ -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']);

View File

@@ -264,12 +264,20 @@
<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-rules" style="padding:7px 14px;font-size:0.82rem;">&#9881; Säännöt</button>
</div>
<div id="ticket-fetch-status" style="display:none;padding:0.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:0.9rem;"></div>
<div id="bulk-actions-toolbar" style="display:none;align-items:center;gap:0.75rem;padding:0.6rem 1rem;background:#fff3cd;border:1px solid #ffc107;border-radius:8px;margin-bottom:0.75rem;font-size:0.85rem;">
<span id="bulk-count" style="font-weight:600;">0 valittu</span>
<button class="btn-secondary" onclick="bulkCloseSelected()" style="padding:5px 14px;font-size:0.82rem;color:#555;border-color:#ddd;">Sulje valitut</button>
<button class="btn-danger" onclick="bulkDeleteSelected()" style="padding:5px 14px;font-size:0.82rem;">Poista valitut</button>
<button style="background:none;border:none;cursor:pointer;font-size:0.82rem;color:#888;margin-left:auto;" onclick="bulkSelectedIds.clear();document.querySelectorAll('.ticket-checkbox,#bulk-select-all').forEach(c=>c.checked=false);updateBulkToolbar();">Tyhjennä valinta</button>
</div>
<div class="table-card">
<table id="tickets-table">
<thead>
<tr>
<th style="width:30px;"><input type="checkbox" id="bulk-select-all" title="Valitse kaikki"></th>
<th>Tila</th>
<th>Tyyppi</th>
<th>Aihe</th>
@@ -312,6 +320,67 @@
</div>
</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 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>
<button class="btn-primary" id="btn-add-rule" style="font-size:0.85rem;">+ Lisää sääntö</button>
</div>
<p style="color:#888;font-size:0.85rem;margin-bottom:1rem;">Säännöt soveltuvat automaattisesti uusiin tiketteihin haettaessa sähköposteja. Ensimmäinen täsmäävä sääntö voittaa.</p>
<div id="rules-list"></div>
</div>
<!-- Sääntölomake -->
<div id="rule-form-container" class="table-card" style="padding:1.5rem;margin-top:1rem;display:none;">
<h4 style="color:#0f3460;margin-bottom:1rem;" id="rule-form-title">Uusi sääntö</h4>
<input type="hidden" id="rule-form-id">
<div class="form-grid" style="max-width:600px;">
<div class="form-group full-width">
<label>Säännön nimi *</label>
<input type="text" id="rule-form-name" placeholder="esim. Sulje notifikaatiot">
</div>
<div class="form-group full-width" style="margin-top:0.5rem;">
<label style="font-weight:600;color:#0f3460;">Ehdot (molemmat pitää täsmätä jos täytetty)</label>
</div>
<div class="form-group">
<label>Lähettäjä sisältää</label>
<input type="text" id="rule-form-from" placeholder="esim. noreply@">
</div>
<div class="form-group">
<label>Otsikko sisältää</label>
<input type="text" id="rule-form-subject" placeholder="esim. saatavuuskysely">
</div>
<div class="form-group full-width" style="margin-top:0.5rem;">
<label style="font-weight:600;color:#0f3460;">Toimenpiteet</label>
</div>
<div class="form-group">
<label>Aseta tila</label>
<select id="rule-form-status">
<option value="">Ei muuteta</option>
<option value="suljettu">Suljettu</option>
<option value="kasittelyssa">Käsittelyssä</option>
<option value="ratkaistu">Ratkaistu</option>
</select>
</div>
<div class="form-group">
<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="muu">Muu</option>
</select>
</div>
</div>
<div style="display:flex;gap:0.5rem;margin-top:1rem;">
<button class="btn-primary" id="btn-save-rule">Tallenna</button>
<button class="btn-secondary" id="btn-cancel-rule">Peruuta</button>
</div>
</div>
</div>
</div>
</div>

180
script.js
View File

@@ -1016,7 +1016,9 @@ function renderTickets() {
const lastType = t.last_message_type === 'reply_out' ? '&#8594;' : (t.last_message_type === 'note' ? '&#128221;' : '&#8592;');
const typeLabel = ticketTypeLabels[t.type] || 'Muu';
const rowClass = t.status === 'kasittelyssa' ? 'ticket-row-active' : '';
const checked = bulkSelectedIds.has(t.id) ? 'checked' : '';
return `<tr data-ticket-id="${t.id}" class="${rowClass}">
<td onclick="event.stopPropagation()"><input type="checkbox" class="ticket-checkbox" data-ticket-id="${t.id}" ${checked}></td>
<td><span class="ticket-status ticket-status-${t.status}">${ticketStatusLabels[t.status] || t.status}</span></td>
<td><span class="ticket-type ticket-type-${t.type || 'muu'}">${typeLabel}</span></td>
<td><strong>${esc(t.subject)}</strong></td>
@@ -1026,6 +1028,14 @@ function renderTickets() {
<td class="nowrap">${esc((t.updated || '').substring(0, 16))}</td>
</tr>`;
}).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 = '<div style="text-align:center;padding:2rem;color:#aaa;">Ei sääntöjä vielä. Lisää ensimmäinen sääntö.</div>';
return;
}
list.innerHTML = ticketRules.map(r => {
const conditions = [];
if (r.from_contains) conditions.push('Lähettäjä: <strong>' + esc(r.from_contains) + '</strong>');
if (r.subject_contains) conditions.push('Otsikko: <strong>' + esc(r.subject_contains) + '</strong>');
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 `<div style="display:flex;justify-content:space-between;align-items:center;padding:0.75rem 1rem;background:${r.enabled ? '#f8f9fb' : '#fafafa'};border:1px solid #e8ebf0;border-radius:8px;margin-bottom:0.5rem;opacity:${r.enabled ? '1' : '0.5'};">
<div>
<div style="font-weight:600;color:#0f3460;font-size:0.9rem;">${esc(r.name)}</div>
<div style="font-size:0.8rem;color:#888;margin-top:2px;">
${conditions.length ? 'Ehdot: ' + conditions.join(', ') : '<em>Ei ehtoja</em>'}${actions.length ? actions.join(', ') : '<em>Ei toimenpiteitä</em>'}
</div>
</div>
<div style="display:flex;gap:0.4rem;align-items:center;flex-shrink:0;">
<label style="cursor:pointer;font-size:0.8rem;color:#888;display:flex;align-items:center;gap:3px;">
<input type="checkbox" ${r.enabled ? 'checked' : ''} onchange="toggleRule('${r.id}', this.checked)"> Päällä
</label>
<button onclick="editRule('${r.id}')" style="background:none;border:none;cursor:pointer;font-size:1rem;padding:4px;">&#9998;</button>
<button onclick="deleteRule('${r.id}')" style="background:none;border:none;cursor:pointer;font-size:1rem;padding:4px;color:#e74c3c;">&#128465;</button>
</div>
</div>`;
}).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() {