Tikettinumerointi (VVNKKNN) + automaattinen vastaus

Tikettinumero:
- Uudet tiketit saavat juoksevan numeron VVNKKNN-formaatissa
  (vuosi+kuukausi sekoitettu sekvenssiin, esim. 2600301)
- Numero näkyy tikettilistassa ja detail-näkymässä (#-merkillä)
- Sähköpostin aihe muotoa "Tiketti #2600301: Alkuperäinen aihe"
- Vastaukset ketjuuntuvat automaattisesti

Autoreply:
- Postilaatikkokohtainen asetus: checkbox + viestisisältö
- Uusi tiketti lähettää automaattisen vastauksen asiakkaalle
- Autoreply näkyy tiketin viestiketjussa ( Automaattinen vastaus)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 00:24:17 +02:00
parent 96a35c7e0b
commit 07e9c63c47
4 changed files with 107 additions and 12 deletions

40
api.php
View File

@@ -2195,9 +2195,15 @@ switch ($action) {
$ticketPriority = 'tärkeä'; $ticketPriority = 'tärkeä';
} }
// Generoi tikettinumero (VVNKKNN)
$ticketNumber = dbNextTicketNumber($companyId);
$originalSubject = $email['subject'] ?: '(Ei aihetta)';
$numberedSubject = "Tiketti #{$ticketNumber}: {$originalSubject}";
$ticket = [ $ticket = [
'id' => generateId(), 'id' => generateId(),
'subject' => $email['subject'] ?: '(Ei aihetta)', 'ticket_number' => $ticketNumber,
'subject' => $numberedSubject,
'from_email' => $email['from_email'], 'from_email' => $email['from_email'],
'from_name' => $email['from_name'], 'from_name' => $email['from_name'],
'status' => 'uusi', 'status' => 'uusi',
@@ -2250,6 +2256,36 @@ switch ($action) {
} }
dbSaveTicket($companyId, $ticket); dbSaveTicket($companyId, $ticket);
// Autoreply — lähetä automaattinen vastaus asiakkaalle
if (!empty($mailbox['auto_reply_enabled']) && !empty($mailbox['auto_reply_body'])) {
$arSubject = 'Re: ' . $ticket['subject'];
$arBody = $mailbox['auto_reply_body'];
$arSent = sendTicketMail(
$email['from_email'],
$arSubject,
$arBody,
$email['message_id'], // In-Reply-To
$email['message_id'], // References
$mailbox,
'' // ei CC
);
if ($arSent) {
// Tallenna autoreply tiketin viestiksi
$arMsg = [
'id' => generateId(),
'type' => 'auto_reply',
'from' => $mailbox['smtp_from_email'] ?? $mailbox['imap_user'] ?? '',
'from_name' => $mailbox['smtp_from_name'] ?? $mailbox['nimi'] ?? '',
'body' => $arBody,
'timestamp' => date('Y-m-d H:i:s'),
'message_id' => '',
];
$ticket['messages'][] = $arMsg;
dbSaveTicket($companyId, $ticket);
}
}
// Telegram-hälytys tärkeille/urgentille // Telegram-hälytys tärkeille/urgentille
if ($ticket['priority'] === 'urgent' || $ticket['priority'] === 'tärkeä') { if ($ticket['priority'] === 'urgent' || $ticket['priority'] === 'tärkeä') {
sendTelegramAlert($companyId, $ticket); sendTelegramAlert($companyId, $ticket);
@@ -3145,6 +3181,8 @@ switch ($action) {
'smtp_user' => trim($input['smtp_user'] ?? ''), 'smtp_user' => trim($input['smtp_user'] ?? ''),
'smtp_encryption' => trim($input['smtp_encryption'] ?? 'tls'), 'smtp_encryption' => trim($input['smtp_encryption'] ?? 'tls'),
'aktiivinen' => $input['aktiivinen'] ?? true, 'aktiivinen' => $input['aktiivinen'] ?? true,
'auto_reply_enabled' => !empty($input['auto_reply_enabled']),
'auto_reply_body' => trim($input['auto_reply_body'] ?? ''),
]; ];
// Hae vanha mailbox salasanojen vertailua varten // Hae vanha mailbox salasanojen vertailua varten
$existingMb = dbGetMailbox($mb['id']); $existingMb = dbGetMailbox($mb['id']);

40
db.php
View File

@@ -236,6 +236,7 @@ function initDatabase(): void {
"CREATE TABLE IF NOT EXISTS tickets ( "CREATE TABLE IF NOT EXISTS tickets (
id VARCHAR(20) PRIMARY KEY, id VARCHAR(20) PRIMARY KEY,
ticket_number INT DEFAULT NULL,
company_id VARCHAR(50) NOT NULL, company_id VARCHAR(50) NOT NULL,
subject VARCHAR(500), subject VARCHAR(500),
from_email VARCHAR(255), from_email VARCHAR(255),
@@ -317,6 +318,8 @@ function initDatabase(): void {
smtp_from_email VARCHAR(255), smtp_from_email VARCHAR(255),
smtp_from_name VARCHAR(255), smtp_from_name VARCHAR(255),
aktiivinen BOOLEAN DEFAULT TRUE, aktiivinen BOOLEAN DEFAULT TRUE,
auto_reply_enabled BOOLEAN DEFAULT FALSE,
auto_reply_body TEXT,
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE, FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
INDEX idx_company (company_id) INDEX idx_company (company_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
@@ -441,6 +444,9 @@ function initDatabase(): void {
"ALTER TABLE mailboxes ADD COLUMN smtp_user VARCHAR(255) DEFAULT '' AFTER smtp_port", "ALTER TABLE mailboxes ADD COLUMN smtp_user VARCHAR(255) DEFAULT '' AFTER smtp_port",
"ALTER TABLE mailboxes ADD COLUMN smtp_password VARCHAR(255) DEFAULT '' AFTER smtp_user", "ALTER TABLE mailboxes ADD COLUMN smtp_password VARCHAR(255) DEFAULT '' AFTER smtp_user",
"ALTER TABLE mailboxes ADD COLUMN smtp_encryption VARCHAR(10) DEFAULT 'tls' AFTER smtp_password", "ALTER TABLE mailboxes ADD COLUMN smtp_encryption VARCHAR(10) DEFAULT 'tls' AFTER smtp_password",
"ALTER TABLE tickets ADD COLUMN ticket_number INT DEFAULT NULL AFTER id",
"ALTER TABLE mailboxes ADD COLUMN auto_reply_enabled BOOLEAN DEFAULT FALSE AFTER aktiivinen",
"ALTER TABLE mailboxes ADD COLUMN auto_reply_body TEXT AFTER auto_reply_enabled",
]; ];
foreach ($alters as $sql) { foreach ($alters as $sql) {
try { $db->query($sql); } catch (\Throwable $e) { /* sarake on jo olemassa / jo ajettu */ } try { $db->query($sql); } catch (\Throwable $e) { /* sarake on jo olemassa / jo ajettu */ }
@@ -986,6 +992,24 @@ function dbDeleteLead(string $leadId): void {
// ==================== TIKETIT ==================== // ==================== TIKETIT ====================
/**
* Generoi seuraava tikettinumero (VVNKKNN-formaatti).
* Vuosi+kuukausi sekoitetaan juoksevaan numeroon.
*/
function dbNextTicketNumber(string $companyId): int {
$yy = (int)date('y');
$mm = (int)date('m');
$fullYear = (int)date('Y');
$count = (int)_dbFetchScalar(
"SELECT COUNT(*) FROM tickets WHERE company_id = ? AND YEAR(created) = ? AND MONTH(created) = ?",
[$companyId, $fullYear, $mm]
);
$seq = $count + 1;
$hundreds = intdiv($seq, 100);
$remainder = $seq % 100;
return $yy * 100000 + $hundreds * 10000 + $mm * 100 + $remainder;
}
function dbLoadTickets(string $companyId): array { function dbLoadTickets(string $companyId): array {
$tickets = _dbFetchAll("SELECT * FROM tickets WHERE company_id = ? ORDER BY updated DESC", [$companyId]); $tickets = _dbFetchAll("SELECT * FROM tickets WHERE company_id = ? ORDER BY updated DESC", [$companyId]);
@@ -1017,9 +1041,9 @@ function dbSaveTicket(string $companyId, array $ticket): void {
$db->begin_transaction(); $db->begin_transaction();
try { try {
_dbExecute(" _dbExecute("
INSERT INTO tickets (id, company_id, subject, from_email, from_name, status, type, INSERT INTO tickets (id, ticket_number, company_id, subject, from_email, from_name, status, type,
assigned_to, customer_id, customer_name, message_id, mailbox_id, cc, priority, 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, :ticket_number, :company_id, :subject, :from_email, :from_name, :status, :type,
:assigned_to, :customer_id, :customer_name, :message_id, :mailbox_id, :cc, :priority, :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),
@@ -1030,6 +1054,7 @@ function dbSaveTicket(string $companyId, array $ticket): void {
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'],
'ticket_number' => $ticket['ticket_number'] ?? null,
'company_id' => $companyId, 'company_id' => $companyId,
'subject' => $ticket['subject'] ?? '', 'subject' => $ticket['subject'] ?? '',
'from_email' => $ticket['from_email'] ?? '', 'from_email' => $ticket['from_email'] ?? '',
@@ -1157,6 +1182,8 @@ function dbLoadMailboxes(string $companyId): array {
$boxes = _dbFetchAll("SELECT * FROM mailboxes WHERE company_id = ?", [$companyId]); $boxes = _dbFetchAll("SELECT * FROM mailboxes WHERE company_id = ?", [$companyId]);
foreach ($boxes as &$b) { foreach ($boxes as &$b) {
$b['aktiivinen'] = (bool)$b['aktiivinen']; $b['aktiivinen'] = (bool)$b['aktiivinen'];
$b['auto_reply_enabled'] = (bool)($b['auto_reply_enabled'] ?? false);
$b['auto_reply_body'] = $b['auto_reply_body'] ?? '';
$b['imap_port'] = (int)$b['imap_port']; $b['imap_port'] = (int)$b['imap_port'];
$b['smtp_port'] = (int)($b['smtp_port'] ?? 587); $b['smtp_port'] = (int)($b['smtp_port'] ?? 587);
unset($b['company_id']); unset($b['company_id']);
@@ -1166,15 +1193,16 @@ function dbLoadMailboxes(string $companyId): array {
function dbSaveMailbox(string $companyId, array $mailbox): void { function dbSaveMailbox(string $companyId, array $mailbox): void {
_dbExecute(" _dbExecute("
INSERT INTO mailboxes (id, company_id, nimi, imap_host, imap_port, imap_user, imap_encryption, imap_password, smtp_from_email, smtp_from_name, smtp_host, smtp_port, smtp_user, smtp_password, smtp_encryption, aktiivinen) INSERT INTO mailboxes (id, company_id, nimi, imap_host, imap_port, imap_user, imap_encryption, imap_password, smtp_from_email, smtp_from_name, smtp_host, smtp_port, smtp_user, smtp_password, smtp_encryption, aktiivinen, auto_reply_enabled, auto_reply_body)
VALUES (:id, :company_id, :nimi, :imap_host, :imap_port, :imap_user, :imap_encryption, :imap_password, :smtp_from_email, :smtp_from_name, :smtp_host, :smtp_port, :smtp_user, :smtp_password, :smtp_encryption, :aktiivinen) VALUES (:id, :company_id, :nimi, :imap_host, :imap_port, :imap_user, :imap_encryption, :imap_password, :smtp_from_email, :smtp_from_name, :smtp_host, :smtp_port, :smtp_user, :smtp_password, :smtp_encryption, :aktiivinen, :auto_reply_enabled, :auto_reply_body)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
nimi = VALUES(nimi), imap_host = VALUES(imap_host), imap_port = VALUES(imap_port), nimi = VALUES(nimi), imap_host = VALUES(imap_host), imap_port = VALUES(imap_port),
imap_user = VALUES(imap_user), imap_encryption = VALUES(imap_encryption), imap_user = VALUES(imap_user), imap_encryption = VALUES(imap_encryption),
imap_password = VALUES(imap_password), smtp_from_email = VALUES(smtp_from_email), imap_password = VALUES(imap_password), smtp_from_email = VALUES(smtp_from_email),
smtp_from_name = VALUES(smtp_from_name), smtp_host = VALUES(smtp_host), smtp_port = VALUES(smtp_port), smtp_from_name = VALUES(smtp_from_name), smtp_host = VALUES(smtp_host), smtp_port = VALUES(smtp_port),
smtp_user = VALUES(smtp_user), smtp_password = VALUES(smtp_password), smtp_user = VALUES(smtp_user), smtp_password = VALUES(smtp_password),
smtp_encryption = VALUES(smtp_encryption), aktiivinen = VALUES(aktiivinen) smtp_encryption = VALUES(smtp_encryption), aktiivinen = VALUES(aktiivinen),
auto_reply_enabled = VALUES(auto_reply_enabled), auto_reply_body = VALUES(auto_reply_body)
", [ ", [
'id' => $mailbox['id'], 'id' => $mailbox['id'],
'company_id' => $companyId, 'company_id' => $companyId,
@@ -1192,6 +1220,8 @@ function dbSaveMailbox(string $companyId, array $mailbox): void {
'smtp_password' => $mailbox['smtp_password'] ?? '', 'smtp_password' => $mailbox['smtp_password'] ?? '',
'smtp_encryption' => $mailbox['smtp_encryption'] ?? 'tls', 'smtp_encryption' => $mailbox['smtp_encryption'] ?? 'tls',
'aktiivinen' => $mailbox['aktiivinen'] ?? true, 'aktiivinen' => $mailbox['aktiivinen'] ?? true,
'auto_reply_enabled' => $mailbox['auto_reply_enabled'] ?? false,
'auto_reply_body' => $mailbox['auto_reply_body'] ?? '',
]); ]);
} }

View File

@@ -856,6 +856,18 @@
<input type="text" id="mailbox-form-smtp-name" placeholder="Yritys Asiakaspalvelu"> <input type="text" id="mailbox-form-smtp-name" placeholder="Yritys Asiakaspalvelu">
</div> </div>
</div> </div>
<!-- Automaattinen vastaus -->
<div style="margin-top:0.75rem;padding:0.75rem;background:#f8f9ff;border-radius:6px;border:1px solid #e0e4f0;">
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;font-weight:600;font-size:0.9rem;">
<input type="checkbox" id="mailbox-form-auto-reply"> Lähetä automaattinen vastaus uusille tiketeille
</label>
<div id="auto-reply-fields" style="display:none;margin-top:0.5rem;">
<div class="form-group">
<label>Vastausviesti</label>
<textarea id="mailbox-form-auto-reply-body" rows="4" placeholder="Kiitos viestistäsi! Olemme vastaanottaneet tikettisi ja palaamme asiaan mahdollisimman pian." style="width:100%;resize:vertical;"></textarea>
</div>
</div>
</div>
</div> </div>
<div style="display:flex;gap:0.5rem;margin-top:0.75rem;"> <div style="display:flex;gap:0.5rem;margin-top:0.75rem;">
<button class="btn-primary" id="btn-save-mailbox">Tallenna</button> <button class="btn-primary" id="btn-save-mailbox">Tallenna</button>

View File

@@ -1295,7 +1295,7 @@ function renderTickets() {
<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>${prioBadge}${companyBadge}<strong>${esc(t.subject)}</strong></td> <td>${prioBadge}${companyBadge}${t.ticket_number ? `<span style="color:#888;font-size:0.8rem;margin-right:0.3rem;">#${t.ticket_number}</span>` : ''}<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>
@@ -1361,7 +1361,7 @@ async function showTicketDetail(id, companyId = '') {
document.getElementById('ticket-detail-header').innerHTML = ` 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 style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:1rem;margin-bottom:1.25rem;">
<div> <div>
<h2 style="color:#0f3460;margin-bottom:0.25rem;font-size:1.2rem;">${esc(ticket.subject)}</h2> <h2 style="color:#0f3460;margin-bottom:0.25rem;font-size:1.2rem;">${ticket.ticket_number ? `<span style="color:#888;font-weight:normal;font-size:0.9rem;">#${ticket.ticket_number}</span> ` : ''}${esc(ticket.subject)}</h2>
<div style="font-size:0.85rem;color:#888;" id="ticket-sender-line"> <div style="font-size:0.85rem;color:#888;" id="ticket-sender-line">
${esc(ticket.from_name)} &lt;${esc(ticket.from_email)}&gt; · Luotu ${esc(ticket.created)} ${esc(ticket.from_name)} &lt;${esc(ticket.from_email)}&gt; · Luotu ${esc(ticket.created)}
</div> </div>
@@ -1552,9 +1552,10 @@ async function showTicketDetail(id, companyId = '') {
const thread = document.getElementById('ticket-thread'); const thread = document.getElementById('ticket-thread');
thread.innerHTML = (ticket.messages || []).map(m => { thread.innerHTML = (ticket.messages || []).map(m => {
const isOut = m.type === 'reply_out'; const isOut = m.type === 'reply_out';
const isAutoReply = m.type === 'auto_reply';
const isNote = m.type === 'note'; const isNote = m.type === 'note';
const typeClass = isOut ? 'ticket-msg-out' : (isNote ? 'ticket-msg-note' : 'ticket-msg-in'); const typeClass = (isOut || isAutoReply) ? 'ticket-msg-out' : (isNote ? 'ticket-msg-note' : 'ticket-msg-in');
const typeIcon = isOut ? '&#8594; Vastaus' : (isNote ? '&#128221; Muistiinpano' : '&#8592; Saapunut'); const typeIcon = isAutoReply ? '&#9889; Automaattinen vastaus' : (isOut ? '&#8594; Vastaus' : (isNote ? '&#128221; Muistiinpano' : '&#8592; Saapunut'));
return `<div class="ticket-message ${typeClass}"> return `<div class="ticket-message ${typeClass}">
<div class="ticket-msg-header"> <div class="ticket-msg-header">
<span class="ticket-msg-type">${typeIcon}</span> <span class="ticket-msg-type">${typeIcon}</span>
@@ -2541,7 +2542,13 @@ function showMailboxForm(mb = null) {
} else { } else {
sameCheck.checked = true; sameCheck.checked = true;
} }
// Autoreply
const arCheck = document.getElementById('mailbox-form-auto-reply');
arCheck.checked = mb ? !!mb.auto_reply_enabled : false;
document.getElementById('mailbox-form-auto-reply-body').value = mb ? (mb.auto_reply_body || '') : '';
toggleAutoReplyFields();
toggleSmtpFields(); toggleSmtpFields();
document.getElementById('smtp-test-result').style.display = 'none';
document.getElementById('mailbox-form-container').style.display = ''; document.getElementById('mailbox-form-container').style.display = '';
} }
@@ -2550,6 +2557,12 @@ function toggleSmtpFields() {
document.getElementById('smtp-custom-fields').style.display = same ? 'none' : ''; document.getElementById('smtp-custom-fields').style.display = same ? 'none' : '';
} }
function toggleAutoReplyFields() {
const enabled = document.getElementById('mailbox-form-auto-reply').checked;
document.getElementById('auto-reply-fields').style.display = enabled ? '' : 'none';
}
document.getElementById('mailbox-form-auto-reply').addEventListener('change', toggleAutoReplyFields);
function editMailbox(id) { function editMailbox(id) {
const mb = mailboxesData.find(m => m.id === id); const mb = mailboxesData.find(m => m.id === id);
if (mb) showMailboxForm(mb); if (mb) showMailboxForm(mb);
@@ -2588,6 +2601,8 @@ document.getElementById('btn-save-mailbox').addEventListener('click', async () =
smtp_password: useSame ? imapPass : document.getElementById('mailbox-form-smtp-pass').value, smtp_password: useSame ? imapPass : document.getElementById('mailbox-form-smtp-pass').value,
smtp_encryption: document.getElementById('mailbox-form-smtp-encryption').value, smtp_encryption: document.getElementById('mailbox-form-smtp-encryption').value,
aktiivinen: true, aktiivinen: true,
auto_reply_enabled: document.getElementById('mailbox-form-auto-reply').checked,
auto_reply_body: document.getElementById('mailbox-form-auto-reply-body').value,
}; };
try { try {
const saved = await apiCall('mailbox_save', 'POST', data); const saved = await apiCall('mailbox_save', 'POST', data);