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