diff --git a/api.php b/api.php
index 61bfccd..996c34d 100644
--- a/api.php
+++ b/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, '
- ');
+ }
+
+ _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']);
diff --git a/db.php b/db.php
index b203a76..ad963d0 100644
--- a/db.php
+++ b/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]);
+}
diff --git a/index.html b/index.html
index bb45a1f..ab423b3 100644
--- a/index.html
+++ b/index.html
@@ -1517,6 +1517,43 @@
+
+
+
Integraatiot
+
Ota käyttöön ja hallitse ulkoisia integraatioita moduuleittain.
+
+
+
+
+
+
+ 📧Zammad — Asetukset
+
+
Synkronoi tiketit Zammad-helpdeskin kautta. O365-sähköpostit kulkevat Zammadin kautta.
+
+
+
+
+
Telegram-hälytykset
diff --git a/script.js b/script.js
index 44e7975..83fb567 100644
--- a/script.js
+++ b/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 += `
+
+
+
`;
+ }
+ 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 = '
Ei ryhmiä.'; return; }
+ container.innerHTML = groups.map(g => `
+
+ `).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!
+ Tikettejä löytyi:
${res.tickets_found}
+ Uusia tikettejä:
${res.created}
+ Päivitettyjä:
${res.updated}
+ Uusia viestejä:
${res.messages_added}`;
+ // 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'; });
diff --git a/style.css b/style.css
index 1bcfbb7..ec0a1f6 100644
--- a/style.css
+++ b/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);
+}