From 42e3648e3dfef163179c4469877b56f8f25c84e0 Mon Sep 17 00:00:00 2001 From: Jukka Lampikoski Date: Tue, 10 Mar 2026 08:52:00 +0200 Subject: [PATCH] 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 --- api.php | 550 ++++++++++++++++++++++++++++++++++++++++++++++++++++- index.html | 98 ++++++++++ script.js | 274 ++++++++++++++++++++++++++ style.css | 147 ++++++++++++++ 4 files changed, 1068 insertions(+), 1 deletion(-) diff --git a/api.php b/api.php index 1cfc3d7..c47d710 100644 --- a/api.php +++ b/api.php @@ -18,6 +18,7 @@ define('LEADS_FILE', DATA_DIR . '/leads.json'); define('TOKENS_FILE', DATA_DIR . '/reset_tokens.json'); define('RATE_FILE', DATA_DIR . '/login_attempts.json'); define('CONFIG_FILE', DATA_DIR . '/config.json'); +define('TICKETS_FILE', DATA_DIR . '/tickets.json'); define('SITE_URL', 'https://intra.cuitunet.fi'); // Sähköpostiasetukset @@ -26,7 +27,7 @@ define('MAIL_FROM_NAME', 'CuituNet Intra'); // Varmista data-kansio ja tiedostot if (!file_exists(DATA_DIR)) mkdir(DATA_DIR, 0755, true); -foreach ([DATA_FILE, USERS_FILE, CHANGELOG_FILE, ARCHIVE_FILE, LEADS_FILE, TOKENS_FILE, RATE_FILE] as $f) { +foreach ([DATA_FILE, USERS_FILE, CHANGELOG_FILE, ARCHIVE_FILE, LEADS_FILE, TOKENS_FILE, RATE_FILE, TICKETS_FILE] as $f) { if (!file_exists($f)) file_put_contents($f, '[]'); } @@ -116,6 +117,232 @@ function sendMail(string $to, string $subject, string $htmlBody): bool { return mail($to, $subject, $htmlBody, $headers, '-f ' . MAIL_FROM); } +// ==================== IMAP CLIENT ==================== + +class ImapClient { + private $connection = null; + + public function connect(array $config): bool { + $host = $config['imap_host'] ?? ''; + $port = intval($config['imap_port'] ?? 993); + $user = $config['imap_user'] ?? ''; + $pass = $config['imap_password'] ?? ''; + $encryption = $config['imap_encryption'] ?? 'ssl'; + + if (empty($host) || empty($user) || empty($pass)) { + return false; + } + + $mailbox = '{' . $host . ':' . $port . '/imap/' . $encryption . '}INBOX'; + + // Suppress warnings, handle errors manually + $this->connection = @imap_open($mailbox, $user, $pass, 0, 1); + + if (!$this->connection) { + return false; + } + return true; + } + + public function fetchMessages(int $limit = 50): array { + if (!$this->connection) return []; + + $messages = []; + $check = imap_check($this->connection); + if (!$check || $check->Nmsgs === 0) return []; + + $start = max(1, $check->Nmsgs - $limit + 1); + $end = $check->Nmsgs; + + for ($i = $end; $i >= $start; $i--) { + $header = @imap_headerinfo($this->connection, $i); + if (!$header) continue; + + $overview = @imap_fetch_overview($this->connection, strval($i), 0); + + // Decode subject + $subject = ''; + if (isset($header->subject)) { + $decoded = imap_mime_header_decode($header->subject); + foreach ($decoded as $part) { + $subject .= $part->text; + } + } + + // From + $fromEmail = ''; + $fromName = ''; + if (isset($header->from[0])) { + $fromEmail = $header->from[0]->mailbox . '@' . ($header->from[0]->host ?? ''); + if (isset($header->from[0]->personal)) { + $decoded = imap_mime_header_decode($header->from[0]->personal); + foreach ($decoded as $part) { + $fromName .= $part->text; + } + } + } + + // Message-ID + $messageId = isset($header->message_id) ? trim($header->message_id) : ''; + + // In-Reply-To + $inReplyTo = ''; + if (isset($header->in_reply_to)) { + $inReplyTo = trim($header->in_reply_to); + } + + // References + $references = ''; + if (isset($header->references)) { + $references = trim($header->references); + } + + // Date + $date = isset($header->date) ? date('Y-m-d H:i:s', strtotime($header->date)) : date('Y-m-d H:i:s'); + + // Body — prefer plain text + $body = $this->getBody($i); + + $messages[] = [ + 'subject' => $subject, + 'from_email' => $fromEmail, + 'from_name' => $fromName, + 'message_id' => $messageId, + 'in_reply_to' => $inReplyTo, + 'references' => $references, + 'date' => $date, + 'body' => $body, + ]; + } + + return $messages; + } + + private function getBody(int $msgNum): string { + if (!$this->connection) return ''; + + $structure = @imap_fetchstructure($this->connection, $msgNum); + if (!$structure) return ''; + + // Simple message (no parts) + if (empty($structure->parts)) { + $body = imap_fetchbody($this->connection, $msgNum, '1'); + return $this->decodeBody($body, $structure->encoding ?? 0); + } + + // Multipart — look for text/plain first, then text/html + $plainBody = ''; + $htmlBody = ''; + + foreach ($structure->parts as $partNum => $part) { + $partIndex = strval($partNum + 1); + + if ($part->type === 0) { // TEXT + $subtype = strtolower($part->subtype ?? ''); + $body = imap_fetchbody($this->connection, $msgNum, $partIndex); + $decoded = $this->decodeBody($body, $part->encoding ?? 0); + + // Handle charset + $charset = $this->getCharset($part); + if ($charset && strtolower($charset) !== 'utf-8') { + $converted = @iconv($charset, 'UTF-8//IGNORE', $decoded); + if ($converted !== false) $decoded = $converted; + } + + if ($subtype === 'plain') { + $plainBody = $decoded; + } elseif ($subtype === 'html') { + $htmlBody = $decoded; + } + } + } + + if ($plainBody) return trim($plainBody); + if ($htmlBody) return trim(strip_tags($htmlBody)); + return ''; + } + + private function decodeBody(string $body, int $encoding): string { + switch ($encoding) { + case 3: return base64_decode($body); // BASE64 + case 4: return quoted_printable_decode($body); // QUOTED-PRINTABLE + default: return $body; + } + } + + private function getCharset($part): string { + if (!isset($part->parameters)) return ''; + foreach ($part->parameters as $param) { + if (strtolower($param->attribute) === 'charset') { + return $param->value; + } + } + return ''; + } + + public function disconnect(): void { + if ($this->connection) { + @imap_close($this->connection); + $this->connection = null; + } + } +} + +// ==================== TICKETS ==================== + +function loadTickets(): array { + return json_decode(file_get_contents(TICKETS_FILE), true) ?: []; +} + +function saveTickets(array $tickets): void { + file_put_contents(TICKETS_FILE, json_encode($tickets, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); +} + +function findTicketByMessageId(array $tickets, string $messageId): ?int { + foreach ($tickets as $i => $t) { + if ($t['message_id'] === $messageId) return $i; + foreach ($t['messages'] ?? [] as $m) { + if (($m['message_id'] ?? '') === $messageId) return $i; + } + } + return null; +} + +function findTicketByReferences(array $tickets, string $inReplyTo, string $references): ?int { + // Check In-Reply-To header + if ($inReplyTo) { + $idx = findTicketByMessageId($tickets, $inReplyTo); + if ($idx !== null) return $idx; + } + // Check References header + if ($references) { + $refs = preg_split('/\s+/', $references); + foreach ($refs as $ref) { + $ref = trim($ref); + if (!$ref) continue; + $idx = findTicketByMessageId($tickets, $ref); + if ($idx !== null) return $idx; + } + } + return null; +} + +function sendTicketMail(string $to, string $subject, string $body, string $inReplyTo = '', string $references = ''): bool { + $config = loadConfig(); + $fromEmail = $config['imap_user'] ?? MAIL_FROM; + $fromName = 'CuituNet Asiakaspalvelu'; + + $headers = "MIME-Version: 1.0\r\n"; + $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; + $headers .= "From: {$fromName} <{$fromEmail}>\r\n"; + $headers .= "Reply-To: {$fromEmail}\r\n"; + if ($inReplyTo) { + $headers .= "In-Reply-To: {$inReplyTo}\r\n"; + $headers .= "References: " . ($references ? $references . ' ' : '') . $inReplyTo . "\r\n"; + } + return mail($to, $subject, $body, $headers, '-f ' . $fromEmail); +} + // ==================== USERS ==================== function initUsers(): void { @@ -339,6 +566,12 @@ switch ($action) { $origins = array_filter(array_map('trim', explode("\n", $input['cors_origins']))); $config['cors_origins'] = array_values($origins); } + // IMAP-asetukset + if (isset($input['imap_host'])) $config['imap_host'] = trim($input['imap_host']); + if (isset($input['imap_port'])) $config['imap_port'] = intval($input['imap_port']); + if (isset($input['imap_user'])) $config['imap_user'] = trim($input['imap_user']); + if (isset($input['imap_password'])) $config['imap_password'] = $input['imap_password']; + if (isset($input['imap_encryption'])) $config['imap_encryption'] = trim($input['imap_encryption']); saveConfig($config); addLog('config_update', '', '', 'Päivitti asetukset'); echo json_encode($config); @@ -996,6 +1229,321 @@ switch ($action) { echo json_encode(['success' => true]); break; + // ---------- TICKETS ---------- + case 'tickets': + requireAuth(); + $tickets = loadTickets(); + // Palauta ilman viestisisältöjä (lista-näkymä) + $list = array_map(function($t) { + $msgCount = count($t['messages'] ?? []); + $lastMsg = $msgCount > 0 ? $t['messages'][$msgCount - 1] : null; + return [ + 'id' => $t['id'], + 'subject' => $t['subject'], + 'from_email' => $t['from_email'], + 'from_name' => $t['from_name'], + 'status' => $t['status'], + 'assigned_to' => $t['assigned_to'] ?? '', + 'created' => $t['created'], + 'updated' => $t['updated'], + 'message_count' => $msgCount, + 'last_message_type' => $lastMsg ? ($lastMsg['type'] ?? '') : '', + 'last_message_time' => $lastMsg ? ($lastMsg['timestamp'] ?? '') : '', + ]; + }, $tickets); + echo json_encode($list); + break; + + case 'ticket_detail': + requireAuth(); + $id = $_GET['id'] ?? ''; + $tickets = loadTickets(); + $ticket = null; + foreach ($tickets as $t) { + if ($t['id'] === $id) { $ticket = $t; break; } + } + if (!$ticket) { + http_response_code(404); + echo json_encode(['error' => 'Tikettiä ei löydy']); + break; + } + echo json_encode($ticket); + break; + + case 'ticket_fetch': + requireAuth(); + if ($method !== 'POST') break; + $config = loadConfig(); + if (empty($config['imap_host']) || empty($config['imap_user']) || empty($config['imap_password'])) { + http_response_code(400); + echo json_encode(['error' => 'IMAP-asetukset puuttuvat. Aseta ne API-välilehdellä.']); + break; + } + + $imap = new ImapClient(); + if (!$imap->connect($config)) { + $errors = imap_errors(); + http_response_code(500); + echo json_encode(['error' => 'IMAP-yhteys epäonnistui' . ($errors ? ': ' . implode(', ', $errors) : '')]); + break; + } + + $emails = $imap->fetchMessages(100); + $imap->disconnect(); + + $tickets = loadTickets(); + $newCount = 0; + $threadedCount = 0; + + // Collect all existing message IDs for duplicate detection + $existingMsgIds = []; + foreach ($tickets as $t) { + if ($t['message_id']) $existingMsgIds[$t['message_id']] = true; + foreach ($t['messages'] ?? [] as $m) { + if (!empty($m['message_id'])) $existingMsgIds[$m['message_id']] = true; + } + } + + foreach ($emails as $email) { + // Skip duplicates + if (!empty($email['message_id']) && isset($existingMsgIds[$email['message_id']])) { + continue; + } + + $msg = [ + 'id' => generateId(), + 'type' => 'email_in', + 'from' => $email['from_email'], + 'from_name' => $email['from_name'], + 'body' => $email['body'], + 'timestamp' => $email['date'], + 'message_id' => $email['message_id'], + ]; + + // Try to thread into existing ticket + $ticketIdx = findTicketByReferences($tickets, $email['in_reply_to'], $email['references']); + + if ($ticketIdx !== null) { + $tickets[$ticketIdx]['messages'][] = $msg; + $tickets[$ticketIdx]['updated'] = $email['date']; + // If ticket was resolved/closed, reopen it + if (in_array($tickets[$ticketIdx]['status'], ['ratkaistu', 'suljettu'])) { + $tickets[$ticketIdx]['status'] = 'kasittelyssa'; + } + $threadedCount++; + } else { + // New ticket + $ticket = [ + 'id' => generateId(), + 'subject' => $email['subject'] ?: '(Ei aihetta)', + 'from_email' => $email['from_email'], + 'from_name' => $email['from_name'], + 'status' => 'uusi', + 'assigned_to' => '', + 'created' => $email['date'], + 'updated' => $email['date'], + 'message_id' => $email['message_id'], + 'messages' => [$msg], + ]; + $tickets[] = $ticket; + $newCount++; + } + + if ($email['message_id']) $existingMsgIds[$email['message_id']] = true; + } + + // Sort tickets by updated date (newest first) + usort($tickets, function($a, $b) { + return strcmp($b['updated'], $a['updated']); + }); + + saveTickets($tickets); + addLog('ticket_fetch', '', '', "Haettu sähköpostit: {$newCount} uutta tikettiä, {$threadedCount} ketjutettu"); + echo json_encode(['success' => true, 'new_tickets' => $newCount, 'threaded' => $threadedCount, 'total' => count($tickets)]); + break; + + case 'ticket_reply': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? ''; + $body = trim($input['body'] ?? ''); + if (empty($body)) { + http_response_code(400); + echo json_encode(['error' => 'Viesti ei voi olla tyhjä']); + break; + } + $tickets = loadTickets(); + $found = false; + foreach ($tickets as &$t) { + if ($t['id'] === $id) { + // Find last message_id for threading + $lastMsgId = $t['message_id'] ?? ''; + $allRefs = $lastMsgId; + foreach ($t['messages'] as $m) { + if (!empty($m['message_id'])) { + $lastMsgId = $m['message_id']; + $allRefs .= ' ' . $m['message_id']; + } + } + + // Send email + $subject = 'Re: ' . $t['subject']; + $sent = sendTicketMail($t['from_email'], $subject, $body, $lastMsgId, trim($allRefs)); + + if (!$sent) { + http_response_code(500); + echo json_encode(['error' => 'Sähköpostin lähetys epäonnistui']); + break 2; + } + + // Add reply to ticket + $reply = [ + 'id' => generateId(), + 'type' => 'reply_out', + 'from' => currentUser(), + 'from_name' => $_SESSION['nimi'] ?? currentUser(), + 'body' => $body, + 'timestamp' => date('Y-m-d H:i:s'), + 'message_id' => '', + ]; + $t['messages'][] = $reply; + $t['updated'] = date('Y-m-d H:i:s'); + if ($t['status'] === 'uusi') $t['status'] = 'kasittelyssa'; + + $found = true; + addLog('ticket_reply', $t['id'], $t['subject'], 'Vastasi tikettiin'); + 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_status': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? ''; + $status = $input['status'] ?? ''; + $validStatuses = ['uusi', 'kasittelyssa', 'odottaa', 'ratkaistu', 'suljettu']; + if (!in_array($status, $validStatuses)) { + http_response_code(400); + echo json_encode(['error' => 'Virheellinen tila']); + break; + } + $tickets = loadTickets(); + $found = false; + foreach ($tickets as &$t) { + if ($t['id'] === $id) { + $oldStatus = $t['status']; + $t['status'] = $status; + $t['updated'] = date('Y-m-d H:i:s'); + $found = true; + addLog('ticket_status', $t['id'], $t['subject'], "Tila: {$oldStatus} → {$status}"); + 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_assign': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? ''; + $assignTo = trim($input['assigned_to'] ?? ''); + $tickets = loadTickets(); + $found = false; + foreach ($tickets as &$t) { + if ($t['id'] === $id) { + $t['assigned_to'] = $assignTo; + $t['updated'] = date('Y-m-d H:i:s'); + $found = true; + addLog('ticket_assign', $t['id'], $t['subject'], "Osoitettu: {$assignTo}"); + 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_note': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? ''; + $body = trim($input['body'] ?? ''); + if (empty($body)) { + http_response_code(400); + echo json_encode(['error' => 'Muistiinpano ei voi olla tyhjä']); + break; + } + $tickets = loadTickets(); + $found = false; + foreach ($tickets as &$t) { + if ($t['id'] === $id) { + $note = [ + 'id' => generateId(), + 'type' => 'note', + 'from' => currentUser(), + 'from_name' => $_SESSION['nimi'] ?? currentUser(), + 'body' => $body, + 'timestamp' => date('Y-m-d H:i:s'), + 'message_id' => '', + ]; + $t['messages'][] = $note; + $t['updated'] = date('Y-m-d H:i:s'); + $found = true; + addLog('ticket_note', $t['id'], $t['subject'], 'Lisäsi muistiinpanon'); + 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_delete': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? ''; + $tickets = loadTickets(); + $deleted = null; + foreach ($tickets as $t) { + if ($t['id'] === $id) { $deleted = $t; break; } + } + $tickets = array_values(array_filter($tickets, fn($t) => $t['id'] !== $id)); + saveTickets($tickets); + if ($deleted) addLog('ticket_delete', $id, $deleted['subject'] ?? '', 'Poisti tiketin'); + echo json_encode(['success' => true]); + break; + default: http_response_code(404); echo json_encode(['error' => 'Tuntematon toiminto']); diff --git a/index.html b/index.html index 8da3e8e..744e2df 100644 --- a/index.html +++ b/index.html @@ -76,6 +76,7 @@ + @@ -235,6 +236,75 @@ + +
+
+ +
+
+ + + +
+ +
+ + + + + + + + + + + + +
TilaAiheLähettäjäViestejäOsoitettuPäivitetty
+ +
+
+ 0 tikettiä + +
+
+ + + +
+
+
@@ -281,6 +351,34 @@
+

