Add ticket tags system, tag filtering, and auto-close feature

- Fix tickets API endpoint: add type, customer_name, customer_id, tags fields
- Add tags array to ticket data structure with add/remove UI
- Add tag filter input to toolbar and tag column in ticket list
- Add ticket_tags API endpoint for updating tags
- Add set_tags and auto_close_days actions to auto-rules
- Auto-close check runs on ticket list load, closes expired tickets
- Add tag CSS styles with editable tag badges in detail view

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 10:32:09 +02:00
parent f918952c3f
commit 562153e040
4 changed files with 170 additions and 1 deletions

View File

@@ -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() {
<td><strong>${esc(t.subject)}</strong></td>
<td>${esc(t.from_name || t.from_email)}</td>
<td>${t.customer_name ? esc(t.customer_name) : '<span style="color:#ccc;">-</span>'}</td>
<td>${(t.tags || []).length > 0 ? (t.tags || []).map(tag => '<span class="ticket-tag">#' + esc(tag) + '</span>').join(' ') : '<span style="color:#ccc;">-</span>'}</td>
<td style="text-align:center;">${lastType} ${t.message_count}</td>
<td class="nowrap">${esc((t.updated || '').substring(0, 16))}</td>
</tr>`;
@@ -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) {
</select>
<button class="btn-danger" id="btn-ticket-delete" style="padding:6px 12px;font-size:0.82rem;">Poista</button>
</div>
</div>
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;margin-top:0.5rem;">
<span style="font-size:0.82rem;color:#888;font-weight:600;">Tagit:</span>
<div id="ticket-tags-container" style="display:flex;gap:0.35rem;flex-wrap:wrap;align-items:center;">
${(ticket.tags || []).map(tag => '<span class="ticket-tag ticket-tag-editable" data-tag="' + esc(tag) + '">#' + esc(tag) + ' <button class="ticket-tag-remove" title="Poista">&times;</button></span>').join('')}
</div>
<div style="display:flex;gap:0.3rem;align-items:center;">
<input type="text" id="ticket-tag-input" placeholder="+ Lisää tagi" style="padding:4px 8px;border:1px solid #ddd;border-radius:6px;font-size:0.82rem;width:120px;">
</div>
${ticket.auto_close_at ? '<span style="font-size:0.78rem;color:#e67e22;margin-left:0.5rem;">&#9200; Auto-close: ' + esc(ticket.auto_close_at.substring(0, 10)) + '</span>' : ''}
</div>`;
// 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 `<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>
@@ -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;