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:
550
api.php
550
api.php
@@ -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']);
|
||||||
|
|||||||
98
index.html
98
index.html
@@ -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">📧 Hae postit</button>
|
||||||
|
<div class="search-bar" style="flex:1;max-width:400px;">
|
||||||
|
<span class="search-icon">🔍</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">📧</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;">← 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">✉ Vastaa</button>
|
||||||
|
<button class="btn-reply-tab" data-reply-type="note">📝 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
274
script.js
@@ -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' ? '→' : (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 ====================
|
// ==================== 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
147
style.css
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user