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:
66
api.php
66
api.php
@@ -1430,6 +1430,22 @@ switch ($action) {
|
|||||||
requireAuth();
|
requireAuth();
|
||||||
$tickets = loadTickets();
|
$tickets = loadTickets();
|
||||||
// Palauta ilman viestisisältöjä (lista-näkymä)
|
// 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) {
|
$list = array_map(function($t) {
|
||||||
$msgCount = count($t['messages'] ?? []);
|
$msgCount = count($t['messages'] ?? []);
|
||||||
$lastMsg = $msgCount > 0 ? $t['messages'][$msgCount - 1] : null;
|
$lastMsg = $msgCount > 0 ? $t['messages'][$msgCount - 1] : null;
|
||||||
@@ -1439,7 +1455,12 @@ switch ($action) {
|
|||||||
'from_email' => $t['from_email'],
|
'from_email' => $t['from_email'],
|
||||||
'from_name' => $t['from_name'],
|
'from_name' => $t['from_name'],
|
||||||
'status' => $t['status'],
|
'status' => $t['status'],
|
||||||
|
'type' => $t['type'] ?? 'muu',
|
||||||
'assigned_to' => $t['assigned_to'] ?? '',
|
'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'],
|
'created' => $t['created'],
|
||||||
'updated' => $t['updated'],
|
'updated' => $t['updated'],
|
||||||
'message_count' => $msgCount,
|
'message_count' => $msgCount,
|
||||||
@@ -1538,6 +1559,8 @@ switch ($action) {
|
|||||||
'assigned_to' => '',
|
'assigned_to' => '',
|
||||||
'customer_id' => '',
|
'customer_id' => '',
|
||||||
'customer_name' => '',
|
'customer_name' => '',
|
||||||
|
'tags' => [],
|
||||||
|
'auto_close_at' => '',
|
||||||
'created' => $email['date'],
|
'created' => $email['date'],
|
||||||
'updated' => $email['date'],
|
'updated' => $email['date'],
|
||||||
'message_id' => $email['message_id'],
|
'message_id' => $email['message_id'],
|
||||||
@@ -1564,6 +1587,16 @@ switch ($action) {
|
|||||||
if ($match) {
|
if ($match) {
|
||||||
if (!empty($rule['set_status'])) $ticket['status'] = $rule['set_status'];
|
if (!empty($rule['set_status'])) $ticket['status'] = $rule['set_status'];
|
||||||
if (!empty($rule['set_type'])) $ticket['type'] = $rule['set_type'];
|
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
|
break; // First matching rule wins
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1830,6 +1863,37 @@ switch ($action) {
|
|||||||
echo json_encode(['success' => true]);
|
echo json_encode(['success' => true]);
|
||||||
break;
|
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':
|
case 'ticket_rules':
|
||||||
requireAuth();
|
requireAuth();
|
||||||
$config = loadConfig();
|
$config = loadConfig();
|
||||||
@@ -1850,6 +1914,8 @@ switch ($action) {
|
|||||||
'subject_contains' => trim($input['subject_contains'] ?? ''),
|
'subject_contains' => trim($input['subject_contains'] ?? ''),
|
||||||
'set_status' => $input['set_status'] ?? '',
|
'set_status' => $input['set_status'] ?? '',
|
||||||
'set_type' => $input['set_type'] ?? '',
|
'set_type' => $input['set_type'] ?? '',
|
||||||
|
'set_tags' => trim($input['set_tags'] ?? ''),
|
||||||
|
'auto_close_days' => intval($input['auto_close_days'] ?? 0),
|
||||||
'enabled' => $input['enabled'] ?? true,
|
'enabled' => $input['enabled'] ?? true,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
10
index.html
10
index.html
@@ -261,6 +261,7 @@
|
|||||||
<option value="odottaa">Odottaa vastausta</option>
|
<option value="odottaa">Odottaa vastausta</option>
|
||||||
<option value="ratkaistu">Ratkaistu</option>
|
<option value="ratkaistu">Ratkaistu</option>
|
||||||
</select>
|
</select>
|
||||||
|
<input type="text" id="ticket-tag-filter" placeholder="#tagi" style="padding:9px 12px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.88rem;max-width:140px;">
|
||||||
<label style="display:flex;align-items:center;gap:0.4rem;font-size:0.85rem;color:#777;cursor:pointer;white-space:nowrap;">
|
<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
|
<input type="checkbox" id="ticket-show-closed"> Suljetut
|
||||||
</label>
|
</label>
|
||||||
@@ -292,6 +293,7 @@
|
|||||||
<th>Aihe</th>
|
<th>Aihe</th>
|
||||||
<th>Lähettäjä</th>
|
<th>Lähettäjä</th>
|
||||||
<th>Asiakas</th>
|
<th>Asiakas</th>
|
||||||
|
<th>Tagit</th>
|
||||||
<th>Viestejä</th>
|
<th>Viestejä</th>
|
||||||
<th>Päivitetty</th>
|
<th>Päivitetty</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -383,6 +385,14 @@
|
|||||||
<option value="muu">Muu</option>
|
<option value="muu">Muu</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Aseta tagit (pilkulla eroteltuna)</label>
|
||||||
|
<input type="text" id="rule-form-tags" placeholder="esim. notification, automaatti">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Auto-close (päivää)</label>
|
||||||
|
<input type="number" id="rule-form-autoclose" min="0" max="365" placeholder="esim. 7" style="max-width:120px;">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:0.5rem;margin-top:1rem;">
|
<div style="display:flex;gap:0.5rem;margin-top:1rem;">
|
||||||
<button class="btn-primary" id="btn-save-rule">Tallenna</button>
|
<button class="btn-primary" id="btn-save-rule">Tallenna</button>
|
||||||
|
|||||||
57
script.js
57
script.js
@@ -1003,11 +1003,18 @@ function renderTickets() {
|
|||||||
filtered = filtered.filter(t => (t.type || 'muu') === typeFilter);
|
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) {
|
if (query) {
|
||||||
filtered = filtered.filter(t =>
|
filtered = filtered.filter(t =>
|
||||||
(t.subject || '').toLowerCase().includes(query) ||
|
(t.subject || '').toLowerCase().includes(query) ||
|
||||||
(t.from_name || '').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><strong>${esc(t.subject)}</strong></td>
|
||||||
<td>${esc(t.from_name || t.from_email)}</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.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 style="text-align:center;">${lastType} ${t.message_count}</td>
|
||||||
<td class="nowrap">${esc((t.updated || '').substring(0, 16))}</td>
|
<td class="nowrap">${esc((t.updated || '').substring(0, 16))}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
@@ -1062,6 +1070,7 @@ function renderTickets() {
|
|||||||
document.getElementById('ticket-search-input').addEventListener('input', () => renderTickets());
|
document.getElementById('ticket-search-input').addEventListener('input', () => renderTickets());
|
||||||
document.getElementById('ticket-status-filter').addEventListener('change', () => renderTickets());
|
document.getElementById('ticket-status-filter').addEventListener('change', () => renderTickets());
|
||||||
document.getElementById('ticket-type-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('ticket-show-closed').addEventListener('change', () => renderTickets());
|
||||||
document.getElementById('bulk-select-all').addEventListener('change', function() {
|
document.getElementById('bulk-select-all').addEventListener('change', function() {
|
||||||
const checkboxes = document.querySelectorAll('.ticket-checkbox');
|
const checkboxes = document.querySelectorAll('.ticket-checkbox');
|
||||||
@@ -1114,6 +1123,16 @@ async function showTicketDetail(id) {
|
|||||||
</select>
|
</select>
|
||||||
<button class="btn-danger" id="btn-ticket-delete" style="padding:6px 12px;font-size:0.82rem;">Poista</button>
|
<button class="btn-danger" id="btn-ticket-delete" style="padding:6px 12px;font-size:0.82rem;">Poista</button>
|
||||||
</div>
|
</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">×</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;">⏰ Auto-close: ' + esc(ticket.auto_close_at.substring(0, 10)) + '</span>' : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// Load users for assignment dropdown
|
// Load users for assignment dropdown
|
||||||
@@ -1180,6 +1199,36 @@ async function showTicketDetail(id) {
|
|||||||
} catch (e) { alert(e.message); }
|
} 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
|
// Thread messages
|
||||||
const thread = document.getElementById('ticket-thread');
|
const thread = document.getElementById('ticket-thread');
|
||||||
thread.innerHTML = (ticket.messages || []).map(m => {
|
thread.innerHTML = (ticket.messages || []).map(m => {
|
||||||
@@ -1362,6 +1411,8 @@ function renderRules() {
|
|||||||
const actions = [];
|
const actions = [];
|
||||||
if (r.set_status) actions.push('Tila → ' + (ticketStatusLabels[r.set_status] || r.set_status));
|
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_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'};">
|
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>
|
||||||
<div style="font-weight:600;color:#0f3460;font-size:0.9rem;">${esc(r.name)}</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-subject').value = rule ? rule.subject_contains : '';
|
||||||
document.getElementById('rule-form-status').value = rule ? (rule.set_status || '') : '';
|
document.getElementById('rule-form-status').value = rule ? (rule.set_status || '') : '';
|
||||||
document.getElementById('rule-form-type').value = rule ? (rule.set_type || '') : '';
|
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;
|
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(),
|
subject_contains: document.getElementById('rule-form-subject').value.trim(),
|
||||||
set_status: document.getElementById('rule-form-status').value,
|
set_status: document.getElementById('rule-form-status').value,
|
||||||
set_type: document.getElementById('rule-form-type').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,
|
enabled: true,
|
||||||
};
|
};
|
||||||
const existingId = document.getElementById('rule-form-id').value;
|
const existingId = document.getElementById('rule-form-id').value;
|
||||||
|
|||||||
38
style.css
38
style.css
@@ -1274,6 +1274,44 @@ span.empty {
|
|||||||
border-color: #0f3460;
|
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 */
|
/* Changelog */
|
||||||
.nowrap {
|
.nowrap {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
Reference in New Issue
Block a user