Lisää Zammad-integraatio ja modulaarinen integraatiot-hallinta
- Uusi integrations-taulu tietokantaan (moduulimalli: type, enabled, config) - ZammadClient-luokka: tiketit, artikkelit, vastaukset, ryhmät - API-endpointit: integration_save, integration_test, zammad_sync, zammad_reply, zammad_groups - Synkronointi: Zammad-tiketit → intran tiketit, artikkelit → viestit - Vastaukset: Zammad-tiketteihin vastaus kulkee Zammad API:n kautta (→ O365) - UI: Integraatiot-osio API-välilehdellä, toggle-kytkimet, Zammad-konfiguraatio - tickets.zammad_ticket_id ja ticket_messages.zammad_article_id linkitys Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
328
api.php
328
api.php
@@ -233,6 +233,99 @@ function sendMail(string $to, string $subject, string $htmlBody): bool {
|
||||
return mail($to, $subject, $htmlBody, $headers, '-f ' . MAIL_FROM);
|
||||
}
|
||||
|
||||
// ==================== ZAMMAD CLIENT ====================
|
||||
|
||||
class ZammadClient {
|
||||
private string $url;
|
||||
private string $token;
|
||||
|
||||
public function __construct(string $url, string $token) {
|
||||
$this->url = rtrim($url, '/');
|
||||
$this->token = $token;
|
||||
}
|
||||
|
||||
private function request(string $method, string $endpoint, ?array $data = null): array {
|
||||
$url = $this->url . '/api/v1/' . ltrim($endpoint, '/');
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Token token=' . $this->token,
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
]);
|
||||
if ($method === 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
if ($data) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
} elseif ($method === 'PUT') {
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
|
||||
if ($data) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
$err = json_decode($response, true);
|
||||
throw new \RuntimeException('Zammad API error (' . $httpCode . '): ' . ($err['error'] ?? $response));
|
||||
}
|
||||
return json_decode($response, true) ?: [];
|
||||
}
|
||||
|
||||
/** Hae tikettejä (search API palauttaa kaikki joihin on oikeus) */
|
||||
public function getTickets(array $groupIds = [], int $page = 1, int $perPage = 100): array {
|
||||
if (!empty($groupIds)) {
|
||||
$query = implode(' OR ', array_map(fn($id) => 'group_id:' . $id, $groupIds));
|
||||
} else {
|
||||
$query = '*';
|
||||
}
|
||||
return $this->request('GET', 'tickets/search?query=' . urlencode($query) . '&per_page=' . $perPage . '&page=' . $page . '&expand=true');
|
||||
}
|
||||
|
||||
/** Hae yksittäinen tiketti */
|
||||
public function getTicket(int $id): array {
|
||||
return $this->request('GET', 'tickets/' . $id . '?expand=true');
|
||||
}
|
||||
|
||||
/** Hae tiketin artikkelit */
|
||||
public function getArticles(int $ticketId): array {
|
||||
return $this->request('GET', 'ticket_articles/by_ticket/' . $ticketId . '?expand=true');
|
||||
}
|
||||
|
||||
/** Lähetä vastaus tikettiin */
|
||||
public function createArticle(int $ticketId, string $body, string $to = '', string $subject = '', string $type = 'email'): array {
|
||||
$data = [
|
||||
'ticket_id' => $ticketId,
|
||||
'body' => $body,
|
||||
'content_type' => 'text/html',
|
||||
'type' => $type,
|
||||
'internal' => false,
|
||||
'sender' => 'Agent',
|
||||
];
|
||||
if ($to) $data['to'] = $to;
|
||||
if ($subject) $data['subject'] = $subject;
|
||||
return $this->request('POST', 'ticket_articles', $data);
|
||||
}
|
||||
|
||||
/** Päivitä tiketin tila */
|
||||
public function updateTicket(int $ticketId, array $fields): array {
|
||||
return $this->request('PUT', 'tickets/' . $ticketId, $fields);
|
||||
}
|
||||
|
||||
/** Hae ryhmät */
|
||||
public function getGroups(): array {
|
||||
return $this->request('GET', 'groups?expand=true');
|
||||
}
|
||||
|
||||
/** Testaa yhteys */
|
||||
public function testConnection(): array {
|
||||
$user = $this->request('GET', 'users/me');
|
||||
$groups = $this->getGroups();
|
||||
return ['user' => $user['login'] ?? '?', 'groups' => count($groups), 'ok' => true];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== IMAP CLIENT (socket-pohjainen, ei vaadi php-imap) ====================
|
||||
|
||||
class ImapClient {
|
||||
@@ -4875,6 +4968,241 @@ switch ($action) {
|
||||
}
|
||||
break;
|
||||
|
||||
// ==================== INTEGRAATIOT ====================
|
||||
|
||||
case 'integrations':
|
||||
requireCompany();
|
||||
$integrations = dbLoadIntegrations($companyId);
|
||||
// Piilota tokenit/salasanat
|
||||
foreach ($integrations as &$integ) {
|
||||
if ($integ['config']) {
|
||||
$cfg = is_string($integ['config']) ? json_decode($integ['config'], true) : $integ['config'];
|
||||
if (isset($cfg['token'])) $cfg['token'] = str_repeat('*', 8);
|
||||
$integ['config'] = $cfg;
|
||||
}
|
||||
}
|
||||
echo json_encode($integrations);
|
||||
break;
|
||||
|
||||
case 'integration_save':
|
||||
requireCompany();
|
||||
$type = $input['type'] ?? '';
|
||||
$enabled = (bool)($input['enabled'] ?? false);
|
||||
$config = $input['config'] ?? [];
|
||||
if (!$type) { http_response_code(400); echo json_encode(['error' => 'Tyyppi puuttuu']); break; }
|
||||
|
||||
// Jos token on maskattua (********), säilytä vanha
|
||||
if (isset($config['token']) && preg_match('/^\*+$/', $config['token'])) {
|
||||
$old = dbGetIntegration($companyId, $type);
|
||||
if ($old && isset($old['config']['token'])) {
|
||||
$config['token'] = $old['config']['token'];
|
||||
}
|
||||
}
|
||||
|
||||
dbSaveIntegration($companyId, $type, $enabled, $config);
|
||||
echo json_encode(['ok' => true]);
|
||||
break;
|
||||
|
||||
case 'integration_test':
|
||||
requireCompany();
|
||||
$type = $input['type'] ?? $_GET['type'] ?? '';
|
||||
$integ = dbGetIntegration($companyId, $type);
|
||||
if (!$integ) { http_response_code(404); echo json_encode(['error' => 'Integraatiota ei löydy']); break; }
|
||||
|
||||
try {
|
||||
if ($type === 'zammad') {
|
||||
$cfg = $integ['config'];
|
||||
$z = new ZammadClient($cfg['url'], $cfg['token']);
|
||||
$result = $z->testConnection();
|
||||
echo json_encode($result);
|
||||
} else {
|
||||
echo json_encode(['error' => 'Tuntematon tyyppi']);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'zammad_groups':
|
||||
requireCompany();
|
||||
$integ = dbGetIntegration($companyId, 'zammad');
|
||||
if (!$integ || !$integ['enabled']) { http_response_code(400); echo json_encode(['error' => 'Zammad ei käytössä']); break; }
|
||||
try {
|
||||
$z = new ZammadClient($integ['config']['url'], $integ['config']['token']);
|
||||
echo json_encode($z->getGroups());
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'zammad_sync':
|
||||
requireCompany();
|
||||
$integ = dbGetIntegration($companyId, 'zammad');
|
||||
if (!$integ || !$integ['enabled']) { http_response_code(400); echo json_encode(['error' => 'Zammad ei käytössä']); break; }
|
||||
|
||||
try {
|
||||
$cfg = $integ['config'];
|
||||
$z = new ZammadClient($cfg['url'], $cfg['token']);
|
||||
$groupIds = $cfg['group_ids'] ?? [];
|
||||
$syncedGroupNames = $cfg['group_names'] ?? [];
|
||||
|
||||
// Hae tikettejä Zammadista
|
||||
$allTickets = [];
|
||||
$page = 1;
|
||||
do {
|
||||
$batch = $z->getTickets($groupIds, $page, 100);
|
||||
if (empty($batch)) break;
|
||||
$allTickets = array_merge($allTickets, $batch);
|
||||
$page++;
|
||||
} while (count($batch) >= 100 && $page <= 10);
|
||||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$messagesAdded = 0;
|
||||
|
||||
// Zammad state → intran status
|
||||
$stateMap = [
|
||||
'new' => 'uusi', 'open' => 'avoin', 'pending reminder' => 'odottaa',
|
||||
'pending close' => 'odottaa', 'closed' => 'suljettu',
|
||||
'merged' => 'suljettu', 'removed' => 'suljettu',
|
||||
];
|
||||
// Zammad priority → intran priority
|
||||
$priorityMap = [
|
||||
'1 low' => 'matala', '2 normal' => 'normaali', '3 high' => 'korkea',
|
||||
];
|
||||
|
||||
foreach ($allTickets as $zt) {
|
||||
$zammadId = (int)$zt['id'];
|
||||
$existing = dbGetTicketByZammadId($companyId, $zammadId);
|
||||
|
||||
// Mäppää Zammad-ryhmä → intran tikettityyppi
|
||||
$group = $zt['group'] ?? '';
|
||||
$type = 'muu';
|
||||
if (stripos($group, 'support') !== false || stripos($group, 'tech') !== false) $type = 'tekniikka';
|
||||
elseif (stripos($group, 'sales') !== false) $type = 'laskutus';
|
||||
elseif (stripos($group, 'abuse') !== false) $type = 'abuse';
|
||||
elseif (stripos($group, 'domain') !== false) $type = 'muu';
|
||||
elseif (stripos($group, 'noc') !== false) $type = 'vika';
|
||||
|
||||
$status = $stateMap[strtolower($zt['state'] ?? '')] ?? 'uusi';
|
||||
$priority = $priorityMap[strtolower($zt['priority'] ?? '')] ?? 'normaali';
|
||||
|
||||
if (!$existing) {
|
||||
// Uusi tiketti
|
||||
$ticketId = substr(uniqid(), -8) . bin2hex(random_bytes(2));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
_dbExecute(
|
||||
"INSERT INTO tickets (id, company_id, subject, from_email, from_name, status, type, priority, zammad_ticket_id, ticket_number, created, updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[$ticketId, $companyId, $zt['title'] ?? '', $zt['customer'] ?? '', $zt['customer'] ?? '',
|
||||
$status, $type, $priority, $zammadId, (int)($zt['number'] ?? 0),
|
||||
$zt['created_at'] ? date('Y-m-d H:i:s', strtotime($zt['created_at'])) : $now,
|
||||
$zt['updated_at'] ? date('Y-m-d H:i:s', strtotime($zt['updated_at'])) : $now]
|
||||
);
|
||||
$created++;
|
||||
} else {
|
||||
$ticketId = $existing['id'];
|
||||
// Päivitä status/priority
|
||||
_dbExecute(
|
||||
"UPDATE tickets SET status = ?, type = ?, priority = ?, subject = ?, updated = ? WHERE id = ?",
|
||||
[$status, $type, $priority, $zt['title'] ?? '', date('Y-m-d H:i:s', strtotime($zt['updated_at'] ?? 'now')), $ticketId]
|
||||
);
|
||||
$updated++;
|
||||
}
|
||||
|
||||
// Synkkaa artikkelit → viestit
|
||||
try {
|
||||
$articles = $z->getArticles($zammadId);
|
||||
foreach ($articles as $art) {
|
||||
if (($art['internal'] ?? false)) continue; // Ohita sisäiset muistiinpanot
|
||||
$artId = (int)$art['id'];
|
||||
$existingMsg = dbGetMessageByZammadArticleId($ticketId, $artId);
|
||||
if ($existingMsg) continue;
|
||||
|
||||
$msgId = substr(uniqid(), -8) . bin2hex(random_bytes(2));
|
||||
$msgType = ($art['sender'] ?? '') === 'Customer' ? 'incoming' : 'outgoing';
|
||||
$body = $art['body'] ?? '';
|
||||
// Strippaa HTML tagit jos content_type on text/html
|
||||
if (($art['content_type'] ?? '') === 'text/html') {
|
||||
$body = strip_tags($body, '<br><p><div><a><b><i><strong><em><ul><ol><li>');
|
||||
}
|
||||
|
||||
_dbExecute(
|
||||
"INSERT INTO ticket_messages (id, ticket_id, type, from_email, from_name, body, timestamp, message_id, zammad_article_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[$msgId, $ticketId, $msgType,
|
||||
$art['from'] ?? '', $art['from'] ?? '',
|
||||
$body,
|
||||
$art['created_at'] ? date('Y-m-d H:i:s', strtotime($art['created_at'])) : date('Y-m-d H:i:s'),
|
||||
$art['message_id'] ?? '',
|
||||
$artId]
|
||||
);
|
||||
$messagesAdded++;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Artikkelihaku epäonnistui — jatka silti
|
||||
error_log("Zammad articles error for ticket $zammadId: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'tickets_found' => count($allTickets),
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
'messages_added' => $messagesAdded,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'zammad_reply':
|
||||
requireCompany();
|
||||
$ticketId = $input['ticket_id'] ?? '';
|
||||
$body = $input['body'] ?? '';
|
||||
if (!$ticketId || !$body) { http_response_code(400); echo json_encode(['error' => 'Tiketti ja viesti vaaditaan']); break; }
|
||||
|
||||
$ticket = _dbFetchRow("SELECT * FROM tickets WHERE id = ? AND company_id = ?", [$ticketId, $companyId]);
|
||||
if (!$ticket || !$ticket['zammad_ticket_id']) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Tiketti ei ole Zammad-tiketti']);
|
||||
break;
|
||||
}
|
||||
|
||||
$integ = dbGetIntegration($companyId, 'zammad');
|
||||
if (!$integ || !$integ['enabled']) { http_response_code(400); echo json_encode(['error' => 'Zammad ei käytössä']); break; }
|
||||
|
||||
try {
|
||||
$z = new ZammadClient($integ['config']['url'], $integ['config']['token']);
|
||||
$result = $z->createArticle(
|
||||
(int)$ticket['zammad_ticket_id'],
|
||||
$body,
|
||||
$ticket['from_email'] ?? '',
|
||||
$ticket['subject'] ?? ''
|
||||
);
|
||||
|
||||
// Tallenna myös paikalliseen tietokantaan
|
||||
$msgId = substr(uniqid(), -8) . bin2hex(random_bytes(2));
|
||||
_dbExecute(
|
||||
"INSERT INTO ticket_messages (id, ticket_id, type, from_email, from_name, body, timestamp, zammad_article_id)
|
||||
VALUES (?, ?, 'outgoing', ?, ?, ?, ?, ?)",
|
||||
[$msgId, $ticketId,
|
||||
$result['from'] ?? '', $result['from'] ?? '',
|
||||
$body, date('Y-m-d H:i:s'),
|
||||
(int)($result['id'] ?? 0)]
|
||||
);
|
||||
|
||||
echo json_encode(['ok' => true, 'message_id' => $msgId]);
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Tuntematon toiminto']);
|
||||
|
||||
54
db.php
54
db.php
@@ -602,6 +602,19 @@ function initDatabase(): void {
|
||||
FOREIGN KEY (laitetila_id) REFERENCES laitetilat(id) ON DELETE CASCADE,
|
||||
INDEX idx_laitetila (laitetila_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS integrations (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
company_id VARCHAR(50) NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
enabled BOOLEAN DEFAULT FALSE,
|
||||
config JSON,
|
||||
created DATETIME,
|
||||
updated DATETIME,
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uk_company_type (company_id, type),
|
||||
INDEX idx_company (company_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
];
|
||||
|
||||
foreach ($tables as $i => $sql) {
|
||||
@@ -644,6 +657,8 @@ function initDatabase(): void {
|
||||
"ALTER TABLE ticket_rules ADD COLUMN enabled BOOLEAN DEFAULT TRUE AFTER auto_close_days",
|
||||
"ALTER TABLE ticket_rules ADD COLUMN set_priority VARCHAR(20) DEFAULT '' AFTER type_set",
|
||||
"ALTER TABLE ticket_rules ADD COLUMN set_tags VARCHAR(255) DEFAULT '' AFTER set_priority",
|
||||
"ALTER TABLE tickets ADD COLUMN zammad_ticket_id INT DEFAULT NULL AFTER mailbox_id",
|
||||
"ALTER TABLE ticket_messages ADD COLUMN zammad_article_id INT DEFAULT NULL AFTER message_id",
|
||||
];
|
||||
foreach ($alters as $sql) {
|
||||
try { $db->query($sql); } catch (\Throwable $e) { /* sarake on jo olemassa / jo ajettu */ }
|
||||
@@ -2223,3 +2238,42 @@ function dbUpdateConnection(int $connectionId, array $data): void {
|
||||
$connectionId
|
||||
]);
|
||||
}
|
||||
|
||||
// ==================== INTEGRAATIOT ====================
|
||||
|
||||
function dbLoadIntegrations(string $companyId): array {
|
||||
return _dbFetchAll("SELECT * FROM integrations WHERE company_id = ? ORDER BY type", [$companyId]);
|
||||
}
|
||||
|
||||
function dbGetIntegration(string $companyId, string $type): ?array {
|
||||
$row = _dbFetchRow("SELECT * FROM integrations WHERE company_id = ? AND type = ?", [$companyId, $type]);
|
||||
if ($row && $row['config']) {
|
||||
$row['config'] = json_decode($row['config'], true) ?: [];
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
function dbSaveIntegration(string $companyId, string $type, bool $enabled, array $config): void {
|
||||
$existing = _dbFetchRow("SELECT id FROM integrations WHERE company_id = ? AND type = ?", [$companyId, $type]);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
if ($existing) {
|
||||
_dbExecute(
|
||||
"UPDATE integrations SET enabled = ?, config = ?, updated = ? WHERE company_id = ? AND type = ?",
|
||||
[$enabled ? 1 : 0, json_encode($config), $now, $companyId, $type]
|
||||
);
|
||||
} else {
|
||||
$id = substr(uniqid(), -8) . bin2hex(random_bytes(2));
|
||||
_dbExecute(
|
||||
"INSERT INTO integrations (id, company_id, type, enabled, config, created, updated) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
[$id, $companyId, $type, $enabled ? 1 : 0, json_encode($config), $now, $now]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function dbGetTicketByZammadId(string $companyId, int $zammadId): ?array {
|
||||
return _dbFetchRow("SELECT * FROM tickets WHERE company_id = ? AND zammad_ticket_id = ?", [$companyId, $zammadId]);
|
||||
}
|
||||
|
||||
function dbGetMessageByZammadArticleId(string $ticketId, int $articleId): ?array {
|
||||
return _dbFetchRow("SELECT * FROM ticket_messages WHERE ticket_id = ? AND zammad_article_id = ?", [$ticketId, $articleId]);
|
||||
}
|
||||
|
||||
37
index.html
37
index.html
@@ -1517,6 +1517,43 @@
|
||||
<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>
|
||||
|
||||
<!-- Integraatiot -->
|
||||
<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;">Integraatiot</h3>
|
||||
<p style="color:#666;font-size:0.85rem;margin-bottom:1rem;">Ota käyttöön ja hallitse ulkoisia integraatioita moduuleittain.</p>
|
||||
<div id="integrations-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Zammad-konfiguraatio (piilotettu kunnes käytössä) -->
|
||||
<div class="table-card integration-config-card" id="zammad-config-card" style="padding:1.5rem;margin-top:1rem;display:none;">
|
||||
<h3 style="color:#0f3460;margin-bottom:0.5rem;border-bottom:2px solid #f0f2f5;padding-bottom:0.5rem;">
|
||||
<span style="margin-right:0.5rem;">📧</span>Zammad — Asetukset
|
||||
</h3>
|
||||
<p style="color:#666;font-size:0.85rem;margin-bottom:1rem;">Synkronoi tiketit Zammad-helpdeskin kautta. O365-sähköpostit kulkevat Zammadin kautta.</p>
|
||||
<div class="form-grid" style="max-width:600px;">
|
||||
<div class="form-group full-width">
|
||||
<label>Zammad URL</label>
|
||||
<input type="text" id="zammad-url" placeholder="https://desk.yritys.fi" style="font-family:monospace;">
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label>API Token</label>
|
||||
<input type="password" id="zammad-token" placeholder="Token..." style="font-family:monospace;">
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label>Synkronoitavat ryhmät</label>
|
||||
<div id="zammad-groups-list" style="margin-bottom:0.5rem;color:#888;font-size:0.85rem;">Tallenna ensin URL ja token, sitten valitse ryhmät.</div>
|
||||
</div>
|
||||
<div class="form-group full-width" style="display:flex;gap:0.5rem;flex-wrap:wrap;">
|
||||
<button class="btn-primary" id="btn-save-zammad">Tallenna</button>
|
||||
<button class="btn-secondary" id="btn-test-zammad">Testaa yhteys</button>
|
||||
<button class="btn-secondary" id="btn-load-zammad-groups">Lataa ryhmät</button>
|
||||
<button class="btn-primary" id="btn-sync-zammad" style="background:#28a745;">▶ Synkronoi nyt</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="zammad-test-result" style="margin-top:0.75rem;display:none;padding:1rem;border-radius:8px;font-size:0.85rem;font-family:monospace;"></div>
|
||||
<div id="zammad-sync-result" style="margin-top:0.75rem;display:none;padding:1rem;border-radius:8px;font-size:0.85rem;"></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>
|
||||
|
||||
204
script.js
204
script.js
@@ -1332,6 +1332,7 @@ document.getElementById('profile-form').addEventListener('submit', async (e) =>
|
||||
|
||||
let tickets = [];
|
||||
let currentTicketId = null;
|
||||
let currentTicketData = null;
|
||||
let ticketReplyType = 'reply';
|
||||
|
||||
const ticketStatusLabels = {
|
||||
@@ -1512,6 +1513,7 @@ async function showTicketDetail(id, companyId = '') {
|
||||
currentTicketCompanyId = companyId;
|
||||
const ticket = await apiCall('ticket_detail&id=' + encodeURIComponent(id) + ticketCompanyParam());
|
||||
currentTicketId = id;
|
||||
currentTicketData = ticket;
|
||||
|
||||
// Header
|
||||
document.getElementById('ticket-detail-header').innerHTML = `
|
||||
@@ -1881,19 +1883,26 @@ document.getElementById('btn-send-reply').addEventListener('click', async () =>
|
||||
btn.textContent = 'Lähetetään...';
|
||||
|
||||
try {
|
||||
const action = ticketReplyType === 'note' ? 'ticket_note' : 'ticket_reply';
|
||||
const payload = { id: currentTicketId, body };
|
||||
if (ticketReplyType !== 'note') {
|
||||
const mbSel = document.getElementById('reply-mailbox-select');
|
||||
const toFld = document.getElementById('reply-to');
|
||||
const ccFld = document.getElementById('reply-cc');
|
||||
const useSig = document.getElementById('reply-use-signature');
|
||||
if (mbSel) payload.mailbox_id = mbSel.value;
|
||||
if (toFld && toFld.value.trim()) payload.to = toFld.value.trim();
|
||||
if (ccFld) payload.cc = ccFld.value.trim();
|
||||
if (useSig && !useSig.checked) payload.no_signature = true;
|
||||
// Tarkista onko Zammad-tiketti
|
||||
const isZammadTicket = currentTicketData?.zammad_ticket_id;
|
||||
if (isZammadTicket && ticketReplyType !== 'note') {
|
||||
// Lähetä Zammad API:n kautta
|
||||
await apiCall('zammad_reply' + ticketCompanyParam(), 'POST', { ticket_id: currentTicketId, body });
|
||||
} else {
|
||||
const action = ticketReplyType === 'note' ? 'ticket_note' : 'ticket_reply';
|
||||
const payload = { id: currentTicketId, body };
|
||||
if (ticketReplyType !== 'note') {
|
||||
const mbSel = document.getElementById('reply-mailbox-select');
|
||||
const toFld = document.getElementById('reply-to');
|
||||
const ccFld = document.getElementById('reply-cc');
|
||||
const useSig = document.getElementById('reply-use-signature');
|
||||
if (mbSel) payload.mailbox_id = mbSel.value;
|
||||
if (toFld && toFld.value.trim()) payload.to = toFld.value.trim();
|
||||
if (ccFld) payload.cc = ccFld.value.trim();
|
||||
if (useSig && !useSig.checked) payload.no_signature = true;
|
||||
}
|
||||
await apiCall(action + ticketCompanyParam(), 'POST', payload);
|
||||
}
|
||||
await apiCall(action + ticketCompanyParam(), 'POST', payload);
|
||||
// Reload the detail view
|
||||
await showTicketDetail(currentTicketId, currentTicketCompanyId);
|
||||
} catch (e) {
|
||||
@@ -2352,6 +2361,10 @@ async function loadSettings() {
|
||||
document.getElementById('settings-telegram-chat').value = config.telegram_chat_id || '';
|
||||
} catch (e) { console.error(e); }
|
||||
|
||||
// Integraatiot
|
||||
loadIntegrations();
|
||||
loadZammadConfig();
|
||||
|
||||
// Vastauspohjat
|
||||
loadTemplates();
|
||||
}
|
||||
@@ -2422,6 +2435,173 @@ document.getElementById('btn-test-api').addEventListener('click', async () => {
|
||||
} catch (e) { result.textContent = 'Virhe: ' + e.message; }
|
||||
});
|
||||
|
||||
// ==================== INTEGRAATIOT ====================
|
||||
|
||||
const INTEGRATION_TYPES = {
|
||||
zammad: { name: 'Zammad', icon: '📧', desc: 'Synkronoi tiketit Zammad-helpdeskistä (O365-sähköpostit)' },
|
||||
};
|
||||
|
||||
async function loadIntegrations() {
|
||||
const container = document.getElementById('integrations-list');
|
||||
if (!container) return;
|
||||
try {
|
||||
const integrations = await apiCall('integrations');
|
||||
renderIntegrations(integrations);
|
||||
} catch (e) {
|
||||
console.error('loadIntegrations:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderIntegrations(integrations) {
|
||||
const container = document.getElementById('integrations-list');
|
||||
const enabledMap = {};
|
||||
integrations.forEach(i => { enabledMap[i.type] = i.enabled; });
|
||||
|
||||
let html = '';
|
||||
for (const [type, meta] of Object.entries(INTEGRATION_TYPES)) {
|
||||
const enabled = enabledMap[type] || false;
|
||||
html += `
|
||||
<div class="integration-item" data-type="${type}">
|
||||
<label class="integration-toggle">
|
||||
<input type="checkbox" class="integration-enabled" data-type="${type}" ${enabled ? 'checked' : ''}>
|
||||
<span class="integration-icon">${meta.icon}</span>
|
||||
<span class="integration-info">
|
||||
<strong>${esc(meta.name)}</strong>
|
||||
<small>${esc(meta.desc)}</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>`;
|
||||
}
|
||||
container.innerHTML = html;
|
||||
|
||||
// Toggle-napit
|
||||
container.querySelectorAll('.integration-enabled').forEach(cb => {
|
||||
cb.addEventListener('change', async () => {
|
||||
const type = cb.dataset.type;
|
||||
const configCard = document.getElementById(type + '-config-card');
|
||||
if (configCard) configCard.style.display = cb.checked ? '' : 'none';
|
||||
// Tallenna toggle-tila
|
||||
try {
|
||||
const existing = await apiCall('integrations');
|
||||
const old = existing.find(i => i.type === type);
|
||||
const config = old?.config || {};
|
||||
await apiCall('integration_save', 'POST', { type, enabled: cb.checked, config });
|
||||
} catch (e) { console.error(e); }
|
||||
});
|
||||
});
|
||||
|
||||
// Näytä config-kortit käytössä oleville
|
||||
integrations.forEach(i => {
|
||||
const card = document.getElementById(i.type + '-config-card');
|
||||
if (card) card.style.display = i.enabled ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
async function loadZammadConfig() {
|
||||
try {
|
||||
const integrations = await apiCall('integrations');
|
||||
const zammad = integrations.find(i => i.type === 'zammad');
|
||||
if (zammad && zammad.config) {
|
||||
document.getElementById('zammad-url').value = zammad.config.url || '';
|
||||
document.getElementById('zammad-token').value = zammad.config.token || '';
|
||||
// Renderöi ryhmät jos tallennettu
|
||||
if (zammad.config.group_ids && zammad.config.group_names) {
|
||||
renderZammadGroupCheckboxes(
|
||||
zammad.config.group_names.map((name, i) => ({
|
||||
id: zammad.config.group_ids[i],
|
||||
name: name,
|
||||
})),
|
||||
zammad.config.group_ids
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function renderZammadGroupCheckboxes(groups, selectedIds = []) {
|
||||
const container = document.getElementById('zammad-groups-list');
|
||||
if (!groups.length) { container.innerHTML = '<span style="color:#888;">Ei ryhmiä.</span>'; return; }
|
||||
container.innerHTML = groups.map(g => `
|
||||
<label style="display:flex;align-items:center;gap:0.4rem;margin-bottom:0.3rem;cursor:pointer;">
|
||||
<input type="checkbox" class="zammad-group-cb" value="${g.id}" data-name="${esc(g.name)}" ${selectedIds.includes(g.id) || selectedIds.includes(String(g.id)) ? 'checked' : ''}>
|
||||
${esc(g.name)}
|
||||
</label>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Zammad — Tallenna
|
||||
document.getElementById('btn-save-zammad')?.addEventListener('click', async () => {
|
||||
const url = document.getElementById('zammad-url').value.trim();
|
||||
const token = document.getElementById('zammad-token').value.trim();
|
||||
if (!url || !token) { alert('URL ja token vaaditaan'); return; }
|
||||
|
||||
const groupCbs = document.querySelectorAll('.zammad-group-cb:checked');
|
||||
const groupIds = Array.from(groupCbs).map(cb => cb.value);
|
||||
const groupNames = Array.from(groupCbs).map(cb => cb.dataset.name);
|
||||
|
||||
try {
|
||||
await apiCall('integration_save', 'POST', {
|
||||
type: 'zammad',
|
||||
enabled: document.querySelector('.integration-enabled[data-type="zammad"]')?.checked || false,
|
||||
config: { url, token, group_ids: groupIds, group_names: groupNames },
|
||||
});
|
||||
alert('Zammad-asetukset tallennettu!');
|
||||
} catch (e) { alert('Virhe: ' + e.message); }
|
||||
});
|
||||
|
||||
// Zammad — Testaa yhteys
|
||||
document.getElementById('btn-test-zammad')?.addEventListener('click', async () => {
|
||||
const result = document.getElementById('zammad-test-result');
|
||||
result.style.display = 'block';
|
||||
result.style.background = '#f8f9fb';
|
||||
result.textContent = 'Testataan yhteyttä...';
|
||||
try {
|
||||
const res = await apiCall('integration_test', 'POST', { type: 'zammad' });
|
||||
result.style.background = '#d4edda';
|
||||
result.textContent = `✅ Yhteys OK! Käyttäjä: ${res.user}, Ryhmiä: ${res.groups}`;
|
||||
} catch (e) {
|
||||
result.style.background = '#f8d7da';
|
||||
result.textContent = '❌ ' + e.message;
|
||||
}
|
||||
});
|
||||
|
||||
// Zammad — Lataa ryhmät
|
||||
document.getElementById('btn-load-zammad-groups')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const groups = await apiCall('zammad_groups');
|
||||
const activeGroups = groups.filter(g => g.active);
|
||||
|
||||
// Hae tallennetut valitut ryhmät
|
||||
const integrations = await apiCall('integrations');
|
||||
const zammad = integrations.find(i => i.type === 'zammad');
|
||||
const selectedIds = zammad?.config?.group_ids || [];
|
||||
|
||||
renderZammadGroupCheckboxes(activeGroups, selectedIds);
|
||||
} catch (e) { alert('Virhe: ' + e.message); }
|
||||
});
|
||||
|
||||
// Zammad — Synkronoi nyt
|
||||
document.getElementById('btn-sync-zammad')?.addEventListener('click', async () => {
|
||||
const result = document.getElementById('zammad-sync-result');
|
||||
result.style.display = 'block';
|
||||
result.style.background = '#f8f9fb';
|
||||
result.innerHTML = '⏳ Synkronoidaan...';
|
||||
try {
|
||||
const res = await apiCall('zammad_sync', 'POST', {});
|
||||
result.style.background = '#d4edda';
|
||||
result.innerHTML = `✅ Synkronointi valmis!<br>
|
||||
Tikettejä löytyi: <strong>${res.tickets_found}</strong><br>
|
||||
Uusia tikettejä: <strong>${res.created}</strong><br>
|
||||
Päivitettyjä: <strong>${res.updated}</strong><br>
|
||||
Uusia viestejä: <strong>${res.messages_added}</strong>`;
|
||||
// Päivitä tikettilista jos ollaan tukivälilehdellä
|
||||
if (typeof loadTickets === 'function') loadTickets();
|
||||
} catch (e) {
|
||||
result.style.background = '#f8d7da';
|
||||
result.innerHTML = '❌ ' + e.message;
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== MODALS ====================
|
||||
|
||||
customerModal.addEventListener('click', (e) => { if (e.target === customerModal) customerModal.style.display = 'none'; });
|
||||
|
||||
34
style.css
34
style.css
@@ -1999,3 +1999,37 @@ span.empty {
|
||||
.combo-badge.taken { background: #fee2e2; color: #991b1b; }
|
||||
.combo-badge.subnet { background: #e0e7ff; color: #3730a3; }
|
||||
.combo-grp { padding: 0.3rem 0.7rem; font-size: 0.75rem; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
|
||||
/* Integraatiot */
|
||||
.integration-item {
|
||||
background: #f8f9fb;
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.integration-item:hover { background: #eef1f6; }
|
||||
.integration-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.integration-toggle input[type="checkbox"] {
|
||||
width: 18px; height: 18px; accent-color: var(--primary-color);
|
||||
}
|
||||
.integration-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.integration-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.integration-info strong { font-size: 0.95rem; color: #333; }
|
||||
.integration-info small { font-size: 0.8rem; color: #888; }
|
||||
.integration-config-card {
|
||||
border-left: 3px solid var(--primary-color);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user