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:
2026-03-12 19:25:51 +02:00
parent 1aea4bde20
commit fa8aaed11e
5 changed files with 645 additions and 12 deletions

328
api.php
View File

@@ -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
View File

@@ -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]);
}

View File

@@ -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>

180
script.js
View File

@@ -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,6 +1883,12 @@ document.getElementById('btn-send-reply').addEventListener('click', async () =>
btn.textContent = 'Lähetetään...';
try {
// 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') {
@@ -1894,6 +1902,7 @@ document.getElementById('btn-send-reply').addEventListener('click', async () =>
if (useSig && !useSig.checked) payload.no_signature = true;
}
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'; });

View File

@@ -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);
}