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

550
api.php
View File

@@ -18,6 +18,7 @@ define('LEADS_FILE', DATA_DIR . '/leads.json');
define('TOKENS_FILE', DATA_DIR . '/reset_tokens.json'); define('TOKENS_FILE', DATA_DIR . '/reset_tokens.json');
define('RATE_FILE', DATA_DIR . '/login_attempts.json'); define('RATE_FILE', DATA_DIR . '/login_attempts.json');
define('CONFIG_FILE', DATA_DIR . '/config.json'); define('CONFIG_FILE', DATA_DIR . '/config.json');
define('TICKETS_FILE', DATA_DIR . '/tickets.json');
define('SITE_URL', 'https://intra.cuitunet.fi'); define('SITE_URL', 'https://intra.cuitunet.fi');
// Sähköpostiasetukset // Sähköpostiasetukset
@@ -26,7 +27,7 @@ define('MAIL_FROM_NAME', 'CuituNet Intra');
// Varmista data-kansio ja tiedostot // Varmista data-kansio ja tiedostot
if (!file_exists(DATA_DIR)) mkdir(DATA_DIR, 0755, true); 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, '[]'); 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); 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 ==================== // ==================== USERS ====================
function initUsers(): void { function initUsers(): void {
@@ -339,6 +566,12 @@ switch ($action) {
$origins = array_filter(array_map('trim', explode("\n", $input['cors_origins']))); $origins = array_filter(array_map('trim', explode("\n", $input['cors_origins'])));
$config['cors_origins'] = array_values($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); saveConfig($config);
addLog('config_update', '', '', 'Päivitti asetukset'); addLog('config_update', '', '', 'Päivitti asetukset');
echo json_encode($config); echo json_encode($config);
@@ -996,6 +1229,321 @@ switch ($action) {
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
break; 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: default:
http_response_code(404); http_response_code(404);
echo json_encode(['error' => 'Tuntematon toiminto']); echo json_encode(['error' => 'Tuntematon toiminto']);

View File

@@ -76,6 +76,7 @@
<button class="tab" data-tab="leads">Liidit</button> <button class="tab" data-tab="leads">Liidit</button>
<button class="tab" data-tab="archive">Arkisto</button> <button class="tab" data-tab="archive">Arkisto</button>
<button class="tab" data-tab="changelog">Muutosloki</button> <button class="tab" data-tab="changelog">Muutosloki</button>
<button class="tab" data-tab="support">Asiakaspalvelu</button>
<button class="tab" data-tab="settings" id="tab-settings" style="display:none">API</button> <button class="tab" data-tab="settings" id="tab-settings" style="display:none">API</button>
</div> </div>
@@ -235,6 +236,75 @@
</div> </div>
</div> </div>
<!-- Tab: Asiakaspalvelu -->
<div class="tab-content" id="tab-content-support">
<div class="main-container">
<!-- Listanäkymä -->
<div id="ticket-list-view">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;gap:0.75rem;flex-wrap:wrap;">
<button class="btn-primary" id="btn-fetch-emails">&#128231; Hae postit</button>
<div class="search-bar" style="flex:1;max-width:400px;">
<span class="search-icon">&#128269;</span>
<input type="text" id="ticket-search-input" placeholder="Hae tiketeistä...">
</div>
<select id="ticket-status-filter" style="padding:9px 12px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.88rem;">
<option value="">Kaikki tilat</option>
<option value="uusi">Uusi</option>
<option value="kasittelyssa">Käsittelyssä</option>
<option value="odottaa">Odottaa vastausta</option>
<option value="ratkaistu">Ratkaistu</option>
<option value="suljettu">Suljettu</option>
</select>
</div>
<div id="ticket-fetch-status" style="display:none;padding:0.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:0.9rem;"></div>
<div class="table-card">
<table id="tickets-table">
<thead>
<tr>
<th>Tila</th>
<th>Aihe</th>
<th>Lähettäjä</th>
<th>Viestejä</th>
<th>Osoitettu</th>
<th>Päivitetty</th>
</tr>
</thead>
<tbody id="tickets-tbody"></tbody>
</table>
<div id="no-tickets" class="empty-state" style="display:none">
<div class="empty-icon">&#128231;</div>
<p>Ei tikettejä.</p>
<p class="empty-hint">Klikkaa "Hae postit" hakeaksesi sähköpostit.</p>
</div>
</div>
<div class="summary-bar">
<span id="ticket-count">0 tikettiä</span>
<span id="ticket-status-summary"></span>
</div>
</div>
<!-- Yksittäisen tiketin näkymä -->
<div id="ticket-detail-view" style="display:none;">
<button class="btn-secondary" id="btn-ticket-back" style="color:#555;border-color:#ddd;margin-bottom:1rem;">&#8592; Takaisin listaan</button>
<div class="table-card" style="padding:1.5rem;">
<div id="ticket-detail-header"></div>
<div id="ticket-thread" class="ticket-thread"></div>
<!-- Vastauslomake -->
<div class="ticket-reply-form">
<div style="display:flex;gap:0.5rem;margin-bottom:0.75rem;">
<button class="btn-reply-tab active" data-reply-type="reply">&#9993; Vastaa</button>
<button class="btn-reply-tab" data-reply-type="note">&#128221; Muistiinpano</button>
</div>
<textarea id="ticket-reply-body" rows="5" placeholder="Kirjoita vastaus..."></textarea>
<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:0.5rem;">
<button class="btn-primary" id="btn-send-reply">Lähetä vastaus</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tab: Käyttäjät (vain admin) --> <!-- Tab: Käyttäjät (vain admin) -->
<div class="tab-content" id="tab-content-users"> <div class="tab-content" id="tab-content-users">
<div class="main-container"> <div class="main-container">
@@ -281,6 +351,34 @@
<button class="btn-primary" id="btn-save-settings">Tallenna asetukset</button> <button class="btn-primary" id="btn-save-settings">Tallenna asetukset</button>
</div> </div>
</div> </div>
<h3 style="color:#0f3460;margin:1.5rem 0 1rem;border-bottom:2px solid #f0f2f5;padding-bottom:0.5rem;">Sähköposti (IMAP)</h3>
<p style="color:#666;font-size:0.85rem;margin-bottom:1rem;">Asiakaspalvelu-sähköpostin IMAP-asetukset. Käytetään tikettien hakuun.</p>
<div class="form-grid" style="max-width:600px;">
<div class="form-group">
<label>IMAP-palvelin</label>
<input type="text" id="settings-imap-host" placeholder="mail.example.com">
</div>
<div class="form-group">
<label>Portti</label>
<input type="number" id="settings-imap-port" value="993" placeholder="993">
</div>
<div class="form-group">
<label>Käyttäjätunnus</label>
<input type="text" id="settings-imap-user" placeholder="asiakaspalvelu@cuitunet.fi">
</div>
<div class="form-group">
<label>Salasana</label>
<input type="password" id="settings-imap-password" placeholder="••••••••">
</div>
<div class="form-group">
<label>Salaus</label>
<select id="settings-imap-encryption">
<option value="ssl">SSL</option>
<option value="tls">TLS</option>
<option value="notls">Ei salausta</option>
</select>
</div>
</div>
<h3 style="color:#0f3460;margin:1.5rem 0 1rem;border-bottom:2px solid #f0f2f5;padding-bottom:0.5rem;">API-ohjeet</h3> <h3 style="color:#0f3460;margin:1.5rem 0 1rem;border-bottom:2px solid #f0f2f5;padding-bottom:0.5rem;">API-ohjeet</h3>
<div style="background:#f8f9fb;padding:1rem;border-radius:8px;font-size:0.85rem;font-family:monospace;overflow-x:auto;"> <div style="background:#f8f9fb;padding:1rem;border-radius:8px;font-size:0.85rem;font-family:monospace;overflow-x:auto;">
<div style="margin-bottom:0.75rem;"><strong>Endpoint:</strong><br>GET https://intra.cuitunet.fi/api.php?action=saatavuus</div> <div style="margin-bottom:0.75rem;"><strong>Endpoint:</strong><br>GET https://intra.cuitunet.fi/api.php?action=saatavuus</div>

274
script.js
View File

@@ -186,6 +186,7 @@ document.querySelectorAll('.tab').forEach(tab => {
if (target === 'leads') loadLeads(); if (target === 'leads') loadLeads();
if (target === 'archive') loadArchive(); if (target === 'archive') loadArchive();
if (target === 'changelog') loadChangelog(); if (target === 'changelog') loadChangelog();
if (target === 'support') { loadTickets(); showTicketListView(); }
if (target === 'users') loadUsers(); if (target === 'users') loadUsers();
if (target === 'settings') loadSettings(); if (target === 'settings') loadSettings();
}); });
@@ -838,6 +839,12 @@ const actionLabels = {
lead_delete: 'Poisti liidin', lead_delete: 'Poisti liidin',
lead_to_customer: 'Muutti liidin asiakkaaksi', lead_to_customer: 'Muutti liidin asiakkaaksi',
config_update: 'Päivitti asetukset', 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() { async function loadChangelog() {
@@ -937,6 +944,262 @@ document.getElementById('user-form').addEventListener('submit', async (e) => {
} catch (e) { alert(e.message); } } 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 ==================== // ==================== SETTINGS ====================
async function loadSettings() { 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'); document.getElementById('settings-cors').value = (config.cors_origins || ['https://cuitunet.fi', 'https://www.cuitunet.fi']).join('\n');
const key = config.api_key || 'AVAIN'; 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`; 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); } } catch (e) { console.error(e); }
} }
@@ -962,6 +1231,11 @@ document.getElementById('btn-save-settings').addEventListener('click', async ()
const config = await apiCall('config_update', 'POST', { const config = await apiCall('config_update', 'POST', {
api_key: document.getElementById('settings-api-key').value, api_key: document.getElementById('settings-api-key').value,
cors_origins: document.getElementById('settings-cors').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!'); alert('Asetukset tallennettu!');
} catch (e) { alert(e.message); } } catch (e) { alert(e.message); }

147
style.css
View File

@@ -1088,6 +1088,153 @@ span.empty {
color: #888; 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 */ /* Changelog */
.nowrap { .nowrap {
white-space: nowrap; white-space: nowrap;