Sähköposti (IMAP)

+

Asiakaspalvelu-sähköpostin IMAP-asetukset. Käytetään tikettien hakuun.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

API-ohjeet

Endpoint:
GET https://intra.cuitunet.fi/api.php?action=saatavuus
diff --git a/script.js b/script.js index 9f1a10b..970c2ba 100644 --- a/script.js +++ b/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 ` + ${ticketStatusLabels[t.status] || t.status} + ${esc(t.subject)} + ${esc(t.from_name || t.from_email)} + ${lastType} ${t.message_count} + ${esc(t.assigned_to || '-')} + ${esc((t.updated || '').substring(0, 16))} + `; + }).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 = ` +
+
+

${esc(ticket.subject)}

+
+ ${esc(ticket.from_name)} <${esc(ticket.from_email)}> · Luotu ${esc(ticket.created)} +
+
+
+ + + +
+
`; + + // 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 `
+
+ ${typeIcon} + ${esc(m.from_name || m.from)} + ${esc(m.timestamp)} +
+
${esc(m.body)}
+
`; + }).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); } diff --git a/style.css b/style.css index 7ec9338..7bbb053 100644 --- a/style.css +++ b/style.css @@ -1088,6 +1088,153 @@ span.empty { color: #888; } +/* Ticket status badges */ +.ticket-status { + display: inline-block; + padding: 3px 10px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.3px; + white-space: nowrap; +} + +.ticket-status-uusi { + background: #3498db; + color: #fff; +} + +.ticket-status-kasittelyssa { + background: #f39c12; + color: #fff; +} + +.ticket-status-odottaa { + background: #f1c40f; + color: #333; +} + +.ticket-status-ratkaistu { + background: #2ecc71; + color: #fff; +} + +.ticket-status-suljettu { + background: #bdc3c7; + color: #555; +} + +/* Ticket thread */ +.ticket-thread { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin: 1.25rem 0; + padding: 1rem 0; + border-top: 2px solid #f0f2f5; + border-bottom: 2px solid #f0f2f5; + max-height: 60vh; + overflow-y: auto; +} + +.ticket-message { + border-radius: 10px; + padding: 0.75rem 1rem; + position: relative; +} + +.ticket-msg-in { + background: #f0f7ff; + border-left: 3px solid #3498db; +} + +.ticket-msg-out { + background: #eafaf1; + border-left: 3px solid #2ecc71; +} + +.ticket-msg-note { + background: #fffbea; + border-left: 3px solid #f39c12; +} + +.ticket-msg-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + font-size: 0.82rem; + color: #555; + flex-wrap: wrap; +} + +.ticket-msg-type { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + opacity: 0.7; +} + +.ticket-msg-time { + margin-left: auto; + color: #999; + font-size: 0.78rem; +} + +.ticket-msg-body { + font-size: 0.9rem; + line-height: 1.6; + color: #333; + white-space: pre-wrap; + word-break: break-word; +} + +/* Ticket reply form */ +.ticket-reply-form { + padding-top: 1rem; +} + +.ticket-reply-form textarea { + width: 100%; + padding: 10px 12px; + border: 2px solid #e0e0e0; + border-radius: 8px; + font-size: 0.9rem; + font-family: inherit; + resize: vertical; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.ticket-reply-form textarea:focus { + outline: none; + border-color: #0f3460; + box-shadow: 0 0 0 3px rgba(15, 52, 96, 0.1); +} + +.btn-reply-tab { + background: #f0f2f5; + border: 2px solid transparent; + padding: 6px 14px; + border-radius: 8px; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + color: #888; + transition: all 0.15s; +} + +.btn-reply-tab:hover { + color: #555; + background: #e8ebf0; +} + +.btn-reply-tab.active { + color: #0f3460; + background: #fff; + border-color: #0f3460; +} + /* Changelog */ .nowrap { white-space: nowrap;