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:
2026-03-10 17:42:05 +02:00
parent 3b7def1186
commit 8485da8cbf
5 changed files with 591 additions and 23 deletions

212
api.php
View File

@@ -262,6 +262,10 @@ class ImapClient {
$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');
// Parse CC
$ccRaw = $this->decodeMimeHeader($headers['cc'] ?? '');
$ccEmails = $this->parseCcAddresses($ccRaw);
// Fetch body (text part)
$body = $this->fetchBody($num);
@@ -274,6 +278,7 @@ class ImapClient {
'references' => $references,
'date' => $date,
'body' => $body,
'cc' => $ccEmails,
];
}
@@ -376,6 +381,22 @@ class ImapClient {
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 {
if (strpos($str, '=?') === false) return trim($str);
$decoded = '';
@@ -496,7 +517,39 @@ class ImapClient {
// ==================== 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;
$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 .= "From: {$fromName} <{$fromEmail}>\r\n";
$headers .= "Reply-To: {$fromEmail}\r\n";
if ($cc) {
$headers .= "Cc: {$cc}\r\n";
}
if ($inReplyTo) {
$headers .= "In-Reply-To: {$inReplyTo}\r\n";
$headers .= "References: " . ($references ? $references . ' ' : '') . $inReplyTo . "\r\n";
@@ -599,9 +655,12 @@ switch ($action) {
case 'config':
requireAdmin();
$companyId = requireCompany();
$globalConf = dbLoadConfig();
echo json_encode([
'api_key' => dbGetCompanyApiKey($companyId),
'cors_origins' => dbGetCompanyCorsOrigins($companyId),
'telegram_bot_token' => $globalConf['telegram_bot_token'] ?? '',
'telegram_chat_id' => $globalConf['telegram_chat_id'] ?? '',
]);
break;
@@ -617,13 +676,46 @@ switch ($action) {
$origins = array_filter(array_map('trim', explode("\n", $input['cors_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([
'api_key' => dbGetCompanyApiKey($companyId),
'cors_origins' => dbGetCompanyCorsOrigins($companyId),
]);
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':
requireAdmin();
$companyId = requireCompany();
@@ -1639,6 +1731,12 @@ switch ($action) {
}
}
} else {
// Tarkista onko lähettäjä priority-listalla
$ticketPriority = 'normaali';
if (dbIsPriorityEmail($companyId, $email['from_email'])) {
$ticketPriority = 'tärkeä';
}
$ticket = [
'id' => generateId(),
'subject' => $email['subject'] ?: '(Ei aihetta)',
@@ -1650,6 +1748,8 @@ switch ($action) {
'customer_id' => '',
'customer_name' => '',
'tags' => [],
'cc' => $email['cc'] ?? '',
'priority' => $ticketPriority,
'auto_close_at' => '',
'mailbox_id' => $mailbox['id'],
'created' => $email['date'],
@@ -1692,6 +1792,10 @@ switch ($action) {
}
dbSaveTicket($companyId, $ticket);
// Telegram-hälytys tärkeille/urgentille
if ($ticket['priority'] === 'urgent' || $ticket['priority'] === 'tärkeä') {
sendTelegramAlert($companyId, $ticket);
}
$newCount++;
}
@@ -1714,6 +1818,8 @@ switch ($action) {
$input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? '';
$body = trim($input['body'] ?? '');
$replyMailboxId = $input['mailbox_id'] ?? '';
$replyCc = trim($input['cc'] ?? '');
if (empty($body)) {
http_response_code(400);
echo json_encode(['error' => 'Viesti ei voi olla tyhjä']);
@@ -1734,10 +1840,12 @@ switch ($action) {
}
// Send email — hae postilaatikon asetukset
// Käytä frontendistä valittua mailboxia tai tiketin oletusta
$companyConf = dbGetCompanyConfig($companyId);
$useMailboxId = $replyMailboxId ?: ($t['mailbox_id'] ?? '');
$replyMailbox = null;
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
if (!$replyMailbox && !empty($companyConf['mailboxes'])) {
@@ -1745,7 +1853,7 @@ switch ($action) {
}
// Hae käyttäjän allekirjoitus tälle postilaatikolle
$mailboxId = $t['mailbox_id'] ?? '';
$mailboxId = $replyMailbox['id'] ?? '';
$signature = '';
$sigUser = dbGetUser($_SESSION['user_id']);
if ($sigUser) {
@@ -1753,8 +1861,11 @@ switch ($action) {
}
$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'];
$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) {
http_response_code(500);
@@ -1762,6 +1873,11 @@ switch ($action) {
break 2;
}
// Päivitä tiketin CC jos muuttunut
if ($replyCc !== '' && $replyCc !== ($t['cc'] ?? '')) {
$t['cc'] = $replyCc;
}
// Add reply to ticket (tallennetaan allekirjoituksen kanssa)
$reply = [
'id' => generateId(),
@@ -2082,6 +2198,92 @@ switch ($action) {
echo json_encode(['success' => true]);
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 ----------
case 'companies':
requireAuth();

82
db.php
View File

@@ -246,6 +246,8 @@ function initDatabase(): void {
customer_name VARCHAR(255) DEFAULT '',
message_id VARCHAR(500) DEFAULT '',
mailbox_id VARCHAR(20) DEFAULT '',
cc TEXT DEFAULT '',
priority VARCHAR(20) DEFAULT 'normaali',
auto_close_at VARCHAR(30) DEFAULT '',
created DATETIME,
updated DATETIME,
@@ -328,6 +330,25 @@ function initDatabase(): void {
INDEX idx_company (company_id)
) 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 (
id INT AUTO_INCREMENT PRIMARY KEY,
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);
}
}
// 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 ====================
@@ -769,14 +799,15 @@ function dbSaveTicket(string $companyId, array $ticket): void {
try {
_dbExecute("
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,
: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
subject = VALUES(subject), from_email = VALUES(from_email), from_name = VALUES(from_name),
status = VALUES(status), type = VALUES(type), assigned_to = VALUES(assigned_to),
customer_id = VALUES(customer_id), customer_name = VALUES(customer_name),
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)
", [
'id' => $ticket['id'],
@@ -791,6 +822,8 @@ function dbSaveTicket(string $companyId, array $ticket): void {
'customer_name' => $ticket['customer_name'] ?? '',
'message_id' => $ticket['message_id'] ?? '',
'mailbox_id' => $ticket['mailbox_id'] ?? '',
'cc' => $ticket['cc'] ?? '',
'priority' => $ticket['priority'] ?? 'normaali',
'auto_close_at' => $ticket['auto_close_at'] ?? '',
'created' => $ticket['created'] ?? 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 {
_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]
);
}

View File

@@ -328,9 +328,24 @@
<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;">
<div style="display:flex;gap:0.5rem;margin-bottom:0.75rem;align-items:center;">
<button class="btn-reply-tab active" data-reply-type="reply">&#9993; Vastaa</button>
<button class="btn-reply-tab" data-reply-type="note">&#128221; Muistiinpano</button>
<div 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>
<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>
@@ -484,6 +499,61 @@
</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>
</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&#10;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>

224
script.js
View File

@@ -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 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) => {
// 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') {
const pa = statusPriority[a.status] ?? 9;
const pb = statusPriority[b.status] ?? 9;
@@ -1158,14 +1164,15 @@ function renderTickets() {
ttbody.innerHTML = filtered.map(t => {
const lastType = t.last_message_type === 'reply_out' ? '&#8594;' : (t.last_message_type === 'note' ? '&#128221;' : '&#8592;');
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 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}">
<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-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>${t.customer_name ? esc(t.customer_name) : '<span style="color:#ccc;">-</span>'}</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;">
<option value="">Ei osoitettu</option>
</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;">
<option value="">Ei asiakkuutta</option>
</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;">
</div>
${ticket.auto_close_at ? '<span style="font-size:0.78rem;color:#e67e22;margin-left:0.5rem;">&#9200; 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
try {
@@ -1324,6 +1337,15 @@ async function showTicketDetail(id, companyId = '') {
} 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
document.getElementById('btn-ticket-delete').addEventListener('click', async () => {
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.getElementById('btn-send-reply').textContent = 'Lähetä vastaus';
// Allekirjoituksen esikatselu
const sigPreview = document.getElementById('signature-preview');
const mailboxId = ticket.mailbox_id || '';
const sig = currentUserSignatures[mailboxId] || '';
if (sig) {
sigPreview.textContent = '-- \n' + sig;
sigPreview.style.display = 'block';
} else {
sigPreview.style.display = 'none';
// CC-kenttä — täytetään tiketin CC:stä
const ccField = document.getElementById('reply-cc');
if (ccField) ccField.value = ticket.cc || '';
// Mailbox-valinta — täytetään yrityksen postilaatikoista
const mbSelect = document.getElementById('reply-mailbox-select');
if (mbSelect) {
try {
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)} &lt;${esc(mb.smtp_from_email)}&gt;</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); }
}
@@ -1433,13 +1496,19 @@ document.querySelectorAll('.btn-reply-tab').forEach(btn => {
const textarea = document.getElementById('ticket-reply-body');
const sendBtn = document.getElementById('btn-send-reply');
const sigPrev = document.getElementById('signature-preview');
const metaFields = document.getElementById('reply-meta-fields');
const tplWrap = document.getElementById('reply-template-select-wrap');
if (ticketReplyType === 'note') {
textarea.placeholder = 'Kirjoita sisäinen muistiinpano...';
sendBtn.textContent = 'Tallenna muistiinpano';
sigPrev.style.display = 'none';
if (metaFields) metaFields.style.display = 'none';
if (tplWrap) tplWrap.style.display = 'none';
} else {
textarea.placeholder = 'Kirjoita vastaus...';
sendBtn.textContent = 'Lähetä vastaus';
if (metaFields) metaFields.style.display = '';
if (tplWrap) tplWrap.style.display = '';
// Näytä allekirjoitus jos on asetettu
if (sigPrev.textContent.trim()) sigPrev.style.display = 'block';
}
@@ -1458,7 +1527,14 @@ document.getElementById('btn-send-reply').addEventListener('click', async () =>
try {
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
await showTicketDetail(currentTicketId, currentTicketCompanyId);
} catch (e) {
@@ -1709,9 +1785,127 @@ async function loadSettings() {
if (apiTitle && currentCompany) apiTitle.textContent = currentCompany.nimi + ' — ';
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`;
// 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); }
}
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 () => {
try {
const config = await apiCall('generate_api_key', 'POST');

View File

@@ -1184,6 +1184,30 @@ span.empty {
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 {
display: flex;