feat: ticket reply improvements + priority + templates + Telegram
Reply form: - Mailbox/sender selection dropdown (choose which email to reply from) - CC field (auto-filled from incoming email CC, editable) - Reply templates dropdown (quick insert pre-made responses) Priority system: - Three levels: normaali, tärkeä, urgent - Priority dropdown in ticket detail view - Priority-based sorting (urgent/tärkeä always on top) - Visual indicators in ticket list (colored rows, emoji badges) - Priority emails: per-company email list that auto-sets "tärkeä" Response templates: - CRUD management in Settings tab - Dropdown selector in reply form - Templates insert into textarea Telegram alerts: - Bot token + chat ID configuration in Settings - Test button to verify connection - Auto-alert on urgent tickets (both manual and from email fetch) - Alert on priority email matches Database changes: - New tables: reply_templates, customer_priority_emails - New columns: tickets.cc, tickets.priority - ALTER TABLE migration in initDatabase() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
212
api.php
212
api.php
@@ -262,6 +262,10 @@ class ImapClient {
|
|||||||
$date = $dateStr ? @date('Y-m-d H:i:s', strtotime($dateStr)) : date('Y-m-d H:i:s');
|
$date = $dateStr ? @date('Y-m-d H:i:s', strtotime($dateStr)) : date('Y-m-d H:i:s');
|
||||||
if (!$date) $date = date('Y-m-d H:i:s');
|
if (!$date) $date = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
// Parse CC
|
||||||
|
$ccRaw = $this->decodeMimeHeader($headers['cc'] ?? '');
|
||||||
|
$ccEmails = $this->parseCcAddresses($ccRaw);
|
||||||
|
|
||||||
// Fetch body (text part)
|
// Fetch body (text part)
|
||||||
$body = $this->fetchBody($num);
|
$body = $this->fetchBody($num);
|
||||||
|
|
||||||
@@ -274,6 +278,7 @@ class ImapClient {
|
|||||||
'references' => $references,
|
'references' => $references,
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'body' => $body,
|
'body' => $body,
|
||||||
|
'cc' => $ccEmails,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +381,22 @@ class ImapClient {
|
|||||||
return ['name' => '', 'email' => $from];
|
return ['name' => '', 'email' => $from];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function parseCcAddresses(string $cc): string {
|
||||||
|
$cc = trim($cc);
|
||||||
|
if (!$cc) return '';
|
||||||
|
// Parse "Name <email>, Name2 <email2>" or "email1, email2"
|
||||||
|
$emails = [];
|
||||||
|
// Split on comma, but be careful with quoted strings
|
||||||
|
$parts = preg_split('/,\s*(?=(?:[^"]*"[^"]*")*[^"]*$)/', $cc);
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$part = trim($part);
|
||||||
|
if (!$part) continue;
|
||||||
|
$parsed = $this->parseFrom($part);
|
||||||
|
if ($parsed['email']) $emails[] = $parsed['email'];
|
||||||
|
}
|
||||||
|
return implode(', ', $emails);
|
||||||
|
}
|
||||||
|
|
||||||
private function decodeMimeHeader(string $str): string {
|
private function decodeMimeHeader(string $str): string {
|
||||||
if (strpos($str, '=?') === false) return trim($str);
|
if (strpos($str, '=?') === false) return trim($str);
|
||||||
$decoded = '';
|
$decoded = '';
|
||||||
@@ -496,7 +517,39 @@ class ImapClient {
|
|||||||
|
|
||||||
// ==================== TICKETS HELPER ====================
|
// ==================== TICKETS HELPER ====================
|
||||||
|
|
||||||
function sendTicketMail(string $to, string $subject, string $body, string $inReplyTo = '', string $references = '', ?array $mailbox = null): bool {
|
function sendTelegramAlert(string $companyId, array $ticket): void {
|
||||||
|
$config = dbLoadConfig();
|
||||||
|
$botToken = $config['telegram_bot_token'] ?? '';
|
||||||
|
$chatId = $config['telegram_chat_id'] ?? '';
|
||||||
|
if (!$botToken || !$chatId) return;
|
||||||
|
|
||||||
|
$text = "🚨 *URGENT TIKETTI*\n\n";
|
||||||
|
$text .= "📋 *" . ($ticket['subject'] ?? '(Ei aihetta)') . "*\n";
|
||||||
|
$text .= "👤 " . ($ticket['from_name'] ?? $ticket['from_email'] ?? 'Tuntematon') . "\n";
|
||||||
|
$text .= "📧 " . ($ticket['from_email'] ?? '') . "\n";
|
||||||
|
$text .= "🏢 " . $companyId . "\n";
|
||||||
|
$text .= "🕐 " . date('d.m.Y H:i');
|
||||||
|
|
||||||
|
$url = "https://api.telegram.org/bot{$botToken}/sendMessage";
|
||||||
|
$data = [
|
||||||
|
'chat_id' => $chatId,
|
||||||
|
'text' => $text,
|
||||||
|
'parse_mode' => 'Markdown',
|
||||||
|
];
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($data),
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendTicketMail(string $to, string $subject, string $body, string $inReplyTo = '', string $references = '', ?array $mailbox = null, string $cc = ''): bool {
|
||||||
$fromEmail = $mailbox['smtp_from_email'] ?? $mailbox['imap_user'] ?? MAIL_FROM;
|
$fromEmail = $mailbox['smtp_from_email'] ?? $mailbox['imap_user'] ?? MAIL_FROM;
|
||||||
$fromName = $mailbox['smtp_from_name'] ?? $mailbox['nimi'] ?? 'Asiakaspalvelu';
|
$fromName = $mailbox['smtp_from_name'] ?? $mailbox['nimi'] ?? 'Asiakaspalvelu';
|
||||||
|
|
||||||
@@ -504,6 +557,9 @@ function sendTicketMail(string $to, string $subject, string $body, string $inRep
|
|||||||
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||||
$headers .= "From: {$fromName} <{$fromEmail}>\r\n";
|
$headers .= "From: {$fromName} <{$fromEmail}>\r\n";
|
||||||
$headers .= "Reply-To: {$fromEmail}\r\n";
|
$headers .= "Reply-To: {$fromEmail}\r\n";
|
||||||
|
if ($cc) {
|
||||||
|
$headers .= "Cc: {$cc}\r\n";
|
||||||
|
}
|
||||||
if ($inReplyTo) {
|
if ($inReplyTo) {
|
||||||
$headers .= "In-Reply-To: {$inReplyTo}\r\n";
|
$headers .= "In-Reply-To: {$inReplyTo}\r\n";
|
||||||
$headers .= "References: " . ($references ? $references . ' ' : '') . $inReplyTo . "\r\n";
|
$headers .= "References: " . ($references ? $references . ' ' : '') . $inReplyTo . "\r\n";
|
||||||
@@ -599,9 +655,12 @@ switch ($action) {
|
|||||||
case 'config':
|
case 'config':
|
||||||
requireAdmin();
|
requireAdmin();
|
||||||
$companyId = requireCompany();
|
$companyId = requireCompany();
|
||||||
|
$globalConf = dbLoadConfig();
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'api_key' => dbGetCompanyApiKey($companyId),
|
'api_key' => dbGetCompanyApiKey($companyId),
|
||||||
'cors_origins' => dbGetCompanyCorsOrigins($companyId),
|
'cors_origins' => dbGetCompanyCorsOrigins($companyId),
|
||||||
|
'telegram_bot_token' => $globalConf['telegram_bot_token'] ?? '',
|
||||||
|
'telegram_chat_id' => $globalConf['telegram_chat_id'] ?? '',
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -617,13 +676,46 @@ switch ($action) {
|
|||||||
$origins = array_filter(array_map('trim', explode("\n", $input['cors_origins'])));
|
$origins = array_filter(array_map('trim', explode("\n", $input['cors_origins'])));
|
||||||
dbSetCompanyCorsOrigins($companyId, array_values($origins));
|
dbSetCompanyCorsOrigins($companyId, array_values($origins));
|
||||||
}
|
}
|
||||||
dbAddLog($companyId, currentUser(), 'config_update', '', '', 'Päivitti API-asetukset');
|
// Telegram-asetukset (globaalit, tallennetaan config-tauluun)
|
||||||
|
if (isset($input['telegram_bot_token'])) {
|
||||||
|
dbSaveConfig(['telegram_bot_token' => trim($input['telegram_bot_token'])]);
|
||||||
|
}
|
||||||
|
if (isset($input['telegram_chat_id'])) {
|
||||||
|
dbSaveConfig(['telegram_chat_id' => trim($input['telegram_chat_id'])]);
|
||||||
|
}
|
||||||
|
dbAddLog($companyId, currentUser(), 'config_update', '', '', 'Päivitti asetuksia');
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'api_key' => dbGetCompanyApiKey($companyId),
|
'api_key' => dbGetCompanyApiKey($companyId),
|
||||||
'cors_origins' => dbGetCompanyCorsOrigins($companyId),
|
'cors_origins' => dbGetCompanyCorsOrigins($companyId),
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'telegram_test':
|
||||||
|
requireAdmin();
|
||||||
|
if ($method !== 'POST') break;
|
||||||
|
$config = dbLoadConfig();
|
||||||
|
$botToken = $config['telegram_bot_token'] ?? '';
|
||||||
|
$chatId = $config['telegram_chat_id'] ?? '';
|
||||||
|
if (!$botToken || !$chatId) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Telegram Bot Token ja Chat ID vaaditaan']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$url = "https://api.telegram.org/bot{$botToken}/sendMessage";
|
||||||
|
$data = ['chat_id' => $chatId, 'text' => '✅ Noxus Intra Telegram-hälytys toimii!', 'parse_mode' => 'Markdown'];
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($data), CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5]);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($httpCode === 200) {
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} else {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Telegram virhe: ' . $resp]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case 'generate_api_key':
|
case 'generate_api_key':
|
||||||
requireAdmin();
|
requireAdmin();
|
||||||
$companyId = requireCompany();
|
$companyId = requireCompany();
|
||||||
@@ -1639,6 +1731,12 @@ switch ($action) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Tarkista onko lähettäjä priority-listalla
|
||||||
|
$ticketPriority = 'normaali';
|
||||||
|
if (dbIsPriorityEmail($companyId, $email['from_email'])) {
|
||||||
|
$ticketPriority = 'tärkeä';
|
||||||
|
}
|
||||||
|
|
||||||
$ticket = [
|
$ticket = [
|
||||||
'id' => generateId(),
|
'id' => generateId(),
|
||||||
'subject' => $email['subject'] ?: '(Ei aihetta)',
|
'subject' => $email['subject'] ?: '(Ei aihetta)',
|
||||||
@@ -1650,6 +1748,8 @@ switch ($action) {
|
|||||||
'customer_id' => '',
|
'customer_id' => '',
|
||||||
'customer_name' => '',
|
'customer_name' => '',
|
||||||
'tags' => [],
|
'tags' => [],
|
||||||
|
'cc' => $email['cc'] ?? '',
|
||||||
|
'priority' => $ticketPriority,
|
||||||
'auto_close_at' => '',
|
'auto_close_at' => '',
|
||||||
'mailbox_id' => $mailbox['id'],
|
'mailbox_id' => $mailbox['id'],
|
||||||
'created' => $email['date'],
|
'created' => $email['date'],
|
||||||
@@ -1692,6 +1792,10 @@ switch ($action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dbSaveTicket($companyId, $ticket);
|
dbSaveTicket($companyId, $ticket);
|
||||||
|
// Telegram-hälytys tärkeille/urgentille
|
||||||
|
if ($ticket['priority'] === 'urgent' || $ticket['priority'] === 'tärkeä') {
|
||||||
|
sendTelegramAlert($companyId, $ticket);
|
||||||
|
}
|
||||||
$newCount++;
|
$newCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1714,6 +1818,8 @@ switch ($action) {
|
|||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
$id = $input['id'] ?? '';
|
$id = $input['id'] ?? '';
|
||||||
$body = trim($input['body'] ?? '');
|
$body = trim($input['body'] ?? '');
|
||||||
|
$replyMailboxId = $input['mailbox_id'] ?? '';
|
||||||
|
$replyCc = trim($input['cc'] ?? '');
|
||||||
if (empty($body)) {
|
if (empty($body)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['error' => 'Viesti ei voi olla tyhjä']);
|
echo json_encode(['error' => 'Viesti ei voi olla tyhjä']);
|
||||||
@@ -1734,10 +1840,12 @@ switch ($action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send email — hae postilaatikon asetukset
|
// Send email — hae postilaatikon asetukset
|
||||||
|
// Käytä frontendistä valittua mailboxia tai tiketin oletusta
|
||||||
$companyConf = dbGetCompanyConfig($companyId);
|
$companyConf = dbGetCompanyConfig($companyId);
|
||||||
|
$useMailboxId = $replyMailboxId ?: ($t['mailbox_id'] ?? '');
|
||||||
$replyMailbox = null;
|
$replyMailbox = null;
|
||||||
foreach ($companyConf['mailboxes'] ?? [] as $mb) {
|
foreach ($companyConf['mailboxes'] ?? [] as $mb) {
|
||||||
if ($mb['id'] === ($t['mailbox_id'] ?? '')) { $replyMailbox = $mb; break; }
|
if ($mb['id'] === $useMailboxId) { $replyMailbox = $mb; break; }
|
||||||
}
|
}
|
||||||
// Fallback: käytä ensimmäistä postilaatikkoa
|
// Fallback: käytä ensimmäistä postilaatikkoa
|
||||||
if (!$replyMailbox && !empty($companyConf['mailboxes'])) {
|
if (!$replyMailbox && !empty($companyConf['mailboxes'])) {
|
||||||
@@ -1745,7 +1853,7 @@ switch ($action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hae käyttäjän allekirjoitus tälle postilaatikolle
|
// Hae käyttäjän allekirjoitus tälle postilaatikolle
|
||||||
$mailboxId = $t['mailbox_id'] ?? '';
|
$mailboxId = $replyMailbox['id'] ?? '';
|
||||||
$signature = '';
|
$signature = '';
|
||||||
$sigUser = dbGetUser($_SESSION['user_id']);
|
$sigUser = dbGetUser($_SESSION['user_id']);
|
||||||
if ($sigUser) {
|
if ($sigUser) {
|
||||||
@@ -1753,8 +1861,11 @@ switch ($action) {
|
|||||||
}
|
}
|
||||||
$emailBody = $signature ? $body . "\n\n-- \n" . $signature : $body;
|
$emailBody = $signature ? $body . "\n\n-- \n" . $signature : $body;
|
||||||
|
|
||||||
|
// CC: käytä frontendistä annettua CC:tä, tai tiketin alkuperäistä CC:tä
|
||||||
|
$ccToSend = $replyCc !== '' ? $replyCc : ($t['cc'] ?? '');
|
||||||
|
|
||||||
$subject = 'Re: ' . $t['subject'];
|
$subject = 'Re: ' . $t['subject'];
|
||||||
$sent = sendTicketMail($t['from_email'], $subject, $emailBody, $lastMsgId, trim($allRefs), $replyMailbox);
|
$sent = sendTicketMail($t['from_email'], $subject, $emailBody, $lastMsgId, trim($allRefs), $replyMailbox, $ccToSend);
|
||||||
|
|
||||||
if (!$sent) {
|
if (!$sent) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
@@ -1762,6 +1873,11 @@ switch ($action) {
|
|||||||
break 2;
|
break 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Päivitä tiketin CC jos muuttunut
|
||||||
|
if ($replyCc !== '' && $replyCc !== ($t['cc'] ?? '')) {
|
||||||
|
$t['cc'] = $replyCc;
|
||||||
|
}
|
||||||
|
|
||||||
// Add reply to ticket (tallennetaan allekirjoituksen kanssa)
|
// Add reply to ticket (tallennetaan allekirjoituksen kanssa)
|
||||||
$reply = [
|
$reply = [
|
||||||
'id' => generateId(),
|
'id' => generateId(),
|
||||||
@@ -2082,6 +2198,92 @@ switch ($action) {
|
|||||||
echo json_encode(['success' => true]);
|
echo json_encode(['success' => true]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'ticket_priority':
|
||||||
|
requireAuth();
|
||||||
|
$companyId = requireCompanyOrParam();
|
||||||
|
if ($method !== 'POST') break;
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$id = $input['id'] ?? '';
|
||||||
|
$priority = $input['priority'] ?? 'normaali';
|
||||||
|
if (!in_array($priority, ['normaali', 'tärkeä', 'urgent'])) $priority = 'normaali';
|
||||||
|
$tickets = dbLoadTickets($companyId);
|
||||||
|
foreach ($tickets as $t) {
|
||||||
|
if ($t['id'] === $id) {
|
||||||
|
$t['priority'] = $priority;
|
||||||
|
$t['updated'] = date('Y-m-d H:i:s');
|
||||||
|
dbSaveTicket($companyId, $t);
|
||||||
|
// Telegram-hälytys urgentille
|
||||||
|
if ($priority === 'urgent') {
|
||||||
|
sendTelegramAlert($companyId, $t);
|
||||||
|
}
|
||||||
|
echo json_encode($t);
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'Tikettiä ei löydy']);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ---------- VASTAUSPOHJAT ----------
|
||||||
|
case 'reply_templates':
|
||||||
|
requireAuth();
|
||||||
|
$companyId = requireCompany();
|
||||||
|
echo json_encode(dbLoadTemplates($companyId));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'reply_template_save':
|
||||||
|
requireAuth();
|
||||||
|
$companyId = requireCompany();
|
||||||
|
if ($method !== 'POST') break;
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (empty($input['nimi']) || empty($input['body'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Nimi ja sisältö vaaditaan']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$tpl = [
|
||||||
|
'id' => $input['id'] ?? generateId(),
|
||||||
|
'nimi' => trim($input['nimi']),
|
||||||
|
'body' => trim($input['body']),
|
||||||
|
'sort_order' => intval($input['sort_order'] ?? 0),
|
||||||
|
];
|
||||||
|
dbSaveTemplate($companyId, $tpl);
|
||||||
|
echo json_encode(['success' => true, 'template' => $tpl]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'reply_template_delete':
|
||||||
|
requireAuth();
|
||||||
|
$companyId = requireCompany();
|
||||||
|
if ($method !== 'POST') break;
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
dbDeleteTemplate($input['id'] ?? '');
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ---------- PRIORITY EMAILS ----------
|
||||||
|
case 'priority_emails':
|
||||||
|
requireAuth();
|
||||||
|
$companyId = requireCompany();
|
||||||
|
echo json_encode(dbLoadPriorityEmails($companyId));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'priority_emails_save':
|
||||||
|
requireAuth();
|
||||||
|
$companyId = requireCompany();
|
||||||
|
if ($method !== 'POST') break;
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$emails = $input['emails'] ?? [];
|
||||||
|
// Poista vanhat ja lisää uudet
|
||||||
|
_dbExecute("DELETE FROM customer_priority_emails WHERE company_id = ?", [$companyId]);
|
||||||
|
foreach ($emails as $email) {
|
||||||
|
$email = strtolower(trim($email));
|
||||||
|
if ($email) {
|
||||||
|
_dbExecute("INSERT IGNORE INTO customer_priority_emails (company_id, email) VALUES (?, ?)", [$companyId, $email]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
break;
|
||||||
|
|
||||||
// ---------- COMPANY MANAGEMENT ----------
|
// ---------- COMPANY MANAGEMENT ----------
|
||||||
case 'companies':
|
case 'companies':
|
||||||
requireAuth();
|
requireAuth();
|
||||||
|
|||||||
82
db.php
82
db.php
@@ -246,6 +246,8 @@ function initDatabase(): void {
|
|||||||
customer_name VARCHAR(255) DEFAULT '',
|
customer_name VARCHAR(255) DEFAULT '',
|
||||||
message_id VARCHAR(500) DEFAULT '',
|
message_id VARCHAR(500) DEFAULT '',
|
||||||
mailbox_id VARCHAR(20) DEFAULT '',
|
mailbox_id VARCHAR(20) DEFAULT '',
|
||||||
|
cc TEXT DEFAULT '',
|
||||||
|
priority VARCHAR(20) DEFAULT 'normaali',
|
||||||
auto_close_at VARCHAR(30) DEFAULT '',
|
auto_close_at VARCHAR(30) DEFAULT '',
|
||||||
created DATETIME,
|
created DATETIME,
|
||||||
updated DATETIME,
|
updated DATETIME,
|
||||||
@@ -328,6 +330,25 @@ function initDatabase(): void {
|
|||||||
INDEX idx_company (company_id)
|
INDEX idx_company (company_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS customer_priority_emails (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
company_id VARCHAR(50) NOT NULL,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_company (company_id),
|
||||||
|
UNIQUE KEY udx_company_email (company_id, email)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS reply_templates (
|
||||||
|
id VARCHAR(20) PRIMARY KEY,
|
||||||
|
company_id VARCHAR(50) NOT NULL,
|
||||||
|
nimi VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_company (company_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||||
|
|
||||||
"CREATE TABLE IF NOT EXISTS files (
|
"CREATE TABLE IF NOT EXISTS files (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
company_id VARCHAR(50) NOT NULL,
|
company_id VARCHAR(50) NOT NULL,
|
||||||
@@ -347,6 +368,15 @@ function initDatabase(): void {
|
|||||||
throw new RuntimeException("Taulun luonti epäonnistui (taulu #" . ($i+1) . "): " . $db->error);
|
throw new RuntimeException("Taulun luonti epäonnistui (taulu #" . ($i+1) . "): " . $db->error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ALTER TABLE -migraatiot (turvallisia, ajetaan kerran)
|
||||||
|
$alters = [
|
||||||
|
"ALTER TABLE tickets ADD COLUMN cc TEXT DEFAULT '' AFTER mailbox_id",
|
||||||
|
"ALTER TABLE tickets ADD COLUMN priority VARCHAR(20) DEFAULT 'normaali' AFTER cc",
|
||||||
|
];
|
||||||
|
foreach ($alters as $sql) {
|
||||||
|
try { $db->query($sql); } catch (\Throwable $e) { /* sarake on jo olemassa */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== YRITYKSET ====================
|
// ==================== YRITYKSET ====================
|
||||||
@@ -769,14 +799,15 @@ function dbSaveTicket(string $companyId, array $ticket): void {
|
|||||||
try {
|
try {
|
||||||
_dbExecute("
|
_dbExecute("
|
||||||
INSERT INTO tickets (id, company_id, subject, from_email, from_name, status, type,
|
INSERT INTO tickets (id, company_id, subject, from_email, from_name, status, type,
|
||||||
assigned_to, customer_id, customer_name, message_id, mailbox_id, auto_close_at, created, updated)
|
assigned_to, customer_id, customer_name, message_id, mailbox_id, cc, priority, auto_close_at, created, updated)
|
||||||
VALUES (:id, :company_id, :subject, :from_email, :from_name, :status, :type,
|
VALUES (:id, :company_id, :subject, :from_email, :from_name, :status, :type,
|
||||||
:assigned_to, :customer_id, :customer_name, :message_id, :mailbox_id, :auto_close_at, :created, :updated)
|
:assigned_to, :customer_id, :customer_name, :message_id, :mailbox_id, :cc, :priority, :auto_close_at, :created, :updated)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
subject = VALUES(subject), from_email = VALUES(from_email), from_name = VALUES(from_name),
|
subject = VALUES(subject), from_email = VALUES(from_email), from_name = VALUES(from_name),
|
||||||
status = VALUES(status), type = VALUES(type), assigned_to = VALUES(assigned_to),
|
status = VALUES(status), type = VALUES(type), assigned_to = VALUES(assigned_to),
|
||||||
customer_id = VALUES(customer_id), customer_name = VALUES(customer_name),
|
customer_id = VALUES(customer_id), customer_name = VALUES(customer_name),
|
||||||
message_id = VALUES(message_id), mailbox_id = VALUES(mailbox_id),
|
message_id = VALUES(message_id), mailbox_id = VALUES(mailbox_id),
|
||||||
|
cc = VALUES(cc), priority = VALUES(priority),
|
||||||
auto_close_at = VALUES(auto_close_at), updated = VALUES(updated)
|
auto_close_at = VALUES(auto_close_at), updated = VALUES(updated)
|
||||||
", [
|
", [
|
||||||
'id' => $ticket['id'],
|
'id' => $ticket['id'],
|
||||||
@@ -791,6 +822,8 @@ function dbSaveTicket(string $companyId, array $ticket): void {
|
|||||||
'customer_name' => $ticket['customer_name'] ?? '',
|
'customer_name' => $ticket['customer_name'] ?? '',
|
||||||
'message_id' => $ticket['message_id'] ?? '',
|
'message_id' => $ticket['message_id'] ?? '',
|
||||||
'mailbox_id' => $ticket['mailbox_id'] ?? '',
|
'mailbox_id' => $ticket['mailbox_id'] ?? '',
|
||||||
|
'cc' => $ticket['cc'] ?? '',
|
||||||
|
'priority' => $ticket['priority'] ?? 'normaali',
|
||||||
'auto_close_at' => $ticket['auto_close_at'] ?? '',
|
'auto_close_at' => $ticket['auto_close_at'] ?? '',
|
||||||
'created' => $ticket['created'] ?? date('Y-m-d H:i:s'),
|
'created' => $ticket['created'] ?? date('Y-m-d H:i:s'),
|
||||||
'updated' => $ticket['updated'] ?? date('Y-m-d H:i:s'),
|
'updated' => $ticket['updated'] ?? date('Y-m-d H:i:s'),
|
||||||
@@ -1015,3 +1048,48 @@ function dbSetCompanyApiKey(string $companyId, string $apiKey): void {
|
|||||||
function dbSetCompanyCorsOrigins(string $companyId, array $origins): void {
|
function dbSetCompanyCorsOrigins(string $companyId, array $origins): void {
|
||||||
_dbExecute("UPDATE companies SET cors_origins = ? WHERE id = ?", [json_encode($origins), $companyId]);
|
_dbExecute("UPDATE companies SET cors_origins = ? WHERE id = ?", [json_encode($origins), $companyId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== VASTAUSPOHJAT ====================
|
||||||
|
|
||||||
|
function dbLoadTemplates(string $companyId): array {
|
||||||
|
$templates = _dbFetchAll("SELECT * FROM reply_templates WHERE company_id = ? ORDER BY sort_order, nimi", [$companyId]);
|
||||||
|
foreach ($templates as &$t) {
|
||||||
|
$t['sort_order'] = (int)$t['sort_order'];
|
||||||
|
unset($t['company_id']);
|
||||||
|
}
|
||||||
|
return $templates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbSaveTemplate(string $companyId, array $tpl): void {
|
||||||
|
_dbExecute("
|
||||||
|
INSERT INTO reply_templates (id, company_id, nimi, body, sort_order)
|
||||||
|
VALUES (:id, :company_id, :nimi, :body, :sort_order)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
nimi = VALUES(nimi), body = VALUES(body), sort_order = VALUES(sort_order)
|
||||||
|
", [
|
||||||
|
'id' => $tpl['id'],
|
||||||
|
'company_id' => $companyId,
|
||||||
|
'nimi' => $tpl['nimi'] ?? '',
|
||||||
|
'body' => $tpl['body'] ?? '',
|
||||||
|
'sort_order' => $tpl['sort_order'] ?? 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbDeleteTemplate(string $templateId): void {
|
||||||
|
_dbExecute("DELETE FROM reply_templates WHERE id = ?", [$templateId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PRIORITY EMAILS (ASIAKKUUDET) ====================
|
||||||
|
|
||||||
|
function dbLoadPriorityEmails(string $companyId): array {
|
||||||
|
return _dbFetchColumn("SELECT email FROM customer_priority_emails WHERE company_id = ?", [$companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbIsPriorityEmail(string $companyId, string $email): bool {
|
||||||
|
$email = strtolower(trim($email));
|
||||||
|
if (!$email) return false;
|
||||||
|
return (bool)_dbFetchScalar(
|
||||||
|
"SELECT COUNT(*) FROM customer_priority_emails WHERE company_id = ? AND LOWER(email) = ?",
|
||||||
|
[$companyId, $email]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
72
index.html
72
index.html
@@ -328,9 +328,24 @@
|
|||||||
<div id="ticket-thread" class="ticket-thread"></div>
|
<div id="ticket-thread" class="ticket-thread"></div>
|
||||||
<!-- Vastauslomake -->
|
<!-- Vastauslomake -->
|
||||||
<div class="ticket-reply-form">
|
<div class="ticket-reply-form">
|
||||||
<div style="display:flex;gap:0.5rem;margin-bottom:0.75rem;">
|
<div style="display:flex;gap:0.5rem;margin-bottom:0.75rem;align-items:center;">
|
||||||
<button class="btn-reply-tab active" data-reply-type="reply">✉ Vastaa</button>
|
<button class="btn-reply-tab active" data-reply-type="reply">✉ Vastaa</button>
|
||||||
<button class="btn-reply-tab" data-reply-type="note">📝 Muistiinpano</button>
|
<button class="btn-reply-tab" data-reply-type="note">📝 Muistiinpano</button>
|
||||||
|
<div id="reply-template-select-wrap" style="margin-left:auto;">
|
||||||
|
<select id="reply-template-select" style="padding:5px 10px;border:1px solid #ddd;border-radius:6px;font-size:0.82rem;color:#555;">
|
||||||
|
<option value="">📝 Vastauspohjat...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="reply-meta-fields" style="display:flex;flex-direction:column;gap:0.4rem;margin-bottom:0.5rem;">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||||
|
<label style="font-size:0.8rem;color:#888;min-width:60px;">Lähettäjä:</label>
|
||||||
|
<select id="reply-mailbox-select" style="flex:1;padding:5px 10px;border:1px solid #ddd;border-radius:6px;font-size:0.85rem;"></select>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;">
|
||||||
|
<label style="font-size:0.8rem;color:#888;min-width:60px;">CC:</label>
|
||||||
|
<input type="text" id="reply-cc" placeholder="email1@example.com, email2@example.com" style="flex:1;padding:5px 10px;border:1px solid #ddd;border-radius:6px;font-size:0.85rem;">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="ticket-reply-body" rows="5" placeholder="Kirjoita vastaus..."></textarea>
|
<textarea id="ticket-reply-body" rows="5" placeholder="Kirjoita vastaus..."></textarea>
|
||||||
<div id="signature-preview" style="display:none;padding:0.5rem 0.75rem;margin-top:0.25rem;border-left:3px solid #d0d5dd;color:#888;font-size:0.82rem;white-space:pre-line;"></div>
|
<div id="signature-preview" style="display:none;padding:0.5rem 0.75rem;margin-top:0.25rem;border-left:3px solid #d0d5dd;color:#888;font-size:0.82rem;white-space:pre-line;"></div>
|
||||||
@@ -484,6 +499,61 @@
|
|||||||
</div>
|
</div>
|
||||||
<pre id="test-api-result" style="margin-top:0.75rem;background:#f8f9fb;padding:1rem;border-radius:8px;font-size:0.85rem;display:none;overflow-x:auto;"></pre>
|
<pre id="test-api-result" style="margin-top:0.75rem;background:#f8f9fb;padding:1rem;border-radius:8px;font-size:0.85rem;display:none;overflow-x:auto;"></pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Vastauspohjat -->
|
||||||
|
<div class="table-card" style="padding:1.5rem;margin-top:1rem;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||||
|
<h3 style="color:#0f3460;margin:0;border-bottom:none;">Vastauspohjat</h3>
|
||||||
|
<button class="btn-primary" id="btn-add-template" style="font-size:0.85rem;">+ Uusi pohja</button>
|
||||||
|
</div>
|
||||||
|
<p style="color:#666;font-size:0.85rem;margin-bottom:1rem;">Nopeat vastauspohjat tiketteihin. Valittavissa vastauslomakkeen valikosta.</p>
|
||||||
|
<div id="templates-list"></div>
|
||||||
|
<div id="template-form" style="display:none;margin-top:1rem;padding:1rem;background:#f8f9fb;border-radius:8px;">
|
||||||
|
<input type="hidden" id="template-edit-id">
|
||||||
|
<div class="form-group" style="margin-bottom:0.5rem;">
|
||||||
|
<label style="font-size:0.85rem;">Nimi</label>
|
||||||
|
<input type="text" id="template-edit-name" placeholder="esim. Kuittaus vastaanotettu">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:0.5rem;">
|
||||||
|
<label style="font-size:0.85rem;">Sisältö</label>
|
||||||
|
<textarea id="template-edit-body" rows="4" placeholder="Kiitos viestistäsi! Olemme vastaanottaneet asiasi ja palaamme siihen mahdollisimman pian."></textarea>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.5rem;">
|
||||||
|
<button class="btn-primary" id="btn-save-template">Tallenna</button>
|
||||||
|
<button class="btn-secondary" id="btn-cancel-template">Peruuta</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Priority-sähköpostit -->
|
||||||
|
<div class="table-card" style="padding:1.5rem;margin-top:1rem;">
|
||||||
|
<h3 style="color:#0f3460;margin-bottom:0.5rem;border-bottom:2px solid #f0f2f5;padding-bottom:0.5rem;">Priority-sähköpostiosoitteet</h3>
|
||||||
|
<p style="color:#666;font-size:0.85rem;margin-bottom:1rem;">Näiltä osoitteilta saapuvat tiketit saavat automaattisesti "Tärkeä"-prioriteetin.</p>
|
||||||
|
<textarea id="priority-emails-textarea" rows="4" style="width:100%;max-width:500px;font-family:monospace;font-size:0.85rem;" placeholder="vip@yritys.fi toimitusjohtaja@firma.fi"></textarea>
|
||||||
|
<div style="margin-top:0.5rem;">
|
||||||
|
<button class="btn-primary" id="btn-save-priority-emails">Tallenna</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Telegram-asetukset -->
|
||||||
|
<div class="table-card" style="padding:1.5rem;margin-top:1rem;">
|
||||||
|
<h3 style="color:#0f3460;margin-bottom:0.5rem;border-bottom:2px solid #f0f2f5;padding-bottom:0.5rem;">Telegram-hälytykset</h3>
|
||||||
|
<p style="color:#666;font-size:0.85rem;margin-bottom:1rem;">URGENT-prioriteetin tiketit lähettävät hälytyksen Telegram-bottiin.</p>
|
||||||
|
<div class="form-grid" style="max-width:500px;">
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label>Bot Token</label>
|
||||||
|
<input type="text" id="settings-telegram-token" placeholder="123456:ABC-DEF..." style="font-family:monospace;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label>Chat ID</label>
|
||||||
|
<input type="text" id="settings-telegram-chat" placeholder="-1001234567890" style="font-family:monospace;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width" style="display:flex;gap:0.5rem;">
|
||||||
|
<button class="btn-primary" id="btn-save-telegram">Tallenna</button>
|
||||||
|
<button class="btn-secondary" id="btn-test-telegram">Testaa</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
224
script.js
224
script.js
@@ -1128,10 +1128,16 @@ function renderTickets() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sorttaus: tila-prioriteetti + päivämäärä
|
// Sorttaus: prioriteetti → tila → päivämäärä
|
||||||
const ticketSortField = document.getElementById('ticket-sort')?.value || 'status';
|
const ticketSortField = document.getElementById('ticket-sort')?.value || 'status';
|
||||||
const statusPriority = { kasittelyssa: 0, uusi: 1, odottaa: 2, suljettu: 3 };
|
const statusPriority = { kasittelyssa: 0, uusi: 1, odottaa: 2, ratkaistu: 3, suljettu: 4 };
|
||||||
|
const priorityOrder = { urgent: 0, 'tärkeä': 1, normaali: 2 };
|
||||||
filtered.sort((a, b) => {
|
filtered.sort((a, b) => {
|
||||||
|
// Urgent/tärkeä aina ensin
|
||||||
|
const prioA = priorityOrder[a.priority || 'normaali'] ?? 2;
|
||||||
|
const prioB = priorityOrder[b.priority || 'normaali'] ?? 2;
|
||||||
|
if (prioA !== prioB) return prioA - prioB;
|
||||||
|
|
||||||
if (ticketSortField === 'status') {
|
if (ticketSortField === 'status') {
|
||||||
const pa = statusPriority[a.status] ?? 9;
|
const pa = statusPriority[a.status] ?? 9;
|
||||||
const pb = statusPriority[b.status] ?? 9;
|
const pb = statusPriority[b.status] ?? 9;
|
||||||
@@ -1158,14 +1164,15 @@ function renderTickets() {
|
|||||||
ttbody.innerHTML = filtered.map(t => {
|
ttbody.innerHTML = filtered.map(t => {
|
||||||
const lastType = t.last_message_type === 'reply_out' ? '→' : (t.last_message_type === 'note' ? '📝' : '←');
|
const lastType = t.last_message_type === 'reply_out' ? '→' : (t.last_message_type === 'note' ? '📝' : '←');
|
||||||
const typeLabel = ticketTypeLabels[t.type] || 'Muu';
|
const typeLabel = ticketTypeLabels[t.type] || 'Muu';
|
||||||
const rowClass = t.status === 'kasittelyssa' ? 'ticket-row-active' : '';
|
const rowClass = t.status === 'kasittelyssa' ? 'ticket-row-active' : (t.priority === 'urgent' ? 'ticket-row-urgent' : (t.priority === 'tärkeä' ? 'ticket-row-important' : ''));
|
||||||
const checked = bulkSelectedIds.has(t.id) ? 'checked' : '';
|
const checked = bulkSelectedIds.has(t.id) ? 'checked' : '';
|
||||||
const companyBadge = multiCompany && t.company_name ? `<span class="company-badge">${esc(t.company_name)}</span> ` : '';
|
const companyBadge = multiCompany && t.company_name ? `<span class="company-badge">${esc(t.company_name)}</span> ` : '';
|
||||||
|
const prioBadge = t.priority === 'urgent' ? '<span class="ticket-prio-urgent">🚨</span> ' : (t.priority === 'tärkeä' ? '<span class="ticket-prio-important">⚠️</span> ' : '');
|
||||||
return `<tr data-ticket-id="${t.id}" data-company-id="${t.company_id || ''}" class="${rowClass}">
|
return `<tr data-ticket-id="${t.id}" data-company-id="${t.company_id || ''}" class="${rowClass}">
|
||||||
<td onclick="event.stopPropagation()"><input type="checkbox" class="ticket-checkbox" data-ticket-id="${t.id}" ${checked}></td>
|
<td onclick="event.stopPropagation()"><input type="checkbox" class="ticket-checkbox" data-ticket-id="${t.id}" ${checked}></td>
|
||||||
<td><span class="ticket-status ticket-status-${t.status}">${ticketStatusLabels[t.status] || t.status}</span></td>
|
<td><span class="ticket-status ticket-status-${t.status}">${ticketStatusLabels[t.status] || t.status}</span></td>
|
||||||
<td><span class="ticket-type ticket-type-${t.type || 'muu'}">${typeLabel}</span></td>
|
<td><span class="ticket-type ticket-type-${t.type || 'muu'}">${typeLabel}</span></td>
|
||||||
<td>${companyBadge}<strong>${esc(t.subject)}</strong></td>
|
<td>${prioBadge}${companyBadge}<strong>${esc(t.subject)}</strong></td>
|
||||||
<td>${esc(t.mailbox_name || t.from_name || t.from_email)}</td>
|
<td>${esc(t.mailbox_name || t.from_name || t.from_email)}</td>
|
||||||
<td>${t.customer_name ? esc(t.customer_name) : '<span style="color:#ccc;">-</span>'}</td>
|
<td>${t.customer_name ? esc(t.customer_name) : '<span style="color:#ccc;">-</span>'}</td>
|
||||||
<td style="text-align:center;">${lastType} ${t.message_count}</td>
|
<td style="text-align:center;">${lastType} ${t.message_count}</td>
|
||||||
@@ -1253,6 +1260,11 @@ async function showTicketDetail(id, companyId = '') {
|
|||||||
<select id="ticket-assign-select" style="padding:6px 10px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.85rem;">
|
<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>
|
<option value="">Ei osoitettu</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select id="ticket-priority-select" style="padding:6px 10px;border:2px solid ${(ticket.priority || 'normaali') === 'urgent' ? '#e74c3c' : (ticket.priority === 'tärkeä' ? '#e67e22' : '#e0e0e0')};border-radius:8px;font-size:0.85rem;${(ticket.priority || 'normaali') === 'urgent' ? 'background:#fef2f2;color:#c0392b;font-weight:700;' : ''}">
|
||||||
|
<option value="normaali" ${(ticket.priority || 'normaali') === 'normaali' ? 'selected' : ''}>Normaali</option>
|
||||||
|
<option value="tärkeä" ${ticket.priority === 'tärkeä' ? 'selected' : ''}>⚠️ Tärkeä</option>
|
||||||
|
<option value="urgent" ${ticket.priority === 'urgent' ? 'selected' : ''}>🚨 URGENT</option>
|
||||||
|
</select>
|
||||||
<select id="ticket-customer-select" style="padding:6px 10px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.85rem;">
|
<select id="ticket-customer-select" style="padding:6px 10px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.85rem;">
|
||||||
<option value="">Ei asiakkuutta</option>
|
<option value="">Ei asiakkuutta</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -1268,7 +1280,8 @@ async function showTicketDetail(id, companyId = '') {
|
|||||||
<input type="text" id="ticket-tag-input" placeholder="+ Lisää tagi" style="padding:4px 8px;border:1px solid #ddd;border-radius:6px;font-size:0.82rem;width:120px;">
|
<input type="text" id="ticket-tag-input" placeholder="+ Lisää tagi" style="padding:4px 8px;border:1px solid #ddd;border-radius:6px;font-size:0.82rem;width:120px;">
|
||||||
</div>
|
</div>
|
||||||
${ticket.auto_close_at ? '<span style="font-size:0.78rem;color:#e67e22;margin-left:0.5rem;">⏰ Auto-close: ' + esc(ticket.auto_close_at.substring(0, 10)) + '</span>' : ''}
|
${ticket.auto_close_at ? '<span style="font-size:0.78rem;color:#e67e22;margin-left:0.5rem;">⏰ Auto-close: ' + esc(ticket.auto_close_at.substring(0, 10)) + '</span>' : ''}
|
||||||
</div>`;
|
</div>
|
||||||
|
${ticket.cc ? '<div style="font-size:0.82rem;color:#888;margin-top:0.4rem;">CC: ' + esc(ticket.cc) + '</div>' : ''}`;
|
||||||
|
|
||||||
// Load users for assignment dropdown
|
// Load users for assignment dropdown
|
||||||
try {
|
try {
|
||||||
@@ -1324,6 +1337,15 @@ async function showTicketDetail(id, companyId = '') {
|
|||||||
} catch (e) { alert(e.message); }
|
} catch (e) { alert(e.message); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Priority handler
|
||||||
|
document.getElementById('ticket-priority-select').addEventListener('change', async function() {
|
||||||
|
try {
|
||||||
|
await apiCall('ticket_priority' + ticketCompanyParam(), 'POST', { id: currentTicketId, priority: this.value });
|
||||||
|
// Päivitä näkymä (visuaalinen muutos)
|
||||||
|
await showTicketDetail(currentTicketId, currentTicketCompanyId);
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
});
|
||||||
|
|
||||||
// Delete handler
|
// Delete handler
|
||||||
document.getElementById('btn-ticket-delete').addEventListener('click', async () => {
|
document.getElementById('btn-ticket-delete').addEventListener('click', async () => {
|
||||||
if (!confirm('Poistetaanko tiketti "' + ticket.subject + '"?')) return;
|
if (!confirm('Poistetaanko tiketti "' + ticket.subject + '"?')) return;
|
||||||
@@ -1393,17 +1415,58 @@ async function showTicketDetail(id, companyId = '') {
|
|||||||
document.querySelector('.btn-reply-tab[data-reply-type="reply"]').classList.add('active');
|
document.querySelector('.btn-reply-tab[data-reply-type="reply"]').classList.add('active');
|
||||||
document.getElementById('btn-send-reply').textContent = 'Lähetä vastaus';
|
document.getElementById('btn-send-reply').textContent = 'Lähetä vastaus';
|
||||||
|
|
||||||
// Allekirjoituksen esikatselu
|
// CC-kenttä — täytetään tiketin CC:stä
|
||||||
const sigPreview = document.getElementById('signature-preview');
|
const ccField = document.getElementById('reply-cc');
|
||||||
const mailboxId = ticket.mailbox_id || '';
|
if (ccField) ccField.value = ticket.cc || '';
|
||||||
const sig = currentUserSignatures[mailboxId] || '';
|
|
||||||
if (sig) {
|
// Mailbox-valinta — täytetään yrityksen postilaatikoista
|
||||||
sigPreview.textContent = '-- \n' + sig;
|
const mbSelect = document.getElementById('reply-mailbox-select');
|
||||||
sigPreview.style.display = 'block';
|
if (mbSelect) {
|
||||||
} else {
|
try {
|
||||||
sigPreview.style.display = 'none';
|
const mailboxes = await apiCall('all_mailboxes');
|
||||||
|
mbSelect.innerHTML = mailboxes.map(mb =>
|
||||||
|
`<option value="${esc(mb.id)}" ${mb.id === (ticket.mailbox_id || '') ? 'selected' : ''}>${esc(mb.nimi || mb.smtp_from_email)} <${esc(mb.smtp_from_email)}></option>`
|
||||||
|
).join('');
|
||||||
|
// Vaihda allekirjoitusta kun mailbox vaihtuu
|
||||||
|
mbSelect.addEventListener('change', function() {
|
||||||
|
updateSignaturePreview(this.value);
|
||||||
|
});
|
||||||
|
} catch (e) { mbSelect.innerHTML = '<option>Ei postilaatikoita</option>'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allekirjoituksen esikatselu
|
||||||
|
function updateSignaturePreview(mbId) {
|
||||||
|
const sigPreview = document.getElementById('signature-preview');
|
||||||
|
const sig = currentUserSignatures[mbId] || '';
|
||||||
|
if (sig) {
|
||||||
|
sigPreview.textContent = '-- \n' + sig;
|
||||||
|
sigPreview.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
sigPreview.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateSignaturePreview(ticket.mailbox_id || '');
|
||||||
|
|
||||||
|
// Vastauspohjat — lataa dropdown
|
||||||
|
try {
|
||||||
|
const templates = await apiCall('reply_templates');
|
||||||
|
const tplSelect = document.getElementById('reply-template-select');
|
||||||
|
tplSelect.innerHTML = '<option value="">📝 Vastauspohjat...</option>';
|
||||||
|
templates.forEach(t => {
|
||||||
|
tplSelect.innerHTML += `<option value="${esc(t.id)}" data-body="${esc(t.body)}">${esc(t.nimi)}</option>`;
|
||||||
|
});
|
||||||
|
tplSelect.addEventListener('change', function() {
|
||||||
|
const opt = this.options[this.selectedIndex];
|
||||||
|
const body = opt.dataset.body || '';
|
||||||
|
if (body) {
|
||||||
|
const textarea = document.getElementById('ticket-reply-body');
|
||||||
|
textarea.value = textarea.value ? textarea.value + '\n\n' + body : body;
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
this.value = ''; // Reset select
|
||||||
|
});
|
||||||
|
} catch (e) { /* templates not critical */ }
|
||||||
|
|
||||||
} catch (e) { alert(e.message); }
|
} catch (e) { alert(e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1433,13 +1496,19 @@ document.querySelectorAll('.btn-reply-tab').forEach(btn => {
|
|||||||
const textarea = document.getElementById('ticket-reply-body');
|
const textarea = document.getElementById('ticket-reply-body');
|
||||||
const sendBtn = document.getElementById('btn-send-reply');
|
const sendBtn = document.getElementById('btn-send-reply');
|
||||||
const sigPrev = document.getElementById('signature-preview');
|
const sigPrev = document.getElementById('signature-preview');
|
||||||
|
const metaFields = document.getElementById('reply-meta-fields');
|
||||||
|
const tplWrap = document.getElementById('reply-template-select-wrap');
|
||||||
if (ticketReplyType === 'note') {
|
if (ticketReplyType === 'note') {
|
||||||
textarea.placeholder = 'Kirjoita sisäinen muistiinpano...';
|
textarea.placeholder = 'Kirjoita sisäinen muistiinpano...';
|
||||||
sendBtn.textContent = 'Tallenna muistiinpano';
|
sendBtn.textContent = 'Tallenna muistiinpano';
|
||||||
sigPrev.style.display = 'none';
|
sigPrev.style.display = 'none';
|
||||||
|
if (metaFields) metaFields.style.display = 'none';
|
||||||
|
if (tplWrap) tplWrap.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
textarea.placeholder = 'Kirjoita vastaus...';
|
textarea.placeholder = 'Kirjoita vastaus...';
|
||||||
sendBtn.textContent = 'Lähetä vastaus';
|
sendBtn.textContent = 'Lähetä vastaus';
|
||||||
|
if (metaFields) metaFields.style.display = '';
|
||||||
|
if (tplWrap) tplWrap.style.display = '';
|
||||||
// Näytä allekirjoitus jos on asetettu
|
// Näytä allekirjoitus jos on asetettu
|
||||||
if (sigPrev.textContent.trim()) sigPrev.style.display = 'block';
|
if (sigPrev.textContent.trim()) sigPrev.style.display = 'block';
|
||||||
}
|
}
|
||||||
@@ -1458,7 +1527,14 @@ document.getElementById('btn-send-reply').addEventListener('click', async () =>
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const action = ticketReplyType === 'note' ? 'ticket_note' : 'ticket_reply';
|
const action = ticketReplyType === 'note' ? 'ticket_note' : 'ticket_reply';
|
||||||
await apiCall(action + ticketCompanyParam(), 'POST', { id: currentTicketId, body });
|
const payload = { id: currentTicketId, body };
|
||||||
|
if (ticketReplyType !== 'note') {
|
||||||
|
const mbSel = document.getElementById('reply-mailbox-select');
|
||||||
|
const ccFld = document.getElementById('reply-cc');
|
||||||
|
if (mbSel) payload.mailbox_id = mbSel.value;
|
||||||
|
if (ccFld) payload.cc = ccFld.value.trim();
|
||||||
|
}
|
||||||
|
await apiCall(action + ticketCompanyParam(), 'POST', payload);
|
||||||
// Reload the detail view
|
// Reload the detail view
|
||||||
await showTicketDetail(currentTicketId, currentTicketCompanyId);
|
await showTicketDetail(currentTicketId, currentTicketCompanyId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1709,9 +1785,127 @@ async function loadSettings() {
|
|||||||
if (apiTitle && currentCompany) apiTitle.textContent = currentCompany.nimi + ' — ';
|
if (apiTitle && currentCompany) apiTitle.textContent = currentCompany.nimi + ' — ';
|
||||||
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`;
|
||||||
|
|
||||||
|
// Telegram-asetukset
|
||||||
|
document.getElementById('settings-telegram-token').value = config.telegram_bot_token || '';
|
||||||
|
document.getElementById('settings-telegram-chat').value = config.telegram_chat_id || '';
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
|
||||||
|
// Vastauspohjat
|
||||||
|
loadTemplates();
|
||||||
|
// Priority emails
|
||||||
|
loadPriorityEmails();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== VASTAUSPOHJAT ====================
|
||||||
|
|
||||||
|
let replyTemplates = [];
|
||||||
|
|
||||||
|
async function loadTemplates() {
|
||||||
|
try {
|
||||||
|
replyTemplates = await apiCall('reply_templates');
|
||||||
|
renderTemplates();
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderTemplates() {
|
||||||
|
const list = document.getElementById('templates-list');
|
||||||
|
if (!list) return;
|
||||||
|
if (replyTemplates.length === 0) {
|
||||||
|
list.innerHTML = '<p style="color:#aaa;font-size:0.85rem;">Ei vastauspohjia vielä.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = replyTemplates.map(t =>
|
||||||
|
`<div style="display:flex;justify-content:space-between;align-items:center;padding:0.5rem 0;border-bottom:1px solid #f0f2f5;">
|
||||||
|
<div>
|
||||||
|
<strong style="font-size:0.9rem;">${esc(t.nimi)}</strong>
|
||||||
|
<div style="font-size:0.8rem;color:#888;max-width:400px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${esc(t.body.substring(0, 80))}</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.3rem;">
|
||||||
|
<button class="btn-secondary" onclick="editTemplate('${t.id}')" style="padding:4px 8px;font-size:0.78rem;">Muokkaa</button>
|
||||||
|
<button class="btn-danger" onclick="deleteTemplate('${t.id}')" style="padding:4px 8px;font-size:0.78rem;">Poista</button>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-add-template').addEventListener('click', () => {
|
||||||
|
document.getElementById('template-edit-id').value = '';
|
||||||
|
document.getElementById('template-edit-name').value = '';
|
||||||
|
document.getElementById('template-edit-body').value = '';
|
||||||
|
document.getElementById('template-form').style.display = 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-cancel-template').addEventListener('click', () => {
|
||||||
|
document.getElementById('template-form').style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-save-template').addEventListener('click', async () => {
|
||||||
|
const id = document.getElementById('template-edit-id').value || undefined;
|
||||||
|
const nimi = document.getElementById('template-edit-name').value.trim();
|
||||||
|
const body = document.getElementById('template-edit-body').value.trim();
|
||||||
|
if (!nimi || !body) { alert('Täytä nimi ja sisältö'); return; }
|
||||||
|
try {
|
||||||
|
await apiCall('reply_template_save', 'POST', { id, nimi, body });
|
||||||
|
document.getElementById('template-form').style.display = 'none';
|
||||||
|
loadTemplates();
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
window.editTemplate = function(id) {
|
||||||
|
const t = replyTemplates.find(x => x.id === id);
|
||||||
|
if (!t) return;
|
||||||
|
document.getElementById('template-edit-id').value = t.id;
|
||||||
|
document.getElementById('template-edit-name').value = t.nimi;
|
||||||
|
document.getElementById('template-edit-body').value = t.body;
|
||||||
|
document.getElementById('template-form').style.display = 'block';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.deleteTemplate = async function(id) {
|
||||||
|
if (!confirm('Poistetaanko vastauspohja?')) return;
|
||||||
|
try {
|
||||||
|
await apiCall('reply_template_delete', 'POST', { id });
|
||||||
|
loadTemplates();
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== PRIORITY EMAILS ====================
|
||||||
|
|
||||||
|
async function loadPriorityEmails() {
|
||||||
|
try {
|
||||||
|
const emails = await apiCall('priority_emails');
|
||||||
|
document.getElementById('priority-emails-textarea').value = (emails || []).join('\n');
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-save-priority-emails').addEventListener('click', async () => {
|
||||||
|
const text = document.getElementById('priority-emails-textarea').value;
|
||||||
|
const emails = text.split('\n').map(e => e.trim()).filter(e => e);
|
||||||
|
try {
|
||||||
|
await apiCall('priority_emails_save', 'POST', { emails });
|
||||||
|
alert('Priority-osoitteet tallennettu!');
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== TELEGRAM ====================
|
||||||
|
|
||||||
|
document.getElementById('btn-save-telegram').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await apiCall('config_update', 'POST', {
|
||||||
|
telegram_bot_token: document.getElementById('settings-telegram-token').value.trim(),
|
||||||
|
telegram_chat_id: document.getElementById('settings-telegram-chat').value.trim(),
|
||||||
|
});
|
||||||
|
alert('Telegram-asetukset tallennettu!');
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-test-telegram').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await apiCall('telegram_test', 'POST');
|
||||||
|
alert('Testiviesti lähetetty!');
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('btn-generate-key').addEventListener('click', async () => {
|
document.getElementById('btn-generate-key').addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
const config = await apiCall('generate_api_key', 'POST');
|
const config = await apiCall('generate_api_key', 'POST');
|
||||||
|
|||||||
24
style.css
24
style.css
@@ -1184,6 +1184,30 @@ span.empty {
|
|||||||
background: #d5f0d5 !important;
|
background: #d5f0d5 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ticket-row-urgent {
|
||||||
|
background: #fef2f2 !important;
|
||||||
|
border-left: 3px solid #e74c3c !important;
|
||||||
|
}
|
||||||
|
.ticket-row-urgent:hover {
|
||||||
|
background: #fee2e2 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-row-important {
|
||||||
|
background: #fffbeb !important;
|
||||||
|
border-left: 3px solid #f59e0b !important;
|
||||||
|
}
|
||||||
|
.ticket-row-important:hover {
|
||||||
|
background: #fef3c7 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-prio-urgent {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-prio-important {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Ticket thread */
|
/* Ticket thread */
|
||||||
.ticket-thread {
|
.ticket-thread {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user