feat: ticket reply improvements + priority + templates + Telegram

Reply form:
- Mailbox/sender selection dropdown (choose which email to reply from)
- CC field (auto-filled from incoming email CC, editable)
- Reply templates dropdown (quick insert pre-made responses)

Priority system:
- Three levels: normaali, tärkeä, urgent
- Priority dropdown in ticket detail view
- Priority-based sorting (urgent/tärkeä always on top)
- Visual indicators in ticket list (colored rows, emoji badges)
- Priority emails: per-company email list that auto-sets "tärkeä"

Response templates:
- CRUD management in Settings tab
- Dropdown selector in reply form
- Templates insert into textarea

Telegram alerts:
- Bot token + chat ID configuration in Settings
- Test button to verify connection
- Auto-alert on urgent tickets (both manual and from email fetch)
- Alert on priority email matches

Database changes:
- New tables: reply_templates, customer_priority_emails
- New columns: tickets.cc, tickets.priority
- ALTER TABLE migration in initDatabase()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 17:42:05 +02:00
parent 3b7def1186
commit 8485da8cbf
5 changed files with 591 additions and 23 deletions

224
script.js
View File

@@ -1128,10 +1128,16 @@ function renderTickets() {
);
}
// Sorttaus: tila-prioriteetti + päivämäärä
// Sorttaus: prioriteetti → tila → päivämäärä
const ticketSortField = document.getElementById('ticket-sort')?.value || 'status';
const statusPriority = { kasittelyssa: 0, uusi: 1, odottaa: 2, suljettu: 3 };
const statusPriority = { kasittelyssa: 0, uusi: 1, odottaa: 2, ratkaistu: 3, suljettu: 4 };
const priorityOrder = { urgent: 0, 'tärkeä': 1, normaali: 2 };
filtered.sort((a, b) => {
// Urgent/tärkeä aina ensin
const prioA = priorityOrder[a.priority || 'normaali'] ?? 2;
const prioB = priorityOrder[b.priority || 'normaali'] ?? 2;
if (prioA !== prioB) return prioA - prioB;
if (ticketSortField === 'status') {
const pa = statusPriority[a.status] ?? 9;
const pb = statusPriority[b.status] ?? 9;
@@ -1158,14 +1164,15 @@ function renderTickets() {
ttbody.innerHTML = filtered.map(t => {
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 rowClass = t.status === 'kasittelyssa' ? 'ticket-row-active' : (t.priority === 'urgent' ? 'ticket-row-urgent' : (t.priority === 'tärkeä' ? 'ticket-row-important' : ''));
const checked = bulkSelectedIds.has(t.id) ? 'checked' : '';
const companyBadge = multiCompany && t.company_name ? `<span class="company-badge">${esc(t.company_name)}</span> ` : '';
const prioBadge = t.priority === 'urgent' ? '<span class="ticket-prio-urgent">🚨</span> ' : (t.priority === 'tärkeä' ? '<span class="ticket-prio-important">⚠️</span> ' : '');
return `<tr data-ticket-id="${t.id}" data-company-id="${t.company_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>${companyBadge}<strong>${esc(t.subject)}</strong></td>
<td>${prioBadge}${companyBadge}<strong>${esc(t.subject)}</strong></td>
<td>${esc(t.mailbox_name || t.from_name || t.from_email)}</td>
<td>${t.customer_name ? esc(t.customer_name) : '<span style="color:#ccc;">-</span>'}</td>
<td style="text-align:center;">${lastType} ${t.message_count}</td>
@@ -1253,6 +1260,11 @@ async function showTicketDetail(id, companyId = '') {
<select id="ticket-assign-select" style="padding:6px 10px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.85rem;">
<option value="">Ei osoitettu</option>
</select>
<select id="ticket-priority-select" style="padding:6px 10px;border:2px solid ${(ticket.priority || 'normaali') === 'urgent' ? '#e74c3c' : (ticket.priority === 'tärkeä' ? '#e67e22' : '#e0e0e0')};border-radius:8px;font-size:0.85rem;${(ticket.priority || 'normaali') === 'urgent' ? 'background:#fef2f2;color:#c0392b;font-weight:700;' : ''}">
<option value="normaali" ${(ticket.priority || 'normaali') === 'normaali' ? 'selected' : ''}>Normaali</option>
<option value="tärkeä" ${ticket.priority === 'tärkeä' ? 'selected' : ''}>⚠️ Tärkeä</option>
<option value="urgent" ${ticket.priority === 'urgent' ? 'selected' : ''}>🚨 URGENT</option>
</select>
<select id="ticket-customer-select" style="padding:6px 10px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.85rem;">
<option value="">Ei asiakkuutta</option>
</select>
@@ -1268,7 +1280,8 @@ async function showTicketDetail(id, companyId = '') {
<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>`;
</div>
${ticket.cc ? '<div style="font-size:0.82rem;color:#888;margin-top:0.4rem;">CC: ' + esc(ticket.cc) + '</div>' : ''}`;
// Load users for assignment dropdown
try {
@@ -1324,6 +1337,15 @@ async function showTicketDetail(id, companyId = '') {
} catch (e) { alert(e.message); }
});
// Priority handler
document.getElementById('ticket-priority-select').addEventListener('change', async function() {
try {
await apiCall('ticket_priority' + ticketCompanyParam(), 'POST', { id: currentTicketId, priority: this.value });
// Päivitä näkymä (visuaalinen muutos)
await showTicketDetail(currentTicketId, currentTicketCompanyId);
} catch (e) { alert(e.message); }
});
// Delete handler
document.getElementById('btn-ticket-delete').addEventListener('click', async () => {
if (!confirm('Poistetaanko tiketti "' + ticket.subject + '"?')) return;
@@ -1393,17 +1415,58 @@ async function showTicketDetail(id, companyId = '') {
document.querySelector('.btn-reply-tab[data-reply-type="reply"]').classList.add('active');
document.getElementById('btn-send-reply').textContent = 'Lähetä vastaus';
// Allekirjoituksen esikatselu
const sigPreview = document.getElementById('signature-preview');
const mailboxId = ticket.mailbox_id || '';
const sig = currentUserSignatures[mailboxId] || '';
if (sig) {
sigPreview.textContent = '-- \n' + sig;
sigPreview.style.display = 'block';
} else {
sigPreview.style.display = 'none';
// CC-kenttä — täytetään tiketin CC:stä
const ccField = document.getElementById('reply-cc');
if (ccField) ccField.value = ticket.cc || '';
// Mailbox-valinta — täytetään yrityksen postilaatikoista
const mbSelect = document.getElementById('reply-mailbox-select');
if (mbSelect) {
try {
const mailboxes = await apiCall('all_mailboxes');
mbSelect.innerHTML = mailboxes.map(mb =>
`<option value="${esc(mb.id)}" ${mb.id === (ticket.mailbox_id || '') ? 'selected' : ''}>${esc(mb.nimi || mb.smtp_from_email)} &lt;${esc(mb.smtp_from_email)}&gt;</option>`
).join('');
// Vaihda allekirjoitusta kun mailbox vaihtuu
mbSelect.addEventListener('change', function() {
updateSignaturePreview(this.value);
});
} catch (e) { mbSelect.innerHTML = '<option>Ei postilaatikoita</option>'; }
}
// Allekirjoituksen esikatselu
function updateSignaturePreview(mbId) {
const sigPreview = document.getElementById('signature-preview');
const sig = currentUserSignatures[mbId] || '';
if (sig) {
sigPreview.textContent = '-- \n' + sig;
sigPreview.style.display = 'block';
} else {
sigPreview.style.display = 'none';
}
}
updateSignaturePreview(ticket.mailbox_id || '');
// Vastauspohjat — lataa dropdown
try {
const templates = await apiCall('reply_templates');
const tplSelect = document.getElementById('reply-template-select');
tplSelect.innerHTML = '<option value="">📝 Vastauspohjat...</option>';
templates.forEach(t => {
tplSelect.innerHTML += `<option value="${esc(t.id)}" data-body="${esc(t.body)}">${esc(t.nimi)}</option>`;
});
tplSelect.addEventListener('change', function() {
const opt = this.options[this.selectedIndex];
const body = opt.dataset.body || '';
if (body) {
const textarea = document.getElementById('ticket-reply-body');
textarea.value = textarea.value ? textarea.value + '\n\n' + body : body;
textarea.focus();
}
this.value = ''; // Reset select
});
} catch (e) { /* templates not critical */ }
} catch (e) { alert(e.message); }
}
@@ -1433,13 +1496,19 @@ document.querySelectorAll('.btn-reply-tab').forEach(btn => {
const textarea = document.getElementById('ticket-reply-body');
const sendBtn = document.getElementById('btn-send-reply');
const sigPrev = document.getElementById('signature-preview');
const metaFields = document.getElementById('reply-meta-fields');
const tplWrap = document.getElementById('reply-template-select-wrap');
if (ticketReplyType === 'note') {
textarea.placeholder = 'Kirjoita sisäinen muistiinpano...';
sendBtn.textContent = 'Tallenna muistiinpano';
sigPrev.style.display = 'none';
if (metaFields) metaFields.style.display = 'none';
if (tplWrap) tplWrap.style.display = 'none';
} else {
textarea.placeholder = 'Kirjoita vastaus...';
sendBtn.textContent = 'Lähetä vastaus';
if (metaFields) metaFields.style.display = '';
if (tplWrap) tplWrap.style.display = '';
// Näytä allekirjoitus jos on asetettu
if (sigPrev.textContent.trim()) sigPrev.style.display = 'block';
}
@@ -1458,7 +1527,14 @@ document.getElementById('btn-send-reply').addEventListener('click', async () =>
try {
const action = ticketReplyType === 'note' ? 'ticket_note' : 'ticket_reply';
await apiCall(action + ticketCompanyParam(), 'POST', { id: currentTicketId, body });
const payload = { id: currentTicketId, body };
if (ticketReplyType !== 'note') {
const mbSel = document.getElementById('reply-mailbox-select');
const ccFld = document.getElementById('reply-cc');
if (mbSel) payload.mailbox_id = mbSel.value;
if (ccFld) payload.cc = ccFld.value.trim();
}
await apiCall(action + ticketCompanyParam(), 'POST', payload);
// Reload the detail view
await showTicketDetail(currentTicketId, currentTicketCompanyId);
} catch (e) {
@@ -1709,9 +1785,127 @@ async function loadSettings() {
if (apiTitle && currentCompany) apiTitle.textContent = currentCompany.nimi + ' — ';
const key = config.api_key || 'AVAIN';
document.getElementById('api-example-url').textContent = `api.php?action=saatavuus&key=${key}&osoite=Kauppakatu+5&postinumero=20100&kaupunki=Turku`;
// Telegram-asetukset
document.getElementById('settings-telegram-token').value = config.telegram_bot_token || '';
document.getElementById('settings-telegram-chat').value = config.telegram_chat_id || '';
} catch (e) { console.error(e); }
// Vastauspohjat
loadTemplates();
// Priority emails
loadPriorityEmails();
}
// ==================== VASTAUSPOHJAT ====================
let replyTemplates = [];
async function loadTemplates() {
try {
replyTemplates = await apiCall('reply_templates');
renderTemplates();
} catch (e) { console.error(e); }
}
function renderTemplates() {
const list = document.getElementById('templates-list');
if (!list) return;
if (replyTemplates.length === 0) {
list.innerHTML = '<p style="color:#aaa;font-size:0.85rem;">Ei vastauspohjia vielä.</p>';
return;
}
list.innerHTML = replyTemplates.map(t =>
`<div style="display:flex;justify-content:space-between;align-items:center;padding:0.5rem 0;border-bottom:1px solid #f0f2f5;">
<div>
<strong style="font-size:0.9rem;">${esc(t.nimi)}</strong>
<div style="font-size:0.8rem;color:#888;max-width:400px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${esc(t.body.substring(0, 80))}</div>
</div>
<div style="display:flex;gap:0.3rem;">
<button class="btn-secondary" onclick="editTemplate('${t.id}')" style="padding:4px 8px;font-size:0.78rem;">Muokkaa</button>
<button class="btn-danger" onclick="deleteTemplate('${t.id}')" style="padding:4px 8px;font-size:0.78rem;">Poista</button>
</div>
</div>`
).join('');
}
document.getElementById('btn-add-template').addEventListener('click', () => {
document.getElementById('template-edit-id').value = '';
document.getElementById('template-edit-name').value = '';
document.getElementById('template-edit-body').value = '';
document.getElementById('template-form').style.display = 'block';
});
document.getElementById('btn-cancel-template').addEventListener('click', () => {
document.getElementById('template-form').style.display = 'none';
});
document.getElementById('btn-save-template').addEventListener('click', async () => {
const id = document.getElementById('template-edit-id').value || undefined;
const nimi = document.getElementById('template-edit-name').value.trim();
const body = document.getElementById('template-edit-body').value.trim();
if (!nimi || !body) { alert('Täytä nimi ja sisältö'); return; }
try {
await apiCall('reply_template_save', 'POST', { id, nimi, body });
document.getElementById('template-form').style.display = 'none';
loadTemplates();
} catch (e) { alert(e.message); }
});
window.editTemplate = function(id) {
const t = replyTemplates.find(x => x.id === id);
if (!t) return;
document.getElementById('template-edit-id').value = t.id;
document.getElementById('template-edit-name').value = t.nimi;
document.getElementById('template-edit-body').value = t.body;
document.getElementById('template-form').style.display = 'block';
};
window.deleteTemplate = async function(id) {
if (!confirm('Poistetaanko vastauspohja?')) return;
try {
await apiCall('reply_template_delete', 'POST', { id });
loadTemplates();
} catch (e) { alert(e.message); }
};
// ==================== PRIORITY EMAILS ====================
async function loadPriorityEmails() {
try {
const emails = await apiCall('priority_emails');
document.getElementById('priority-emails-textarea').value = (emails || []).join('\n');
} catch (e) { console.error(e); }
}
document.getElementById('btn-save-priority-emails').addEventListener('click', async () => {
const text = document.getElementById('priority-emails-textarea').value;
const emails = text.split('\n').map(e => e.trim()).filter(e => e);
try {
await apiCall('priority_emails_save', 'POST', { emails });
alert('Priority-osoitteet tallennettu!');
} catch (e) { alert(e.message); }
});
// ==================== TELEGRAM ====================
document.getElementById('btn-save-telegram').addEventListener('click', async () => {
try {
await apiCall('config_update', 'POST', {
telegram_bot_token: document.getElementById('settings-telegram-token').value.trim(),
telegram_chat_id: document.getElementById('settings-telegram-chat').value.trim(),
});
alert('Telegram-asetukset tallennettu!');
} catch (e) { alert(e.message); }
});
document.getElementById('btn-test-telegram').addEventListener('click', async () => {
try {
await apiCall('telegram_test', 'POST');
alert('Testiviesti lähetetty!');
} catch (e) { alert(e.message); }
});
document.getElementById('btn-generate-key').addEventListener('click', async () => {
try {
const config = await apiCall('generate_api_key', 'POST');