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:
274
script.js
274
script.js
@@ -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' ? '→' : (t.last_message_type === 'note' ? '📝' : '←');
|
||||
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)} <${esc(ticket.from_email)}> · 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 ? '→ Vastaus' : (isNote ? '📝 Muistiinpano' : '← 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); }
|
||||
|
||||
Reference in New Issue
Block a user