Files
intra.noxus.fi/script.js
2026-03-11 14:03:06 +02:00

4411 lines
213 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const API = 'api.php';
let customers = [];
let sortField = 'yritys';
let sortAsc = true;
let currentDetailId = null;
let currentUser = { username: '', nimi: '', role: '' };
let currentCompany = null; // {id, nimi}
let availableCompanies = []; // [{id, nimi}, ...]
let currentTicketCompanyId = ''; // Avatun tiketin yritys (cross-company tuki)
let currentUserSignatures = {}; // {mailbox_id: "allekirjoitus teksti"}
// Elements
const loginScreen = document.getElementById('login-screen');
const dashboard = document.getElementById('dashboard');
const loginForm = document.getElementById('login-form');
const loginError = document.getElementById('login-error');
const searchInput = document.getElementById('search-input');
const tbody = document.getElementById('customer-tbody');
const noCustomers = document.getElementById('no-customers');
const customerCount = document.getElementById('customer-count');
const totalBilling = document.getElementById('total-billing');
const customerModal = document.getElementById('customer-modal');
const detailModal = document.getElementById('detail-modal');
const customerForm = document.getElementById('customer-form');
const userModal = document.getElementById('user-modal');
// API helpers
async function apiCall(action, method = 'GET', body = null) {
const opts = { method, credentials: 'include' };
if (body) {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(body);
}
const res = await fetch(`${API}?action=${action}`, opts);
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch (e) {
console.error('API JSON parse error:', action, text.slice(0, 500));
throw new Error('API virhe (' + action + '): ' + text.slice(0, 300));
}
if (!res.ok) throw new Error(data.error || 'Virhe');
return data;
}
// ==================== AUTH ====================
const forgotBox = document.getElementById('forgot-box');
const resetBox = document.getElementById('reset-box');
const loginBox = document.querySelector('.login-box');
function showLoginView() {
loginBox.style.display = '';
forgotBox.style.display = 'none';
resetBox.style.display = 'none';
}
function showForgotView() {
loginBox.style.display = 'none';
forgotBox.style.display = '';
resetBox.style.display = 'none';
}
function showResetView() {
loginBox.style.display = 'none';
forgotBox.style.display = 'none';
resetBox.style.display = '';
}
document.getElementById('forgot-link').addEventListener('click', (e) => { e.preventDefault(); showForgotView(); });
document.getElementById('forgot-back').addEventListener('click', (e) => { e.preventDefault(); showLoginView(); });
async function loadCaptcha() {
try {
const data = await apiCall('captcha');
document.getElementById('captcha-question').textContent = data.question;
} catch (e) {
document.getElementById('captcha-question').textContent = 'Virhe';
}
}
// Salasanan palautuspyyntö
document.getElementById('forgot-form').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('forgot-username').value;
const forgotMsg = document.getElementById('forgot-msg');
const forgotError = document.getElementById('forgot-error');
forgotMsg.style.display = 'none';
forgotError.style.display = 'none';
try {
await apiCall('password_reset_request', 'POST', { username });
forgotMsg.textContent = 'Jos käyttäjätunnukselle on sähköposti, palautuslinkki on lähetetty.';
forgotMsg.style.display = 'block';
} catch (err) {
forgotError.textContent = err.message;
forgotError.style.display = 'block';
}
});
// Salasanan vaihto (reset token)
document.getElementById('reset-form').addEventListener('submit', async (e) => {
e.preventDefault();
const pw1 = document.getElementById('reset-password').value;
const pw2 = document.getElementById('reset-password2').value;
const resetMsg = document.getElementById('reset-msg');
const resetError = document.getElementById('reset-error');
resetMsg.style.display = 'none';
resetError.style.display = 'none';
if (pw1 !== pw2) { resetError.textContent = 'Salasanat eivät täsmää'; resetError.style.display = 'block'; return; }
const params = new URLSearchParams(window.location.search);
const token = params.get('reset');
try {
await apiCall('password_reset', 'POST', { token, password: pw1 });
resetMsg.textContent = 'Salasana vaihdettu! Voit nyt kirjautua.';
resetMsg.style.display = 'block';
document.getElementById('reset-form').style.display = 'none';
setTimeout(() => { window.location.href = window.location.pathname; }, 3000);
} catch (err) {
resetError.textContent = err.message;
resetError.style.display = 'block';
}
});
async function checkAuth() {
// Tarkista onko URL:ssa reset-token
const params = new URLSearchParams(window.location.search);
if (params.get('reset')) {
loginScreen.style.display = 'flex';
try {
const data = await apiCall('validate_reset_token&token=' + encodeURIComponent(params.get('reset')));
if (data.valid) { showResetView(); return; }
} catch (e) {}
showResetView();
document.getElementById('reset-error').textContent = 'Palautuslinkki on vanhentunut tai virheellinen';
document.getElementById('reset-error').style.display = 'block';
document.getElementById('reset-form').style.display = 'none';
return;
}
try {
const data = await apiCall('check_auth');
if (data.authenticated) {
currentUser = { username: data.username, nimi: data.nimi, role: data.role, id: data.user_id };
availableCompanies = data.companies || [];
currentCompany = availableCompanies.find(c => c.id === data.company_id) || availableCompanies[0] || null;
currentUserSignatures = data.signatures || {};
if (data.branding) applyBranding(data.branding);
applyModules(data.enabled_modules || []);
showDashboard();
return;
}
} catch (e) { /* not logged in */ }
// Ei kirjautuneena → näytä login
loginScreen.style.display = 'flex';
}
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
const captcha = document.getElementById('login-captcha').value;
try {
const data = await apiCall('login', 'POST', { username, password, captcha: parseInt(captcha) });
loginError.style.display = 'none';
currentUser = { username: data.username, nimi: data.nimi, role: data.role, id: data.user_id };
availableCompanies = data.companies || [];
currentCompany = availableCompanies.find(c => c.id === data.company_id) || availableCompanies[0] || null;
currentUserSignatures = data.signatures || {};
showDashboard();
} catch (err) {
loginError.textContent = err.message;
loginError.style.display = 'block';
document.getElementById('login-captcha').value = '';
loadCaptcha();
}
});
document.getElementById('btn-logout').addEventListener('click', async () => {
await apiCall('logout');
dashboard.style.display = 'none';
loginScreen.style.display = 'flex';
document.getElementById('login-username').value = '';
document.getElementById('login-password').value = '';
document.getElementById('login-captcha').value = '';
showLoginView();
loadCaptcha();
loadBranding(); // Domain-pohjainen brändäys uudelleen
});
async function showDashboard() {
loginScreen.style.display = 'none';
dashboard.style.display = 'block';
document.getElementById('user-info').textContent = currentUser.nimi || currentUser.username;
const isSuperAdmin = currentUser.role === 'superadmin';
const isAdmin = currentUser.role === 'admin' || isSuperAdmin;
// Näytä admin-toiminnot roolin mukaan
document.getElementById('btn-users').style.display = isAdmin ? '' : 'none';
document.getElementById('tab-settings').style.display = isAdmin ? '' : 'none';
document.getElementById('btn-companies').style.display = isAdmin ? '' : 'none';
// Yritysvalitsin
populateCompanySelector();
// Avaa oikea tabi URL-hashin perusteella (tai customers oletuks)
const hash = window.location.hash.replace('#', '');
const [mainHash, subHash] = hash.split('/');
const validTabs = ['customers', 'leads', 'tekniikka', 'ohjeet', 'todo', 'archive', 'changelog', 'support', 'users', 'settings', 'companies'];
const startTab = validTabs.includes(mainHash) ? mainHash : 'customers';
switchToTab(startTab, subHash);
}
function populateCompanySelector() {
const sel = document.getElementById('company-selector');
if (availableCompanies.length <= 1) {
sel.style.display = 'none';
return;
}
sel.style.display = '';
sel.innerHTML = availableCompanies.map(c =>
`<option value="${c.id}" ${currentCompany && c.id === currentCompany.id ? 'selected' : ''}>${esc(c.nimi)}</option>`
).join('');
}
async function switchCompany(companyId) {
try {
await apiCall('company_switch', 'POST', { company_id: companyId });
currentCompany = availableCompanies.find(c => c.id === companyId) || null;
// Päivitä brändäys vaihdetun yrityksen mukaan
try {
const auth = await apiCall('check_auth');
if (auth.branding) applyBranding(auth.branding);
applyModules(auth.enabled_modules || []);
} catch (e2) {}
// Lataa uudelleen aktiivinen tab
const hash = window.location.hash.replace('#', '') || 'customers';
const [mainTab, subTab] = hash.split('/');
switchToTab(mainTab, subTab);
} catch (e) { alert(e.message); }
}
// ==================== TABS ====================
function switchToTab(target, subTab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
const tabBtn = document.querySelector(`.tab[data-tab="${target}"]`);
if (tabBtn) tabBtn.classList.add('active');
const content = document.getElementById('tab-content-' + target);
if (content) content.classList.add('active');
// Tallenna aktiivinen tabi URL-hashiin
if (target === 'tekniikka' && subTab) {
window.location.hash = target + '/' + subTab;
} else {
window.location.hash = target;
}
// Lataa sisältö tarvittaessa
if (target === 'customers') loadCustomers();
if (target === 'leads') loadLeads();
if (target === 'tekniikka') {
loadDevices(); loadSitesTab(); loadIpam();
// Palauta sub-tab
const validSubTabs = ['devices', 'sites', 'ipam'];
if (subTab && validSubTabs.includes(subTab)) switchSubTab(subTab);
}
if (target === 'archive') loadArchive();
if (target === 'changelog') loadChangelog();
if (target === 'ohjeet') loadGuides();
if (target === 'todo') { loadTodos(); if (subTab) switchTodoSubTab(subTab); }
if (target === 'support') { loadTickets(); showTicketListView(); if (document.getElementById('ticket-auto-refresh').checked) startTicketAutoRefresh(); }
if (target === 'users') loadUsers();
if (target === 'settings') loadSettings();
if (target === 'companies') loadCompaniesTab();
}
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
switchToTab(tab.dataset.tab);
});
});
// Logo -> Asiakkaat (alkunäkymä)
document.getElementById('brand-home').addEventListener('click', () => {
switchToTab('customers');
});
// Käyttäjät-nappi headerissa
document.getElementById('btn-users').addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.getElementById('tab-content-users').classList.add('active');
loadUsers();
});
document.getElementById('btn-companies').addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.getElementById('tab-content-companies').classList.add('active');
window.location.hash = 'companies';
loadCompaniesTab();
});
// ==================== CUSTOMERS ====================
async function loadCustomers() {
customers = await apiCall('customers');
renderTable();
}
function flattenRows(customerList) {
const rows = [];
customerList.forEach(c => {
const liittymat = c.liittymat || [];
if (liittymat.length === 0) {
rows.push({ customer: c, liittyma: { asennusosoite: '', postinumero: '', kaupunki: '', liittymanopeus: '', hinta: 0, sopimuskausi: '', alkupvm: '' }, index: 0 });
} else {
liittymat.forEach((l, i) => rows.push({ customer: c, liittyma: l, index: i }));
}
});
return rows;
}
function renderTable() {
const query = searchInput.value.toLowerCase().trim();
let filtered = customers;
if (query) {
filtered = customers.filter(c => {
const liittymat = c.liittymat || [];
const inL = liittymat.some(l =>
(l.asennusosoite || '').toLowerCase().includes(query) ||
(l.postinumero || '').toLowerCase().includes(query) ||
(l.kaupunki || '').toLowerCase().includes(query) ||
(l.liittymanopeus || '').toLowerCase().includes(query) ||
(l.vlan || '').toLowerCase().includes(query) ||
(l.laite || '').toLowerCase().includes(query) ||
(l.portti || '').toLowerCase().includes(query) ||
(l.ip || '').toLowerCase().includes(query)
);
return c.yritys.toLowerCase().includes(query) ||
(c.yhteyshenkilö || '').toLowerCase().includes(query) || inL;
});
}
const rows = flattenRows(filtered);
rows.sort((a, b) => {
let va, vb;
if (['asennusosoite', 'postinumero', 'kaupunki', 'liittymanopeus', 'hinta', 'sopimuskausi'].includes(sortField)) {
va = a.liittyma[sortField] ?? '';
vb = b.liittyma[sortField] ?? '';
} else {
va = a.customer[sortField] ?? '';
vb = b.customer[sortField] ?? '';
}
if (sortField === 'hinta') { va = parseFloat(va) || 0; vb = parseFloat(vb) || 0; }
else { va = String(va).toLowerCase(); vb = String(vb).toLowerCase(); }
if (va < vb) return sortAsc ? -1 : 1;
if (va > vb) return sortAsc ? 1 : -1;
return 0;
});
if (rows.length === 0) {
tbody.innerHTML = '';
noCustomers.style.display = 'block';
document.getElementById('customer-table').style.display = 'none';
} else {
noCustomers.style.display = 'none';
document.getElementById('customer-table').style.display = 'table';
let prevId = null;
tbody.innerHTML = rows.map(r => {
const c = r.customer, l = r.liittyma;
const isFirst = c.id !== prevId;
prevId = c.id;
const sopimusStr = contractRemaining(l.sopimuskausi, l.alkupvm);
return `<tr data-id="${c.id}" class="${isFirst ? '' : 'sub-row'}">
<td>${isFirst ? '<strong>' + esc(c.yritys) + '</strong>' : '<span class="sub-marker">&#8627;</span>'}</td>
<td>${esc(l.asennusosoite)}${l.postinumero ? ', ' + esc(l.postinumero) : ''}</td>
<td>${esc(l.kaupunki)}</td>
<td>${esc(l.liittymanopeus)}</td>
<td class="price-cell">${formatPrice(l.hinta)}</td>
<td>${sopimusStr}</td>
<td class="actions-cell">${isFirst ? `<button onclick="event.stopPropagation();editCustomer('${c.id}')" title="Muokkaa">&#9998;</button><button onclick="event.stopPropagation();deleteCustomer('${c.id}','${esc(c.yritys)}')" title="Arkistoi">&#128451;</button>` : ''}</td>
</tr>`;
}).join('');
}
updateSummary();
}
function getAllLiittymat() {
const all = [];
customers.forEach(c => (c.liittymat || []).forEach(l => all.push(l)));
return all;
}
function updateSummary() {
const liittymat = getAllLiittymat();
const count = customers.length;
const connCount = liittymat.length;
const total = liittymat.reduce((sum, l) => sum + (parseFloat(l.hinta) || 0), 0);
customerCount.textContent = `${count} asiakasta, ${connCount} liittymää`;
totalBilling.textContent = `Laskutus yhteensä: ${formatPrice(total)}/kk`;
setText('stat-count', count);
setText('stat-connections', connCount);
setText('stat-billing', formatPrice(total));
setText('stat-yearly', formatPrice(total * 12));
updateTrivia(liittymat, connCount);
}
function updateTrivia(liittymat, connCount) {
if (connCount === 0) {
setTrivia('stat-top-zip', '-', '');
setText('stat-avg-price', '-');
const st = document.getElementById('stat-speed-table');
if (st) st.innerHTML = '<span style="color:#aaa;font-size:0.85rem;">-</span>';
return;
}
// Postinumero
const zipCounts = {};
liittymat.forEach(l => { const z = (l.postinumero || '').trim(); if (z) zipCounts[z] = (zipCounts[z] || 0) + 1; });
const topZip = Object.entries(zipCounts).sort((a, b) => b[1] - a[1])[0];
if (topZip) {
const city = liittymat.find(l => (l.postinumero || '').trim() === topZip[0]);
setTrivia('stat-top-zip', topZip[0], `${topZip[1]} liittymää` + (city && city.kaupunki ? ` (${city.kaupunki})` : ''));
} else { setTrivia('stat-top-zip', '-', ''); }
// Nopeudet
const speedCounts = {};
liittymat.forEach(l => { const s = (l.liittymanopeus || '').trim(); if (s) speedCounts[s] = (speedCounts[s] || 0) + 1; });
const speedTable = document.getElementById('stat-speed-table');
if (speedTable) {
const sorted = Object.entries(speedCounts).sort((a, b) => b[1] - a[1]);
const maxC = sorted.length > 0 ? sorted[0][1] : 0;
speedTable.innerHTML = sorted.length === 0 ? '<span style="color:#aaa;font-size:0.85rem;">-</span>' :
sorted.map(([sp, cnt]) => {
const isTop = cnt === maxC;
const w = Math.max(15, (cnt / maxC) * 50);
return `<span class="speed-item ${isTop ? 'top' : ''}">${esc(sp)} (${cnt})<span class="speed-bar" style="width:${w}px"></span></span>`;
}).join('');
}
// Keskihinta
const total = liittymat.reduce((sum, l) => sum + (parseFloat(l.hinta) || 0), 0);
setText('stat-avg-price', formatPrice(total / connCount));
}
function setTrivia(id, value, sub) {
const el = document.getElementById(id);
const subEl = document.getElementById(id + '-detail');
if (el) el.textContent = value;
if (subEl) subEl.textContent = sub;
}
function setText(id, value) { const el = document.getElementById(id); if (el) el.textContent = value; }
function formatPrice(val) { return parseFloat(val || 0).toFixed(2).replace('.', ',') + ' €'; }
function contractRemaining(sopimuskausi, alkupvm) {
if (!sopimuskausi) return '';
const months = parseInt(sopimuskausi);
if (!months || !alkupvm) return months + ' kk';
const start = new Date(alkupvm);
if (isNaN(start.getTime())) return months + ' kk';
const end = new Date(start);
end.setMonth(end.getMonth() + months);
const now = new Date();
const diffMs = end - now;
if (diffMs <= 0) return `${months} kk <span style="color:#27ae60;font-size:0.8rem;">(jatkuva)</span>`;
const remainMonths = Math.ceil(diffMs / (1000 * 60 * 60 * 24 * 30.44));
return `${months} kk <span style="color:#888;font-size:0.8rem;">(${remainMonths} kk jäljellä)</span>`;
}
// Hintojen näyttö/piilotus
(function() {
const toggle = document.getElementById('toggle-prices');
if (!toggle) return;
// Oletuksena piilossa
document.getElementById('customer-table')?.classList.add('prices-hidden');
toggle.addEventListener('change', () => {
document.getElementById('customer-table')?.classList.toggle('prices-hidden', !toggle.checked);
// Blurraa myös asiakaskortin hinnat
document.querySelectorAll('.customer-detail-card').forEach(el => {
el.classList.toggle('prices-hidden', !toggle.checked);
});
});
})();
function esc(str) { if (!str) return ''; const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
function timeAgo(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr.replace(' ', 'T'));
const now = new Date();
const diffMs = now - date;
if (diffMs < 0) return 'juuri nyt';
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return 'juuri nyt';
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return diffMin + ' min sitten';
const diffHours = Math.floor(diffMin / 60);
if (diffHours < 24) return diffHours + 'h sitten';
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) return diffDays + 'pv sitten';
if (diffDays < 30) return Math.floor(diffDays / 7) + 'vk sitten';
// Yli kuukausi → näytä päivämäärä
return dateStr.substring(0, 10);
}
// Search & Sort
searchInput.addEventListener('input', () => renderTable());
document.querySelectorAll('th[data-sort]').forEach(th => {
th.addEventListener('click', () => {
const f = th.dataset.sort;
if (sortField === f) sortAsc = !sortAsc;
else { sortField = f; sortAsc = true; }
renderTable();
});
});
// Row click
tbody.addEventListener('click', (e) => {
const row = e.target.closest('tr');
if (row) showDetail(row.dataset.id);
});
function detailVal(val) { return val ? esc(val) : '<span class="empty">-</span>'; }
function detailLink(val, type) {
if (!val) return '<span class="empty">-</span>';
if (type === 'tel') return `<a href="tel:${esc(val)}">${esc(val)}</a>`;
if (type === 'email') return `<a href="mailto:${esc(val)}">${esc(val)}</a>`;
return esc(val);
}
function showDetail(id) {
const c = customers.find(x => x.id === id);
if (!c) return;
currentDetailId = id;
const liittymat = c.liittymat || [];
const fullBilling = [c.laskutusosoite, c.laskutuspostinumero, c.laskutuskaupunki].filter(Boolean).join(', ');
const liittymatHtml = liittymat.map((l, i) => {
const addr = [l.asennusosoite, l.postinumero, l.kaupunki].filter(Boolean).join(', ');
return `<div class="liittyma-card">
${liittymat.length > 1 ? `<div class="liittyma-num">Liittymä ${i + 1}</div>` : ''}
<div class="detail-grid">
<div class="detail-item"><div class="detail-label">Osoite</div><div class="detail-value">${detailVal(addr)}</div></div>
<div class="detail-item"><div class="detail-label">Nopeus</div><div class="detail-value">${detailVal(l.liittymanopeus)}</div></div>
<div class="detail-item"><div class="detail-label">Hinta / kk</div><div class="detail-value price-cell">${formatPrice(l.hinta)}</div></div>
<div class="detail-item"><div class="detail-label">Sopimuskausi</div><div class="detail-value">${contractRemaining(l.sopimuskausi, l.alkupvm) || '-'}</div></div>
<div class="detail-item"><div class="detail-label">Alkaen</div><div class="detail-value">${detailVal(l.alkupvm)}</div></div>
<div class="detail-item"><div class="detail-label">VLAN</div><div class="detail-value">${detailVal(l.vlan)}</div></div>
<div class="detail-item"><div class="detail-label">Laite</div><div class="detail-value">${detailVal(l.laite)}</div></div>
<div class="detail-item"><div class="detail-label">Portti</div><div class="detail-value">${detailVal(l.portti)}</div></div>
<div class="detail-item"><div class="detail-label">IP</div><div class="detail-value">${detailVal(l.ip)}</div></div>
</div>
</div>`;
}).join('');
const totalH = liittymat.reduce((s, l) => s + (parseFloat(l.hinta) || 0), 0);
document.getElementById('detail-title').textContent = c.yritys;
document.getElementById('detail-body').innerHTML = `
<div class="detail-section"><h3>Perustiedot</h3><div class="detail-grid">
<div class="detail-item"><div class="detail-label">Yritys</div><div class="detail-value">${detailVal(c.yritys)}</div></div>
<div class="detail-item"><div class="detail-label">Y-tunnus</div><div class="detail-value">${detailVal(c.ytunnus)}</div></div>
</div></div>
<div class="detail-section"><h3>Liittymät (${liittymat.length})</h3>${liittymatHtml}
${liittymat.length > 1 ? `<div class="liittyma-total"><span class="price-cell">${formatPrice(totalH)}/kk</span></div>` : ''}
</div>
<div class="detail-section"><h3>Yhteystiedot</h3><div class="detail-grid">
<div class="detail-item"><div class="detail-label">Yhteyshenkilö</div><div class="detail-value">${detailVal(c.yhteyshenkilö)}</div></div>
<div class="detail-item"><div class="detail-label">Puhelin</div><div class="detail-value">${detailLink(c.puhelin, 'tel')}</div></div>
<div class="detail-item"><div class="detail-label">Sähköposti</div><div class="detail-value">${detailLink(c.sahkoposti, 'email')}</div></div>
</div></div>
<div class="detail-section"><h3>Laskutustiedot</h3><div class="detail-grid">
<div class="detail-item"><div class="detail-label">Laskutusosoite</div><div class="detail-value">${detailVal(fullBilling)}</div></div>
<div class="detail-item"><div class="detail-label">Laskutussähköposti</div><div class="detail-value">${detailLink(c.laskutussahkoposti, 'email')}</div></div>
<div class="detail-item"><div class="detail-label">E-laskuosoite</div><div class="detail-value">${detailVal(c.elaskuosoite)}</div></div>
<div class="detail-item"><div class="detail-label">E-laskuvälittäjä</div><div class="detail-value">${detailVal(c.elaskuvalittaja)}</div></div>
</div></div>
${c.lisatiedot ? `<div class="detail-section"><h3>Lisätiedot</h3><p style="white-space:pre-wrap;color:#555;">${esc(c.lisatiedot)}</p></div>` : ''}
<div class="detail-section"><h3>Tiedostot</h3>
<div class="file-upload-area">
<label class="file-upload-btn btn-primary" style="display:inline-block;cursor:pointer;font-size:0.85rem;padding:8px 16px;">
+ Lisää tiedosto <input type="file" id="file-upload-input" style="display:none" multiple>
</label>
<span style="font-size:0.8rem;color:#999;margin-left:8px;">Max 20 MB / tiedosto</span>
</div>
<div id="file-list" class="file-list" style="margin-top:0.75rem;"></div>
</div>`;
// Synkronoi prices-hidden tila detail-modaliin
const pricesHidden = document.getElementById('customer-table')?.classList.contains('prices-hidden');
detailModal.querySelector('.modal-content')?.classList.toggle('prices-hidden', !!pricesHidden);
detailModal.style.display = 'flex';
loadFiles(id);
document.getElementById('file-upload-input').addEventListener('change', async function () {
for (const file of this.files) {
const fd = new FormData();
fd.append('customer_id', id);
fd.append('file', file);
try {
const res = await fetch(`${API}?action=file_upload`, { method: 'POST', credentials: 'include', body: fd });
const data = await res.json();
if (!res.ok) alert(data.error || 'Virhe');
} catch (e) { alert('Tiedoston lähetys epäonnistui'); }
}
this.value = '';
loadFiles(id);
});
}
async function loadFiles(customerId) {
const fileList = document.getElementById('file-list');
if (!fileList) return;
try {
const files = await apiCall(`file_list&customer_id=${customerId}`);
if (files.length === 0) { fileList.innerHTML = '<p style="color:#aaa;font-size:0.85rem;">Ei tiedostoja.</p>'; return; }
fileList.innerHTML = files.map(f => `<div class="file-item">
<div class="file-info">
<a href="${API}?action=file_download&customer_id=${customerId}&filename=${encodeURIComponent(f.filename)}" class="file-name" target="_blank">${esc(f.filename)}</a>
<span class="file-meta">${formatFileSize(f.size)} &middot; ${f.modified}</span>
</div>
<button class="file-delete-btn" onclick="deleteFile('${customerId}','${esc(f.filename)}')" title="Poista">&#10005;</button>
</div>`).join('');
} catch (e) { fileList.innerHTML = '<p style="color:#e74c3c;font-size:0.85rem;">Virhe ladattaessa tiedostoja.</p>'; }
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
async function deleteFile(customerId, filename) {
if (!confirm(`Poistetaanko tiedosto "${filename}"?`)) return;
await apiCall('file_delete', 'POST', { customer_id: customerId, filename });
loadFiles(customerId);
}
// Detail modal actions
document.getElementById('detail-close').addEventListener('click', () => detailModal.style.display = 'none');
document.getElementById('detail-cancel').addEventListener('click', () => detailModal.style.display = 'none');
document.getElementById('detail-edit').addEventListener('click', () => { detailModal.style.display = 'none'; editCustomer(currentDetailId); });
document.getElementById('detail-delete').addEventListener('click', () => {
const c = customers.find(x => x.id === currentDetailId);
if (c) { detailModal.style.display = 'none'; deleteCustomer(currentDetailId, c.yritys); }
});
// ==================== FORM: Liittymät ====================
function createLiittymaRow(data = {}, index = 0) {
const div = document.createElement('div');
div.className = 'liittyma-row';
div.dataset.index = index;
div.innerHTML = `<div class="liittyma-row-header">
<span class="liittyma-row-title">Liittymä ${index + 1}</span>
<button type="button" class="btn-remove-row" title="Poista liittymä">&#10005;</button>
</div>
<div class="form-grid form-grid-liittyma">
<div class="form-group"><label>Osoite</label><input type="text" class="l-asennusosoite" value="${esc(data.asennusosoite || '')}" placeholder="esim. Esimerkkikatu 1"></div>
<div class="form-group"><label>Postinumero</label><input type="text" class="l-postinumero" value="${esc(data.postinumero || '')}" placeholder="00100"></div>
<div class="form-group"><label>Kaupunki</label><input type="text" class="l-kaupunki" value="${esc(data.kaupunki || '')}" placeholder="Helsinki"></div>
<div class="form-group"><label>Nopeus</label><input type="text" class="l-liittymanopeus" value="${esc(data.liittymanopeus || '')}" placeholder="esim. 100/100"></div>
<div class="form-group"><label>Hinta €/kk</label><input type="number" class="l-hinta" step="0.01" min="0" value="${data.hinta || ''}"></div>
<div class="form-group"><label>Sopimuskausi</label><select class="l-sopimuskausi">
<option value="">- Valitse -</option>
<option value="1" ${data.sopimuskausi === '1' ? 'selected' : ''}>1 kk</option>
<option value="12" ${data.sopimuskausi === '12' ? 'selected' : ''}>12 kk</option>
<option value="24" ${data.sopimuskausi === '24' ? 'selected' : ''}>24 kk</option>
<option value="36" ${data.sopimuskausi === '36' ? 'selected' : ''}>36 kk</option>
</select></div>
<div class="form-group"><label>Alkaen</label><input type="date" class="l-alkupvm" value="${esc(data.alkupvm || '')}"></div>
<div class="form-group"><label>VLAN</label><input type="text" class="l-vlan" value="${esc(data.vlan || '')}" placeholder="esim. 100"></div>
<div class="form-group"><label>Laite</label><input type="text" class="l-laite" value="${esc(data.laite || '')}" placeholder="esim. SW-CORE-01"></div>
<div class="form-group"><label>Portti</label><input type="text" class="l-portti" value="${esc(data.portti || '')}" placeholder="esim. Gi0/1"></div>
<div class="form-group"><label>IP</label><input type="text" class="l-ip" value="${esc(data.ip || '')}" placeholder="esim. 10.0.0.5"></div>
</div>`;
div.querySelector('.btn-remove-row').addEventListener('click', () => { div.remove(); renumberLiittymaRows(); });
return div;
}
function renumberLiittymaRows() {
document.getElementById('liittymat-container').querySelectorAll('.liittyma-row').forEach((row, i) => {
row.dataset.index = i;
row.querySelector('.liittyma-row-title').textContent = `Liittymä ${i + 1}`;
});
}
function collectLiittymatFromForm() {
return Array.from(document.getElementById('liittymat-container').querySelectorAll('.liittyma-row')).map(row => ({
asennusosoite: row.querySelector('.l-asennusosoite').value,
postinumero: row.querySelector('.l-postinumero').value,
kaupunki: row.querySelector('.l-kaupunki').value,
liittymanopeus: row.querySelector('.l-liittymanopeus').value,
hinta: row.querySelector('.l-hinta').value,
sopimuskausi: row.querySelector('.l-sopimuskausi').value,
alkupvm: row.querySelector('.l-alkupvm').value,
vlan: row.querySelector('.l-vlan').value,
laite: row.querySelector('.l-laite').value,
portti: row.querySelector('.l-portti').value,
ip: row.querySelector('.l-ip').value,
}));
}
document.getElementById('btn-add-liittyma').addEventListener('click', () => {
const container = document.getElementById('liittymat-container');
container.appendChild(createLiittymaRow({}, container.querySelectorAll('.liittyma-row').length));
});
document.getElementById('form-billing-same').addEventListener('change', function () {
const bf = document.getElementById('billing-fields');
if (this.checked) {
bf.style.display = 'none';
const first = document.querySelector('.liittyma-row');
if (first) {
document.getElementById('form-laskutusosoite').value = first.querySelector('.l-asennusosoite').value;
document.getElementById('form-laskutuspostinumero').value = first.querySelector('.l-postinumero').value;
document.getElementById('form-laskutuskaupunki').value = first.querySelector('.l-kaupunki').value;
}
} else { bf.style.display = 'block'; }
});
// Add/Edit modal
document.getElementById('btn-add').addEventListener('click', () => openCustomerForm());
document.getElementById('modal-close').addEventListener('click', () => customerModal.style.display = 'none');
document.getElementById('form-cancel').addEventListener('click', () => customerModal.style.display = 'none');
function openCustomerForm(customer = null) {
const c = customer;
document.getElementById('modal-title').textContent = c ? 'Muokkaa asiakasta' : 'Lisää asiakas';
document.getElementById('form-submit').textContent = c ? 'Päivitä' : 'Tallenna';
document.getElementById('form-id').value = c ? c.id : '';
document.getElementById('form-yritys').value = c ? c.yritys : '';
document.getElementById('form-ytunnus').value = c ? (c.ytunnus || '') : '';
document.getElementById('form-yhteyshenkilo').value = c ? (c.yhteyshenkilö || '') : '';
document.getElementById('form-puhelin').value = c ? (c.puhelin || '') : '';
document.getElementById('form-sahkoposti').value = c ? (c.sahkoposti || '') : '';
document.getElementById('form-laskutusosoite').value = c ? (c.laskutusosoite || '') : '';
document.getElementById('form-laskutuspostinumero').value = c ? (c.laskutuspostinumero || '') : '';
document.getElementById('form-laskutuskaupunki').value = c ? (c.laskutuskaupunki || '') : '';
document.getElementById('form-laskutussahkoposti').value = c ? (c.laskutussahkoposti || '') : '';
document.getElementById('form-elaskuosoite').value = c ? (c.elaskuosoite || '') : '';
document.getElementById('form-elaskuvalittaja').value = c ? (c.elaskuvalittaja || '') : '';
document.getElementById('form-lisatiedot').value = c ? (c.lisatiedot || '') : '';
document.getElementById('form-priority-emails').value = c ? (c.priority_emails || '') : '';
document.getElementById('form-billing-same').checked = false;
document.getElementById('billing-fields').style.display = 'block';
const container = document.getElementById('liittymat-container');
container.innerHTML = '';
(c ? (c.liittymat || []) : [{}]).forEach((l, i) => container.appendChild(createLiittymaRow(l, i)));
customerModal.style.display = 'flex';
document.getElementById('form-yritys').focus();
}
function editCustomer(id) { const c = customers.find(x => x.id === id); if (c) openCustomerForm(c); }
async function deleteCustomer(id, name) {
if (!confirm(`Arkistoidaanko asiakas "${name}"?\n\nAsiakas siirretään arkistoon, josta sen voi palauttaa.`)) return;
await apiCall('customer_delete', 'POST', { id });
await loadCustomers();
}
customerForm.addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('form-id').value;
if (document.getElementById('form-billing-same').checked) {
const first = document.querySelector('.liittyma-row');
if (first) {
document.getElementById('form-laskutusosoite').value = first.querySelector('.l-asennusosoite').value;
document.getElementById('form-laskutuspostinumero').value = first.querySelector('.l-postinumero').value;
document.getElementById('form-laskutuskaupunki').value = first.querySelector('.l-kaupunki').value;
}
}
const data = {
yritys: document.getElementById('form-yritys').value,
ytunnus: document.getElementById('form-ytunnus').value,
yhteyshenkilö: document.getElementById('form-yhteyshenkilo').value,
puhelin: document.getElementById('form-puhelin').value,
sahkoposti: document.getElementById('form-sahkoposti').value,
laskutusosoite: document.getElementById('form-laskutusosoite').value,
laskutuspostinumero: document.getElementById('form-laskutuspostinumero').value,
laskutuskaupunki: document.getElementById('form-laskutuskaupunki').value,
laskutussahkoposti: document.getElementById('form-laskutussahkoposti').value,
elaskuosoite: document.getElementById('form-elaskuosoite').value,
elaskuvalittaja: document.getElementById('form-elaskuvalittaja').value,
lisatiedot: document.getElementById('form-lisatiedot').value,
priority_emails: document.getElementById('form-priority-emails').value,
liittymat: collectLiittymatFromForm(),
};
if (id) { data.id = id; await apiCall('customer_update', 'POST', data); }
else { await apiCall('customer', 'POST', data); }
customerModal.style.display = 'none';
await loadCustomers();
});
// ==================== LEADS ====================
let leads = [];
let currentLeadId = null;
const leadModal = document.getElementById('lead-modal');
const leadDetailModal = document.getElementById('lead-detail-modal');
const leadStatusLabels = {
uusi: 'Uusi',
kontaktoitu: 'Kontaktoitu',
kiinnostunut: 'Kiinnostunut',
odottaa: 'Odottaa toimitusta',
ei_kiinnosta: 'Ei kiinnosta',
};
async function loadLeads() {
try {
leads = await apiCall('leads');
renderLeads();
} catch (e) { console.error(e); }
}
function renderLeads() {
const query = document.getElementById('lead-search-input').value.toLowerCase().trim();
let filtered = leads;
if (query) {
filtered = leads.filter(l =>
(l.yritys || '').toLowerCase().includes(query) ||
(l.yhteyshenkilo || '').toLowerCase().includes(query) ||
(l.kaupunki || '').toLowerCase().includes(query)
);
}
const ltbody = document.getElementById('leads-tbody');
const noLeads = document.getElementById('no-leads');
if (filtered.length === 0) {
ltbody.innerHTML = '';
noLeads.style.display = 'block';
document.getElementById('leads-table').style.display = 'none';
} else {
noLeads.style.display = 'none';
document.getElementById('leads-table').style.display = 'table';
ltbody.innerHTML = filtered.map(l => `<tr data-lead-id="${l.id}">
<td><strong>${esc(l.yritys)}</strong></td>
<td>${esc(l.yhteyshenkilo || '')}</td>
<td>${esc(l.kaupunki || '')}</td>
<td><span class="lead-status lead-status-${l.tila || 'uusi'}">${leadStatusLabels[l.tila] || l.tila || 'Uusi'}</span></td>
<td class="nowrap">${esc((l.luotu || '').substring(0, 10))}</td>
<td class="actions-cell">
<button onclick="event.stopPropagation();editLead('${l.id}')" title="Muokkaa">&#9998;</button>
<button onclick="event.stopPropagation();deleteLead('${l.id}','${esc(l.yritys)}')" title="Poista">&#128465;</button>
</td>
</tr>`).join('');
}
document.getElementById('lead-count').textContent = `${leads.length} liidiä`;
}
document.getElementById('lead-search-input').addEventListener('input', () => renderLeads());
document.getElementById('leads-tbody').addEventListener('click', (e) => {
const row = e.target.closest('tr');
if (row && row.dataset.leadId) showLeadDetail(row.dataset.leadId);
});
function showLeadDetail(id) {
const l = leads.find(x => x.id === id);
if (!l) return;
currentLeadId = id;
document.getElementById('lead-detail-title').textContent = l.yritys;
document.getElementById('lead-detail-body').innerHTML = `
<div style="padding:1.5rem;">
<div class="detail-grid">
<div class="detail-item"><div class="detail-label">Yritys</div><div class="detail-value">${detailVal(l.yritys)}</div></div>
<div class="detail-item"><div class="detail-label">Tila</div><div class="detail-value"><span class="lead-status lead-status-${l.tila || 'uusi'}">${leadStatusLabels[l.tila] || 'Uusi'}</span></div></div>
<div class="detail-item"><div class="detail-label">Yhteyshenkilö</div><div class="detail-value">${detailVal(l.yhteyshenkilo)}</div></div>
<div class="detail-item"><div class="detail-label">Puhelin</div><div class="detail-value">${detailLink(l.puhelin, 'tel')}</div></div>
<div class="detail-item"><div class="detail-label">Sähköposti</div><div class="detail-value">${detailLink(l.sahkoposti, 'email')}</div></div>
<div class="detail-item"><div class="detail-label">Osoite</div><div class="detail-value">${detailVal([l.osoite, l.kaupunki].filter(Boolean).join(', '))}</div></div>
<div class="detail-item"><div class="detail-label">Lisätty</div><div class="detail-value">${detailVal(l.luotu)} (${esc(l.luoja || '')})</div></div>
${l.muokattu ? `<div class="detail-item"><div class="detail-label">Muokattu</div><div class="detail-value">${timeAgo(l.muokattu)} (${esc(l.muokkaaja || '')})</div></div>` : ''}
</div>
${l.muistiinpanot ? `<div style="margin-top:1.25rem;"><div class="detail-label" style="margin-bottom:0.5rem;">MUISTIINPANOT</div><div style="white-space:pre-wrap;color:#555;background:#f8f9fb;padding:1rem;border-radius:8px;border:1px solid #e8ebf0;font-size:0.9rem;">${esc(l.muistiinpanot)}</div></div>` : ''}
</div>`;
leadDetailModal.style.display = 'flex';
}
// Lead form
document.getElementById('btn-add-lead').addEventListener('click', () => openLeadForm());
document.getElementById('lead-modal-close').addEventListener('click', () => leadModal.style.display = 'none');
document.getElementById('lead-form-cancel').addEventListener('click', () => leadModal.style.display = 'none');
function openLeadForm(lead = null) {
document.getElementById('lead-modal-title').textContent = lead ? 'Muokkaa liidiä' : 'Lisää liidi';
document.getElementById('lead-form-submit').textContent = lead ? 'Päivitä' : 'Tallenna';
document.getElementById('lead-form-id').value = lead ? lead.id : '';
document.getElementById('lead-form-yritys').value = lead ? lead.yritys : '';
document.getElementById('lead-form-yhteyshenkilo').value = lead ? (lead.yhteyshenkilo || '') : '';
document.getElementById('lead-form-puhelin').value = lead ? (lead.puhelin || '') : '';
document.getElementById('lead-form-sahkoposti').value = lead ? (lead.sahkoposti || '') : '';
document.getElementById('lead-form-osoite').value = lead ? (lead.osoite || '') : '';
document.getElementById('lead-form-kaupunki').value = lead ? (lead.kaupunki || '') : '';
document.getElementById('lead-form-tila').value = lead ? (lead.tila || 'uusi') : 'uusi';
document.getElementById('lead-form-muistiinpanot').value = lead ? (lead.muistiinpanot || '') : '';
leadModal.style.display = 'flex';
document.getElementById('lead-form-yritys').focus();
}
function editLead(id) {
const l = leads.find(x => x.id === id);
if (l) { leadDetailModal.style.display = 'none'; openLeadForm(l); }
}
async function deleteLead(id, name) {
if (!confirm(`Poistetaanko liidi "${name}"?`)) return;
await apiCall('lead_delete', 'POST', { id });
leadDetailModal.style.display = 'none';
await loadLeads();
}
async function convertLeadToCustomer(id) {
const l = leads.find(x => x.id === id);
if (!l) return;
if (!confirm(`Muutetaanko "${l.yritys}" asiakkaaksi?\n\nLiidi poistetaan ja asiakas luodaan sen tiedoilla.`)) return;
await apiCall('lead_to_customer', 'POST', { id });
leadDetailModal.style.display = 'none';
await loadLeads();
await loadCustomers();
}
document.getElementById('lead-form').addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('lead-form-id').value;
const data = {
yritys: document.getElementById('lead-form-yritys').value,
yhteyshenkilo: document.getElementById('lead-form-yhteyshenkilo').value,
puhelin: document.getElementById('lead-form-puhelin').value,
sahkoposti: document.getElementById('lead-form-sahkoposti').value,
osoite: document.getElementById('lead-form-osoite').value,
kaupunki: document.getElementById('lead-form-kaupunki').value,
tila: document.getElementById('lead-form-tila').value,
muistiinpanot: document.getElementById('lead-form-muistiinpanot').value,
};
if (id) { data.id = id; await apiCall('lead_update', 'POST', data); }
else { await apiCall('lead_create', 'POST', data); }
leadModal.style.display = 'none';
await loadLeads();
});
// Lead detail actions
document.getElementById('lead-detail-close').addEventListener('click', () => leadDetailModal.style.display = 'none');
document.getElementById('lead-detail-cancel').addEventListener('click', () => leadDetailModal.style.display = 'none');
document.getElementById('lead-detail-edit').addEventListener('click', () => editLead(currentLeadId));
document.getElementById('lead-detail-delete').addEventListener('click', () => {
const l = leads.find(x => x.id === currentLeadId);
if (l) deleteLead(currentLeadId, l.yritys);
});
document.getElementById('lead-detail-convert').addEventListener('click', () => convertLeadToCustomer(currentLeadId));
// ==================== ARCHIVE ====================
async function loadArchive() {
try {
const archive = await apiCall('archived_customers');
const atbody = document.getElementById('archive-tbody');
const noArc = document.getElementById('no-archive');
if (archive.length === 0) {
atbody.innerHTML = '';
noArc.style.display = 'block';
document.getElementById('archive-table').style.display = 'none';
} else {
noArc.style.display = 'none';
document.getElementById('archive-table').style.display = 'table';
atbody.innerHTML = archive.map(c => `<tr>
<td><strong>${esc(c.yritys)}</strong></td>
<td>${(c.liittymat || []).length}</td>
<td>${esc(c.arkistoitu || '')}</td>
<td>${esc(c.arkistoija || '')}</td>
<td class="actions-cell">
<button onclick="restoreCustomer('${c.id}')" class="btn-small btn-restore" title="Palauta">&#8634; Palauta</button>
${currentUser.role === 'admin' ? `<button onclick="permanentDelete('${c.id}','${esc(c.yritys)}')" class="btn-small btn-perm-delete" title="Poista pysyvästi">&#10005; Poista</button>` : ''}
</td>
</tr>`).join('');
}
} catch (e) { console.error(e); }
}
async function restoreCustomer(id) {
if (!confirm('Palautetaanko asiakas arkistosta?')) return;
await apiCall('customer_restore', 'POST', { id });
loadArchive();
loadCustomers();
}
async function permanentDelete(id, name) {
if (!confirm(`Poistetaanko "${name}" PYSYVÄSTI?\n\nTätä ei voi perua!`)) return;
await apiCall('customer_permanent_delete', 'POST', { id });
loadArchive();
}
// ==================== CHANGELOG ====================
const actionLabels = {
customer_create: 'Lisäsi asiakkaan',
customer_update: 'Muokkasi asiakasta',
customer_archive: 'Arkistoi asiakkaan',
customer_restore: 'Palautti asiakkaan',
customer_permanent_delete: 'Poisti pysyvästi',
user_create: 'Lisäsi käyttäjän',
user_update: 'Muokkasi käyttäjää',
user_delete: 'Poisti käyttäjän',
lead_create: 'Lisäsi liidin',
lead_update: 'Muokkasi liidiä',
lead_delete: 'Poisti liidin',
lead_to_customer: 'Muutti liidin asiakkaaksi',
config_update: 'Päivitti asetukset',
ticket_fetch: 'Haki sähköpostit',
ticket_reply: 'Vastasi tikettiin',
ticket_status: 'Muutti tiketin tilaa',
ticket_assign: 'Osoitti tiketin',
ticket_note: 'Lisäsi muistiinpanon',
ticket_delete: 'Poisti tiketin',
ticket_customer: 'Linkitti tiketin asiakkaaseen',
ticket_type: 'Muutti tiketin tyyppiä',
};
async function loadChangelog() {
try {
const log = await apiCall('changelog&limit=200');
const ctbody = document.getElementById('changelog-tbody');
const noLog = document.getElementById('no-changelog');
if (log.length === 0) {
ctbody.innerHTML = '';
noLog.style.display = 'block';
document.getElementById('changelog-table').style.display = 'none';
} else {
noLog.style.display = 'none';
document.getElementById('changelog-table').style.display = 'table';
ctbody.innerHTML = log.map(e => `<tr>
<td class="nowrap">${esc(e.timestamp)}</td>
<td><strong>${esc(e.user)}</strong></td>
<td>${actionLabels[e.action] || esc(e.action)}</td>
<td>${esc(e.customer_name)}</td>
<td class="text-muted">${esc(e.details)}</td>
</tr>`).join('');
}
} catch (e) { console.error(e); }
}
// ==================== USERS ====================
async function loadUsers() {
try {
const users = await apiCall('users');
const utbody = document.getElementById('users-tbody');
utbody.innerHTML = users.map(u => `<tr>
<td><strong>${esc(u.username)}</strong></td>
<td>${esc(u.nimi)}</td>
<td>${esc(u.email || '')}</td>
<td><span class="role-badge role-${u.role}">${u.role === 'superadmin' ? 'Pääkäyttäjä' : (u.role === 'admin' ? 'Yritysadmin' : 'Käyttäjä')}</span></td>
<td>${esc(u.luotu)}</td>
<td class="actions-cell">
<button onclick="editUser('${u.id}')" title="Muokkaa">&#9998;</button>
${u.id !== '${currentUser.id}' ? `<button onclick="deleteUser('${u.id}','${esc(u.username)}')" title="Poista">&#128465;</button>` : ''}
</td>
</tr>`).join('');
} catch (e) { console.error(e); }
}
let usersCache = [];
document.getElementById('btn-add-user').addEventListener('click', () => openUserForm());
document.getElementById('user-modal-close').addEventListener('click', () => userModal.style.display = 'none');
document.getElementById('user-form-cancel').addEventListener('click', () => userModal.style.display = 'none');
function openUserForm(user = null) {
document.getElementById('user-modal-title').textContent = user ? 'Muokkaa käyttäjää' : 'Lisää käyttäjä';
document.getElementById('user-form-id').value = user ? user.id : '';
document.getElementById('user-form-username').value = user ? user.username : '';
document.getElementById('user-form-username').disabled = !!user;
document.getElementById('user-form-nimi').value = user ? user.nimi : '';
document.getElementById('user-form-email').value = user ? (user.email || '') : '';
document.getElementById('user-form-password').value = '';
document.getElementById('user-pw-hint').textContent = user ? '(jätä tyhjäksi jos ei muuteta)' : '*';
document.getElementById('user-form-role').value = user ? user.role : 'user';
// Piilota superadmin-vaihtoehto ellei ole superadmin
const saOption = document.querySelector('#user-form-role option[value="superadmin"]');
if (saOption) saOption.style.display = currentUser?.role === 'superadmin' ? '' : 'none';
// Piilota yrityscheckboxit adminilta (näkee vain oman yrityksen)
const compSection = document.getElementById('user-company-checkboxes')?.closest('.form-group');
if (compSection) compSection.style.display = currentUser?.role === 'superadmin' ? '' : 'none';
// Yrityscheckboxit
const allComps = availableCompanies.length > 0 ? availableCompanies : [];
const userComps = user ? (user.companies || []) : [];
const container = document.getElementById('user-company-checkboxes');
// Hae kaikki yritykset admin-näkymää varten
apiCall('companies_all').then(companies => {
container.innerHTML = companies.map(c =>
`<label style="display:flex;align-items:center;gap:0.3rem;cursor:pointer;">
<input type="checkbox" class="user-company-cb" value="${c.id}" ${userComps.includes(c.id) ? 'checked' : ''}>
${esc(c.nimi)}
</label>`
).join('');
}).catch(() => {
container.innerHTML = allComps.map(c =>
`<label style="display:flex;align-items:center;gap:0.3rem;cursor:pointer;">
<input type="checkbox" class="user-company-cb" value="${c.id}" ${userComps.includes(c.id) ? 'checked' : ''}>
${esc(c.nimi)}
</label>`
).join('');
});
// Allekirjoitukset per postilaatikko
const sigSection = document.getElementById('user-signatures-section');
const sigList = document.getElementById('user-signatures-list');
const userSigs = user ? (user.signatures || {}) : {};
apiCall('all_mailboxes').then(mailboxes => {
if (mailboxes.length === 0) {
sigSection.style.display = 'none';
return;
}
sigSection.style.display = '';
sigList.innerHTML = mailboxes.map(mb =>
`<div style="margin-bottom:0.75rem;">
<label style="font-weight:600;font-size:0.85rem;color:#333;">${esc(mb.company_nimi)}${esc(mb.nimi)}</label>
<textarea class="sig-textarea" data-mailbox-id="${mb.id}" rows="3"
style="width:100%;margin-top:0.25rem;padding:8px;border:1px solid #ddd;border-radius:6px;font-size:0.85rem;font-family:inherit;resize:vertical;"
placeholder="esim.\nJukka\nYritys Oy\ninfo@yritys.fi">${esc(userSigs[mb.id] || '')}</textarea>
</div>`
).join('');
}).catch(() => {
sigSection.style.display = 'none';
});
userModal.style.display = 'flex';
}
async function editUser(id) {
try {
const users = await apiCall('users');
const u = users.find(x => x.id === id);
if (u) openUserForm(u);
} catch (e) { alert(e.message); }
}
async function deleteUser(id, username) {
if (!confirm(`Poistetaanko käyttäjä "${username}"?`)) return;
try {
await apiCall('user_delete', 'POST', { id });
loadUsers();
} catch (e) { alert(e.message); }
}
document.getElementById('user-form').addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('user-form-id').value;
const companies = [...document.querySelectorAll('.user-company-cb:checked')].map(cb => cb.value);
// Kerää allekirjoitukset
const signatures = {};
document.querySelectorAll('.sig-textarea').forEach(ta => {
const mbId = ta.dataset.mailboxId;
const val = ta.value.trim();
if (val) signatures[mbId] = val;
});
const data = {
username: document.getElementById('user-form-username').value,
nimi: document.getElementById('user-form-nimi').value,
email: document.getElementById('user-form-email').value,
role: document.getElementById('user-form-role').value,
companies,
signatures,
};
const pw = document.getElementById('user-form-password').value;
if (pw) data.password = pw;
else if (!id) { alert('Salasana vaaditaan uudelle käyttäjälle'); return; }
try {
if (id) { data.id = id; await apiCall('user_update', 'POST', data); }
else { await apiCall('user_create', 'POST', data); }
userModal.style.display = 'none';
loadUsers();
// Päivitä omat allekirjoitukset (check_auth palauttaa tuoreet)
const auth = await apiCall('check_auth');
if (auth.authenticated) {
currentUser = { username: auth.username, nimi: auth.nimi, role: auth.role, id: auth.user_id };
currentUserSignatures = auth.signatures || {};
}
} catch (e) { alert(e.message); }
});
// ==================== OMA PROFIILI ====================
const profileModal = document.getElementById('profile-modal');
document.getElementById('btn-profile').addEventListener('click', openProfileModal);
document.getElementById('user-info').addEventListener('click', openProfileModal);
document.getElementById('profile-modal-close').addEventListener('click', () => profileModal.style.display = 'none');
document.getElementById('profile-form-cancel').addEventListener('click', () => profileModal.style.display = 'none');
async function openProfileModal() {
// Hae tuoreet tiedot
const auth = await apiCall('check_auth');
if (!auth.authenticated) return;
document.getElementById('profile-username').value = auth.username;
document.getElementById('profile-nimi').value = auth.nimi || '';
document.getElementById('profile-email').value = auth.email || '';
document.getElementById('profile-password').value = '';
// Allekirjoitukset
const sigSection = document.getElementById('profile-signatures-section');
const sigList = document.getElementById('profile-signatures-list');
const userSigs = auth.signatures || {};
try {
const mailboxes = await apiCall('all_mailboxes');
if (mailboxes.length === 0) {
sigSection.style.display = 'none';
} else {
sigSection.style.display = '';
sigList.innerHTML = mailboxes.map(mb =>
`<div style="margin-bottom:0.75rem;">
<label style="font-weight:600;font-size:0.85rem;color:#333;">${esc(mb.company_nimi)}${esc(mb.nimi)}</label>
<textarea class="profile-sig-textarea" data-mailbox-id="${mb.id}" rows="3"
style="width:100%;margin-top:0.25rem;padding:8px;border:1px solid #ddd;border-radius:6px;font-size:0.85rem;font-family:inherit;resize:vertical;"
placeholder="esim.\nNimi\nYritys Oy\ninfo@yritys.fi">${esc(userSigs[mb.id] || '')}</textarea>
</div>`
).join('');
}
} catch {
sigSection.style.display = 'none';
}
profileModal.style.display = 'flex';
}
document.getElementById('profile-form').addEventListener('submit', async (e) => {
e.preventDefault();
const signatures = {};
document.querySelectorAll('.profile-sig-textarea').forEach(ta => {
const mbId = ta.dataset.mailboxId;
const val = ta.value.trim();
if (val) signatures[mbId] = val;
});
const data = {
nimi: document.getElementById('profile-nimi').value,
email: document.getElementById('profile-email').value,
signatures,
};
const pw = document.getElementById('profile-password').value;
if (pw) data.password = pw;
try {
await apiCall('profile_update', 'POST', data);
// Päivitä UI
const auth = await apiCall('check_auth');
if (auth.authenticated) {
currentUser = { username: auth.username, nimi: auth.nimi, role: auth.role, id: auth.user_id };
currentUserSignatures = auth.signatures || {};
document.getElementById('user-info').textContent = auth.nimi || auth.username;
}
profileModal.style.display = 'none';
alert('Profiili päivitetty!');
} catch (e) { alert(e.message); }
});
// ==================== TICKETS (ASIAKASPALVELU) ====================
let tickets = [];
let currentTicketId = null;
let ticketReplyType = 'reply';
const ticketStatusLabels = {
uusi: 'Uusi',
kasittelyssa: 'Käsittelyssä',
odottaa: 'Odottaa vastausta',
suljettu: 'Suljettu',
};
const ticketTypeLabels = {
laskutus: 'Laskutus',
tekniikka: 'Tekniikka',
vika: 'Vika',
muu: 'Muu',
};
async function loadTickets() {
try {
// Hae kaikkien yritysten tiketit jos useampi yritys
const allParam = availableCompanies.length > 1 ? '&all=1' : '';
tickets = await apiCall('tickets' + allParam);
renderTickets();
} catch (e) { console.error(e); }
}
function renderTickets() {
const query = document.getElementById('ticket-search-input').value.toLowerCase().trim();
const statusFilter = document.getElementById('ticket-status-filter').value;
const typeFilter = document.getElementById('ticket-type-filter').value;
const showClosed = document.getElementById('ticket-show-closed').checked;
let filtered = tickets;
// Suljetut näkyvät vain kun täppä on päällä
if (showClosed) {
filtered = filtered.filter(t => t.status === 'suljettu');
} else {
filtered = filtered.filter(t => t.status !== 'suljettu');
if (statusFilter) {
filtered = filtered.filter(t => t.status === statusFilter);
}
}
if (typeFilter) {
filtered = filtered.filter(t => (t.type || 'muu') === typeFilter);
}
// Tag filter
const tagFilter = (document.getElementById('ticket-tag-filter').value || '').trim().toLowerCase().replace(/^#/, '');
if (tagFilter) {
filtered = filtered.filter(t => (t.tags || []).some(tag => tag.toLowerCase().includes(tagFilter)));
}
if (query) {
filtered = filtered.filter(t =>
(t.subject || '').toLowerCase().includes(query) ||
(t.from_name || '').toLowerCase().includes(query) ||
(t.from_email || '').toLowerCase().includes(query) ||
(t.tags || []).some(tag => tag.toLowerCase().includes(query))
);
}
// Sorttaus: prioriteetti → tila → päivämäärä
const ticketSortField = document.getElementById('ticket-sort')?.value || 'status';
const statusPriority = { kasittelyssa: 0, uusi: 1, odottaa: 2, suljettu: 3 };
const priorityOrder = { urgent: 0, 'tärkeä': 1, normaali: 2 };
filtered.sort((a, b) => {
// Urgent/tärkeä aina ensin
const prioA = priorityOrder[a.priority || 'normaali'] ?? 2;
const prioB = priorityOrder[b.priority || 'normaali'] ?? 2;
if (prioA !== prioB) return prioA - prioB;
if (ticketSortField === 'status') {
const pa = statusPriority[a.status] ?? 9;
const pb = statusPriority[b.status] ?? 9;
if (pa !== pb) return pa - pb;
return (b.updated || '').localeCompare(a.updated || '');
} else if (ticketSortField === 'updated') {
return (b.updated || '').localeCompare(a.updated || '');
} else if (ticketSortField === 'created') {
return (b.created || '').localeCompare(a.created || '');
}
return 0;
});
const ttbody = document.getElementById('tickets-tbody');
const noTickets = document.getElementById('no-tickets');
if (filtered.length === 0) {
ttbody.innerHTML = '';
noTickets.style.display = 'block';
document.getElementById('tickets-table').style.display = 'none';
} else {
noTickets.style.display = 'none';
document.getElementById('tickets-table').style.display = 'table';
const multiCompany = availableCompanies.length > 1;
ttbody.innerHTML = filtered.map(t => {
const lastType = t.last_message_type === 'reply_out' ? '&#8594;' : (t.last_message_type === 'note' ? '&#128221;' : '&#8592;');
const typeLabel = ticketTypeLabels[t.type] || 'Muu';
const rowClass = t.priority === 'urgent' ? 'ticket-row-urgent' : (t.priority === 'tärkeä' ? 'ticket-row-important' : (t.status === 'kasittelyssa' ? 'ticket-row-active' : ''));
const checked = bulkSelectedIds.has(t.id) ? 'checked' : '';
const companyBadge = multiCompany && t.company_name ? `<span class="company-badge">${esc(t.company_name)}</span> ` : '';
const prioBadge = t.priority === 'urgent' ? '<span class="ticket-prio-urgent">🚨</span> ' : (t.priority === 'tärkeä' ? '<span class="ticket-prio-important">⚠️</span> ' : '');
return `<tr data-ticket-id="${t.id}" data-company-id="${t.company_id || ''}" class="${rowClass}">
<td onclick="event.stopPropagation()"><input type="checkbox" class="ticket-checkbox" data-ticket-id="${t.id}" ${checked}></td>
<td><span class="ticket-status ticket-status-${t.status}">${ticketStatusLabels[t.status] || t.status}</span></td>
<td>${t.customer_name ? esc(t.customer_name) : '<span style="color:#ccc;">-</span>'}</td>
<td><span class="ticket-type ticket-type-${t.type || 'muu'}">${typeLabel}</span></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 style="text-align:center;">${lastType} ${t.message_count}</td>
<td class="nowrap" title="${esc((t.updated || '').substring(0, 16))}">${timeAgo(t.updated)}</td>
<td>${t.assigned_to ? esc(t.assigned_to) : '<span style="color:#ccc;">—</span>'}</td>
</tr>`;
}).join('');
// Re-attach checkbox listeners
document.querySelectorAll('.ticket-checkbox').forEach(cb => {
cb.addEventListener('change', function() {
if (this.checked) bulkSelectedIds.add(this.dataset.ticketId);
else bulkSelectedIds.delete(this.dataset.ticketId);
updateBulkToolbar();
});
});
}
const openCount = tickets.filter(t => t.status !== 'suljettu').length;
document.getElementById('ticket-count').textContent = `${openCount} avointa tikettiä (${tickets.length} yht.)`;
// Status summary
const counts = {};
tickets.forEach(t => { counts[t.status] = (counts[t.status] || 0) + 1; });
const parts = [];
if (counts.uusi) parts.push(`${counts.uusi} uutta`);
if (counts.kasittelyssa) parts.push(`${counts.kasittelyssa} käsittelyssä`);
if (counts.odottaa) parts.push(`${counts.odottaa} odottaa`);
document.getElementById('ticket-status-summary').textContent = parts.join(' · ');
}
document.getElementById('ticket-search-input').addEventListener('input', () => renderTickets());
document.getElementById('ticket-status-filter').addEventListener('change', () => renderTickets());
document.getElementById('ticket-type-filter').addEventListener('change', () => renderTickets());
document.getElementById('ticket-tag-filter').addEventListener('input', () => renderTickets());
document.getElementById('ticket-sort').addEventListener('change', () => renderTickets());
document.getElementById('ticket-show-closed').addEventListener('change', () => renderTickets());
document.getElementById('bulk-select-all').addEventListener('change', function() {
const checkboxes = document.querySelectorAll('.ticket-checkbox');
checkboxes.forEach(cb => {
cb.checked = this.checked;
if (this.checked) bulkSelectedIds.add(cb.dataset.ticketId);
else bulkSelectedIds.delete(cb.dataset.ticketId);
});
updateBulkToolbar();
});
document.getElementById('tickets-tbody').addEventListener('click', (e) => {
const row = e.target.closest('tr');
if (row && row.dataset.ticketId) showTicketDetail(row.dataset.ticketId, row.dataset.companyId || '');
});
// Helper: lisää company_id query parametri tiketti-kutsuihin
function ticketCompanyParam() {
return currentTicketCompanyId ? '&company_id=' + encodeURIComponent(currentTicketCompanyId) : '';
}
async function showTicketDetail(id, companyId = '') {
try {
currentTicketCompanyId = companyId;
const ticket = await apiCall('ticket_detail&id=' + encodeURIComponent(id) + ticketCompanyParam());
currentTicketId = id;
// Header
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>
<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">
${esc(ticket.from_name)} &lt;${esc(ticket.from_email)}&gt; · Luotu ${esc(ticket.created)}
</div>
</div>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<select id="ticket-type-select" style="padding:6px 10px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.85rem;">
<option value="muu" ${(ticket.type || 'muu') === 'muu' ? 'selected' : ''}>Muu</option>
<option value="laskutus" ${ticket.type === 'laskutus' ? 'selected' : ''}>Laskutus</option>
<option value="tekniikka" ${ticket.type === 'tekniikka' ? 'selected' : ''}>Tekniikka</option>
<option value="vika" ${ticket.type === 'vika' ? 'selected' : ''}>Vika</option>
</select>
<select id="ticket-status-select" style="padding:6px 10px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.85rem;">
<option value="uusi" ${ticket.status === 'uusi' ? 'selected' : ''}>Uusi</option>
<option value="kasittelyssa" ${ticket.status === 'kasittelyssa' ? 'selected' : ''}>Käsittelyssä</option>
<option value="odottaa" ${ticket.status === 'odottaa' ? 'selected' : ''}>Odottaa vastausta</option>
<option value="suljettu" ${ticket.status === 'suljettu' || ticket.status === 'ratkaistu' ? 'selected' : ''}>Suljettu</option>
</select>
<select id="ticket-assign-select" style="padding:6px 10px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.85rem;">
<option value="">Ei agenttia</option>
</select>
<select id="ticket-priority-select" style="padding:6px 10px;border:2px solid ${(ticket.priority || 'normaali') === 'urgent' ? '#e74c3c' : (ticket.priority === 'tärkeä' ? '#e67e22' : '#e0e0e0')};border-radius:8px;font-size:0.85rem;${(ticket.priority || 'normaali') === 'urgent' ? 'background:#fef2f2;color:#c0392b;font-weight:700;' : ''}">
<option value="normaali" ${(ticket.priority || 'normaali') === 'normaali' ? 'selected' : ''}>Normaali</option>
<option value="tärkeä" ${ticket.priority === 'tärkeä' ? 'selected' : ''}>⚠️ Tärkeä</option>
<option value="urgent" ${ticket.priority === 'urgent' ? 'selected' : ''}>🚨 URGENT</option>
</select>
<select id="ticket-customer-select" style="padding:6px 10px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.85rem;">
<option value="">Ei asiakkuutta</option>
</select>
<button class="btn-danger" id="btn-ticket-delete" style="padding:6px 12px;font-size:0.82rem;">Poista</button>
</div>
</div>
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;margin-top:0.5rem;">
<span style="font-size:0.82rem;color:#888;font-weight:600;">Tagit:</span>
<div id="ticket-tags-container" style="display:flex;gap:0.35rem;flex-wrap:wrap;align-items:center;">
${(ticket.tags || []).map(tag => '<span class="ticket-tag ticket-tag-editable" data-tag="' + esc(tag) + '">#' + esc(tag) + ' <button class="ticket-tag-remove" title="Poista">&times;</button></span>').join('')}
</div>
<div style="display:flex;gap:0.3rem;align-items:center;">
<input type="text" id="ticket-tag-input" placeholder="+ Lisää tagi" style="padding:4px 8px;border:1px solid #ddd;border-radius:6px;font-size:0.82rem;width:120px;">
</div>
${ticket.auto_close_at ? '<span style="font-size:0.78rem;color:#e67e22;margin-left:0.5rem;">&#9200; Auto-close: ' + esc(ticket.auto_close_at.substring(0, 10)) + '</span>' : ''}
</div>
${ticket.cc ? '<div style="font-size:0.82rem;color:#888;margin-top:0.4rem;">CC: ' + esc(ticket.cc) + '</div>' : ''}`;
// Load users for assignment dropdown
try {
const users = await apiCall('users');
const assignSelect = document.getElementById('ticket-assign-select');
users.forEach(u => {
const opt = document.createElement('option');
opt.value = u.username;
opt.textContent = u.nimi || u.username;
if (u.username === ticket.assigned_to) opt.selected = true;
assignSelect.appendChild(opt);
});
} catch (e) { /* non-admin may not access users */ }
// Type change handler
document.getElementById('ticket-type-select').addEventListener('change', async function() {
try {
await apiCall('ticket_type' + ticketCompanyParam(), 'POST', { id: currentTicketId, type: this.value });
} catch (e) { alert(e.message); }
});
// Status change handler
document.getElementById('ticket-status-select').addEventListener('change', async function() {
try {
await apiCall('ticket_status' + ticketCompanyParam(), 'POST', { id: currentTicketId, status: this.value });
} catch (e) { alert(e.message); }
});
// Assign handler
document.getElementById('ticket-assign-select').addEventListener('change', async function() {
try {
await apiCall('ticket_assign' + ticketCompanyParam(), 'POST', { id: currentTicketId, assigned_to: this.value });
} catch (e) { alert(e.message); }
});
// Customer link — load customers dropdown + automaattinen tunnistus
try {
const custSelect = document.getElementById('ticket-customer-select');
customers.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.yritys;
if (c.id === ticket.customer_id) opt.selected = true;
custSelect.appendChild(opt);
});
// Automaattinen asiakastunnistus sähköpostin perusteella
const senderEmail = (ticket.from_email || '').toLowerCase().trim();
const senderLine = document.getElementById('ticket-sender-line');
if (senderEmail && !ticket.customer_id) {
const matchedCustomer = customers.find(c =>
(c.sahkoposti || '').toLowerCase().trim() === senderEmail ||
(c.laskutussahkoposti || '').toLowerCase().trim() === senderEmail
);
if (matchedCustomer) {
// Löytyi asiakas → linkitä automaattisesti
custSelect.value = matchedCustomer.id;
custSelect.dispatchEvent(new Event('change'));
if (senderLine) {
senderLine.insertAdjacentHTML('beforeend',
` <span style="background:#e6f9ee;color:#1a7d42;padding:2px 8px;border-radius:10px;font-size:0.78rem;font-weight:600;">✓ ${esc(matchedCustomer.yritys)}</span>`);
}
} else {
// Ei löytynyt → näytä "Lisää liidi" -nappi
if (senderLine) {
senderLine.insertAdjacentHTML('beforeend',
` <button class="btn-link" id="btn-ticket-add-lead" style="font-size:0.82rem;color:#2563eb;font-weight:600;margin-left:0.5rem;" title="Luo liidi tämän lähettäjän tiedoilla">+ Lisää liidi</button>`);
document.getElementById('btn-ticket-add-lead')?.addEventListener('click', () => {
// Avaa liidilomake esitäytetyillä tiedoilla
openLeadForm(null);
document.getElementById('lead-form-yhteyshenkilo').value = ticket.from_name || '';
document.getElementById('lead-form-sahkoposti').value = ticket.from_email || '';
document.getElementById('lead-form-muistiinpanot').value = 'Tiketti: ' + (ticket.subject || '') + '\\nLähettäjä: ' + (ticket.from_email || '');
});
}
}
} else if (senderEmail && ticket.customer_id) {
// Asiakas jo linkitetty — näytä badge
const linked = customers.find(c => c.id === ticket.customer_id);
if (linked && senderLine) {
senderLine.insertAdjacentHTML('beforeend',
` <span style="background:#e6f9ee;color:#1a7d42;padding:2px 8px;border-radius:10px;font-size:0.78rem;font-weight:600;">✓ ${esc(linked.yritys)}</span>`);
}
}
} catch (e) {}
document.getElementById('ticket-customer-select').addEventListener('change', async function() {
const selOpt = this.options[this.selectedIndex];
const custName = this.value ? selOpt.textContent : '';
try {
await apiCall('ticket_customer' + ticketCompanyParam(), 'POST', { id: currentTicketId, customer_id: this.value, customer_name: custName });
} catch (e) { alert(e.message); }
});
// Priority handler
document.getElementById('ticket-priority-select').addEventListener('change', async function() {
try {
await apiCall('ticket_priority' + ticketCompanyParam(), 'POST', { id: currentTicketId, priority: this.value });
// Päivitä näkymä (visuaalinen muutos)
await showTicketDetail(currentTicketId, currentTicketCompanyId);
} catch (e) { alert(e.message); }
});
// Delete handler
document.getElementById('btn-ticket-delete').addEventListener('click', async () => {
if (!confirm('Poistetaanko tiketti "' + ticket.subject + '"?')) return;
try {
await apiCall('ticket_delete' + ticketCompanyParam(), 'POST', { id: currentTicketId });
showTicketListView();
loadTickets();
} catch (e) { alert(e.message); }
});
// Tags: add new tag on Enter
document.getElementById('ticket-tag-input').addEventListener('keydown', async (e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
const input = e.target;
const newTag = input.value.trim().toLowerCase().replace(/^#/, '');
if (!newTag) return;
const currentTags = (ticket.tags || []).slice();
if (!currentTags.includes(newTag)) currentTags.push(newTag);
input.value = '';
try {
await apiCall('ticket_tags' + ticketCompanyParam(), 'POST', { id: currentTicketId, tags: currentTags });
await showTicketDetail(currentTicketId, currentTicketCompanyId);
} catch (e2) { alert(e2.message); }
});
// Tags: remove tag
document.querySelectorAll('.ticket-tag-remove').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const tagEl = btn.closest('.ticket-tag-editable');
const tagToRemove = tagEl.dataset.tag;
const currentTags = (ticket.tags || []).filter(t => t !== tagToRemove);
try {
await apiCall('ticket_tags' + ticketCompanyParam(), 'POST', { id: currentTicketId, tags: currentTags });
await showTicketDetail(currentTicketId, currentTicketCompanyId);
} catch (e2) { alert(e2.message); }
});
});
// Thread messages
const thread = document.getElementById('ticket-thread');
thread.innerHTML = (ticket.messages || []).map(m => {
const isOut = m.type === 'reply_out';
const isAutoReply = m.type === 'auto_reply';
const isNote = m.type === 'note';
const typeClass = (isOut || isAutoReply) ? 'ticket-msg-out' : (isNote ? 'ticket-msg-note' : 'ticket-msg-in');
const typeIcon = isAutoReply ? '&#9889; Automaattinen vastaus' : (isOut ? '&#8594; Vastaus' : (isNote ? '&#128221; Muistiinpano' : '&#8592; Saapunut'));
return `<div class="ticket-message ${typeClass}">
<div class="ticket-msg-header">
<span class="ticket-msg-type">${typeIcon}</span>
<strong>${esc(m.from_name || m.from)}</strong>
<span class="ticket-msg-time">${esc(m.timestamp)}</span>
</div>
<div class="ticket-msg-body">${esc(m.body)}</div>
</div>`;
}).join('');
// Show detail, hide list + other views
document.getElementById('ticket-list-view').style.display = 'none';
document.getElementById('ticket-rules-view').style.display = 'none';
document.getElementById('ticket-templates-view').style.display = 'none';
document.getElementById('ticket-detail-view').style.display = 'block';
// Reset reply form
document.getElementById('ticket-reply-body').value = '';
document.getElementById('ticket-reply-body').placeholder = 'Kirjoita vastaus...';
ticketReplyType = 'reply';
document.querySelectorAll('.btn-reply-tab').forEach(b => b.classList.remove('active'));
document.querySelector('.btn-reply-tab[data-reply-type="reply"]').classList.add('active');
document.getElementById('btn-send-reply').textContent = 'Lähetä vastaus';
// TO-kenttä — tiketin alkuperäinen lähettäjä
const toField = document.getElementById('reply-to');
if (toField) toField.value = ticket.from_email || '';
// CC-kenttä — täytetään tiketin CC:stä
const ccField = document.getElementById('reply-cc');
if (ccField) ccField.value = ticket.cc || '';
// Mailbox-valinta — täytetään yrityksen postilaatikoista
const mbSelect = document.getElementById('reply-mailbox-select');
if (mbSelect) {
try {
const mailboxes = await apiCall('all_mailboxes');
mbSelect.innerHTML = mailboxes.map(mb =>
`<option value="${esc(mb.id)}" ${mb.id === (ticket.mailbox_id || '') ? 'selected' : ''}>${esc(mb.nimi || mb.smtp_from_email)} &lt;${esc(mb.smtp_from_email)}&gt;</option>`
).join('');
// Vaihda allekirjoitusta kun mailbox vaihtuu
mbSelect.addEventListener('change', function() {
updateSignaturePreview(this.value);
});
} catch (e) { mbSelect.innerHTML = '<option>Ei postilaatikoita</option>'; }
}
// Allekirjoituksen esikatselu
function updateSignaturePreview(mbId) {
const sigPreview = document.getElementById('signature-preview');
const useSigCheck = document.getElementById('reply-use-signature');
// Etsi allekirjoitus: ensin suoraan mailbox-id:llä, sitten fallback ensimmäiseen löytyvään
let sig = currentUserSignatures[mbId] || '';
if (!sig && mbId) {
// Kokeile myös string/number-konversiota
sig = currentUserSignatures[String(mbId)] || currentUserSignatures[Number(mbId)] || '';
}
if (!sig) {
// Fallback: käytä ensimmäistä löytyvää allekirjoitusta
const keys = Object.keys(currentUserSignatures);
if (keys.length > 0) sig = currentUserSignatures[keys[0]] || '';
}
if (sig && useSigCheck && useSigCheck.checked) {
sigPreview.textContent = '-- \n' + sig;
sigPreview.style.display = 'block';
} else {
sigPreview.style.display = 'none';
}
}
updateSignaturePreview(ticket.mailbox_id || '');
// Allekirjoitus-checkbox: päivitä esikatselu vaihdettaessa
const useSigCheckbox = document.getElementById('reply-use-signature');
if (useSigCheckbox) {
useSigCheckbox.addEventListener('change', () => {
const mbSelect = document.getElementById('reply-mailbox-select');
updateSignaturePreview(mbSelect ? mbSelect.value : '');
});
}
// Vastauspohjat — lataa dropdown
try {
const templates = await apiCall('reply_templates');
const tplSelect = document.getElementById('reply-template-select');
tplSelect.innerHTML = '<option value="">📝 Vastauspohjat...</option>';
templates.forEach(t => {
tplSelect.innerHTML += `<option value="${esc(t.id)}" data-body="${esc(t.body)}">${esc(t.nimi)}</option>`;
});
tplSelect.addEventListener('change', function() {
const opt = this.options[this.selectedIndex];
const body = opt.dataset.body || '';
if (body) {
const textarea = document.getElementById('ticket-reply-body');
textarea.value = textarea.value ? textarea.value + '\n\n' + body : body;
textarea.focus();
}
this.value = ''; // Reset select
});
} catch (e) { /* templates not critical */ }
} catch (e) { alert(e.message); }
}
function showTicketListView() {
document.getElementById('ticket-detail-view').style.display = 'none';
document.getElementById('ticket-rules-view').style.display = 'none';
document.getElementById('ticket-templates-view').style.display = 'none';
document.getElementById('ticket-list-view').style.display = 'block';
currentTicketId = null;
// Reset bulk selection
bulkSelectedIds.clear();
const selectAll = document.getElementById('bulk-select-all');
if (selectAll) selectAll.checked = false;
updateBulkToolbar();
}
document.getElementById('btn-ticket-back').addEventListener('click', () => {
showTicketListView();
loadTickets();
});
// Reply type tabs
document.querySelectorAll('.btn-reply-tab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.btn-reply-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
ticketReplyType = btn.dataset.replyType;
const textarea = document.getElementById('ticket-reply-body');
const sendBtn = document.getElementById('btn-send-reply');
const sigPrev = document.getElementById('signature-preview');
const metaFields = document.getElementById('reply-meta-fields');
const tplWrap = document.getElementById('reply-template-select-wrap');
const useSigEl = document.getElementById('reply-use-signature');
const sigLabel = useSigEl ? useSigEl.closest('label') : null;
if (ticketReplyType === 'note') {
textarea.placeholder = 'Kirjoita sisäinen muistiinpano...';
sendBtn.textContent = 'Tallenna muistiinpano';
sigPrev.style.display = 'none';
if (metaFields) metaFields.style.display = 'none';
if (tplWrap) tplWrap.style.display = 'none';
if (sigLabel) sigLabel.style.display = 'none';
} else {
textarea.placeholder = 'Kirjoita vastaus...';
sendBtn.textContent = 'Lähetä vastaus';
if (metaFields) metaFields.style.display = '';
if (tplWrap) tplWrap.style.display = '';
if (sigLabel) sigLabel.style.display = '';
// Näytä allekirjoitus jos checkbox päällä
if (sigPrev.textContent.trim() && useSigEl && useSigEl.checked) sigPrev.style.display = 'block';
}
});
});
// Send reply or note
document.getElementById('btn-send-reply').addEventListener('click', async () => {
const body = document.getElementById('ticket-reply-body').value.trim();
if (!body) { alert('Kirjoita viesti ensin'); return; }
if (!currentTicketId) return;
const btn = document.getElementById('btn-send-reply');
btn.disabled = true;
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;
}
await apiCall(action + ticketCompanyParam(), 'POST', payload);
// Reload the detail view
await showTicketDetail(currentTicketId, currentTicketCompanyId);
} catch (e) {
alert(e.message);
} finally {
btn.disabled = false;
btn.textContent = ticketReplyType === 'note' ? 'Tallenna muistiinpano' : 'Lähetä vastaus';
}
});
// Fetch emails
document.getElementById('btn-fetch-emails').addEventListener('click', async () => {
const btn = document.getElementById('btn-fetch-emails');
const status = document.getElementById('ticket-fetch-status');
btn.disabled = true;
btn.textContent = '⏳ Haetaan...';
status.style.display = 'block';
status.className = '';
status.style.background = '#f0f7ff';
status.style.color = '#0f3460';
status.textContent = 'Yhdistetään sähköpostipalvelimeen...';
try {
const result = await apiCall('ticket_fetch', 'POST');
status.style.background = '#eafaf1';
status.style.color = '#27ae60';
status.textContent = `Valmis! ${result.new_tickets} uutta tikettiä, ${result.threaded} ketjutettu viestiä. Yhteensä ${result.total} tikettiä.`;
await loadTickets();
} catch (e) {
status.style.background = '#fef2f2';
status.style.color = '#e74c3c';
status.textContent = 'Virhe: ' + e.message;
} finally {
btn.disabled = false;
btn.textContent = '📧 Hae postit';
setTimeout(() => { status.style.display = 'none'; }, 8000);
}
});
// ==================== TICKET AUTO-REFRESH ====================
let ticketAutoRefreshTimer = null;
function startTicketAutoRefresh() {
stopTicketAutoRefresh();
const seconds = parseInt(document.getElementById('ticket-refresh-interval').value) || 60;
ticketAutoRefreshTimer = setInterval(() => {
// Vain jos support-tabi on aktiivinen ja listanäkymä näkyy
const supportActive = document.getElementById('tab-content-support').classList.contains('active');
const listVisible = document.getElementById('ticket-list-view').style.display !== 'none';
if (supportActive && listVisible) {
loadTickets();
}
}, seconds * 1000);
}
function stopTicketAutoRefresh() {
if (ticketAutoRefreshTimer) {
clearInterval(ticketAutoRefreshTimer);
ticketAutoRefreshTimer = null;
}
}
document.getElementById('ticket-auto-refresh').addEventListener('change', function() {
if (this.checked) {
startTicketAutoRefresh();
} else {
stopTicketAutoRefresh();
}
});
document.getElementById('ticket-refresh-interval').addEventListener('change', function() {
if (document.getElementById('ticket-auto-refresh').checked) {
startTicketAutoRefresh(); // Käynnistä uudelleen uudella intervallilla
}
});
// ==================== TICKET RULES (AUTOMAATTISÄÄNNÖT) ====================
let ticketRules = [];
let editingRuleId = null;
async function loadRules() {
try {
ticketRules = await apiCall('ticket_rules');
renderRules();
} catch (e) { console.error(e); }
}
function renderRules() {
const list = document.getElementById('rules-list');
if (ticketRules.length === 0) {
list.innerHTML = '<div style="text-align:center;padding:2rem;color:#aaa;">Ei sääntöjä vielä. Lisää ensimmäinen sääntö.</div>';
return;
}
list.innerHTML = ticketRules.map(r => {
const conditions = [];
if (r.from_contains) conditions.push('Lähettäjä: <strong>' + esc(r.from_contains) + '</strong>');
if (r.subject_contains) conditions.push('Otsikko: <strong>' + esc(r.subject_contains) + '</strong>');
const actions = [];
if (r.set_status) actions.push('Tila → ' + (ticketStatusLabels[r.set_status] || r.set_status));
if (r.set_type) actions.push('Tyyppi → ' + (ticketTypeLabels[r.set_type] || r.set_type));
if (r.set_tags) actions.push('Tagit: #' + r.set_tags.split(',').map(t => t.trim()).join(' #'));
if (r.auto_close_days) actions.push('Auto-close: ' + r.auto_close_days + 'pv');
return `<div style="display:flex;justify-content:space-between;align-items:center;padding:0.75rem 1rem;background:${r.enabled ? '#f8f9fb' : '#fafafa'};border:1px solid #e8ebf0;border-radius:8px;margin-bottom:0.5rem;opacity:${r.enabled ? '1' : '0.5'};">
<div>
<div style="font-weight:600;color:#0f3460;font-size:0.9rem;">${esc(r.name)}</div>
<div style="font-size:0.8rem;color:#888;margin-top:2px;">
${conditions.length ? 'Ehdot: ' + conditions.join(', ') : '<em>Ei ehtoja</em>'}${actions.length ? actions.join(', ') : '<em>Ei toimenpiteitä</em>'}
</div>
</div>
<div style="display:flex;gap:0.4rem;align-items:center;flex-shrink:0;">
<label style="cursor:pointer;font-size:0.8rem;color:#888;display:flex;align-items:center;gap:3px;">
<input type="checkbox" ${r.enabled ? 'checked' : ''} onchange="toggleRule('${r.id}', this.checked)"> Päällä
</label>
<button onclick="editRule('${r.id}')" style="background:none;border:none;cursor:pointer;font-size:1rem;padding:4px;">&#9998;</button>
<button onclick="deleteRule('${r.id}')" style="background:none;border:none;cursor:pointer;font-size:1rem;padding:4px;color:#e74c3c;">&#128465;</button>
</div>
</div>`;
}).join('');
}
function showRulesView() {
document.getElementById('ticket-list-view').style.display = 'none';
document.getElementById('ticket-detail-view').style.display = 'none';
document.getElementById('ticket-templates-view').style.display = 'none';
document.getElementById('ticket-rules-view').style.display = 'block';
loadRules();
}
function hideRulesView() {
document.getElementById('ticket-rules-view').style.display = 'none';
document.getElementById('ticket-list-view').style.display = 'block';
}
function showRuleForm(rule) {
document.getElementById('rule-form-container').style.display = '';
document.getElementById('rule-form-title').textContent = rule ? 'Muokkaa sääntöä' : 'Uusi sääntö';
document.getElementById('rule-form-id').value = rule ? rule.id : '';
document.getElementById('rule-form-name').value = rule ? rule.name : '';
document.getElementById('rule-form-from').value = rule ? rule.from_contains : '';
document.getElementById('rule-form-subject').value = rule ? rule.subject_contains : '';
document.getElementById('rule-form-status').value = rule ? (rule.set_status || '') : '';
document.getElementById('rule-form-type').value = rule ? (rule.set_type || '') : '';
document.getElementById('rule-form-tags').value = rule ? (rule.set_tags || '') : '';
document.getElementById('rule-form-autoclose').value = rule ? (rule.auto_close_days || '') : '';
editingRuleId = rule ? rule.id : null;
}
function hideRuleForm() {
document.getElementById('rule-form-container').style.display = 'none';
editingRuleId = null;
}
document.getElementById('btn-ticket-rules').addEventListener('click', () => showRulesView());
document.getElementById('btn-rules-back').addEventListener('click', () => hideRulesView());
document.getElementById('btn-add-rule').addEventListener('click', () => showRuleForm(null));
document.getElementById('btn-cancel-rule').addEventListener('click', () => hideRuleForm());
document.getElementById('btn-save-rule').addEventListener('click', async () => {
const name = document.getElementById('rule-form-name').value.trim();
if (!name) { alert('Nimi puuttuu'); return; }
const data = {
name,
from_contains: document.getElementById('rule-form-from').value.trim(),
subject_contains: document.getElementById('rule-form-subject').value.trim(),
set_status: document.getElementById('rule-form-status').value,
set_type: document.getElementById('rule-form-type').value,
set_tags: document.getElementById('rule-form-tags').value.trim(),
auto_close_days: parseInt(document.getElementById('rule-form-autoclose').value) || 0,
enabled: true,
};
const existingId = document.getElementById('rule-form-id').value;
if (existingId) data.id = existingId;
try {
await apiCall('ticket_rule_save', 'POST', data);
hideRuleForm();
await loadRules();
} catch (e) { alert(e.message); }
});
async function editRule(id) {
const rule = ticketRules.find(r => r.id === id);
if (rule) showRuleForm(rule);
}
async function deleteRule(id) {
if (!confirm('Poistetaanko sääntö?')) return;
try {
await apiCall('ticket_rule_delete', 'POST', { id });
await loadRules();
} catch (e) { alert(e.message); }
}
async function toggleRule(id, enabled) {
const rule = ticketRules.find(r => r.id === id);
if (!rule) return;
try {
await apiCall('ticket_rule_save', 'POST', { ...rule, enabled });
await loadRules();
} catch (e) { alert(e.message); }
}
// ==================== VASTAUSPOHJAT (TUKITABISSA) ====================
function showTemplatesView() {
document.getElementById('ticket-list-view').style.display = 'none';
document.getElementById('ticket-detail-view').style.display = 'none';
document.getElementById('ticket-rules-view').style.display = 'none';
document.getElementById('ticket-templates-view').style.display = 'block';
hideTplForm();
renderTplList();
}
function hideTemplatesView() {
document.getElementById('ticket-templates-view').style.display = 'none';
document.getElementById('ticket-list-view').style.display = 'block';
}
function renderTplList() {
const list = document.getElementById('tpl-list');
if (!list) return;
if (replyTemplates.length === 0) {
list.innerHTML = '<p style="color:#aaa;font-size:0.85rem;">Ei vastauspohjia vielä. Lisää ensimmäinen klikkaamalla "+ Lisää pohja".</p>';
return;
}
list.innerHTML = replyTemplates.map(t =>
`<div style="display:flex;justify-content:space-between;align-items:center;padding:0.6rem 0;border-bottom:1px solid #f0f2f5;">
<div style="min-width:0;flex:1;">
<strong style="font-size:0.9rem;">${esc(t.nimi)}</strong>
<div style="font-size:0.8rem;color:#888;max-width:450px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${esc(t.body.substring(0, 100))}</div>
</div>
<div style="display:flex;gap:0.3rem;flex-shrink:0;">
<button class="btn-secondary" onclick="editTpl('${t.id}')" style="padding:4px 10px;font-size:0.8rem;">Muokkaa</button>
<button class="btn-danger" onclick="deleteTpl('${t.id}')" style="padding:4px 10px;font-size:0.8rem;">Poista</button>
</div>
</div>`
).join('');
}
function showTplForm(tpl) {
document.getElementById('tpl-form-container').style.display = '';
document.getElementById('tpl-form-title').textContent = tpl ? 'Muokkaa vastauspohjaa' : 'Uusi vastauspohja';
document.getElementById('tpl-form-id').value = tpl ? tpl.id : '';
document.getElementById('tpl-form-name').value = tpl ? tpl.nimi : '';
document.getElementById('tpl-form-body').value = tpl ? tpl.body : '';
}
function hideTplForm() {
document.getElementById('tpl-form-container').style.display = 'none';
}
document.getElementById('btn-ticket-templates').addEventListener('click', async () => {
await loadTemplates();
showTemplatesView();
});
document.getElementById('btn-templates-back').addEventListener('click', () => hideTemplatesView());
document.getElementById('btn-add-tpl').addEventListener('click', () => showTplForm(null));
document.getElementById('btn-cancel-tpl').addEventListener('click', () => hideTplForm());
document.getElementById('btn-save-tpl').addEventListener('click', async () => {
const nimi = document.getElementById('tpl-form-name').value.trim();
const body = document.getElementById('tpl-form-body').value.trim();
if (!nimi || !body) { alert('Täytä nimi ja sisältö'); return; }
const id = document.getElementById('tpl-form-id').value || undefined;
try {
await apiCall('reply_template_save', 'POST', { id, nimi, body });
hideTplForm();
await loadTemplates();
renderTplList();
} catch (e) { alert(e.message); }
});
window.editTpl = function(id) {
const t = replyTemplates.find(x => x.id === id);
if (t) showTplForm(t);
};
window.deleteTpl = async function(id) {
if (!confirm('Poistetaanko vastauspohja?')) return;
try {
await apiCall('reply_template_delete', 'POST', { id });
await loadTemplates();
renderTplList();
} catch (e) { alert(e.message); }
};
// ==================== BULK ACTIONS ====================
let bulkSelectedIds = new Set();
function updateBulkToolbar() {
const toolbar = document.getElementById('bulk-actions-toolbar');
if (bulkSelectedIds.size > 0) {
toolbar.style.display = 'flex';
document.getElementById('bulk-count').textContent = bulkSelectedIds.size + ' valittu';
} else {
toolbar.style.display = 'none';
}
}
async function bulkCloseSelected() {
if (bulkSelectedIds.size === 0) return;
if (!confirm(`Suljetaanko ${bulkSelectedIds.size} tikettiä?`)) return;
try {
await apiCall('ticket_bulk_status', 'POST', { ids: [...bulkSelectedIds], status: 'suljettu' });
bulkSelectedIds.clear();
updateBulkToolbar();
await loadTickets();
} catch (e) { alert(e.message); }
}
async function bulkDeleteSelected() {
if (bulkSelectedIds.size === 0) return;
if (!confirm(`Poistetaanko ${bulkSelectedIds.size} tikettiä pysyvästi?`)) return;
try {
await apiCall('ticket_bulk_delete', 'POST', { ids: [...bulkSelectedIds] });
bulkSelectedIds.clear();
updateBulkToolbar();
await loadTickets();
} catch (e) { alert(e.message); }
}
// ==================== SETTINGS ====================
async function loadSettings() {
try {
const config = await apiCall('config');
document.getElementById('settings-api-key').value = config.api_key || '';
document.getElementById('settings-cors').value = (config.cors_origins || []).join('\n');
// Näytä yrityksen nimi API-otsikossa
const apiTitle = document.getElementById('api-company-name');
if (apiTitle && currentCompany) apiTitle.textContent = currentCompany.nimi + ' — ';
const key = config.api_key || 'AVAIN';
document.getElementById('api-example-url').textContent = `api.php?action=saatavuus&key=${key}&osoite=Esimerkkikatu+1&postinumero=00100&kaupunki=Helsinki`;
// Telegram-asetukset
document.getElementById('settings-telegram-token').value = config.telegram_bot_token || '';
document.getElementById('settings-telegram-chat').value = config.telegram_chat_id || '';
} catch (e) { console.error(e); }
// Vastauspohjat
loadTemplates();
}
// ==================== VASTAUSPOHJAT ====================
let replyTemplates = [];
async function loadTemplates() {
try {
replyTemplates = await apiCall('reply_templates');
renderTplList();
} catch (e) { console.error(e); }
}
// ==================== TELEGRAM ====================
document.getElementById('btn-save-telegram').addEventListener('click', async () => {
try {
await apiCall('config_update', 'POST', {
telegram_bot_token: document.getElementById('settings-telegram-token').value.trim(),
telegram_chat_id: document.getElementById('settings-telegram-chat').value.trim(),
});
alert('Telegram-asetukset tallennettu!');
} catch (e) { alert(e.message); }
});
document.getElementById('btn-test-telegram').addEventListener('click', async () => {
try {
await apiCall('telegram_test', 'POST');
alert('Testiviesti lähetetty!');
} catch (e) { alert(e.message); }
});
document.getElementById('btn-generate-key').addEventListener('click', async () => {
try {
const config = await apiCall('generate_api_key', 'POST');
document.getElementById('settings-api-key').value = config.api_key || '';
document.getElementById('api-example-url').textContent = `api.php?action=saatavuus&key=${config.api_key}&osoite=Esimerkkikatu+1&postinumero=00100&kaupunki=Helsinki`;
} catch (e) { alert(e.message); }
});
document.getElementById('btn-save-settings').addEventListener('click', async () => {
try {
const config = await apiCall('config_update', 'POST', {
api_key: document.getElementById('settings-api-key').value,
cors_origins: document.getElementById('settings-cors').value,
});
alert('Asetukset tallennettu!');
} catch (e) { alert(e.message); }
});
document.getElementById('btn-test-api').addEventListener('click', async () => {
const osoite = document.getElementById('test-api-address').value.trim();
const postinumero = document.getElementById('test-api-zip').value.trim();
const kaupunki = document.getElementById('test-api-city').value.trim();
const apiKey = document.getElementById('settings-api-key').value;
if (!osoite || !postinumero || !kaupunki) { alert('Täytä osoite, postinumero ja kaupunki'); return; }
const result = document.getElementById('test-api-result');
result.style.display = 'block';
result.textContent = 'Haetaan...';
try {
const params = `osoite=${encodeURIComponent(osoite)}&postinumero=${encodeURIComponent(postinumero)}&kaupunki=${encodeURIComponent(kaupunki)}`;
const res = await fetch(`${API}?action=saatavuus&key=${encodeURIComponent(apiKey)}&${params}`);
const data = await res.json();
result.textContent = JSON.stringify(data, null, 2);
} catch (e) { result.textContent = 'Virhe: ' + e.message; }
});
// ==================== MODALS ====================
customerModal.addEventListener('click', (e) => { if (e.target === customerModal) customerModal.style.display = 'none'; });
detailModal.addEventListener('click', (e) => { if (e.target === detailModal) detailModal.style.display = 'none'; });
userModal.addEventListener('click', (e) => { if (e.target === userModal) userModal.style.display = 'none'; });
leadModal.addEventListener('click', (e) => { if (e.target === leadModal) leadModal.style.display = 'none'; });
leadDetailModal.addEventListener('click', (e) => { if (e.target === leadDetailModal) leadDetailModal.style.display = 'none'; });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
customerModal.style.display = 'none';
detailModal.style.display = 'none';
userModal.style.display = 'none';
leadModal.style.display = 'none';
leadDetailModal.style.display = 'none';
}
});
// ==================== COMPANY SELECTOR ====================
document.getElementById('company-selector').addEventListener('change', function () {
switchCompany(this.value);
});
// ==================== YRITYKSET-TAB (admin) ====================
let companiesTabData = [];
let currentCompanyDetail = null;
async function loadCompaniesTab() {
try {
companiesTabData = await apiCall('companies_all');
renderCompaniesTable();
} catch (e) {
console.error(e);
// Fallback: käytä availableCompanies
companiesTabData = availableCompanies;
renderCompaniesTable();
}
}
function renderCompaniesTable() {
const tbody = document.getElementById('companies-tbody');
const superAdmin = currentUser?.role === 'superadmin';
tbody.innerHTML = companiesTabData.map(c => `<tr>
<td><code>${esc(c.id)}</code></td>
<td><strong>${esc(c.nimi)}</strong></td>
<td>-</td>
<td class="nowrap">${esc((c.luotu || '').substring(0, 10))}</td>
<td>${c.aktiivinen !== false ? '<span style="color:#22c55e;">Aktiivinen</span>' : '<span style="color:#888;">Ei aktiivinen</span>'}</td>
<td>
<button class="btn-link" onclick="showCompanyDetail('${c.id}')">Asetukset</button>
${superAdmin ? `<button class="btn-link" style="color:#dc2626;" onclick="deleteCompany('${c.id}','${esc(c.nimi)}')">Poista</button>` : ''}
</td>
</tr>`).join('');
// Piilota "Lisää yritys" nappi jos ei superadmin
const addBtn = document.getElementById('btn-add-company');
if (addBtn) addBtn.style.display = superAdmin ? '' : 'none';
document.getElementById('companies-list-view').style.display = '';
document.getElementById('company-detail-view').style.display = 'none';
}
document.getElementById('btn-add-company').addEventListener('click', () => {
const nimi = prompt('Yrityksen nimi:');
if (!nimi) return;
const id = prompt('Yrityksen ID (pienillä kirjaimilla, a-z, 0-9, viiva sallittu):');
if (!id) return;
apiCall('company_create', 'POST', { id, nimi }).then(() => {
loadCompaniesTab();
// Päivitä myös company-selector
apiCall('check_auth').then(data => {
if (data.authenticated) {
availableCompanies = data.companies || [];
currentCompany = availableCompanies.find(c => c.id === data.company_id) || currentCompany;
populateCompanySelector();
}
});
}).catch(e => alert(e.message));
});
async function deleteCompany(id, nimi) {
if (!confirm(`Poistetaanko yritys "${nimi}"? Tämä poistaa pääsyn yrityksen dataan.`)) return;
try {
await apiCall('company_delete', 'POST', { id });
loadCompaniesTab();
// Päivitä selector
availableCompanies = availableCompanies.filter(c => c.id !== id);
if (currentCompany && currentCompany.id === id) {
currentCompany = availableCompanies[0] || null;
if (currentCompany) switchCompany(currentCompany.id);
}
populateCompanySelector();
} catch (e) { alert(e.message); }
}
async function showCompanyDetail(id) {
currentCompanyDetail = id;
document.getElementById('companies-list-view').style.display = 'none';
document.getElementById('company-detail-view').style.display = '';
const comp = companiesTabData.find(c => c.id === id);
document.getElementById('company-detail-title').textContent = (comp ? comp.nimi : id) + ' — Asetukset';
document.getElementById('company-edit-nimi').value = comp ? comp.nimi : '';
// Brändäyskentät
document.getElementById('company-edit-subtitle').value = comp?.subtitle || '';
const color = comp?.primary_color || '#0f3460';
document.getElementById('company-edit-color').value = color;
document.getElementById('company-edit-color-text').value = color;
document.getElementById('company-edit-domains').value = (comp?.domains || []).join('\n');
// Logo-esikatselu
const logoPreview = document.getElementById('company-logo-preview');
if (comp?.logo_file) {
logoPreview.src = 'api.php?action=company_logo&company_id=' + encodeURIComponent(id) + '&t=' + Date.now();
logoPreview.style.display = '';
} else {
logoPreview.style.display = 'none';
}
// Moduuli-checkboxit (yhteensopivuus: vanha 'devices' → 'tekniikka')
let enabledMods = comp?.enabled_modules || [];
if (enabledMods.includes('devices') && !enabledMods.includes('tekniikka')) {
enabledMods = enabledMods.map(m => m === 'devices' ? 'tekniikka' : m);
}
document.querySelectorAll('#modules-checkboxes input[data-module]').forEach(cb => {
const mod = cb.dataset.module;
// Jos enabled_modules on tyhjä → kaikki päällä (oletus)
cb.checked = enabledMods.length === 0 ? DEFAULT_MODULES.includes(mod) : enabledMods.includes(mod);
});
// Sallitut IP-osoitteet
document.getElementById('company-edit-allowed-ips').value = comp?.allowed_ips || 'kaikki';
// Vaihda aktiivinen yritys jotta API-kutsut kohdistuvat oikein
await apiCall('company_switch', 'POST', { company_id: id });
// Lataa postilaatikot
loadMailboxes();
// Lataa sijainnit
loadSites();
// Lataa käyttäjäoikeudet
loadCompanyUsers(id);
}
document.getElementById('btn-company-back').addEventListener('click', () => {
// Vaihda takaisin alkuperäiseen yritykseen
if (currentCompany) apiCall('company_switch', 'POST', { company_id: currentCompany.id });
renderCompaniesTable();
});
// Synkronoi color picker <-> text input
document.getElementById('company-edit-color').addEventListener('input', function() {
document.getElementById('company-edit-color-text').value = this.value;
});
document.getElementById('company-edit-color-text').addEventListener('input', function() {
if (/^#[0-9a-fA-F]{6}$/.test(this.value)) {
document.getElementById('company-edit-color').value = this.value;
}
});
// Poimi hallitseva väri kuvasta (canvas)
function extractDominantColor(file) {
return new Promise((resolve) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
const canvas = document.createElement('canvas');
const size = 50; // Pieni koko nopeuttaa
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, size, size);
const pixels = ctx.getImageData(0, 0, size, size).data;
URL.revokeObjectURL(url);
// Laske värien esiintymät (ryhmiteltynä 32-askeleen tarkkuudella)
const colorCounts = {};
for (let i = 0; i < pixels.length; i += 4) {
const r = pixels[i], g = pixels[i+1], b = pixels[i+2], a = pixels[i+3];
if (a < 128) continue; // Ohita läpinäkyvät
// Ohita lähes valkoiset, mustat ja harmaat
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const saturation = max === 0 ? 0 : (max - min) / max;
if (max > 230 && min > 200) continue; // Valkoinen
if (max < 30) continue; // Musta
if (saturation < 0.15 && max > 60) continue; // Harmaa
// Ryhmittele
const qr = Math.round(r / 32) * 32;
const qg = Math.round(g / 32) * 32;
const qb = Math.round(b / 32) * 32;
const key = `${qr},${qg},${qb}`;
colorCounts[key] = (colorCounts[key] || 0) + 1;
}
// Etsi yleisin
let bestKey = null, bestCount = 0;
for (const [key, count] of Object.entries(colorCounts)) {
if (count > bestCount) { bestCount = count; bestKey = key; }
}
if (bestKey) {
const [r, g, b] = bestKey.split(',').map(Number);
const hex = '#' + [r, g, b].map(v => Math.min(255, v).toString(16).padStart(2, '0')).join('');
resolve(hex);
} else {
resolve(null); // Ei löytynyt selkeää väriä
}
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
img.src = url;
});
}
// Logo-upload — poimi väri automaattisesti
document.getElementById('company-logo-upload').addEventListener('change', async function() {
if (!this.files[0] || !currentCompanyDetail) return;
const file = this.files[0];
// Poimi väri logosta ennen uploadia
const dominantColor = await extractDominantColor(file);
const formData = new FormData();
formData.append('logo', file);
formData.append('company_id', currentCompanyDetail);
try {
const res = await fetch('api.php?action=company_logo_upload', { method: 'POST', body: formData, credentials: 'include' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Virhe');
// Päivitä preview
const preview = document.getElementById('company-logo-preview');
preview.src = data.logo_url + '&t=' + Date.now();
preview.style.display = '';
// Päivitä paikallinen data
const comp = companiesTabData.find(c => c.id === currentCompanyDetail);
if (comp) comp.logo_file = data.logo_file;
// Aseta logosta poimittu väri teemaväriksi
if (dominantColor) {
const colorInput = document.getElementById('company-edit-color');
if (colorInput) {
colorInput.value = dominantColor;
// Näytä ilmoitus
const msg = document.createElement('span');
msg.textContent = ` Väri ${dominantColor} poimittu logosta`;
msg.style.cssText = 'color:#27ae60;font-size:0.85rem;margin-left:8px;';
colorInput.parentElement.appendChild(msg);
setTimeout(() => msg.remove(), 4000);
}
}
} catch (e) { alert(e.message); }
this.value = ''; // Reset file input
});
document.getElementById('btn-save-company-settings').addEventListener('click', async () => {
const nimi = document.getElementById('company-edit-nimi').value.trim();
if (!nimi) return;
const subtitle = document.getElementById('company-edit-subtitle').value.trim();
const primary_color = document.getElementById('company-edit-color').value;
const domainsText = document.getElementById('company-edit-domains').value;
const domains = domainsText.split('\n').map(d => d.trim()).filter(d => d);
// Moduulit
const enabled_modules = [];
document.querySelectorAll('#modules-checkboxes input[data-module]:checked').forEach(cb => {
enabled_modules.push(cb.dataset.module);
});
const allowed_ips = document.getElementById('company-edit-allowed-ips').value.trim();
try {
await apiCall('company_update', 'POST', { id: currentCompanyDetail, nimi, subtitle, primary_color, domains, enabled_modules, allowed_ips });
alert('Asetukset tallennettu!');
// Päivitä paikalliset tiedot
const comp = companiesTabData.find(c => c.id === currentCompanyDetail);
if (comp) { comp.nimi = nimi; comp.subtitle = subtitle; comp.primary_color = primary_color; comp.domains = domains; comp.enabled_modules = enabled_modules; comp.allowed_ips = allowed_ips; }
const avail = availableCompanies.find(c => c.id === currentCompanyDetail);
if (avail) avail.nimi = nimi;
populateCompanySelector();
// Jos tämä on aktiivinen yritys → päivitä brändäys ja moduulit heti
if (currentCompany && currentCompany.id === currentCompanyDetail) {
applyBranding({
nimi, subtitle, primary_color,
logo_url: comp?.logo_file ? 'api.php?action=company_logo&company_id=' + encodeURIComponent(currentCompanyDetail) + '&t=' + Date.now() : ''
});
applyModules(enabled_modules);
}
} catch (e) { alert(e.message); }
});
// ==================== POSTILAATIKOT ====================
let mailboxesData = [];
async function loadMailboxes() {
try {
mailboxesData = await apiCall('mailboxes');
renderMailboxes();
} catch (e) { console.error(e); }
}
function renderMailboxes() {
const container = document.getElementById('mailboxes-list');
if (mailboxesData.length === 0) {
container.innerHTML = '<p style="color:#888;font-size:0.9rem;">Ei postilaatikoita. Lisää ensimmäinen postilaatikko.</p>';
return;
}
container.innerHTML = mailboxesData.map(mb => `<div class="mailbox-item" style="display:flex;justify-content:space-between;align-items:center;padding:0.75rem;background:#fff;border:1px solid #e0e0e0;border-radius:8px;margin-bottom:0.5rem;">
<div>
<strong>${esc(mb.nimi)}</strong>
<span style="color:#888;font-size:0.85rem;margin-left:0.75rem;">${esc(mb.imap_user)}</span>
<span style="color:${mb.aktiivinen !== false ? '#22c55e' : '#888'};font-size:0.8rem;margin-left:0.5rem;">${mb.aktiivinen !== false ? 'Aktiivinen' : 'Ei aktiivinen'}</span>
</div>
<div style="display:flex;gap:0.5rem;">
<button class="btn-link" onclick="editMailbox('${mb.id}')">Muokkaa</button>
<button class="btn-link" style="color:#dc2626;" onclick="deleteMailbox('${mb.id}','${esc(mb.nimi)}')">Poista</button>
</div>
</div>`).join('');
}
document.getElementById('btn-add-mailbox').addEventListener('click', () => {
showMailboxForm();
});
function showMailboxForm(mb = null) {
document.getElementById('mailbox-form-title').textContent = mb ? 'Muokkaa postilaatikkoa' : 'Uusi postilaatikko';
document.getElementById('mailbox-form-id').value = mb ? mb.id : '';
document.getElementById('mailbox-form-nimi').value = mb ? mb.nimi : '';
document.getElementById('mailbox-form-host').value = mb ? mb.imap_host : '';
document.getElementById('mailbox-form-port').value = mb ? mb.imap_port : 993;
document.getElementById('mailbox-form-user').value = mb ? mb.imap_user : '';
document.getElementById('mailbox-form-password').value = mb ? mb.imap_password : '';
document.getElementById('mailbox-form-encryption').value = mb ? (mb.imap_encryption || 'ssl') : 'ssl';
document.getElementById('mailbox-form-smtp-email').value = mb ? (mb.smtp_from_email || '') : '';
document.getElementById('mailbox-form-smtp-name').value = mb ? (mb.smtp_from_name || '') : '';
document.getElementById('mailbox-form-smtp-host').value = mb ? (mb.smtp_host || '') : '';
document.getElementById('mailbox-form-smtp-port').value = mb ? (mb.smtp_port || 587) : 587;
document.getElementById('mailbox-form-smtp-user').value = mb ? (mb.smtp_user || '') : '';
document.getElementById('mailbox-form-smtp-pass').value = mb ? (mb.smtp_password || '') : '';
document.getElementById('mailbox-form-smtp-encryption').value = mb ? (mb.smtp_encryption || 'tls') : 'tls';
// "Käytä samoja tunnuksia" — oletuksena päällä uudelle, olemassa olevalle tarkistetaan
const sameCheck = document.getElementById('mailbox-form-smtp-same');
if (mb) {
// Jos SMTP-host on tyhjä tai sama kuin IMAP -> samoja tunnuksia
const smtpIsSame = !mb.smtp_host || mb.smtp_host === mb.imap_host;
sameCheck.checked = smtpIsSame;
} else {
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();
document.getElementById('smtp-test-result').style.display = 'none';
document.getElementById('mailbox-form-container').style.display = '';
}
function toggleSmtpFields() {
const same = document.getElementById('mailbox-form-smtp-same').checked;
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) {
const mb = mailboxesData.find(m => m.id === id);
if (mb) showMailboxForm(mb);
}
async function deleteMailbox(id, nimi) {
if (!confirm(`Poistetaanko postilaatikko "${nimi}"?`)) return;
try {
await apiCall('mailbox_delete', 'POST', { id });
loadMailboxes();
} catch (e) { alert(e.message); }
}
// SMTP "Käytä samoja tunnuksia" -checkbox toggle
document.getElementById('mailbox-form-smtp-same').addEventListener('change', toggleSmtpFields);
document.getElementById('btn-save-mailbox').addEventListener('click', async () => {
const useSame = document.getElementById('mailbox-form-smtp-same').checked;
const imapHost = document.getElementById('mailbox-form-host').value;
const imapUser = document.getElementById('mailbox-form-user').value;
const imapPass = document.getElementById('mailbox-form-password').value;
const imapEnc = document.getElementById('mailbox-form-encryption').value;
const data = {
id: document.getElementById('mailbox-form-id').value || undefined,
nimi: document.getElementById('mailbox-form-nimi').value,
imap_host: imapHost,
imap_port: parseInt(document.getElementById('mailbox-form-port').value) || 993,
imap_user: imapUser,
imap_password: imapPass,
imap_encryption: imapEnc,
smtp_from_email: document.getElementById('mailbox-form-smtp-email').value,
smtp_from_name: document.getElementById('mailbox-form-smtp-name').value,
smtp_host: document.getElementById('mailbox-form-smtp-host').value || (useSame ? imapHost : ''),
smtp_port: parseInt(document.getElementById('mailbox-form-smtp-port').value) || 587,
smtp_user: useSame ? imapUser : document.getElementById('mailbox-form-smtp-user').value,
smtp_password: useSame ? imapPass : document.getElementById('mailbox-form-smtp-pass').value,
smtp_encryption: document.getElementById('mailbox-form-smtp-encryption').value,
aktiivinen: true,
auto_reply_enabled: document.getElementById('mailbox-form-auto-reply').checked,
auto_reply_body: document.getElementById('mailbox-form-auto-reply-body').value,
};
try {
const saved = await apiCall('mailbox_save', 'POST', data);
// Päivitä lomakkeen ID (uusi laatikko saa ID:n backendiltä)
if (saved.id) document.getElementById('mailbox-form-id').value = saved.id;
// Päivitä lista taustalle mutta pidä lomake auki
loadMailboxes();
// Näytä lyhyt "Tallennettu" -ilmoitus
const btn = document.getElementById('btn-save-mailbox');
const orig = btn.textContent;
btn.textContent = '✓ Tallennettu';
btn.style.background = '#4caf50';
setTimeout(() => { btn.textContent = orig; btn.style.background = ''; }, 2000);
} catch (e) { alert(e.message); }
});
document.getElementById('btn-cancel-mailbox').addEventListener('click', () => {
document.getElementById('mailbox-form-container').style.display = 'none';
});
// SMTP-testaus
document.getElementById('btn-test-smtp').addEventListener('click', async () => {
const mailboxId = document.getElementById('mailbox-form-id').value;
const resultEl = document.getElementById('smtp-test-result');
if (!mailboxId) {
resultEl.style.display = '';
resultEl.textContent = '⚠️ Tallenna postilaatikko ensin, sitten testaa.';
return;
}
resultEl.style.display = '';
resultEl.textContent = '⏳ Testataan SMTP-yhteyttä...';
try {
const res = await apiCall('smtp_test', 'POST', { mailbox_id: mailboxId });
let output = '=== TIETOKANNAN ARVOT ===\n';
if (res.db_values) {
for (const [k, v] of Object.entries(res.db_values)) {
output += ` ${k}: ${v === '' ? '(tyhjä)' : v}\n`;
}
}
output += `\n=== KÄYTETTÄVÄT ARVOT ===\n`;
output += ` Käyttäjä: ${res.effective_user || '(tyhjä)'}\n`;
output += ` Salasana: ${res.effective_pass_hint || '?'} (${res.effective_pass_len} merkkiä)\n\n`;
output += `=== TESTIN VAIHEET ===\n`;
if (res.steps) {
res.steps.forEach(s => { output += ` ${s}\n`; });
}
resultEl.textContent = output;
} catch (e) {
resultEl.textContent = '❌ Virhe: ' + e.message;
}
});
// ==================== YRITYKSEN KÄYTTÄJÄOIKEUDET ====================
async function loadCompanyUsers(companyId) {
try {
const users = await apiCall('users');
const container = document.getElementById('company-users-list');
container.innerHTML = users.map(u => {
const hasAccess = (u.companies || []).includes(companyId);
return `<label style="display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0;cursor:pointer;">
<input type="checkbox" class="company-user-cb" data-user-id="${u.id}" ${hasAccess ? 'checked' : ''} onchange="toggleCompanyUser('${u.id}','${companyId}',this.checked)">
<strong>${esc(u.nimi || u.username)}</strong>
<span style="color:#888;font-size:0.85rem;">(${u.username}) — ${u.role === 'superadmin' ? 'Pääkäyttäjä' : (u.role === 'admin' ? 'Yritysadmin' : 'Käyttäjä')}</span>
</label>`;
}).join('');
} catch (e) { console.error(e); }
}
async function toggleCompanyUser(userId, companyId, add) {
try {
const users = await apiCall('users');
const user = users.find(u => u.id === userId);
if (!user) return;
let companies = user.companies || [];
if (add && !companies.includes(companyId)) {
companies.push(companyId);
} else if (!add) {
companies = companies.filter(c => c !== companyId);
}
await apiCall('user_update', 'POST', { id: userId, companies });
} catch (e) { alert(e.message); }
}
// ==================== LAITTEET (DEVICES) ====================
let devicesData = [];
let sitesData = [];
async function loadDevices() {
try {
devicesData = await apiCall('devices');
renderDevices();
} catch (e) { console.error(e); }
}
function renderDevices() {
const query = (document.getElementById('device-search-input')?.value || '').toLowerCase().trim();
let filtered = devicesData;
if (query) {
filtered = devicesData.filter(d =>
(d.nimi || '').toLowerCase().includes(query) ||
(d.hallintaosoite || '').toLowerCase().includes(query) ||
(d.serial || '').toLowerCase().includes(query) ||
(d.site_name || '').toLowerCase().includes(query) ||
(d.funktio || '').toLowerCase().includes(query) ||
(d.tyyppi || '').toLowerCase().includes(query) ||
(d.malli || '').toLowerCase().includes(query)
);
}
const tbody = document.getElementById('device-tbody');
const noDevices = document.getElementById('no-devices');
if (filtered.length === 0) {
tbody.innerHTML = '';
noDevices.style.display = 'block';
} else {
noDevices.style.display = 'none';
tbody.innerHTML = filtered.map(d => {
const pingIcon = d.ping_check ? (d.ping_status === 'up' ? '🟢' : (d.ping_status === 'down' ? '🔴' : '⚪')) : '<span style="color:#ccc;">—</span>';
return `<tr>
<td><strong>${esc(d.nimi)}</strong></td>
<td><code style="font-size:0.82rem;">${esc(d.hallintaosoite || '-')}</code></td>
<td style="font-size:0.85rem;">${esc(d.serial || '-')}</td>
<td>${d.site_name ? esc(d.site_name) : '<span style="color:#ccc;">-</span>'}</td>
<td>${esc(d.tyyppi || '-')}</td>
<td>${esc(d.funktio || '-')}</td>
<td>${esc(d.malli || '-')}</td>
<td style="text-align:center;">${pingIcon}</td>
<td class="actions-cell">
<button class="btn-link" onclick="editDevice('${d.id}')">✎</button>
<button class="btn-link" style="color:#dc2626;" onclick="deleteDevice('${d.id}','${esc(d.nimi)}')">🗑</button>
</td>
</tr>`;
}).join('');
}
document.getElementById('device-count').textContent = filtered.length + ' laitetta' + (query ? ` (${devicesData.length} yhteensä)` : '');
}
function setSelectValue(selectId, val) {
const sel = document.getElementById(selectId);
if (!val) { sel.value = ''; return; }
const exists = Array.from(sel.options).some(o => o.value === val);
if (!exists) {
const opt = document.createElement('option');
opt.value = val;
opt.textContent = val;
sel.appendChild(opt);
}
sel.value = val;
}
async function editDevice(id) {
const d = devicesData.find(x => x.id === id);
if (!d) return;
document.getElementById('device-form-id').value = d.id;
document.getElementById('device-form-nimi').value = d.nimi || '';
document.getElementById('device-form-hallintaosoite').value = d.hallintaosoite || '';
document.getElementById('device-form-serial').value = d.serial || '';
setSelectValue('device-form-tyyppi', d.tyyppi || '');
setSelectValue('device-form-funktio', d.funktio || '');
document.getElementById('device-form-malli').value = d.malli || '';
document.getElementById('device-form-ping-check').checked = d.ping_check || false;
document.getElementById('device-form-lisatiedot').value = d.lisatiedot || '';
await loadSitesForDropdown();
document.getElementById('device-form-site').value = d.site_id || '';
document.getElementById('device-modal-title').textContent = 'Muokkaa laitetta';
document.getElementById('device-modal').style.display = 'flex';
}
async function deleteDevice(id, name) {
if (!confirm(`Poistetaanko laite "${name}"?`)) return;
try {
await apiCall('device_delete', 'POST', { id });
loadDevices();
} catch (e) { alert(e.message); }
}
async function loadSitesForDropdown() {
try {
sitesData = await apiCall('sites');
const sel = document.getElementById('device-form-site');
sel.innerHTML = '<option value="">— Ei sijaintia —</option>' +
sitesData.map(s => `<option value="${s.id}">${esc(s.nimi)}${s.kaupunki ? ' (' + esc(s.kaupunki) + ')' : ''}</option>`).join('');
} catch (e) { console.error(e); }
}
document.getElementById('btn-add-device')?.addEventListener('click', async () => {
document.getElementById('device-form-id').value = '';
document.getElementById('device-form').reset();
await loadSitesForDropdown();
document.getElementById('device-modal-title').textContent = 'Lisää laite';
document.getElementById('device-modal').style.display = 'flex';
});
document.getElementById('device-modal-close')?.addEventListener('click', () => {
document.getElementById('device-modal').style.display = 'none';
});
document.getElementById('device-form-cancel')?.addEventListener('click', () => {
document.getElementById('device-modal').style.display = 'none';
});
document.getElementById('device-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('device-form-id').value;
const data = {
nimi: document.getElementById('device-form-nimi').value.trim(),
hallintaosoite: document.getElementById('device-form-hallintaosoite').value.trim(),
serial: document.getElementById('device-form-serial').value.trim(),
site_id: document.getElementById('device-form-site').value || null,
funktio: document.getElementById('device-form-funktio').value.trim(),
tyyppi: document.getElementById('device-form-tyyppi').value.trim(),
malli: document.getElementById('device-form-malli').value.trim(),
ping_check: document.getElementById('device-form-ping-check').checked,
lisatiedot: document.getElementById('device-form-lisatiedot').value.trim(),
};
if (id) data.id = id;
try {
await apiCall('device_save', 'POST', data);
document.getElementById('device-modal').style.display = 'none';
loadDevices();
} catch (e) { alert(e.message); }
});
document.getElementById('device-search-input')?.addEventListener('input', () => renderDevices());
// ==================== TEKNIIKKA SUB-TABS ====================
function switchSubTab(target) {
document.querySelectorAll('#tab-content-tekniikka .sub-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('#tab-content-tekniikka .sub-tab-content').forEach(c => c.classList.remove('active'));
const btn = document.querySelector(`.sub-tab[data-subtab="${target}"]`);
if (btn) btn.classList.add('active');
const content = document.getElementById('subtab-' + target);
if (content) content.classList.add('active');
// Tallenna sub-tab hashiin (esim. #tekniikka/ipam)
window.location.hash = 'tekniikka/' + target;
}
document.querySelectorAll('#tab-content-tekniikka .sub-tab').forEach(btn => {
btn.addEventListener('click', () => switchSubTab(btn.dataset.subtab));
});
// ==================== SIJAINNIT (SITES) — TEKNIIKKA TAB ====================
let sitesTabData = [];
async function loadSitesTab() {
try {
sitesData = await apiCall('sites');
sitesTabData = sitesData;
renderSitesTab();
renderSitesSettings(); // Päivitä myös asetuksissa
} catch (e) { console.error(e); }
}
function renderSitesTab() {
const query = (document.getElementById('site-search-input')?.value || '').toLowerCase().trim();
let filtered = sitesTabData;
if (query) {
filtered = sitesTabData.filter(s =>
(s.nimi || '').toLowerCase().includes(query) ||
(s.osoite || '').toLowerCase().includes(query) ||
(s.kaupunki || '').toLowerCase().includes(query)
);
}
const tbody = document.getElementById('site-tbody');
const noSites = document.getElementById('no-sites-tab');
if (filtered.length === 0) {
tbody.innerHTML = '';
if (noSites) noSites.style.display = 'block';
} else {
if (noSites) noSites.style.display = 'none';
tbody.innerHTML = filtered.map(s => {
const deviceCount = devicesData.filter(d => d.site_id === s.id).length;
return `<tr>
<td><strong>${esc(s.nimi)}</strong></td>
<td>${esc(s.osoite || '-')}</td>
<td>${esc(s.kaupunki || '-')}</td>
<td style="text-align:center;">${deviceCount}</td>
<td class="actions-cell">
<button class="btn-link" onclick="editSiteTab('${s.id}')">✎</button>
<button class="btn-link" style="color:#dc2626;" onclick="deleteSite('${s.id}','${esc(s.nimi)}')">🗑</button>
</td>
</tr>`;
}).join('');
}
document.getElementById('site-count').textContent = filtered.length + ' sijaintia';
}
function editSiteTab(id) {
const s = sitesData.find(x => x.id === id);
if (!s) return;
document.getElementById('site-form-id').value = s.id;
document.getElementById('site-form-nimi').value = s.nimi || '';
document.getElementById('site-form-osoite').value = s.osoite || '';
document.getElementById('site-form-kaupunki').value = s.kaupunki || '';
document.getElementById('site-form-title').textContent = 'Muokkaa sijaintia';
document.getElementById('site-form-container').style.display = '';
// Varmista että asetukset-tab ja yrityksen tiedot näkyvissä, scrollaa lomakkeeseen
switchToTab('settings');
document.getElementById('company-detail-view').style.display = '';
document.getElementById('companies-list-view').style.display = 'none';
setTimeout(() => document.getElementById('site-form-container')?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 100);
}
// Alias vanhalle editSite-funktiolle
function editSite(id) { editSiteTab(id); }
async function deleteSite(id, name) {
if (!confirm(`Poistetaanko sijainti "${name}"? Laitteet joissa tämä sijainti on menettävät sijainti-viittauksen.`)) return;
try {
await apiCall('site_delete', 'POST', { id });
loadSitesTab();
loadDevices();
} catch (e) { alert(e.message); }
}
// Renderöi sijainnit myös asetuksissa (company detail)
function renderSitesSettings() {
const container = document.getElementById('sites-list');
if (!container) return;
if (sitesData.length === 0) {
container.innerHTML = '<p style="color:#888;font-size:0.9rem;">Ei sijainteja. Lisää ensimmäinen sijainti.</p>';
return;
}
container.innerHTML = sitesData.map(s => `<div style="display:flex;justify-content:space-between;align-items:center;padding:0.75rem;background:#fff;border:1px solid #e0e0e0;border-radius:8px;margin-bottom:0.5rem;">
<div>
<strong>${esc(s.nimi)}</strong>
${s.osoite ? `<span style="color:#888;font-size:0.85rem;margin-left:0.75rem;">${esc(s.osoite)}</span>` : ''}
${s.kaupunki ? `<span style="color:#888;font-size:0.85rem;margin-left:0.5rem;">${esc(s.kaupunki)}</span>` : ''}
</div>
<div style="display:flex;gap:0.5rem;">
<button class="btn-link" onclick="editSite('${s.id}')">Muokkaa</button>
<button class="btn-link" style="color:#dc2626;" onclick="deleteSite('${s.id}','${esc(s.nimi)}')">Poista</button>
</div>
</div>`).join('');
}
// Alias loadSites asetuksista kutsuun
async function loadSites() { await loadSitesTab(); }
function renderSites() { renderSitesSettings(); }
document.getElementById('site-search-input')?.addEventListener('input', () => renderSitesTab());
document.getElementById('btn-add-site')?.addEventListener('click', () => {
document.getElementById('site-form-id').value = '';
document.getElementById('site-form-nimi').value = '';
document.getElementById('site-form-osoite').value = '';
document.getElementById('site-form-kaupunki').value = '';
document.getElementById('site-form-title').textContent = 'Uusi sijainti';
document.getElementById('site-form-container').style.display = '';
});
document.getElementById('btn-save-site')?.addEventListener('click', async () => {
const id = document.getElementById('site-form-id').value;
const nimi = document.getElementById('site-form-nimi').value.trim();
if (!nimi) return alert('Sijainnin nimi vaaditaan');
const data = {
nimi,
osoite: document.getElementById('site-form-osoite').value.trim(),
kaupunki: document.getElementById('site-form-kaupunki').value.trim(),
};
if (id) data.id = id;
try {
await apiCall('site_save', 'POST', data);
document.getElementById('site-form-container').style.display = 'none';
loadSitesTab();
} catch (e) { alert(e.message); }
});
document.getElementById('btn-cancel-site')?.addEventListener('click', () => {
document.getElementById('site-form-container').style.display = 'none';
});
// ==================== IPAM ====================
let ipamData = [];
let ipamExpandedIds = new Set();
let ipamDrillStack = []; // [{id, label}] breadcrumb
// --- IP-laskenta-apufunktiot (IPv4 + IPv6) ---
function ipv4ToBI(ip) {
return ip.split('.').reduce((acc, oct) => (acc << 8n) + BigInt(parseInt(oct)), 0n);
}
function ipv6ToBI(ip) {
// Expand :: shorthand
let parts = ip.split('::');
let left = parts[0] ? parts[0].split(':') : [];
let right = parts.length > 1 && parts[1] ? parts[1].split(':') : [];
const missing = 8 - left.length - right.length;
const full = [...left, ...Array(missing).fill('0'), ...right];
return full.reduce((acc, hex) => (acc << 16n) + BigInt(parseInt(hex || '0', 16)), 0n);
}
function parseNetwork(verkko) {
if (!verkko) return null;
const v = verkko.trim();
let ip, prefix;
if (v.includes('/')) {
const slash = v.lastIndexOf('/');
ip = v.substring(0, slash);
prefix = parseInt(v.substring(slash + 1));
if (isNaN(prefix) || prefix < 0) return null;
} else {
ip = v;
prefix = null; // auto-detect
}
// IPv6?
if (ip.includes(':')) {
const maxBits = 128;
if (prefix === null) prefix = 128;
if (prefix > maxBits) return null;
try {
return { net: ipv6ToBI(ip), prefix, bits: maxBits, v6: true };
} catch { return null; }
}
// IPv4
const parts = ip.split('.');
if (parts.length !== 4) return null;
const maxBits = 32;
if (prefix === null) prefix = 32;
if (prefix > maxBits) return null;
return { net: ipv4ToBI(ip), prefix, bits: maxBits, v6: false };
}
function isSubnetOf(childNet, childPrefix, childBits, parentNet, parentPrefix, parentBits) {
if (childBits !== parentBits) return false; // eri perhe (v4 vs v6)
if (childPrefix <= parentPrefix) return false;
const shift = BigInt(parentBits - parentPrefix);
return (childNet >> shift) === (parentNet >> shift);
}
// BigInt -> IP-osoite merkkijono
function biToIpv4(bi) {
return [Number((bi >> 24n) & 0xFFn), Number((bi >> 16n) & 0xFFn), Number((bi >> 8n) & 0xFFn), Number(bi & 0xFFn)].join('.');
}
function biToIpv6(bi) {
const parts = [];
for (let i = 7; i >= 0; i--) parts.push(Number((bi >> BigInt(i * 16)) & 0xFFFFn).toString(16));
// Yksinkertaistettu: ei :: kompressointia
return parts.join(':');
}
function biToIp(bi, v6) { return v6 ? biToIpv6(bi) : biToIpv4(bi); }
// Laske vapaat lohkot parent-subnetin sisällä (aukot lasten välissä)
function findFreeSpaces(parentNode, maxEntries = 30) {
if (!parentNode || parentNode.bits === 0 || parentNode.entry.tyyppi !== 'subnet') return [];
const pNet = parentNode.net;
const pPrefix = parentNode.prefix;
const pBits = parentNode.bits;
const hostBits = BigInt(pBits - pPrefix);
const parentStart = (pNet >> hostBits) << hostBits;
const parentSize = 1n << hostBits;
const parentEnd = parentStart + parentSize;
// Kerää lapset samasta osoiteperheestä, järjestä osoitteen mukaan
const children = parentNode.children
.filter(c => c.bits === pBits)
.sort((a, b) => a.net < b.net ? -1 : a.net > b.net ? 1 : 0);
const result = [];
let pos = parentStart;
for (const child of children) {
const cHostBits = BigInt(pBits - child.prefix);
const childStart = (child.net >> cHostBits) << cHostBits;
const childEnd = childStart + (1n << cHostBits);
if (pos < childStart) {
addAlignedBlocks(result, pos, childStart, pBits, pBits === 128, maxEntries - result.length);
}
if (childEnd > pos) pos = childEnd;
}
if (pos < parentEnd) {
addAlignedBlocks(result, pos, parentEnd, pBits, pBits === 128, maxEntries - result.length);
}
return result;
}
function addAlignedBlocks(result, start, end, totalBits, v6, maxAdd) {
let pos = start;
const tb = BigInt(totalBits);
while (pos < end && maxAdd > 0) {
const space = end - pos;
// Alignment: kuinka monta trailing nollaa pos:ssa
let alignBits = 0n;
if (pos === 0n) {
alignBits = tb;
} else {
let tmp = pos;
while ((tmp & 1n) === 0n && alignBits < tb) { alignBits++; tmp >>= 1n; }
}
// Suurin 2^n joka mahtuu tilaan
let spaceBits = 0n;
let tmp = space >> 1n;
while (tmp > 0n) { spaceBits++; tmp >>= 1n; }
// Tarkista ettei ylitä
if ((1n << spaceBits) > space) spaceBits--;
const blockBits = alignBits < spaceBits ? alignBits : spaceBits;
if (blockBits < 0n) break;
const prefix = totalBits - Number(blockBits);
result.push({ net: pos, prefix, bits: totalBits, v6, verkko: biToIp(pos, v6) + '/' + prefix });
pos += (1n << blockBits);
maxAdd--;
}
}
// Laske subnetin käyttöaste: kuinka monta lasta (direct children) vs kapasiteetti
function subnetUsageHtml(node) {
if (node.entry.tyyppi !== 'subnet' || node.children.length === 0) return '';
const childCount = node.children.length;
// Laske kuinka monta "slottia" tässä subnetissa on seuraavalla tasolla
// Etsi yleisin lapsi-prefix
const childPrefixes = node.children.filter(c => c.prefix > node.prefix).map(c => c.prefix);
if (childPrefixes.length === 0) return `<span class="ipam-usage">${childCount}</span>`;
// Käytä pienintä child-prefixiä (isoimpia aliverkkoja) kapasiteetin laskuun
const commonPrefix = Math.min(...childPrefixes);
const bits = node.entry.tyyppi === 'subnet' ? (node.children[0]?.bits || 32) : 32;
const slotBits = commonPrefix - node.prefix;
if (slotBits <= 0 || slotBits > 20) return `<span class="ipam-usage">${childCount}</span>`;
const totalSlots = 1 << slotBits; // 2^slotBits
const sameLevel = node.children.filter(c => c.prefix === commonPrefix).length;
const freeSlots = totalSlots - sameLevel;
return `<span class="ipam-usage" title="${sameLevel}/${totalSlots} /${commonPrefix} käytössä">${sameLevel}/${totalSlots}</span>`;
}
// --- Puurakenne ---
function buildIpamTree(entries) {
// Parsitaan verkko-osoitteet; ei-parsittavat lisätään juureen sellaisenaan
const unparsed = [];
const items = [];
for (const e of entries) {
const parsed = parseNetwork(e.verkko);
if (parsed) {
items.push({ entry: e, net: parsed.net, prefix: parsed.prefix, bits: parsed.bits, v6: parsed.v6, children: [] });
} else {
unparsed.push({ entry: e, net: 0n, prefix: 0, bits: 0, v6: false, children: [] });
}
}
// Järjestetään: v4 ennen v6, pienin prefix ensin, sitten osoitteen mukaan
items.sort((a, b) => {
if (a.v6 !== b.v6) return a.v6 ? 1 : -1;
if (a.prefix !== b.prefix) return a.prefix - b.prefix;
return a.net < b.net ? -1 : a.net > b.net ? 1 : 0;
});
const roots = [];
for (const item of items) {
// Etsi lähin parent (suurin prefix joka sisältää tämän)
const findParent = (nodes) => {
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i];
if (isSubnetOf(item.net, item.prefix, item.bits, node.net, node.prefix, node.bits)) {
// Tarkista ensin onko jokin lapsi tarkempi parent
if (!findParent(node.children)) {
node.children.push(item);
}
return true;
}
}
return false;
};
if (!findParent(roots)) {
roots.push(item);
}
}
// Lisää ei-parsittavat (esim. virheelliset osoitteet) juureen
roots.push(...unparsed);
return roots;
}
function flattenTree(nodes, depth, drillId) {
// Jos drill-down aktiivinen, etsi drill-node ja renderöi vain sen lapset
if (drillId) {
const findNode = (list) => {
for (const n of list) {
if (n.entry.id === drillId) return n;
const found = findNode(n.children);
if (found) return found;
}
return null;
};
const drillNode = findNode(nodes);
if (drillNode) {
nodes = drillNode.children;
depth = 0;
}
}
const rows = [];
const render = (list, d) => {
for (const node of list) {
const hasChildren = node.children.length > 0;
const expanded = ipamExpandedIds.has(node.entry.id);
rows.push({ entry: node.entry, depth: d, hasChildren, expanded, node, isFree: false });
if (hasChildren && expanded) {
// Laske vapaat lohkot vain jos verkko ei ole varattu
const freeSpaces = node.entry.tila === 'varattu' ? [] : findFreeSpaces(node);
if (freeSpaces.length > 0) {
// Yhdistä lapset + vapaat, järjestä osoitteen mukaan
const allItems = [
...node.children.map(c => ({ type: 'node', item: c, sortKey: c.net })),
...freeSpaces.map(f => ({ type: 'free', item: f, sortKey: f.net }))
];
allItems.sort((a, b) => a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0);
for (const ai of allItems) {
if (ai.type === 'node') {
render([ai.item], d + 1);
} else {
rows.push({
entry: { verkko: ai.item.verkko, tyyppi: 'free', nimi: '', tila: 'vapaa' },
depth: d + 1, hasChildren: false, expanded: false, node: ai.item, isFree: true
});
}
}
} else {
render(node.children, d + 1);
}
}
}
};
render(nodes, depth);
return rows;
}
async function loadIpam() {
try {
ipamData = await apiCall('ipam');
renderIpam();
} catch (e) { console.error(e); }
}
function renderIpam() {
const query = (document.getElementById('ipam-search-input')?.value || '').toLowerCase().trim();
// --- Verkot + IP:t (hierarkkinen puu) ---
let networkEntries = ipamData.filter(e => e.tyyppi === 'subnet' || e.tyyppi === 'ip');
if (query) {
networkEntries = networkEntries.filter(e =>
(e.tyyppi || '').toLowerCase().includes(query) ||
(e.verkko || '').toLowerCase().includes(query) ||
(e.nimi || '').toLowerCase().includes(query) ||
(e.site_name || '').toLowerCase().includes(query) ||
(e.lisatiedot || '').toLowerCase().includes(query) ||
String(e.vlan_id || '').includes(query)
);
}
const tree = buildIpamTree(networkEntries);
// Breadcrumb
const bcEl = document.getElementById('ipam-breadcrumb');
if (bcEl) {
const drillId = ipamDrillStack.length > 0 ? ipamDrillStack[ipamDrillStack.length - 1].id : null;
if (ipamDrillStack.length === 0) {
bcEl.style.display = 'none';
} else {
bcEl.style.display = '';
bcEl.innerHTML = `<span class="ipam-bc-link" onclick="ipamDrillTo(-1)">Kaikki verkot</span>` +
ipamDrillStack.map((s, i) =>
` <span style="color:#aaa;"></span> <span class="ipam-bc-link${i === ipamDrillStack.length - 1 ? ' ipam-bc-current' : ''}" onclick="ipamDrillTo(${i})">${esc(s.label)}</span>`
).join('');
}
// Flatten tree siten että drill-down huomioidaan
var drillTarget = drillId;
} else {
var drillTarget = null;
}
const rows = flattenTree(tree, 0, drillTarget);
const tbody = document.getElementById('ipam-tbody');
const noIpam = document.getElementById('no-ipam');
const tilaClass = { vapaa: 'ipam-tila-vapaa', varattu: 'ipam-tila-varattu', reserved: 'ipam-tila-reserved' };
const tilaLabel = { vapaa: 'Vapaa', varattu: 'Varattu', reserved: 'Reserved' };
if (rows.length === 0 && !query) {
tbody.innerHTML = '';
if (noIpam) noIpam.style.display = 'block';
} else {
if (noIpam) noIpam.style.display = 'none';
tbody.innerHTML = rows.map(r => {
const e = r.entry;
const indent = r.depth * 1.5;
// Vapaa-lohko (ei oikea entry, vaan laskettu vapaa tila)
if (r.isFree) {
return `<tr class="ipam-tree-row ipam-free-row" onclick="ipamAddFromFree('${esc(e.verkko)}')" title="Klikkaa varataksesi tämä verkko" style="cursor:pointer;">
<td style="padding-left:${indent}rem;white-space:nowrap;">
<span class="ipam-toggle-placeholder"></span> <span class="ipam-type-free">Vapaa</span>
</td>
<td><code class="ipam-network ipam-network-free">${esc(e.verkko)}</code></td>
<td colspan="4" style="color:#999;font-style:italic;">Klikkaa varataksesi</td>
</tr>`;
}
const toggleIcon = r.hasChildren
? `<span class="ipam-toggle" onclick="event.stopPropagation();ipamToggle('${e.id}')">${r.expanded ? '▼' : '▶'}</span> `
: '<span class="ipam-toggle-placeholder"></span> ';
const typeTag = e.tyyppi === 'subnet'
? '<span class="ipam-type-subnet">Subnet</span>'
: '<span class="ipam-type-ip">IP</span>';
const drillBtn = (e.tyyppi === 'subnet' && r.hasChildren)
? ` <span class="ipam-drill" onclick="event.stopPropagation();ipamDrillInto('${e.id}','${esc(e.verkko || e.nimi)}')" title="Poraudu sisään">→</span>`
: '';
return `<tr class="ipam-tree-row" onclick="ipamToggle('${e.id}')">
<td style="padding-left:${indent}rem;white-space:nowrap;">
${toggleIcon}${typeTag}
</td>
<td><code class="ipam-network">${esc(e.verkko || '-')}</code>${drillBtn} ${subnetUsageHtml(r.node)}</td>
<td>${esc(e.nimi || '-')}</td>
<td>${vlanRefHtml(e.vlan_id)}</td>
<td>${e.site_name ? esc(e.site_name) : '<span style="color:#ccc;">—</span>'}</td>
<td><span class="ipam-tila ${tilaClass[e.tila] || ''}">${tilaLabel[e.tila] || e.tila}</span></td>
<td class="actions-cell" onclick="event.stopPropagation()">
<button class="btn-link" onclick="editIpam('${e.id}')">✎</button>
<button class="btn-link" style="color:#dc2626;" onclick="deleteIpam('${e.id}','${esc(e.nimi || e.verkko)}')">🗑</button>
</td>
</tr>`;
}).join('');
}
const netCount = networkEntries.length;
document.getElementById('ipam-count').textContent = netCount + ' verkkoa/IP:tä' + (query ? ` (${ipamData.filter(e => e.tyyppi !== 'vlan').length} yhteensä)` : '');
// --- VLANit ---
renderIpamVlans(query);
}
function renderIpamVlans(query) {
let vlans = ipamData.filter(e => e.tyyppi === 'vlan');
if (query) {
vlans = vlans.filter(e =>
String(e.vlan_id || '').includes(query) ||
(e.nimi || '').toLowerCase().includes(query) ||
(e.verkko || '').toLowerCase().includes(query) ||
(e.site_name || '').toLowerCase().includes(query)
);
}
vlans.sort((a, b) => (a.vlan_id || 0) - (b.vlan_id || 0));
const tbody = document.getElementById('ipam-vlan-tbody');
const section = document.getElementById('ipam-vlan-section');
if (!tbody) return;
const tilaClass = { vapaa: 'ipam-tila-vapaa', varattu: 'ipam-tila-varattu', reserved: 'ipam-tila-reserved' };
const tilaLabel = { vapaa: 'Vapaa', varattu: 'Varattu', reserved: 'Reserved' };
if (section) section.style.display = '';
if (vlans.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:#aaa;padding:1rem;">Ei VLANeja vielä.</td></tr>';
} else {
tbody.innerHTML = vlans.map(e => `<tr>
<td><strong>${e.vlan_id || '-'}</strong></td>
<td><code style="font-size:0.85rem;">${esc(e.verkko || '-')}</code></td>
<td>${esc(e.nimi || '-')}</td>
<td>${e.site_name ? esc(e.site_name) : '<span style="color:#ccc;">—</span>'}</td>
<td><span class="ipam-tila ${tilaClass[e.tila] || ''}">${tilaLabel[e.tila] || e.tila}</span></td>
<td class="actions-cell">
<button class="btn-link" onclick="editIpam('${e.id}')">✎</button>
<button class="btn-link" style="color:#dc2626;" onclick="deleteIpam('${e.id}','${esc(e.nimi || e.verkko)}')">🗑</button>
</td>
</tr>`).join('');
}
document.getElementById('ipam-vlan-count').textContent = vlans.length + ' VLANia';
}
// --- VLAN-viite apufunktio ---
function vlanRefHtml(vlanId) {
if (!vlanId) return '<span style="color:#ccc;">—</span>';
const vl = ipamData.find(v => v.tyyppi === 'vlan' && String(v.vlan_id) === String(vlanId));
const label = vl ? esc(vl.nimi) : '';
return `<strong>${vlanId}</strong>${label ? ` <small style="color:#888;">${label}</small>` : ''}`;
}
// --- Toggle & Drill ---
function ipamToggle(id) {
if (ipamExpandedIds.has(id)) ipamExpandedIds.delete(id);
else ipamExpandedIds.add(id);
renderIpam();
}
function ipamDrillInto(id, label) {
ipamDrillStack.push({ id, label });
ipamExpandedIds.clear(); // reset expand-tila uudessa näkymässä
renderIpam();
}
function ipamDrillTo(index) {
if (index < 0) {
ipamDrillStack = [];
} else {
ipamDrillStack = ipamDrillStack.slice(0, index + 1);
}
ipamExpandedIds.clear();
renderIpam();
}
async function ipamAddFromFree(verkko) {
document.getElementById('ipam-form-id').value = '';
document.getElementById('ipam-form').reset();
document.getElementById('ipam-form-tyyppi').value = 'subnet';
document.getElementById('ipam-form-verkko').value = verkko;
document.getElementById('ipam-form-tila').value = 'varattu';
await loadIpamSitesDropdown();
document.getElementById('ipam-modal-title').textContent = 'Lisää verkko / IP';
document.getElementById('ipam-modal').style.display = 'flex';
// Fokusoi nimi-kenttään koska verkko on jo täytetty
document.getElementById('ipam-form-nimi')?.focus();
}
async function loadIpamSitesDropdown() {
try {
if (!sitesData || sitesData.length === 0) sitesData = await apiCall('sites');
const sel = document.getElementById('ipam-form-site');
sel.innerHTML = '<option value="">— Ei sijaintia —</option>' +
sitesData.map(s => `<option value="${s.id}">${esc(s.nimi)}${s.kaupunki ? ' (' + esc(s.kaupunki) + ')' : ''}</option>`).join('');
} catch (e) { console.error(e); }
}
async function editIpam(id) {
const e = ipamData.find(x => x.id === id);
if (!e) return;
document.getElementById('ipam-form-id').value = e.id;
document.getElementById('ipam-form-tyyppi').value = e.tyyppi || 'ip';
document.getElementById('ipam-form-verkko').value = e.verkko || '';
document.getElementById('ipam-form-nimi').value = e.nimi || '';
document.getElementById('ipam-form-tila').value = e.tila || 'vapaa';
document.getElementById('ipam-form-lisatiedot').value = e.lisatiedot || '';
document.getElementById('ipam-form-vlan').value = e.vlan_id || '';
await loadIpamSitesDropdown();
document.getElementById('ipam-form-site').value = e.site_id || '';
document.getElementById('ipam-modal-title').textContent = e.tyyppi === 'vlan' ? 'Muokkaa VLANia' : 'Muokkaa verkkoa / IP:tä';
document.getElementById('ipam-modal').style.display = 'flex';
}
async function deleteIpam(id, name) {
if (!confirm(`Poistetaanko IPAM-merkintä "${name}"?`)) return;
try {
await apiCall('ipam_delete', 'POST', { id });
loadIpam();
} catch (e) { alert(e.message); }
}
document.getElementById('btn-add-ipam')?.addEventListener('click', async () => {
document.getElementById('ipam-form-id').value = '';
document.getElementById('ipam-form').reset();
document.getElementById('ipam-form-tyyppi').value = 'subnet';
document.getElementById('ipam-form-tila').value = 'varattu';
await loadIpamSitesDropdown();
document.getElementById('ipam-modal-title').textContent = 'Lisää verkko / IP';
document.getElementById('ipam-modal').style.display = 'flex';
});
document.getElementById('btn-add-vlan')?.addEventListener('click', async () => {
document.getElementById('ipam-form-id').value = '';
document.getElementById('ipam-form').reset();
document.getElementById('ipam-form-tyyppi').value = 'vlan';
document.getElementById('ipam-form-tila').value = 'varattu';
await loadIpamSitesDropdown();
document.getElementById('ipam-modal-title').textContent = 'Lisää VLAN';
document.getElementById('ipam-modal').style.display = 'flex';
});
document.getElementById('ipam-modal-close')?.addEventListener('click', () => {
document.getElementById('ipam-modal').style.display = 'none';
});
document.getElementById('ipam-form-cancel')?.addEventListener('click', () => {
document.getElementById('ipam-modal').style.display = 'none';
});
document.getElementById('ipam-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('ipam-form-id').value;
const data = {
tyyppi: document.getElementById('ipam-form-tyyppi').value,
verkko: document.getElementById('ipam-form-verkko').value.trim(),
vlan_id: document.getElementById('ipam-form-vlan').value || null,
nimi: document.getElementById('ipam-form-nimi').value.trim(),
site_id: document.getElementById('ipam-form-site').value || null,
tila: document.getElementById('ipam-form-tila').value,
lisatiedot: document.getElementById('ipam-form-lisatiedot').value.trim(),
};
if (id) data.id = id;
try {
const res = await fetch(`${API}?action=ipam_save`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (res.status === 409 && result.warning) {
if (confirm(result.warning)) {
data.force = true;
await apiCall('ipam_save', 'POST', data);
} else {
return;
}
} else if (!res.ok) {
throw new Error(result.error || 'Virhe');
}
document.getElementById('ipam-modal').style.display = 'none';
loadIpam();
} catch (e) { alert(e.message); }
});
document.getElementById('ipam-search-input')?.addEventListener('input', () => renderIpam());
// ==================== OHJEET ====================
let guidesData = [];
let guideCategories = [];
let currentGuideId = null;
// Markdown-renderöijä (kevyt, ei ulkoisia kirjastoja)
// Kuva-lightbox ohjeissa
function openGuideLightbox(src, alt) {
let overlay = document.getElementById('guide-lightbox');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'guide-lightbox';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;cursor:zoom-out;padding:2rem;';
overlay.addEventListener('click', () => overlay.style.display = 'none');
document.body.appendChild(overlay);
}
overlay.innerHTML = `<img src="${src}" alt="${alt}" style="max-width:95%;max-height:95%;border-radius:8px;box-shadow:0 8px 40px rgba(0,0,0,0.5);">`;
overlay.style.display = 'flex';
}
function renderMarkdown(md) {
if (!md) return '';
let html = esc(md);
// Koodilohkot ``` ... ```
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (m, lang, code) => `<pre><code>${code}</code></pre>`);
// Inline-koodi
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// Otsikot
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Lihavointi + kursiivi
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Kuvat (ennen linkkejä!) — klikkaa avataksesi isompana
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" class="guide-img" onclick="openGuideLightbox(this.src, this.alt)" title="Klikkaa suurentaaksesi">');
// Linkit
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Lainaukset
html = html.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
// Vaakaviiva
html = html.replace(/^---$/gm, '<hr>');
// Listat: kerätään peräkkäiset lista-rivit yhteen
html = html.replace(/(^[\-\*] .+\n?)+/gm, (match) => {
const items = match.trim().split('\n').map(l => '<li>' + l.replace(/^[\-\*] /, '') + '</li>').join('');
return '<ul>' + items + '</ul>';
});
html = html.replace(/(^\d+\. .+\n?)+/gm, (match) => {
const items = match.trim().split('\n').map(l => '<li>' + l.replace(/^\d+\. /, '') + '</li>').join('');
return '<ol>' + items + '</ol>';
});
// Kappalejaot
html = html.replace(/\n\n/g, '</p><p>');
html = html.replace(/\n/g, '<br>');
return '<p>' + html + '</p>';
}
async function loadGuides() {
try {
[guidesData, guideCategories] = await Promise.all([
apiCall('guides'),
apiCall('guide_categories')
]);
populateGuideCategoryFilter();
renderGuidesList();
showGuideListView();
const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'superadmin';
document.getElementById('btn-add-guide').style.display = isAdmin ? '' : 'none';
document.getElementById('btn-manage-guide-cats').style.display = isAdmin ? '' : 'none';
} catch (e) { console.error(e); }
}
function populateGuideCategoryFilter() {
const sel = document.getElementById('guide-category-filter');
const formSel = document.getElementById('guide-form-category');
const opts = guideCategories.map(c => `<option value="${c.id}">${esc(c.nimi)}</option>`).join('');
if (sel) sel.innerHTML = '<option value="">Kaikki kategoriat</option>' + opts;
if (formSel) formSel.innerHTML = '<option value="">Ei kategoriaa</option>' + opts;
}
function renderGuidesList() {
const query = (document.getElementById('guide-search-input')?.value || '').toLowerCase().trim();
const catFilter = document.getElementById('guide-category-filter')?.value || '';
let filtered = guidesData;
if (catFilter) filtered = filtered.filter(g => g.category_id === catFilter);
if (query) {
filtered = filtered.filter(g =>
(g.title || '').toLowerCase().includes(query) ||
(g.tags || '').toLowerCase().includes(query) ||
(g.category_name || '').toLowerCase().includes(query) ||
(g.content || '').toLowerCase().includes(query)
);
}
const grid = document.getElementById('guides-grid');
const noGuides = document.getElementById('no-guides');
if (!grid) return;
if (filtered.length === 0) {
grid.innerHTML = '';
if (noGuides) noGuides.style.display = 'block';
} else {
if (noGuides) noGuides.style.display = 'none';
grid.innerHTML = filtered.map(g => {
const preview = (g.content || '').substring(0, 150).replace(/[#*`\[\]]/g, '');
const tags = (g.tags || '').split(',').filter(t => t.trim());
return `<div class="guide-card ${g.pinned ? 'guide-pinned' : ''}" onclick="openGuideRead('${g.id}')">
<div class="guide-card-header">
${g.pinned ? '<span class="guide-pin-icon" title="Kiinnitetty">&#128204;</span>' : ''}
${g.category_name ? `<span class="guide-category-badge">${esc(g.category_name)}</span>` : ''}
</div>
<h3 class="guide-card-title">${esc(g.title)}</h3>
<p class="guide-card-preview">${esc(preview)}${(g.content || '').length > 150 ? '...' : ''}</p>
<div class="guide-card-footer">
<span>${esc(g.author || '')}</span>
<span>${timeAgo(g.muokattu || g.luotu)}</span>
</div>
${tags.length > 0 ? `<div class="guide-card-tags">${tags.map(t => `<span class="guide-tag">${esc(t.trim())}</span>`).join('')}</div>` : ''}
</div>`;
}).join('');
}
}
function showGuideListView() {
document.getElementById('guides-list-view').style.display = '';
document.getElementById('guide-read-view').style.display = 'none';
document.getElementById('guide-edit-view').style.display = 'none';
}
function showGuideReadView() {
document.getElementById('guides-list-view').style.display = 'none';
document.getElementById('guide-read-view').style.display = '';
document.getElementById('guide-edit-view').style.display = 'none';
}
function showGuideEditView() {
document.getElementById('guides-list-view').style.display = 'none';
document.getElementById('guide-read-view').style.display = 'none';
document.getElementById('guide-edit-view').style.display = '';
}
async function openGuideRead(id) {
try {
const guide = await apiCall('guide&id=' + encodeURIComponent(id));
currentGuideId = id;
document.getElementById('guide-read-title').textContent = guide.title;
document.getElementById('guide-read-meta').innerHTML = [
guide.category_name ? `<span>&#128193; ${esc(guide.category_name)}</span>` : '',
`<span>&#9998; ${esc(guide.author || 'Tuntematon')}</span>`,
`<span>&#128197; ${esc((guide.luotu || '').substring(0, 10))}</span>`,
guide.muokattu ? `<span>Päivitetty: ${timeAgo(guide.muokattu)} (${esc(guide.muokkaaja || '')})</span>` : ''
].filter(Boolean).join('');
document.getElementById('guide-read-content').innerHTML = renderMarkdown(guide.content);
const tags = (guide.tags || '').split(',').filter(t => t.trim());
document.getElementById('guide-read-tags').innerHTML = tags.length > 0
? tags.map(t => `<span class="guide-tag">${esc(t.trim())}</span>`).join(' ')
: '';
const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'superadmin';
document.getElementById('guide-read-actions').style.display = isAdmin ? 'block' : 'none';
showGuideReadView();
} catch (e) { alert(e.message); }
}
function openGuideEdit(guide) {
document.getElementById('guide-edit-title').textContent = guide ? 'Muokkaa ohjetta' : 'Uusi ohje';
document.getElementById('guide-form-id').value = guide ? guide.id : '';
document.getElementById('guide-form-title').value = guide ? guide.title : '';
document.getElementById('guide-form-content').value = guide ? guide.content : '';
document.getElementById('guide-form-tags').value = guide ? (guide.tags || '') : '';
document.getElementById('guide-form-pinned').checked = guide ? guide.pinned : false;
document.getElementById('guide-form-content').style.display = '';
document.getElementById('guide-preview-pane').style.display = 'none';
populateGuideCategoryFilter();
if (guide) document.getElementById('guide-form-category').value = guide.category_id || '';
showGuideEditView();
document.getElementById('guide-form-title').focus();
}
// Tallenna ohje
document.getElementById('guide-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('guide-form-id').value;
const body = {
title: document.getElementById('guide-form-title').value.trim(),
category_id: document.getElementById('guide-form-category').value || null,
content: document.getElementById('guide-form-content').value,
tags: document.getElementById('guide-form-tags').value.trim(),
pinned: document.getElementById('guide-form-pinned').checked,
};
if (id) {
body.id = id;
const existing = guidesData.find(g => g.id === id);
if (existing) { body.luotu = existing.luotu; body.author = existing.author; }
}
try {
const saved = await apiCall('guide_save', 'POST', body);
await loadGuides();
openGuideRead(saved.id);
} catch (e) { alert(e.message); }
});
async function deleteGuide(id) {
if (!confirm('Haluatko varmasti poistaa tämän ohjeen?')) return;
try {
await apiCall('guide_delete', 'POST', { id });
await loadGuides();
showGuideListView();
} catch (e) { alert(e.message); }
}
// Event listenerit
document.getElementById('guide-search-input')?.addEventListener('input', () => renderGuidesList());
document.getElementById('guide-category-filter')?.addEventListener('change', () => renderGuidesList());
document.getElementById('btn-add-guide')?.addEventListener('click', () => openGuideEdit(null));
document.getElementById('btn-guide-back')?.addEventListener('click', () => { showGuideListView(); currentGuideId = null; });
document.getElementById('btn-guide-edit-cancel')?.addEventListener('click', () => {
if (currentGuideId) openGuideRead(currentGuideId); else showGuideListView();
});
document.getElementById('guide-form-cancel')?.addEventListener('click', () => {
if (currentGuideId) openGuideRead(currentGuideId); else showGuideListView();
});
document.getElementById('btn-edit-guide')?.addEventListener('click', () => {
const guide = guidesData.find(g => g.id === currentGuideId);
if (guide) openGuideEdit(guide);
});
document.getElementById('btn-delete-guide')?.addEventListener('click', () => {
if (currentGuideId) deleteGuide(currentGuideId);
});
// Markdown toolbar
document.querySelectorAll('.guide-tb-btn[data-md]').forEach(btn => {
btn.addEventListener('click', () => {
const ta = document.getElementById('guide-form-content');
const start = ta.selectionStart;
const end = ta.selectionEnd;
const sel = ta.value.substring(start, end);
let ins = '';
switch (btn.dataset.md) {
case 'bold': ins = `**${sel || 'teksti'}**`; break;
case 'italic': ins = `*${sel || 'teksti'}*`; break;
case 'h2': ins = `\n## ${sel || 'Otsikko'}\n`; break;
case 'h3': ins = `\n### ${sel || 'Alaotsikko'}\n`; break;
case 'ul': ins = `\n- ${sel || 'kohta'}\n`; break;
case 'ol': ins = `\n1. ${sel || 'kohta'}\n`; break;
case 'link': ins = `[${sel || 'linkki'}](https://)`; break;
case 'code': ins = sel.includes('\n') ? `\n\`\`\`\n${sel}\n\`\`\`\n` : `\`${sel || 'koodi'}\``; break;
case 'quote': ins = `\n> ${sel || 'lainaus'}\n`; break;
}
ta.value = ta.value.substring(0, start) + ins + ta.value.substring(end);
ta.focus();
ta.selectionStart = ta.selectionEnd = start + ins.length;
});
});
// Esikatselu-toggle
document.getElementById('btn-guide-preview-toggle')?.addEventListener('click', () => {
const ta = document.getElementById('guide-form-content');
const preview = document.getElementById('guide-preview-pane');
if (ta.style.display !== 'none') {
preview.innerHTML = renderMarkdown(ta.value);
ta.style.display = 'none';
preview.style.display = '';
} else {
ta.style.display = '';
preview.style.display = 'none';
}
});
// Kuva-upload: yhteinen upload-funktio
async function guideUploadImage(file) {
const ta = document.getElementById('guide-form-content');
if (!ta) return;
const pos = ta.selectionStart;
// Näytä upload-placeholder
const placeholder = `![Ladataan: ${file.name}...]()`;
ta.value = ta.value.substring(0, pos) + placeholder + ta.value.substring(ta.selectionEnd);
ta.focus();
const formData = new FormData();
formData.append('image', file);
try {
const res = await fetch(`${API}?action=guide_image_upload`, {
method: 'POST', credentials: 'include', body: formData
});
const result = await res.json();
if (!res.ok) throw new Error(result.error || 'Virhe');
const mdImg = `![${file.name}](${result.url})`;
ta.value = ta.value.replace(placeholder, mdImg);
} catch (err) {
ta.value = ta.value.replace(placeholder, '');
alert('Kuvan lataus epäonnistui: ' + err.message);
}
}
// Toolbar-nappi
document.getElementById('btn-guide-image')?.addEventListener('click', () => {
document.getElementById('guide-image-input')?.click();
});
document.getElementById('guide-image-input')?.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (file) await guideUploadImage(file);
e.target.value = '';
});
// Paste screenshot leikepöydältä (Ctrl+V / Cmd+V)
document.getElementById('guide-form-content')?.addEventListener('paste', async (e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
// Anna tiedostolle nimi aikaleimalla
const ext = file.type.split('/')[1] || 'png';
const named = new File([file], `screenshot-${Date.now()}.${ext}`, { type: file.type });
await guideUploadImage(named);
}
return;
}
}
});
// Drag & drop kuvat editoriin
const guideTA = document.getElementById('guide-form-content');
if (guideTA) {
guideTA.addEventListener('dragover', (e) => {
if (e.dataTransfer?.types?.includes('Files')) {
e.preventDefault();
guideTA.style.borderColor = 'var(--primary-color)';
guideTA.style.background = '#f0f7ff';
}
});
guideTA.addEventListener('dragleave', () => {
guideTA.style.borderColor = '';
guideTA.style.background = '';
});
guideTA.addEventListener('drop', async (e) => {
guideTA.style.borderColor = '';
guideTA.style.background = '';
const files = e.dataTransfer?.files;
if (!files?.length) return;
for (const file of files) {
if (file.type.startsWith('image/')) {
e.preventDefault();
await guideUploadImage(file);
}
}
});
}
// Kategorianhallinta
document.getElementById('btn-manage-guide-cats')?.addEventListener('click', () => {
renderGuideCatList();
document.getElementById('guide-cat-modal').style.display = 'flex';
});
document.getElementById('guide-cat-modal-close')?.addEventListener('click', () => {
document.getElementById('guide-cat-modal').style.display = 'none';
});
function renderGuideCatList() {
const list = document.getElementById('guide-cat-list');
if (!list) return;
if (guideCategories.length === 0) {
list.innerHTML = '<p style="color:#888;font-size:0.9rem;">Ei kategorioita.</p>';
return;
}
list.innerHTML = guideCategories.map(c => `
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0;border-bottom:1px solid #f0f0f0;">
<span style="flex:1;font-weight:600;">${esc(c.nimi)}</span>
<button onclick="deleteGuideCategory('${c.id}','${esc(c.nimi)}')" class="btn-link" style="color:#dc2626;" title="Poista">&times;</button>
</div>
`).join('');
}
document.getElementById('btn-guide-cat-add')?.addEventListener('click', async () => {
const inp = document.getElementById('guide-cat-new-name');
const nimi = inp.value.trim();
if (!nimi) return;
try {
await apiCall('guide_category_save', 'POST', { nimi, sort_order: guideCategories.length });
inp.value = '';
guideCategories = await apiCall('guide_categories');
renderGuideCatList();
populateGuideCategoryFilter();
} catch (e) { alert(e.message); }
});
async function deleteGuideCategory(id, name) {
if (!confirm(`Poista kategoria "${name}"? Ohjeet siirtyvät kategoriattomiksi.`)) return;
try {
await apiCall('guide_category_delete', 'POST', { id });
guideCategories = await apiCall('guide_categories');
renderGuideCatList();
populateGuideCategoryFilter();
} catch (e) { alert(e.message); }
}
// ==================== TEHTÄVÄT (TODO) ====================
let todosData = [];
let currentTodoId = null;
let currentTodoSubTab = 'tasks';
const todoStatusLabels = { avoin:'Avoin', kaynnissa:'Käynnissä', odottaa:'Odottaa', valmis:'Valmis', ehdotettu:'Ehdotettu', harkinnassa:'Harkinnassa', toteutettu:'Toteutettu', hylatty:'Hylätty' };
const todoPriorityLabels = { normaali:'Normaali', tarkea:'Tärkeä', kiireellinen:'Kiireellinen' };
const todoCategoryLabels = { tekniikka:'Tekniikka', laskutus:'Laskutus', myynti:'Myynti', asennus:'Asennus', muu:'Muu' };
function switchTodoSubTab(target) {
currentTodoSubTab = target;
document.querySelectorAll('[data-todotab]').forEach(b => {
b.classList.toggle('active', b.dataset.todotab === target);
b.style.borderBottomColor = b.dataset.todotab === target ? 'var(--primary-color)' : 'transparent';
b.style.color = b.dataset.todotab === target ? 'var(--primary-color)' : '#888';
});
document.getElementById('todo-subtab-tasks').style.display = target === 'tasks' ? '' : 'none';
document.getElementById('todo-subtab-features').style.display = target === 'features' ? '' : 'none';
// Palauta listanäkymään kun vaihdetaan tabia
if (target === 'tasks') showTaskListView();
if (target === 'features') showFeatureListView();
window.location.hash = 'todo/' + target;
}
async function loadTodos() {
try {
todosData = await apiCall('todos');
renderTasksList();
renderFeaturesList();
populateTodoAssignedFilter();
const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'superadmin';
const btnTask = document.getElementById('btn-add-task');
if (btnTask) btnTask.style.display = isAdmin ? '' : 'none';
} catch (e) { console.error('loadTodos:', e); }
}
function populateTodoAssignedFilter() {
const sel = document.getElementById('todo-assigned-filter');
if (!sel) return;
const users = [...new Set(todosData.filter(t => t.assigned_to).map(t => t.assigned_to))].sort();
sel.innerHTML = '<option value="">Kaikki vastuuhenkilöt</option>' + users.map(u => `<option value="${esc(u)}">${esc(u)}</option>`).join('');
}
// ---- Tehtävät ----
function renderTasksList() {
const query = (document.getElementById('todo-search-input')?.value || '').toLowerCase().trim();
const statusF = document.getElementById('todo-status-filter')?.value || '';
const assignF = document.getElementById('todo-assigned-filter')?.value || '';
const catF = document.getElementById('todo-category-filter')?.value || '';
let tasks = todosData.filter(t => t.type === 'task');
if (query) tasks = tasks.filter(t => (t.title||'').toLowerCase().includes(query) || (t.description||'').toLowerCase().includes(query) || (t.assigned_to||'').toLowerCase().includes(query));
if (statusF) tasks = tasks.filter(t => t.status === statusF);
if (assignF) tasks = tasks.filter(t => t.assigned_to === assignF);
if (catF) tasks = tasks.filter(t => t.category === catF);
// Lajittelu: deadline lähimmät ensin (null-deadlinet loppuun), sitten prioriteetti
const today = new Date().toISOString().slice(0,10);
const prioOrder = { kiireellinen: 0, tarkea: 1, normaali: 2 };
const statusOrder = { avoin: 0, kaynnissa: 1, odottaa: 2, valmis: 3 };
tasks.sort((a, b) => {
// Valmiit aina loppuun
if ((a.status === 'valmis') !== (b.status === 'valmis')) return a.status === 'valmis' ? 1 : -1;
// Deadline: lähimmät ensin, null loppuun
const da = a.deadline || '9999-99-99';
const db = b.deadline || '9999-99-99';
if (da !== db) return da.localeCompare(db);
// Prioriteetti
const pa = prioOrder[a.priority] ?? 2;
const pb = prioOrder[b.priority] ?? 2;
if (pa !== pb) return pa - pb;
return 0;
});
const tbody = document.getElementById('tasks-tbody');
const table = document.getElementById('tasks-table');
const noEl = document.getElementById('no-tasks');
if (!tbody) return;
if (!tasks.length) { tbody.innerHTML = ''; table.style.display = 'none'; if (noEl) noEl.style.display = ''; return; }
if (noEl) noEl.style.display = 'none';
table.style.display = 'table';
tbody.innerHTML = tasks.map(t => {
const overdue = t.deadline && t.status !== 'valmis' && t.deadline < today;
const soon = t.deadline && t.status !== 'valmis' && !overdue && t.deadline <= new Date(Date.now()+3*86400000).toISOString().slice(0,10);
const rowClass = overdue ? 'todo-row-overdue' : (soon ? 'todo-row-soon' : (t.status === 'kaynnissa' ? 'todo-row-active' : (t.status === 'valmis' ? 'todo-row-done' : '')));
return `<tr class="${rowClass}" onclick="openTaskRead('${t.id}')" style="cursor:pointer;">
<td class="nowrap">${t.deadline ? `<span${overdue ? ' style="color:#e74c3c;font-weight:600;"' : (soon ? ' style="color:#f39c12;font-weight:600;"' : '')}>${t.deadline}</span>` : '<span style="color:#ccc;">—</span>'}</td>
<td><span class="todo-status status-${t.status}">${todoStatusLabels[t.status]||t.status}</span></td>
<td><span class="priority-badge priority-${t.priority}">${todoPriorityLabels[t.priority]||t.priority}</span></td>
<td>${t.category ? `<span class="todo-category cat-${t.category}">${todoCategoryLabels[t.category]||t.category}</span>` : '<span style="color:#ccc;">—</span>'}</td>
<td><strong>${esc(t.title)}</strong></td>
<td>${t.assigned_to ? esc(t.assigned_to) : '<span style="color:#ccc;">—</span>'}</td>
<td style="text-align:center;">${t.total_hours > 0 ? t.total_hours + 'h' : '<span style="color:#ccc;">—</span>'}</td>
<td style="text-align:center;">${t.comment_count > 0 ? t.comment_count : ''}</td>
</tr>`;
}).join('');
}
function showTaskListView() {
document.getElementById('tasks-list-view').style.display = '';
document.getElementById('task-read-view').style.display = 'none';
document.getElementById('task-edit-view').style.display = 'none';
}
async function openTaskRead(id) {
currentTodoId = id;
try {
const t = await apiCall('todo_detail&id=' + id);
const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'superadmin';
document.getElementById('task-read-title').textContent = t.title;
document.getElementById('task-read-meta').innerHTML = `Luoja: ${esc(t.created_by)} &nbsp;|&nbsp; Luotu: ${(t.luotu||'').slice(0,10)} ${t.muokattu ? '&nbsp;|&nbsp; Muokattu: ' + t.muokattu.slice(0,10) : ''}`;
document.getElementById('task-read-badges').innerHTML = `
<span class="priority-badge priority-${t.priority}">${todoPriorityLabels[t.priority]||t.priority}</span>
<span class="todo-status status-${t.status}">${todoStatusLabels[t.status]||t.status}</span>`;
document.getElementById('task-read-fields').innerHTML = `
<div><strong style="font-size:0.78rem;color:#888;">Status</strong><br>${isAdmin ? `<select onchange="updateTaskField('${t.id}','status',this.value)" style="font-size:0.88rem;padding:0.25rem 0.5rem;border-radius:6px;border:1px solid #ddd;">
<option value="avoin" ${t.status==='avoin'?'selected':''}>Avoin</option><option value="kaynnissa" ${t.status==='kaynnissa'?'selected':''}>Käynnissä</option><option value="odottaa" ${t.status==='odottaa'?'selected':''}>Odottaa</option><option value="valmis" ${t.status==='valmis'?'selected':''}>Valmis</option>
</select>` : (todoStatusLabels[t.status]||t.status)}</div>
<div><strong style="font-size:0.78rem;color:#888;">Vastuuhenkilö</strong><br>${isAdmin ? `<select onchange="updateTaskField('${t.id}','assigned',this.value)" style="font-size:0.88rem;padding:0.25rem 0.5rem;border-radius:6px;border:1px solid #ddd;" id="task-read-assigned-sel">
<option value="">— Ei —</option>
</select>` : esc(t.assigned_to || '—')}</div>
<div><strong style="font-size:0.78rem;color:#888;">Prioriteetti</strong><br>${todoPriorityLabels[t.priority]||t.priority}</div>
<div><strong style="font-size:0.78rem;color:#888;">Tyyppi</strong><br>${t.category ? (todoCategoryLabels[t.category]||t.category) : '—'}</div>
<div><strong style="font-size:0.78rem;color:#888;">Deadline</strong><br>${t.deadline || '—'}</div>`;
// Populoi vastuuhenkilö-dropdown
if (isAdmin) {
try {
const users = await apiCall('users');
const sel = document.getElementById('task-read-assigned-sel');
if (sel) { users.forEach(u => { const o = document.createElement('option'); o.value = u.username; o.textContent = u.nimi || u.username; if (u.username === t.assigned_to) o.selected = true; sel.appendChild(o); }); }
} catch(e) {}
}
document.getElementById('task-read-description').textContent = t.description || '(Ei kuvausta)';
// Aikakirjaukset
const entries = t.time_entries || [];
document.getElementById('task-time-total').textContent = `(yhteensä: ${t.total_hours || 0}h)`;
document.getElementById('task-time-tbody').innerHTML = entries.length ? entries.map(e => `<tr>
<td>${e.work_date}</td><td>${esc(e.user)}</td><td>${e.hours}h</td><td>${esc(e.description||'')}</td>
<td>${(e.user === currentUser?.username || isAdmin) ? `<button onclick="deleteTimeEntry('${e.id}','${t.id}')" style="background:none;border:none;cursor:pointer;color:#ccc;font-size:1rem;" title="Poista">&#128465;</button>` : ''}</td>
</tr>`).join('') : '<tr><td colspan="5" style="color:#aaa;text-align:center;">Ei kirjauksia</td></tr>';
// Kommentit
renderTodoComments(t.comments || [], 'task');
document.getElementById('task-comment-count').textContent = `(${(t.comments||[]).length})`;
// Actionit
document.getElementById('task-read-actions').innerHTML = isAdmin ? `<button class="btn-secondary" onclick="openTaskEdit('${t.id}')">&#9998; Muokkaa</button><button class="btn-secondary" onclick="deleteTodo('${t.id}')" style="color:#e74c3c;">&#128465; Poista</button>` : '';
// Aikakirjaus-lomake valmistelu
document.getElementById('time-form-date').value = new Date().toISOString().slice(0,10);
document.getElementById('time-form-hours').value = '';
document.getElementById('time-form-desc').value = '';
document.getElementById('task-time-form').style.display = 'none';
document.getElementById('tasks-list-view').style.display = 'none';
document.getElementById('task-edit-view').style.display = 'none';
document.getElementById('task-read-view').style.display = '';
} catch (e) { alert('Virhe: ' + e.message); }
}
async function updateTaskField(id, field, value) {
try {
if (field === 'status') await apiCall('todo_status', 'POST', { id, status: value });
if (field === 'assigned') await apiCall('todo_assign', 'POST', { id, assigned_to: value });
await loadTodos();
// Päivitä lukunäkymä jos auki
const taskReadView = document.getElementById('task-read-view');
const featureReadView = document.getElementById('feature-read-view');
if (taskReadView && taskReadView.style.display !== 'none') {
await openTaskRead(id);
} else if (featureReadView && featureReadView.style.display !== 'none') {
await openFeatureRead(id);
}
} catch (e) { alert(e.message); }
}
async function openTaskEdit(id) {
const t = id ? todosData.find(x => x.id === id) : null;
currentTodoId = t?.id || null;
document.getElementById('task-form-id').value = t?.id || '';
document.getElementById('task-form-type').value = 'task';
document.getElementById('task-form-title').value = t?.title || '';
document.getElementById('task-form-priority').value = t?.priority || 'normaali';
document.getElementById('task-form-status').value = t?.status || 'avoin';
document.getElementById('task-form-category').value = t?.category || '';
document.getElementById('task-form-deadline').value = t?.deadline || '';
document.getElementById('task-form-desc').value = t?.description || '';
document.getElementById('task-edit-title').textContent = t ? 'Muokkaa tehtävää' : 'Uusi tehtävä';
// Populoi vastuuhenkilö-dropdown
const asel = document.getElementById('task-form-assigned');
asel.innerHTML = '<option value="">— Ei vastuuhenkilöä —</option>';
try {
const users = await apiCall('users');
users.forEach(u => {
const o = document.createElement('option');
o.value = u.username; o.textContent = u.nimi || u.username;
if (t && u.username === t.assigned_to) o.selected = true;
asel.appendChild(o);
});
} catch(e) {}
document.getElementById('tasks-list-view').style.display = 'none';
document.getElementById('task-read-view').style.display = 'none';
document.getElementById('task-edit-view').style.display = '';
}
document.getElementById('task-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('task-form-id').value;
const existing = id ? todosData.find(t => t.id === id) : null;
const body = {
id: id || undefined,
type: 'task',
title: document.getElementById('task-form-title').value.trim(),
description: document.getElementById('task-form-desc').value.trim(),
priority: document.getElementById('task-form-priority').value,
category: document.getElementById('task-form-category').value,
status: document.getElementById('task-form-status').value,
deadline: document.getElementById('task-form-deadline').value || null,
assigned_to: document.getElementById('task-form-assigned').value,
created_by: existing?.created_by,
luotu: existing?.luotu,
};
if (!body.title) return;
try {
const saved = await apiCall('todo_save', 'POST', body);
await loadTodos();
openTaskRead(saved.id);
} catch (e) { alert(e.message); }
});
// ---- Kehitysehdotukset ----
function renderFeaturesList() {
const query = (document.getElementById('feature-search-input')?.value || '').toLowerCase().trim();
const statusF = document.getElementById('feature-status-filter')?.value || '';
let features = todosData.filter(t => t.type === 'feature_request');
if (query) features = features.filter(t => (t.title||'').toLowerCase().includes(query) || (t.description||'').toLowerCase().includes(query));
if (statusF) features = features.filter(t => t.status === statusF);
// Lajittelu: uusimmat ensin, toteutetut/hylätyt loppuun
features.sort((a, b) => {
const doneA = (a.status === 'toteutettu' || a.status === 'hylatty') ? 1 : 0;
const doneB = (b.status === 'toteutettu' || b.status === 'hylatty') ? 1 : 0;
if (doneA !== doneB) return doneA - doneB;
return (b.luotu || '').localeCompare(a.luotu || '');
});
const tbody = document.getElementById('features-tbody');
const table = document.getElementById('features-table');
const noEl = document.getElementById('no-features');
if (!tbody) return;
if (!features.length) { tbody.innerHTML = ''; table.style.display = 'none'; if (noEl) noEl.style.display = ''; return; }
if (noEl) noEl.style.display = 'none';
table.style.display = 'table';
tbody.innerHTML = features.map(t => {
const done = t.status === 'toteutettu' || t.status === 'hylatty';
const rowClass = done ? 'todo-row-done' : '';
return `<tr class="${rowClass}" onclick="openFeatureRead('${t.id}')" style="cursor:pointer;">
<td class="nowrap">${(t.luotu||'').slice(0,10)}</td>
<td><span class="todo-status status-${t.status}">${todoStatusLabels[t.status]||t.status}</span></td>
<td><strong>${esc(t.title)}</strong></td>
<td>${esc(t.created_by)}</td>
<td style="text-align:center;">${t.comment_count > 0 ? t.comment_count : ''}</td>
</tr>`;
}).join('');
}
function showFeatureListView() {
document.getElementById('features-list-view').style.display = '';
document.getElementById('feature-read-view').style.display = 'none';
document.getElementById('feature-edit-view').style.display = 'none';
}
async function openFeatureRead(id) {
currentTodoId = id;
try {
const t = await apiCall('todo_detail&id=' + id);
const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'superadmin';
const isOwner = t.created_by === currentUser?.username;
document.getElementById('feature-read-title').textContent = t.title;
document.getElementById('feature-read-meta').innerHTML = `Ehdottaja: ${esc(t.created_by)} &nbsp;|&nbsp; ${(t.luotu||'').slice(0,10)}`;
document.getElementById('feature-read-badges').innerHTML = isAdmin
? `<select onchange="updateTaskField('${t.id}','status',this.value)" style="font-size:0.88rem;padding:0.3rem 0.6rem;border-radius:6px;border:1px solid #ddd;">
<option value="ehdotettu" ${t.status==='ehdotettu'?'selected':''}>Ehdotettu</option><option value="harkinnassa" ${t.status==='harkinnassa'?'selected':''}>Harkinnassa</option><option value="toteutettu" ${t.status==='toteutettu'?'selected':''}>Toteutettu</option><option value="hylatty" ${t.status==='hylatty'?'selected':''}>Hylätty</option>
</select>`
: `<span class="todo-status status-${t.status}">${todoStatusLabels[t.status]||t.status}</span>`;
document.getElementById('feature-read-description').textContent = t.description || '(Ei kuvausta)';
renderTodoComments(t.comments || [], 'feature');
document.getElementById('feature-comment-count').textContent = `(${(t.comments||[]).length})`;
document.getElementById('feature-read-actions').innerHTML = (isAdmin || isOwner)
? `<button class="btn-secondary" onclick="openFeatureEdit('${t.id}')">&#9998; Muokkaa</button>${isAdmin ? `<button class="btn-secondary" onclick="deleteTodo('${t.id}')" style="color:#e74c3c;">&#128465; Poista</button>` : ''}`
: '';
document.getElementById('features-list-view').style.display = 'none';
document.getElementById('feature-edit-view').style.display = 'none';
document.getElementById('feature-read-view').style.display = '';
} catch (e) { alert('Virhe: ' + e.message); }
}
async function openFeatureEdit(id) {
const t = id ? todosData.find(x => x.id === id) : null;
currentTodoId = t?.id || null;
document.getElementById('feature-form-id').value = t?.id || '';
document.getElementById('feature-form-type').value = 'feature_request';
document.getElementById('feature-form-title').value = t?.title || '';
document.getElementById('feature-form-desc').value = t?.description || '';
document.getElementById('feature-edit-title').textContent = t ? 'Muokkaa ehdotusta' : 'Uusi kehitysehdotus';
document.getElementById('features-list-view').style.display = 'none';
document.getElementById('feature-read-view').style.display = 'none';
document.getElementById('feature-edit-view').style.display = '';
}
document.getElementById('feature-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('feature-form-id').value;
const existing = id ? todosData.find(t => t.id === id) : null;
const body = {
id: id || undefined,
type: 'feature_request',
title: document.getElementById('feature-form-title').value.trim(),
description: document.getElementById('feature-form-desc').value.trim(),
status: existing?.status || 'ehdotettu',
priority: 'normaali',
created_by: existing?.created_by,
luotu: existing?.luotu,
};
if (!body.title) return;
try {
const saved = await apiCall('todo_save', 'POST', body);
await loadTodos();
openFeatureRead(saved.id);
} catch (e) { alert(e.message); }
});
// ---- Yhteiset funktiot ----
function renderTodoComments(comments, prefix) {
const list = document.getElementById(prefix + '-comments-list');
if (!list) return;
const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'superadmin';
list.innerHTML = comments.length ? comments.map(c => `<div class="todo-comment">
<div class="todo-comment-meta">${esc(c.author)} &nbsp;·&nbsp; ${(c.luotu||'').replace('T',' ').slice(0,16)}</div>
<div style="white-space:pre-wrap;">${esc(c.body)}</div>
${(c.author === currentUser?.username || isAdmin) ? `<button onclick="deleteTodoComment('${c.id}')" style="background:none;border:none;color:#ccc;cursor:pointer;font-size:0.78rem;margin-top:0.25rem;">Poista</button>` : ''}
</div>`).join('') : '<p style="color:#aaa;font-size:0.88rem;">Ei kommentteja vielä.</p>';
}
async function sendTodoComment(prefix) {
const input = document.getElementById(prefix + '-comment-input');
const body = input?.value.trim();
if (!body || !currentTodoId) return;
try {
await apiCall('todo_comment', 'POST', { todo_id: currentTodoId, body });
input.value = '';
if (prefix === 'task') await openTaskRead(currentTodoId);
else await openFeatureRead(currentTodoId);
} catch (e) { alert(e.message); }
}
async function deleteTodoComment(commentId) {
if (!confirm('Poistetaanko kommentti?')) return;
try {
await apiCall('todo_comment_delete', 'POST', { id: commentId });
if (currentTodoSubTab === 'tasks') await openTaskRead(currentTodoId);
else await openFeatureRead(currentTodoId);
} catch (e) { alert(e.message); }
}
async function deleteTodo(id) {
if (!confirm('Poistetaanko pysyvästi?')) return;
try {
await apiCall('todo_delete', 'POST', { id });
await loadTodos();
if (currentTodoSubTab === 'tasks') showTaskListView();
else showFeatureListView();
} catch (e) { alert(e.message); }
}
async function addTimeEntry() {
if (!currentTodoId) return;
const hours = parseFloat(document.getElementById('time-form-hours').value);
const desc = document.getElementById('time-form-desc').value.trim();
const date = document.getElementById('time-form-date').value;
if (!hours || hours <= 0) { alert('Syötä tunnit'); return; }
try {
await apiCall('todo_time_add', 'POST', { todo_id: currentTodoId, hours, description: desc, work_date: date });
await loadTodos();
await openTaskRead(currentTodoId);
} catch (e) { alert(e.message); }
}
async function deleteTimeEntry(entryId, todoId) {
if (!confirm('Poistetaanko aikakirjaus?')) return;
try {
await apiCall('todo_time_delete', 'POST', { id: entryId });
await loadTodos();
await openTaskRead(todoId);
} catch (e) { alert(e.message); }
}
// Event listeners
document.getElementById('todo-search-input')?.addEventListener('input', () => renderTasksList());
document.getElementById('todo-status-filter')?.addEventListener('change', () => renderTasksList());
document.getElementById('todo-assigned-filter')?.addEventListener('change', () => renderTasksList());
document.getElementById('todo-category-filter')?.addEventListener('change', () => renderTasksList());
document.getElementById('feature-search-input')?.addEventListener('input', () => renderFeaturesList());
document.getElementById('feature-status-filter')?.addEventListener('change', () => renderFeaturesList());
document.getElementById('btn-add-task')?.addEventListener('click', () => openTaskEdit(null));
document.getElementById('btn-add-feature')?.addEventListener('click', () => openFeatureEdit(null));
document.getElementById('btn-task-back')?.addEventListener('click', () => { showTaskListView(); currentTodoId = null; });
document.getElementById('btn-feature-back')?.addEventListener('click', () => { showFeatureListView(); currentTodoId = null; });
document.getElementById('btn-task-edit-cancel')?.addEventListener('click', () => showTaskListView());
document.getElementById('task-form-cancel')?.addEventListener('click', () => showTaskListView());
document.getElementById('btn-feature-edit-cancel')?.addEventListener('click', () => showFeatureListView());
document.getElementById('feature-form-cancel')?.addEventListener('click', () => showFeatureListView());
document.getElementById('btn-task-comment-send')?.addEventListener('click', () => sendTodoComment('task'));
document.getElementById('btn-feature-comment-send')?.addEventListener('click', () => sendTodoComment('feature'));
document.getElementById('btn-add-time')?.addEventListener('click', () => {
document.getElementById('task-time-form').style.display = 'flex';
document.getElementById('btn-add-time').style.display = 'none';
});
document.getElementById('btn-time-cancel')?.addEventListener('click', () => {
document.getElementById('task-time-form').style.display = 'none';
document.getElementById('btn-add-time').style.display = '';
});
document.getElementById('btn-time-save')?.addEventListener('click', () => addTimeEntry());
// ==================== MODUULIT ====================
const ALL_MODULES = ['customers', 'support', 'leads', 'tekniikka', 'ohjeet', 'todo', 'archive', 'changelog', 'settings'];
const DEFAULT_MODULES = ['customers', 'support', 'archive', 'changelog', 'settings'];
function applyModules(modules) {
// Yhteensopivuus: vanha 'devices' → 'tekniikka'
if (modules && modules.includes('devices') && !modules.includes('tekniikka')) {
modules = modules.map(m => m === 'devices' ? 'tekniikka' : m);
}
// Jos tyhjä array → kaikki moduulit päällä (fallback)
const enabled = (modules && modules.length > 0) ? modules : ALL_MODULES;
const isAdminUser = currentUser?.role === 'admin' || currentUser?.role === 'superadmin';
ALL_MODULES.forEach(mod => {
const tabBtn = document.querySelector(`.tab[data-tab="${mod}"]`);
if (tabBtn) {
// settings-tabi näkyy vain adminille/superadminille
if (mod === 'settings') {
tabBtn.style.display = (enabled.includes(mod) && isAdminUser) ? '' : 'none';
} else {
tabBtn.style.display = enabled.includes(mod) ? '' : 'none';
}
}
});
// Jos aktiivinen tabi on piilotettu → vaihda ensimmäiseen näkyvään
const activeTab = document.querySelector('.tab.active');
if (activeTab && activeTab.style.display === 'none') {
const firstVisible = document.querySelector('.tab[data-tab]:not([style*="display: none"])');
if (firstVisible) switchToTab(firstVisible.dataset.tab);
}
}
// ==================== BRANDING ====================
function applyBranding(branding) {
const color = branding.primary_color || '#0f3460';
const nimi = branding.nimi || 'Noxus Intra';
const subtitle = branding.subtitle || '';
const logoUrl = branding.logo_url || '';
// CSS-muuttuja
document.documentElement.style.setProperty('--primary-color', color);
// Laske tumma variantti
document.documentElement.style.setProperty('--primary-dark', color);
// Login-sivu
const loginLogo = document.getElementById('login-logo');
const loginTitle = document.getElementById('login-title');
const loginSubtitle = document.getElementById('login-subtitle');
if (loginLogo) {
if (logoUrl) { loginLogo.src = logoUrl; loginLogo.style.display = ''; }
else { loginLogo.style.display = 'none'; }
}
if (loginTitle) { loginTitle.style.display = logoUrl ? 'none' : ''; if (!logoUrl) loginTitle.textContent = nimi; }
if (loginSubtitle) { loginSubtitle.style.display = logoUrl ? 'none' : ''; if (!logoUrl) loginSubtitle.textContent = subtitle || 'Kirjaudu sisään'; }
// Muut login-boxien otsikot
document.querySelectorAll('.login-brand-title').forEach(el => el.textContent = nimi);
// Header
const headerLogo = document.getElementById('header-logo');
const headerIcon = document.getElementById('header-brand-icon');
const headerTitle = document.getElementById('header-title');
const headerSubtitle = document.getElementById('header-subtitle');
if (headerLogo) {
if (logoUrl) { headerLogo.src = logoUrl; headerLogo.style.display = ''; if (headerIcon) headerIcon.style.display = 'none'; }
else { headerLogo.style.display = 'none'; if (headerIcon) headerIcon.style.display = ''; }
}
// Kun logo on, piilotetaan tekstit — logo riittää
if (headerTitle) { headerTitle.style.display = logoUrl ? 'none' : ''; if (!logoUrl) headerTitle.textContent = nimi; }
if (headerSubtitle) { headerSubtitle.style.display = logoUrl ? 'none' : ''; if (!logoUrl) headerSubtitle.textContent = subtitle || ''; }
// Sivun title
document.title = nimi;
}
async function loadBranding() {
try {
const data = await apiCall('branding');
applyBranding(data);
} catch (e) {
// Oletusbrändäys
applyBranding({ nimi: 'Noxus Intra', primary_color: '#0f3460', subtitle: 'Hallintapaneeli', logo_url: '' });
}
}
// Init — branding ensin (luo session-cookien), sitten captcha + auth
loadBranding().then(() => {
loadCaptcha();
checkAuth();
});