diff --git a/api.php b/api.php index 9b96962..3a842bc 100644 --- a/api.php +++ b/api.php @@ -1430,6 +1430,22 @@ switch ($action) { requireAuth(); $tickets = loadTickets(); // Palauta ilman viestisisältöjä (lista-näkymä) + // Auto-close tarkistus: sulje tiketit joiden auto_close_at on ohitettu + $now = date('Y-m-d H:i:s'); + $autoCloseCount = 0; + foreach ($tickets as &$tc) { + if (!empty($tc['auto_close_at']) && $tc['auto_close_at'] <= $now && !in_array($tc['status'], ['suljettu'])) { + $tc['status'] = 'suljettu'; + $tc['updated'] = $now; + $autoCloseCount++; + } + } + unset($tc); + if ($autoCloseCount > 0) { + saveTickets($tickets); + addLog('ticket_auto_close', '', '', "Automaattisulku: $autoCloseCount tikettiä"); + } + $list = array_map(function($t) { $msgCount = count($t['messages'] ?? []); $lastMsg = $msgCount > 0 ? $t['messages'][$msgCount - 1] : null; @@ -1439,7 +1455,12 @@ switch ($action) { 'from_email' => $t['from_email'], 'from_name' => $t['from_name'], 'status' => $t['status'], + 'type' => $t['type'] ?? 'muu', 'assigned_to' => $t['assigned_to'] ?? '', + 'customer_id' => $t['customer_id'] ?? '', + 'customer_name' => $t['customer_name'] ?? '', + 'tags' => $t['tags'] ?? [], + 'auto_close_at' => $t['auto_close_at'] ?? '', 'created' => $t['created'], 'updated' => $t['updated'], 'message_count' => $msgCount, @@ -1538,6 +1559,8 @@ switch ($action) { 'assigned_to' => '', 'customer_id' => '', 'customer_name' => '', + 'tags' => [], + 'auto_close_at' => '', 'created' => $email['date'], 'updated' => $email['date'], 'message_id' => $email['message_id'], @@ -1564,6 +1587,16 @@ switch ($action) { if ($match) { if (!empty($rule['set_status'])) $ticket['status'] = $rule['set_status']; if (!empty($rule['set_type'])) $ticket['type'] = $rule['set_type']; + if (!empty($rule['set_tags'])) { + $ruleTags = array_map('trim', explode(',', $rule['set_tags'])); + $ticket['tags'] = array_values(array_unique(array_merge($ticket['tags'], $ruleTags))); + } + if (!empty($rule['auto_close_days'])) { + $days = intval($rule['auto_close_days']); + if ($days > 0) { + $ticket['auto_close_at'] = date('Y-m-d H:i:s', strtotime("+{$days} days")); + } + } break; // First matching rule wins } } @@ -1830,6 +1863,37 @@ switch ($action) { echo json_encode(['success' => true]); break; + case 'ticket_tags': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? ''; + $tags = $input['tags'] ?? []; + // Sanitize tags: trim, lowercase, remove empty + $tags = array_values(array_filter(array_map(function($t) { + return trim(strtolower($t)); + }, $tags))); + $tickets = loadTickets(); + $found = false; + foreach ($tickets as &$t) { + if ($t['id'] === $id) { + $t['tags'] = $tags; + $t['updated'] = date('Y-m-d H:i:s'); + $found = true; + addLog('ticket_tags', $t['id'], $t['subject'], 'Tagit: ' . implode(', ', $tags)); + echo json_encode($t); + break; + } + } + unset($t); + if (!$found) { + http_response_code(404); + echo json_encode(['error' => 'Tikettiä ei löydy']); + break; + } + saveTickets($tickets); + break; + case 'ticket_rules': requireAuth(); $config = loadConfig(); @@ -1850,6 +1914,8 @@ switch ($action) { 'subject_contains' => trim($input['subject_contains'] ?? ''), 'set_status' => $input['set_status'] ?? '', 'set_type' => $input['set_type'] ?? '', + 'set_tags' => trim($input['set_tags'] ?? ''), + 'auto_close_days' => intval($input['auto_close_days'] ?? 0), 'enabled' => $input['enabled'] ?? true, ]; diff --git a/index.html b/index.html index 6626730..183a253 100644 --- a/index.html +++ b/index.html @@ -261,6 +261,7 @@ + @@ -292,6 +293,7 @@ Aihe Lähettäjä Asiakas + Tagit Viestejä Päivitetty @@ -383,6 +385,14 @@ +
+ + +
+
+ + +
diff --git a/script.js b/script.js index e7a5921..de0ad93 100644 --- a/script.js +++ b/script.js @@ -1003,11 +1003,18 @@ function renderTickets() { filtered = filtered.filter(t => (t.type || 'muu') === typeFilter); } + // Tag filter + const tagFilter = (document.getElementById('ticket-tag-filter').value || '').trim().toLowerCase().replace(/^#/, ''); + if (tagFilter) { + filtered = filtered.filter(t => (t.tags || []).some(tag => tag.toLowerCase().includes(tagFilter))); + } + if (query) { filtered = filtered.filter(t => (t.subject || '').toLowerCase().includes(query) || (t.from_name || '').toLowerCase().includes(query) || - (t.from_email || '').toLowerCase().includes(query) + (t.from_email || '').toLowerCase().includes(query) || + (t.tags || []).some(tag => tag.toLowerCase().includes(query)) ); } @@ -1032,6 +1039,7 @@ function renderTickets() { ${esc(t.subject)} ${esc(t.from_name || t.from_email)} ${t.customer_name ? esc(t.customer_name) : '-'} + ${(t.tags || []).length > 0 ? (t.tags || []).map(tag => '#' + esc(tag) + '').join(' ') : '-'} ${lastType} ${t.message_count} ${esc((t.updated || '').substring(0, 16))} `; @@ -1062,6 +1070,7 @@ function renderTickets() { document.getElementById('ticket-search-input').addEventListener('input', () => renderTickets()); document.getElementById('ticket-status-filter').addEventListener('change', () => renderTickets()); document.getElementById('ticket-type-filter').addEventListener('change', () => renderTickets()); +document.getElementById('ticket-tag-filter').addEventListener('input', () => renderTickets()); document.getElementById('ticket-show-closed').addEventListener('change', () => renderTickets()); document.getElementById('bulk-select-all').addEventListener('change', function() { const checkboxes = document.querySelectorAll('.ticket-checkbox'); @@ -1114,6 +1123,16 @@ async function showTicketDetail(id) {
+ +
+ Tagit: +
+ ${(ticket.tags || []).map(tag => '#' + esc(tag) + ' ').join('')} +
+
+ +
+ ${ticket.auto_close_at ? '⏰ Auto-close: ' + esc(ticket.auto_close_at.substring(0, 10)) + '' : ''}
`; // Load users for assignment dropdown @@ -1180,6 +1199,36 @@ async function showTicketDetail(id) { } catch (e) { alert(e.message); } }); + // Tags: add new tag on Enter + document.getElementById('ticket-tag-input').addEventListener('keydown', async (e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + const input = e.target; + const newTag = input.value.trim().toLowerCase().replace(/^#/, ''); + if (!newTag) return; + const currentTags = (ticket.tags || []).slice(); + if (!currentTags.includes(newTag)) currentTags.push(newTag); + input.value = ''; + try { + await apiCall('ticket_tags', 'POST', { id: currentTicketId, tags: currentTags }); + await showTicketDetail(currentTicketId); + } catch (e2) { alert(e2.message); } + }); + + // Tags: remove tag + document.querySelectorAll('.ticket-tag-remove').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const tagEl = btn.closest('.ticket-tag-editable'); + const tagToRemove = tagEl.dataset.tag; + const currentTags = (ticket.tags || []).filter(t => t !== tagToRemove); + try { + await apiCall('ticket_tags', 'POST', { id: currentTicketId, tags: currentTags }); + await showTicketDetail(currentTicketId); + } catch (e2) { alert(e2.message); } + }); + }); + // Thread messages const thread = document.getElementById('ticket-thread'); thread.innerHTML = (ticket.messages || []).map(m => { @@ -1362,6 +1411,8 @@ function renderRules() { 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)); + if (r.set_tags) actions.push('Tagit: #' + r.set_tags.split(',').map(t => t.trim()).join(' #')); + if (r.auto_close_days) actions.push('Auto-close: ' + r.auto_close_days + 'pv'); return `
${esc(r.name)}
@@ -1401,6 +1452,8 @@ function showRuleForm(rule) { 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 || '') : ''; + document.getElementById('rule-form-tags').value = rule ? (rule.set_tags || '') : ''; + document.getElementById('rule-form-autoclose').value = rule ? (rule.auto_close_days || '') : ''; editingRuleId = rule ? rule.id : null; } @@ -1423,6 +1476,8 @@ document.getElementById('btn-save-rule').addEventListener('click', async () => { 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, + set_tags: document.getElementById('rule-form-tags').value.trim(), + auto_close_days: parseInt(document.getElementById('rule-form-autoclose').value) || 0, enabled: true, }; const existingId = document.getElementById('rule-form-id').value; diff --git a/style.css b/style.css index 64a83f8..e1bcd44 100644 --- a/style.css +++ b/style.css @@ -1274,6 +1274,44 @@ span.empty { border-color: #0f3460; } +/* Ticket tags */ +.ticket-tag { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 0.72rem; + font-weight: 600; + background: #e8ebf0; + color: #555; + white-space: nowrap; + letter-spacing: 0.2px; +} + +.ticket-tag-editable { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 3px 6px 3px 8px; + cursor: default; +} + +.ticket-tag-remove { + background: none; + border: none; + cursor: pointer; + font-size: 0.85rem; + color: #999; + padding: 0 2px; + line-height: 1; + border-radius: 3px; + transition: color 0.15s, background 0.15s; +} + +.ticket-tag-remove:hover { + color: #e74c3c; + background: rgba(231, 76, 60, 0.1); +} + /* Changelog */ .nowrap { white-space: nowrap;