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:
224
script.js
224
script.js
@@ -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' ? '→' : (t.last_message_type === 'note' ? '📝' : '←');
|
||||
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;">⏰ 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)} <${esc(mb.smtp_from_email)}></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');
|
||||
|
||||
Reference in New Issue
Block a user