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;