Add Asiakaspalvelu email ticketing system

IMAP client for fetching emails from asiakaspalvelu@cuitunet.fi,
Freshdesk-style ticket management with status tracking, message
threading, reply/note functionality, and IMAP settings in API tab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 08:52:00 +02:00
parent cc3a6c465d
commit 42e3648e3d
4 changed files with 1068 additions and 1 deletions

274
script.js
View File

@@ -186,6 +186,7 @@ document.querySelectorAll('.tab').forEach(tab => {
if (target === 'leads') loadLeads();
if (target === 'archive') loadArchive();
if (target === 'changelog') loadChangelog();
if (target === 'support') { loadTickets(); showTicketListView(); }
if (target === 'users') loadUsers();
if (target === 'settings') loadSettings();
});
@@ -838,6 +839,12 @@ const actionLabels = {
lead_delete: 'Poisti liidin',
lead_to_customer: 'Muutti liidin asiakkaaksi',
config_update: 'Päivitti asetukset',
ticket_fetch: 'Haki sähköpostit',
ticket_reply: 'Vastasi tikettiin',
ticket_status: 'Muutti tiketin tilaa',
ticket_assign: 'Osoitti tiketin',
ticket_note: 'Lisäsi muistiinpanon',
ticket_delete: 'Poisti tiketin',
};
async function loadChangelog() {
@@ -937,6 +944,262 @@ document.getElementById('user-form').addEventListener('submit', async (e) => {
} catch (e) { alert(e.message); }
});
// ==================== TICKETS (ASIAKASPALVELU) ====================
let tickets = [];
let currentTicketId = null;
let ticketReplyType = 'reply';
const ticketStatusLabels = {
uusi: 'Uusi',
kasittelyssa: 'Käsittelyssä',
odottaa: 'Odottaa vastausta',
ratkaistu: 'Ratkaistu',
suljettu: 'Suljettu',
};
async function loadTickets() {
try {
tickets = await apiCall('tickets');
renderTickets();
} catch (e) { console.error(e); }
}
function renderTickets() {
const query = document.getElementById('ticket-search-input').value.toLowerCase().trim();
const statusFilter = document.getElementById('ticket-status-filter').value;
let filtered = tickets;
if (query) {
filtered = filtered.filter(t =>
(t.subject || '').toLowerCase().includes(query) ||
(t.from_name || '').toLowerCase().includes(query) ||
(t.from_email || '').toLowerCase().includes(query)
);
}
if (statusFilter) {
filtered = filtered.filter(t => t.status === statusFilter);
}
const ttbody = document.getElementById('tickets-tbody');
const noTickets = document.getElementById('no-tickets');
if (filtered.length === 0) {
ttbody.innerHTML = '';
noTickets.style.display = 'block';
document.getElementById('tickets-table').style.display = 'none';
} else {
noTickets.style.display = 'none';
document.getElementById('tickets-table').style.display = 'table';
ttbody.innerHTML = filtered.map(t => {
const lastType = t.last_message_type === 'reply_out' ? '&#8594;' : (t.last_message_type === 'note' ? '&#128221;' : '&#8592;');
return `<tr data-ticket-id="${t.id}">
<td><span class="ticket-status ticket-status-${t.status}">${ticketStatusLabels[t.status] || t.status}</span></td>
<td><strong>${esc(t.subject)}</strong></td>
<td>${esc(t.from_name || t.from_email)}</td>
<td style="text-align:center;">${lastType} ${t.message_count}</td>
<td>${esc(t.assigned_to || '-')}</td>
<td class="nowrap">${esc((t.updated || '').substring(0, 16))}</td>
</tr>`;
}).join('');
}
document.getElementById('ticket-count').textContent = `${tickets.length} tikettiä`;
// Status summary
const counts = {};
tickets.forEach(t => { counts[t.status] = (counts[t.status] || 0) + 1; });
const parts = [];
if (counts.uusi) parts.push(`${counts.uusi} uutta`);
if (counts.kasittelyssa) parts.push(`${counts.kasittelyssa} käsittelyssä`);
if (counts.odottaa) parts.push(`${counts.odottaa} odottaa`);
document.getElementById('ticket-status-summary').textContent = parts.join(' · ');
}
document.getElementById('ticket-search-input').addEventListener('input', () => renderTickets());
document.getElementById('ticket-status-filter').addEventListener('change', () => renderTickets());
document.getElementById('tickets-tbody').addEventListener('click', (e) => {
const row = e.target.closest('tr');
if (row && row.dataset.ticketId) showTicketDetail(row.dataset.ticketId);
});
async function showTicketDetail(id) {
try {
const ticket = await apiCall('ticket_detail&id=' + encodeURIComponent(id));
currentTicketId = id;
// Header
document.getElementById('ticket-detail-header').innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:1rem;margin-bottom:1.25rem;">
<div>
<h2 style="color:#0f3460;margin-bottom:0.25rem;font-size:1.2rem;">${esc(ticket.subject)}</h2>
<div style="font-size:0.85rem;color:#888;">
${esc(ticket.from_name)} &lt;${esc(ticket.from_email)}&gt; · Luotu ${esc(ticket.created)}
</div>
</div>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<select id="ticket-status-select" style="padding:6px 10px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.85rem;">
<option value="uusi" ${ticket.status === 'uusi' ? 'selected' : ''}>Uusi</option>
<option value="kasittelyssa" ${ticket.status === 'kasittelyssa' ? 'selected' : ''}>Käsittelyssä</option>
<option value="odottaa" ${ticket.status === 'odottaa' ? 'selected' : ''}>Odottaa vastausta</option>
<option value="ratkaistu" ${ticket.status === 'ratkaistu' ? 'selected' : ''}>Ratkaistu</option>
<option value="suljettu" ${ticket.status === 'suljettu' ? 'selected' : ''}>Suljettu</option>
</select>
<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>
<button class="btn-danger" id="btn-ticket-delete" style="padding:6px 12px;font-size:0.82rem;">Poista</button>
</div>
</div>`;
// Load users for assignment dropdown
try {
const users = await apiCall('users');
const assignSelect = document.getElementById('ticket-assign-select');
users.forEach(u => {
const opt = document.createElement('option');
opt.value = u.username;
opt.textContent = u.nimi || u.username;
if (u.username === ticket.assigned_to) opt.selected = true;
assignSelect.appendChild(opt);
});
} catch (e) { /* non-admin may not access users */ }
// Status change handler
document.getElementById('ticket-status-select').addEventListener('change', async function() {
try {
await apiCall('ticket_status', 'POST', { id: currentTicketId, status: this.value });
} catch (e) { alert(e.message); }
});
// Assign handler
document.getElementById('ticket-assign-select').addEventListener('change', async function() {
try {
await apiCall('ticket_assign', 'POST', { id: currentTicketId, assigned_to: this.value });
} catch (e) { alert(e.message); }
});
// Delete handler
document.getElementById('btn-ticket-delete').addEventListener('click', async () => {
if (!confirm('Poistetaanko tiketti "' + ticket.subject + '"?')) return;
try {
await apiCall('ticket_delete', 'POST', { id: currentTicketId });
showTicketListView();
loadTickets();
} catch (e) { alert(e.message); }
});
// Thread messages
const thread = document.getElementById('ticket-thread');
thread.innerHTML = (ticket.messages || []).map(m => {
const isOut = m.type === 'reply_out';
const isNote = m.type === 'note';
const typeClass = isOut ? 'ticket-msg-out' : (isNote ? 'ticket-msg-note' : 'ticket-msg-in');
const typeIcon = isOut ? '&#8594; Vastaus' : (isNote ? '&#128221; Muistiinpano' : '&#8592; Saapunut');
return `<div class="ticket-message ${typeClass}">
<div class="ticket-msg-header">
<span class="ticket-msg-type">${typeIcon}</span>
<strong>${esc(m.from_name || m.from)}</strong>
<span class="ticket-msg-time">${esc(m.timestamp)}</span>
</div>
<div class="ticket-msg-body">${esc(m.body)}</div>
</div>`;
}).join('');
// Show detail, hide list
document.getElementById('ticket-list-view').style.display = 'none';
document.getElementById('ticket-detail-view').style.display = 'block';
// Reset reply form
document.getElementById('ticket-reply-body').value = '';
document.getElementById('ticket-reply-body').placeholder = 'Kirjoita vastaus...';
ticketReplyType = 'reply';
document.querySelectorAll('.btn-reply-tab').forEach(b => b.classList.remove('active'));
document.querySelector('.btn-reply-tab[data-reply-type="reply"]').classList.add('active');
document.getElementById('btn-send-reply').textContent = 'Lähetä vastaus';
} catch (e) { alert(e.message); }
}
function showTicketListView() {
document.getElementById('ticket-detail-view').style.display = 'none';
document.getElementById('ticket-list-view').style.display = 'block';
currentTicketId = null;
}
document.getElementById('btn-ticket-back').addEventListener('click', () => {
showTicketListView();
loadTickets();
});
// Reply type tabs
document.querySelectorAll('.btn-reply-tab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.btn-reply-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
ticketReplyType = btn.dataset.replyType;
const textarea = document.getElementById('ticket-reply-body');
const sendBtn = document.getElementById('btn-send-reply');
if (ticketReplyType === 'note') {
textarea.placeholder = 'Kirjoita sisäinen muistiinpano...';
sendBtn.textContent = 'Tallenna muistiinpano';
} else {
textarea.placeholder = 'Kirjoita vastaus...';
sendBtn.textContent = 'Lähetä vastaus';
}
});
});
// Send reply or note
document.getElementById('btn-send-reply').addEventListener('click', async () => {
const body = document.getElementById('ticket-reply-body').value.trim();
if (!body) { alert('Kirjoita viesti ensin'); return; }
if (!currentTicketId) return;
const btn = document.getElementById('btn-send-reply');
btn.disabled = true;
btn.textContent = 'Lähetetään...';
try {
const action = ticketReplyType === 'note' ? 'ticket_note' : 'ticket_reply';
await apiCall(action, 'POST', { id: currentTicketId, body });
// Reload the detail view
await showTicketDetail(currentTicketId);
} catch (e) {
alert(e.message);
} finally {
btn.disabled = false;
btn.textContent = ticketReplyType === 'note' ? 'Tallenna muistiinpano' : 'Lähetä vastaus';
}
});
// Fetch emails
document.getElementById('btn-fetch-emails').addEventListener('click', async () => {
const btn = document.getElementById('btn-fetch-emails');
const status = document.getElementById('ticket-fetch-status');
btn.disabled = true;
btn.textContent = '⏳ Haetaan...';
status.style.display = 'block';
status.className = '';
status.style.background = '#f0f7ff';
status.style.color = '#0f3460';
status.textContent = 'Yhdistetään sähköpostipalvelimeen...';
try {
const result = await apiCall('ticket_fetch', 'POST');
status.style.background = '#eafaf1';
status.style.color = '#27ae60';
status.textContent = `Valmis! ${result.new_tickets} uutta tikettiä, ${result.threaded} ketjutettu viestiä. Yhteensä ${result.total} tikettiä.`;
await loadTickets();
} catch (e) {
status.style.background = '#fef2f2';
status.style.color = '#e74c3c';
status.textContent = 'Virhe: ' + e.message;
} finally {
btn.disabled = false;
btn.textContent = '📧 Hae postit';
setTimeout(() => { status.style.display = 'none'; }, 8000);
}
});
// ==================== SETTINGS ====================
async function loadSettings() {
@@ -946,6 +1209,12 @@ async function loadSettings() {
document.getElementById('settings-cors').value = (config.cors_origins || ['https://cuitunet.fi', 'https://www.cuitunet.fi']).join('\n');
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`;
// IMAP settings
document.getElementById('settings-imap-host').value = config.imap_host || '';
document.getElementById('settings-imap-port').value = config.imap_port || 993;
document.getElementById('settings-imap-user').value = config.imap_user || '';
document.getElementById('settings-imap-password').value = config.imap_password || '';
document.getElementById('settings-imap-encryption').value = config.imap_encryption || 'ssl';
} catch (e) { console.error(e); }
}
@@ -962,6 +1231,11 @@ document.getElementById('btn-save-settings').addEventListener('click', async ()
const config = await apiCall('config_update', 'POST', {
api_key: document.getElementById('settings-api-key').value,
cors_origins: document.getElementById('settings-cors').value,
imap_host: document.getElementById('settings-imap-host').value,
imap_port: document.getElementById('settings-imap-port').value,
imap_user: document.getElementById('settings-imap-user').value,
imap_password: document.getElementById('settings-imap-password').value,
imap_encryption: document.getElementById('settings-imap-encryption').value,
});
alert('Asetukset tallennettu!');
} catch (e) { alert(e.message); }