Files
intra.noxus.fi/script.js
2026-03-12 10:08:39 +02:00

5811 lines
275 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: '', company_role: '' };
let currentCompany = null; // {id, nimi}
let availableCompanies = []; // [{id, nimi}, ...]
let currentTicketCompanyId = ''; // Avatun tiketin yritys (cross-company tuki)
let currentUserSignatures = {}; // {mailbox_id: "allekirjoitus teksti"}
let currentHiddenMailboxes = []; // ['mailbox_id1', ...] — piilotetut postilaatikot
// 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, company_role: data.company_role || '', id: data.user_id };
availableCompanies = data.companies || [];
currentCompany = availableCompanies.find(c => c.id === data.company_id) || availableCompanies[0] || null;
currentUserSignatures = data.signatures || {};
currentHiddenMailboxes = data.hidden_mailboxes || [];
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, company_role: data.company_role || '', id: data.user_id };
availableCompanies = data.companies || [];
currentCompany = availableCompanies.find(c => c.id === data.company_id) || availableCompanies[0] || null;
currentUserSignatures = data.signatures || {};
currentHiddenMailboxes = data.hidden_mailboxes || [];
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
});
function isCurrentUserAdmin() {
if (currentUser.role === 'superadmin') return true;
return currentUser.company_role === 'admin';
}
function updateAdminVisibility() {
const isAdmin = isCurrentUserAdmin();
document.getElementById('btn-users').style.display = isAdmin ? '' : 'none';
document.getElementById('tab-settings').style.display = isAdmin ? '' : 'none';
document.getElementById('btn-companies').style.display = isAdmin ? '' : 'none';
}
async function showDashboard() {
loginScreen.style.display = 'none';
dashboard.style.display = 'block';
document.getElementById('user-info').textContent = currentUser.nimi || currentUser.username;
updateAdminVisibility();
// 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', 'documents', 'laitetilat', 'netadmin', 'archive', 'changelog', 'support', 'users', 'settings', 'companies'];
// ohjeet, laitetilat, archive ovat nyt sub-tabeja — switchToTab hoitaa uudelleenohjauksen
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 => {
const blocked = c.ip_blocked ? ' (IP-rajoitus)' : '';
const disabled = c.ip_blocked ? ' disabled' : '';
return `<option value="${c.id}" ${currentCompany && c.id === currentCompany.id ? 'selected' : ''}${disabled}>${esc(c.nimi)}${blocked}</option>`;
}).join('');
}
async function switchCompany(companyId) {
try {
const result = await apiCall('company_switch', 'POST', { company_id: companyId });
currentCompany = availableCompanies.find(c => c.id === companyId) || null;
// Päivitä yrityskohtainen rooli
if (result.company_role) {
currentUser.company_role = result.company_role;
}
// 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 || []);
currentUser.company_role = auth.company_role || '';
currentUserSignatures = auth.signatures || {};
currentHiddenMailboxes = auth.hidden_mailboxes || [];
} catch (e2) {}
// Päivitä admin-näkyvyys yritysroolin mukaan
updateAdminVisibility();
// 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) {
// Yhteensopivuus: vanhat hash-linkit → uusi rakenne
if (target === 'ohjeet') { target = 'support'; subTab = 'ohjeet'; }
if (target === 'archive') { target = 'customers'; subTab = 'archive'; }
if (target === 'laitetilat') { target = 'tekniikka'; subTab = 'laitetilat'; }
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 (subTab) {
window.location.hash = target + '/' + subTab;
} else {
window.location.hash = target;
}
// Lataa sisältö tarvittaessa
if (target === 'customers') {
loadCustomers();
if (subTab === 'archive') {
switchCustomerSubTab('customers-archive');
} else {
switchCustomerSubTab('customers-list');
}
}
if (target === 'leads') loadLeads();
if (target === 'tekniikka') {
loadDevices(); loadIpam();
const validSubTabs = ['devices', 'ipam', 'laitetilat'];
if (subTab && validSubTabs.includes(subTab)) {
switchSubTab(subTab);
if (subTab === 'laitetilat') { loadLaitetilat(); showLaitetilatListView(); }
} else {
switchSubTab('devices');
}
}
if (target === 'changelog') loadChangelog();
if (target === 'todo') { loadTodos(); if (subTab) switchTodoSubTab(subTab); }
if (target === 'support') {
loadTickets(); showTicketListView();
if (document.getElementById('ticket-auto-refresh').checked) startTicketAutoRefresh();
if (subTab === 'ohjeet') {
switchSupportSubTab('support-ohjeet');
} else {
switchSupportSubTab('support-tickets');
}
}
if (target === 'documents') {
if (subTab && subTab !== 'kokoukset') {
// subTab on customer_id → avaa suoraan asiakkaan kansio
currentDocCustomerId = subTab;
loadDocuments();
openDocCustomerFolder(subTab);
} else {
currentDocCustomerId = null;
loadDocuments();
showDocCustomerFoldersView();
}
}
if (target === 'netadmin') loadNetadmin();
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>Dokumentit</h3>
<div id="customer-docs-list" style="margin-bottom:0.75rem;"><span style="color:#aaa;font-size:0.85rem;">Ladataan...</span></div>
<button class="btn-primary" style="font-size:0.85rem;padding:6px 14px;" onclick="openDocEditForCustomer('${id}')">+ Lisää dokumentti</button>
<button class="btn-primary" style="font-size:0.85rem;padding:6px 14px;margin-left:0.25rem;" onclick="openDocEditForCustomer('${id}', 'kokousmuistio')">+ Kokousmuistio</button>
</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';
loadCustomerDocuments(id);
}
async function loadCustomerDocuments(customerId) {
const container = document.getElementById('customer-docs-list');
if (!container) return;
try {
const docs = await apiCall(`documents&customer_id=${customerId}`);
if (docs.length === 0) {
container.innerHTML = '<p style="color:#aaa;font-size:0.85rem;">Ei dokumentteja.</p>';
return;
}
container.innerHTML = docs.map(d => {
const catLabel = docCategoryLabels[d.category] || d.category || 'Muu';
const date = d.muokattu ? new Date(d.muokattu).toLocaleDateString('fi-FI') : '';
return `<div class="customer-doc-item">
<a href="#" onclick="openDocFromCustomer('${d.id}');return false;">${esc(d.title)}</a>
<span><span class="doc-category cat-${d.category || 'muu'}" style="font-size:0.75rem;">${catLabel}</span> <span style="color:#aaa;font-size:0.8rem;">${date}</span></span>
</div>`;
}).join('');
} catch (e) {
container.innerHTML = '<p style="color:red;font-size:0.85rem;">Virhe ladattaessa dokumentteja.</p>';
}
}
window.openDocFromCustomer = async function(docId) {
detailModal.style.display = 'none';
switchToTab('documents');
try {
currentDocument = await apiCall(`document&id=${docId}`);
renderDocReadView();
showDocReadView();
} catch (e) { alert('Dokumentin avaus epäonnistui: ' + e.message); }
};
window.openDocEditForCustomer = function(customerId, forceCategory) {
detailModal.style.display = 'none';
switchToTab('documents');
openDocEdit(null, forceCategory || null, customerId);
};
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';
}
// 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><select class="l-liittymanopeus">
<option value="">- Valitse -</option>
${['10/10','50/10','50/50','100/10','100/100','200/200','300/300','500/500','1000/1000','2000/2000','10000/10000'].map(s =>
`<option value="${s}" ${data.liittymanopeus === s ? 'selected' : ''}>${s}</option>`
).join('')}
</select></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><div class="combo-wrap l-combo-vlan"><input type="text" placeholder="Hae VLANia..." autocomplete="off"><input type="hidden" class="l-vlan"><div class="combo-list"></div></div></div>
<div class="form-group"><label>Laite</label><div class="combo-wrap l-combo-laite"><input type="text" placeholder="Hae laitetta..." autocomplete="off"><input type="hidden" class="l-laite"><div class="combo-list"></div></div></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 / Verkko</label><div class="combo-wrap l-combo-ip"><input type="text" placeholder="Hae IP:tä..." autocomplete="off"><input type="hidden" class="l-ip"><div class="combo-list"></div></div></div>
</div>`;
div.querySelector('.btn-remove-row').addEventListener('click', () => { div.remove(); renumberLiittymaRows(); });
// Populoi hakukentät IPAM/laite-datasta
populateLiittymaRowCombos(div, data);
return div;
}
// Populoi liittymärivin comboboxit
function populateLiittymaRowCombos(row, data = {}) {
const vlans = (netadminData.vlans && netadminData.vlans.length) ? netadminData.vlans : (ipamData || []).filter(e => e.tyyppi === 'vlan');
const ips = (netadminData.ips && netadminData.ips.length) ? netadminData.ips : (ipamData || []).filter(e => e.tyyppi === 'ip' || e.tyyppi === 'subnet');
const devices = (netadminData.devices && netadminData.devices.length) ? netadminData.devices : (devicesData || []);
initCombo(row.querySelector('.l-combo-vlan'), getVlanComboOptions(vlans), data.vlan || '');
initCombo(row.querySelector('.l-combo-laite'), getDeviceComboOptions(devices), data.laite || '');
initCombo(row.querySelector('.l-combo-ip'), getIpComboOptions(ips), data.ip || '');
}
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');
async 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';
// Lataa IPAM/laite-data dropdowendeja varten (jos ei vielä ladattu)
await ensureIpamDevicesLoaded();
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();
}
// Varmista IPAM/laite-data on ladattu dropdowneja varten
async function ensureIpamDevicesLoaded() {
try {
// Jos netadminData:ssa ei ole IPAM-dataa, lataa suoraan
if (!netadminData.vlans || !netadminData.vlans.length || !netadminData.devices || !netadminData.devices.length) {
const [ipam, devices] = await Promise.all([
apiCall('ipam'),
apiCall('devices')
]);
if (!netadminData.vlans || !netadminData.vlans.length) {
netadminData.vlans = ipam.filter(e => e.tyyppi === 'vlan');
netadminData.ips = ipam.filter(e => e.tyyppi === 'ip');
}
if (!netadminData.devices || !netadminData.devices.length) {
netadminData.devices = devices;
}
}
} catch (e) { console.error('IPAM/laite-datan lataus epäonnistui:', e); }
}
async function editCustomer(id) { const c = customers.find(x => x.id === id); if (c) await 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>
${isCurrentUserAdmin() ? `<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>${u.role === 'superadmin' ? '<span class="role-badge role-superadmin">Pääkäyttäjä</span>' :
Object.entries(u.company_roles || {}).filter(([,r]) => r === 'admin').length > 0
? Object.entries(u.company_roles).map(([cid, r]) => r === 'admin' ? `<span class="role-badge role-admin" title="${cid}">Admin</span>` : '').filter(Boolean).join(' ')
: '<span class="role-badge role-user">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)' : '*';
// Globaali rooli: user vs superadmin
document.getElementById('user-form-role').value = (user && user.role === 'superadmin') ? 'superadmin' : 'user';
// Piilota superadmin-kenttä ellei ole superadmin
const roleGroup = document.getElementById('user-role-group');
if (roleGroup) roleGroup.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 + yrityskohtaiset roolit
const allComps = availableCompanies.length > 0 ? availableCompanies : [];
const userComps = user ? (user.companies || []) : [];
const companyRoles = user ? (user.company_roles || {}) : {};
const container = document.getElementById('user-company-checkboxes');
function renderCompanyCheckboxes(companies) {
container.innerHTML = companies.map(c => {
const checked = userComps.includes(c.id);
const role = companyRoles[c.id] || 'user';
return `<div style="display:flex;align-items:center;gap:0.5rem;">
<label style="display:flex;align-items:center;gap:0.3rem;cursor:pointer;min-width:150px;">
<input type="checkbox" class="user-company-cb" value="${c.id}" ${checked ? 'checked' : ''}>
${esc(c.nimi)}
</label>
<select class="user-company-role" data-company-id="${c.id}" style="padding:4px 8px;border:1px solid #ddd;border-radius:4px;font-size:0.85rem;${checked ? '' : 'opacity:0.4;pointer-events:none;'}">
<option value="user" ${role === 'user' ? 'selected' : ''}>Käyttäjä</option>
<option value="admin" ${role === 'admin' ? 'selected' : ''}>Admin</option>
</select>
</div>`;
}).join('');
// Checkbox toggle: näytä/piilota rooli-dropdown
container.querySelectorAll('.user-company-cb').forEach(cb => {
cb.addEventListener('change', () => {
const sel = container.querySelector(`.user-company-role[data-company-id="${cb.value}"]`);
if (sel) {
sel.style.opacity = cb.checked ? '1' : '0.4';
sel.style.pointerEvents = cb.checked ? '' : 'none';
}
});
});
}
// Hae kaikki yritykset admin-näkymää varten
apiCall('companies_all').then(companies => {
renderCompanyCheckboxes(companies);
}).catch(() => {
renderCompanyCheckboxes(allComps);
});
// 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ää yrityskohtaiset roolit
const company_roles = {};
document.querySelectorAll('.user-company-role').forEach(sel => {
const cid = sel.dataset.companyId;
if (companies.includes(cid)) {
company_roles[cid] = sel.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,
company_roles,
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, company_role: auth.company_role || '', id: auth.user_id };
currentUserSignatures = auth.signatures || {};
currentHiddenMailboxes = auth.hidden_mailboxes || [];
}
} 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 = '';
profileModal.style.display = 'flex';
}
document.getElementById('profile-form').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
nimi: document.getElementById('profile-nimi').value,
email: document.getElementById('profile-email').value,
};
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, company_role: auth.company_role || '', id: auth.user_id };
currentUserSignatures = auth.signatures || {};
currentHiddenMailboxes = auth.hidden_mailboxes || [];
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;
// Piilota piilotettujen postilaatikoiden tiketit
if (currentHiddenMailboxes.length > 0) {
filtered = filtered.filter(t => !currentHiddenMailboxes.includes(String(t.mailbox_id)) && !currentHiddenMailboxes.includes(t.mailbox_id));
}
// 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-secondary" id="btn-ticket-to-todo" style="padding:6px 12px;font-size:0.82rem;" title="Luo tehtävä tästä tiketistä">📋 Tehtävä</button>
<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); }
});
// Luo tehtävä tiketistä
document.getElementById('btn-ticket-to-todo')?.addEventListener('click', () => {
createTodoFromTicket(ticket);
});
// 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-settings-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');
// Suodata pois piilotetut postilaatikot (paitsi jos tiketin oma mailbox on piilotettu — se näytetään silti)
const visibleMailboxes = mailboxes.filter(mb =>
mb.id === (ticket.mailbox_id || '') ||
(!currentHiddenMailboxes.includes(String(mb.id)) && !currentHiddenMailboxes.includes(mb.id))
);
mbSelect.innerHTML = visibleMailboxes.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-settings-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-settings-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-settings-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); }
};
// ==================== OMAT ASETUKSET (TIKETTIEN ASETUKSET) ====================
async function openTicketSettings() {
// Piilota muut näkymät, näytä asetukset
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 = 'none';
document.getElementById('ticket-settings-view').style.display = 'block';
const sigContainer = document.getElementById('ticket-settings-signatures');
const visContainer = document.getElementById('ticket-settings-mailbox-visibility');
sigContainer.innerHTML = '<p style="color:#888;font-size:0.85rem;">Ladataan...</p>';
visContainer.innerHTML = '';
try {
const mailboxes = await apiCall('all_mailboxes');
if (mailboxes.length === 0) {
sigContainer.innerHTML = '<p style="color:#888;font-size:0.85rem;">Ei postilaatikoita.</p>';
visContainer.innerHTML = '<p style="color:#888;font-size:0.85rem;">Ei postilaatikoita.</p>';
return;
}
// Allekirjoitukset per postilaatikko
sigContainer.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="ticket-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(currentUserSignatures[mb.id] || '')}</textarea>
</div>`
).join('');
// Postilaatikoiden näkyvyys — checkbox per postilaatikko
visContainer.innerHTML = mailboxes.map(mb => {
const isHidden = currentHiddenMailboxes.includes(String(mb.id)) || currentHiddenMailboxes.includes(mb.id);
return `<label style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;font-size:0.9rem;cursor:pointer;">
<input type="checkbox" class="mb-visibility-cb" data-mailbox-id="${mb.id}" ${!isHidden ? 'checked' : ''}>
<span>${esc(mb.company_nimi)}${esc(mb.nimi)} &lt;${esc(mb.smtp_from_email)}&gt;</span>
</label>`;
}).join('');
} catch (e) {
sigContainer.innerHTML = '<p style="color:red;font-size:0.85rem;">Virhe ladattaessa postilaatikoita.</p>';
}
}
function closeTicketSettings() {
document.getElementById('ticket-settings-view').style.display = 'none';
document.getElementById('ticket-list-view').style.display = 'block';
}
document.getElementById('btn-ticket-settings').addEventListener('click', () => openTicketSettings());
document.getElementById('btn-save-ticket-settings').addEventListener('click', async () => {
// Kerää allekirjoitukset
const signatures = {};
document.querySelectorAll('.ticket-sig-textarea').forEach(ta => {
const mbId = ta.getAttribute('data-mailbox-id');
signatures[mbId] = ta.value;
});
// Kerää piilotetut postilaatikot (ne joissa checkbox EI ole päällä)
const hiddenMailboxes = [];
document.querySelectorAll('.mb-visibility-cb').forEach(cb => {
if (!cb.checked) {
hiddenMailboxes.push(cb.getAttribute('data-mailbox-id'));
}
});
try {
await apiCall('profile_update', 'POST', { signatures, hidden_mailboxes: hiddenMailboxes });
// Päivitä lokaalit muuttujat
currentUserSignatures = signatures;
currentHiddenMailboxes = hiddenMailboxes;
closeTicketSettings();
// Lataa tiketit uudelleen suodatuksen päivittämiseksi
loadTickets();
alert('Asetukset tallennettu!');
} 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 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.company_roles || {})[companyId] === 'admin' ? 'Admin' : '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 = [];
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.laitetila_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.laitetila_name ? esc(d.laitetila_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 loadLaitetilatForDropdown();
document.getElementById('device-form-laitetila').value = d.laitetila_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() { await loadLaitetilatForDropdown(); }
async function loadSitesAndLaitetilatForDropdown() { await loadLaitetilatForDropdown(); }
async function loadLaitetilatForDropdown() {
try {
const tilat = await apiCall('laitetilat');
const tilaSel = document.getElementById('device-form-laitetila');
tilaSel.innerHTML = '<option value="">— Ei sijaintia —</option>' +
tilat.map(t => `<option value="${t.id}">${esc(t.nimi)}${t.osoite ? ' (' + esc(t.osoite) + ')' : ''}</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 loadLaitetilatForDropdown();
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(),
laitetila_id: document.getElementById('device-form-laitetila').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');
if (target === 'laitetilat') { loadLaitetilat(); showLaitetilatListView(); }
window.location.hash = 'tekniikka/' + target;
}
document.querySelectorAll('#tab-content-tekniikka .sub-tab').forEach(btn => {
btn.addEventListener('click', () => switchSubTab(btn.dataset.subtab));
});
// ==================== ASIAKASPALVELU SUB-TABS ====================
function switchSupportSubTab(target) {
document.querySelectorAll('#support-sub-tab-bar .sub-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('#tab-content-support > .sub-tab-content').forEach(c => c.classList.remove('active'));
const btn = document.querySelector(`[data-support-subtab="${target}"]`);
if (btn) btn.classList.add('active');
const content = document.getElementById('subtab-' + target);
if (content) content.classList.add('active');
if (target === 'support-ohjeet') { loadGuides(); window.location.hash = 'support/ohjeet'; }
else { window.location.hash = 'support'; }
}
document.querySelectorAll('#support-sub-tab-bar .sub-tab').forEach(btn => {
btn.addEventListener('click', () => switchSupportSubTab(btn.dataset.supportSubtab));
});
// ==================== ASIAKKAAT SUB-TABS ====================
function switchCustomerSubTab(target) {
document.querySelectorAll('#customers-sub-tab-bar .sub-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('#tab-content-customers > .sub-tab-content').forEach(c => c.classList.remove('active'));
const btn = document.querySelector(`[data-cust-subtab="${target}"]`);
if (btn) btn.classList.add('active');
const content = document.getElementById('subtab-' + target);
if (content) content.classList.add('active');
if (target === 'customers-archive') { loadArchive(); window.location.hash = 'customers/archive'; }
else { window.location.hash = 'customers'; }
}
document.querySelectorAll('#customers-sub-tab-bar .sub-tab').forEach(btn => {
btn.addEventListener('click', () => switchCustomerSubTab(btn.dataset.custSubtab));
});
// ==================== SIJAINNIT — YHDISTETTY LAITETILOIHIN ====================
// Sites-koodi poistettu: sijainnit hallitaan nyt Laitetilat-välilehdellä.
// ==================== 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();
}
let _ipamLaitetilatCache = null;
async function loadIpamSitesDropdown() {
try {
if (!_ipamLaitetilatCache || _ipamLaitetilatCache.length === 0) _ipamLaitetilatCache = await apiCall('laitetilat');
const sel = document.getElementById('ipam-form-site');
sel.innerHTML = '<option value="">— Ei sijaintia —</option>' +
_ipamLaitetilatCache.map(t => `<option value="${t.id}">${esc(t.nimi)}${t.osoite ? ' (' + esc(t.osoite) + ')' : ''}</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 = isCurrentUserAdmin();
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 = isCurrentUserAdmin();
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';
function createTodoFromTicket(ticket) {
// Vaihda todo-välilehdelle ja avaa uusi tehtävälomake esitäytetyillä tiedoilla
switchToTab('todo');
switchTodoSubTab('tasks');
// Pieni viive jotta tab ehtii renderöityä
setTimeout(async () => {
await openTaskEdit(null);
const num = ticket.ticket_number ? `#${ticket.ticket_number} ` : '';
document.getElementById('task-form-title').value = num + (ticket.subject || '');
// Mapataan tiketin tyyppi tehtävän kategoriaan
const catMap = { tekniikka: 'tekniikka', laskutus: 'laskutus', vika: 'tekniikka', muu: 'muu' };
document.getElementById('task-form-category').value = catMap[ticket.type] || '';
// Mapataan prioriteetti
const prioMap = { urgent: 'kiireellinen', 'tärkeä': 'tarkea', normaali: 'normaali' };
document.getElementById('task-form-priority').value = prioMap[ticket.priority] || 'normaali';
// Kuvaus: lähettäjä + lyhyt viite
const desc = `Tiketti${num ? ' ' + num : ''}: ${ticket.subject || ''}\nLähettäjä: ${ticket.from_name || ''} <${ticket.from_email || ''}>`;
document.getElementById('task-form-desc').value = desc;
}, 100);
}
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 btnTask = document.getElementById('btn-add-task');
if (btnTask) btnTask.style.display = isCurrentUserAdmin() ? '' : '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('');
}
// ---- Osatehtävät (subtaskit) ----
function renderSubtasks(subtasks, todoId) {
const list = document.getElementById('task-subtasks-list');
const countEl = document.getElementById('task-subtask-count');
if (!list) return;
const done = subtasks.filter(s => s.completed).length;
const total = subtasks.length;
if (countEl) countEl.textContent = total > 0 ? `(${done}/${total})` : '';
list.innerHTML = subtasks.length ? subtasks.map(s => `<div class="subtask-item${s.completed ? ' completed' : ''}">
<label><input type="checkbox" ${s.completed ? 'checked' : ''} onchange="toggleSubtask('${s.id}','${todoId}')"> <span>${esc(s.title)}</span></label>
<button class="subtask-delete" onclick="deleteSubtask('${s.id}','${todoId}')" title="Poista">&times;</button>
</div>`).join('') : '<div style="color:#aaa;font-size:0.85rem;">Ei osatehtäviä</div>';
}
async function addSubtask(todoId) {
const input = document.getElementById('subtask-input');
const title = (input?.value || '').trim();
if (!title) return;
try {
await apiCall('todo_subtask_add', 'POST', { todo_id: todoId, title });
input.value = '';
await openTaskRead(todoId);
} catch (e) { alert(e.message); }
}
async function toggleSubtask(subtaskId, todoId) {
try {
await apiCall('todo_subtask_toggle', 'POST', { id: subtaskId });
await openTaskRead(todoId);
await loadTodos();
} catch (e) { alert(e.message); }
}
async function deleteSubtask(subtaskId, todoId) {
try {
await apiCall('todo_subtask_delete', 'POST', { id: subtaskId });
await openTaskRead(todoId);
await loadTodos();
} catch (e) { alert(e.message); }
}
// ---- 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>${t.subtask_count > 0 ? ` <span class="subtask-progress">&#9745; ${t.subtask_done}/${t.subtask_count}</span>` : ''}</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 = isCurrentUserAdmin();
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>';
// Osatehtävät
renderSubtasks(t.subtasks || [], t.id);
document.getElementById('btn-add-subtask')?.replaceWith(document.getElementById('btn-add-subtask')?.cloneNode(true));
document.getElementById('btn-add-subtask')?.addEventListener('click', () => addSubtask(t.id));
document.getElementById('subtask-input')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addSubtask(t.id); } });
// 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 = isCurrentUserAdmin();
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 = isCurrentUserAdmin();
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());
// ==================== NETADMIN ====================
let netadminData = { connections: [], devices: [], vlans: [], ips: [] };
async function loadNetadmin() {
try {
netadminData = await apiCall('netadmin_connections');
populateNetadminFilters();
renderNetadminTable();
} catch (e) { console.error('NetAdmin lataus epäonnistui:', e); }
}
function populateNetadminFilters() {
const conns = netadminData.connections || [];
// Kaupungit
const cities = [...new Set(conns.map(c => c.kaupunki).filter(Boolean))].sort();
const citySel = document.getElementById('netadmin-filter-city');
const cityVal = citySel.value;
citySel.innerHTML = '<option value="">Kaikki kaupungit</option>' +
cities.map(c => `<option value="${c}">${esc(c)}</option>`).join('');
citySel.value = cityVal;
// Nopeudet
const speeds = [...new Set(conns.map(c => c.liittymanopeus).filter(Boolean))].sort();
const speedSel = document.getElementById('netadmin-filter-speed');
const speedVal = speedSel.value;
speedSel.innerHTML = '<option value="">Kaikki nopeudet</option>' +
speeds.map(s => `<option value="${s}">${esc(s)}</option>`).join('');
speedSel.value = speedVal;
// Laitteet
const devs = [...new Set(conns.map(c => c.laite).filter(Boolean))].sort();
const devSel = document.getElementById('netadmin-filter-device');
const devVal = devSel.value;
devSel.innerHTML = '<option value="">Kaikki laitteet</option>' +
devs.map(d => `<option value="${d}">${esc(d)}</option>`).join('');
devSel.value = devVal;
}
function renderNetadminTable() {
const query = (document.getElementById('netadmin-search')?.value || '').toLowerCase().trim();
const filterCity = document.getElementById('netadmin-filter-city')?.value || '';
const filterSpeed = document.getElementById('netadmin-filter-speed')?.value || '';
const filterDevice = document.getElementById('netadmin-filter-device')?.value || '';
let filtered = netadminData.connections || [];
if (query) {
filtered = filtered.filter(c => {
const searchStr = [
c.customer_name, c.asennusosoite, c.kaupunki, c.postinumero,
c.liittymanopeus, c.vlan, c.laite, c.portti, c.ip
].filter(Boolean).join(' ').toLowerCase();
return searchStr.includes(query);
});
}
if (filterCity) filtered = filtered.filter(c => c.kaupunki === filterCity);
if (filterSpeed) filtered = filtered.filter(c => c.liittymanopeus === filterSpeed);
if (filterDevice) filtered = filtered.filter(c => c.laite === filterDevice);
const tbody = document.getElementById('netadmin-tbody');
const noEl = document.getElementById('no-netadmin');
const countEl = document.getElementById('netadmin-count');
countEl.textContent = `${filtered.length} / ${(netadminData.connections || []).length} liittymää`;
if (filtered.length === 0) {
tbody.innerHTML = '';
noEl.style.display = '';
return;
}
noEl.style.display = 'none';
tbody.innerHTML = filtered.map(c => {
const addr = c.asennusosoite || '-';
const deviceInfo = c.device_info;
const pingClass = deviceInfo?.ping_status === 'up' ? 'netadmin-status-up' :
deviceInfo?.ping_status === 'down' ? 'netadmin-status-down' : '';
const deviceDisplay = c.laite ? `<span class="${pingClass}">${esc(c.laite)}</span>` : '-';
return `<tr onclick="openNetadminDetail(${c.id})" style="cursor:pointer;" title="Avaa liittymän tiedot">
<td><strong>${esc(c.customer_name || '-')}</strong></td>
<td>${esc(addr)}</td>
<td>${esc(c.kaupunki || '-')}</td>
<td><span class="netadmin-speed">${esc(c.liittymanopeus || '-')}</span></td>
<td>${esc(c.vlan || '-')}</td>
<td>${deviceDisplay}</td>
<td>${esc(c.portti || '-')}</td>
<td><code>${esc(c.ip || '-')}</code></td>
</tr>`;
}).join('');
}
document.getElementById('netadmin-search')?.addEventListener('input', renderNetadminTable);
document.getElementById('netadmin-filter-city')?.addEventListener('change', renderNetadminTable);
document.getElementById('netadmin-filter-speed')?.addEventListener('change', renderNetadminTable);
document.getElementById('netadmin-filter-device')?.addEventListener('change', renderNetadminTable);
// ---- Searchable Combobox ----
// Luo combobox hakukenttä wrap-elementin sisälle
// options: [{value, label, sub, badge, badgeClass, searchStr}]
function initCombo(wrapEl, options, currentValue) {
const input = wrapEl.querySelector('input[type="text"]');
const hidden = wrapEl.querySelector('input[type="hidden"]');
const list = wrapEl.querySelector('.combo-list');
if (!input || !hidden || !list) return;
wrapEl._comboOptions = options;
hidden.value = currentValue || '';
// Näytä nykyinen arvo inputissa
if (currentValue) {
const match = options.find(o => o.value === currentValue);
input.value = match ? match.label : currentValue;
} else {
input.value = '';
}
function renderList(query) {
const q = (query || '').toLowerCase().trim();
let filtered = options;
if (q) {
filtered = options.filter(o =>
(o.searchStr || o.label || '').toLowerCase().includes(q) ||
(o.value || '').toLowerCase().includes(q)
);
}
if (filtered.length === 0) {
list.innerHTML = '<div class="combo-opt" style="color:#aaa;cursor:default;">Ei tuloksia</div>';
} else {
let lastGroup = null;
list.innerHTML = filtered.map(o => {
let grpHtml = '';
if (o.group && o.group !== lastGroup) {
lastGroup = o.group;
grpHtml = `<div class="combo-grp">${esc(o.group)}</div>`;
}
const badge = o.badge ? `<span class="combo-badge ${o.badgeClass || ''}">${esc(o.badge)}</span>` : '';
const sub = o.sub ? `<span class="combo-sub">${esc(o.sub)}</span>` : '';
return grpHtml + `<div class="combo-opt" data-value="${esc(o.value)}">${badge}<span>${esc(o.label)}</span>${sub}</div>`;
}).join('');
}
list.classList.add('open');
}
function selectValue(val) {
hidden.value = val;
const match = options.find(o => o.value === val);
input.value = match ? match.label : val;
list.classList.remove('open');
}
// Poista vanhat listenerit (uudelleeninitiin)
const newInput = input.cloneNode(true);
input.parentNode.replaceChild(newInput, input);
const newList = list.cloneNode(true);
list.parentNode.replaceChild(newList, list);
newInput.addEventListener('focus', () => renderList(newInput.value));
newInput.addEventListener('input', () => {
renderList(newInput.value);
// Jos tyhjä, tyhjennä valinta
if (!newInput.value.trim()) hidden.value = '';
});
newInput.addEventListener('blur', () => {
// Pieni viive jotta klikkaus ehtii rekisteröityä
setTimeout(() => {
newList.classList.remove('open');
// Jos input ei vastaa mitään optiota, käytä vapaa teksti arvona
if (newInput.value.trim() && !hidden.value) {
hidden.value = newInput.value.trim();
}
}, 200);
});
newInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { newList.classList.remove('open'); newInput.blur(); }
if (e.key === 'Enter') {
e.preventDefault();
const active = newList.querySelector('.combo-opt.active');
if (active && active.dataset.value !== undefined) {
selectValue(active.dataset.value);
} else {
// Valitse ensimmäinen tulos
const first = newList.querySelector('.combo-opt[data-value]');
if (first) selectValue(first.dataset.value);
else { hidden.value = newInput.value.trim(); newList.classList.remove('open'); }
}
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const items = [...newList.querySelectorAll('.combo-opt[data-value]')];
if (!items.length) return;
const idx = items.findIndex(i => i.classList.contains('active'));
items.forEach(i => i.classList.remove('active'));
let next = e.key === 'ArrowDown' ? idx + 1 : idx - 1;
if (next < 0) next = items.length - 1;
if (next >= items.length) next = 0;
items[next].classList.add('active');
items[next].scrollIntoView({ block: 'nearest' });
}
});
newList.addEventListener('mousedown', (e) => {
const opt = e.target.closest('.combo-opt[data-value]');
if (opt) { e.preventDefault(); selectValue(opt.dataset.value); }
});
}
// Rakennetaan VLAN-combobox optiot
function getVlanComboOptions(source) {
const vlans = source || netadminData.vlans || [];
return [...vlans].sort((a, b) => (a.vlan_id || 0) - (b.vlan_id || 0)).map(v => ({
value: String(v.vlan_id || ''),
label: String(v.vlan_id || '') + (v.nimi ? `${v.nimi}` : ''),
sub: v.site_name || '',
searchStr: `${v.vlan_id} ${v.nimi || ''} ${v.site_name || ''}`,
}));
}
// Rakennetaan laite-combobox optiot
function getDeviceComboOptions(source) {
const devices = source || netadminData.devices || [];
return [...devices].sort((a, b) => (a.nimi || '').localeCompare(b.nimi || '')).map(d => {
const pingDot = d.ping_status === 'up' ? '🟢' : d.ping_status === 'down' ? '🔴' : '';
return {
value: d.nimi,
label: (pingDot ? pingDot + ' ' : '') + d.nimi,
sub: [d.hallintaosoite, d.malli].filter(Boolean).join(' — '),
searchStr: `${d.nimi} ${d.hallintaosoite || ''} ${d.malli || ''} ${d.funktio || ''} ${d.site_name || ''}`,
};
});
}
// Rakennetaan IP/verkko -combobox optiot
function getIpComboOptions(source) {
const ips = source || netadminData.ips || [];
const items = [];
// Vapaat IP:t
const free = ips.filter(i => i.tila === 'vapaa' && i.tyyppi === 'ip').sort((a, b) => (a.verkko || '').localeCompare(b.verkko || ''));
free.forEach(i => items.push({
value: i.verkko,
label: i.verkko,
sub: [i.nimi, i.site_name].filter(Boolean).join(' — '),
badge: 'vapaa', badgeClass: 'free',
group: 'Vapaat IP:t',
searchStr: `${i.verkko} ${i.nimi || ''} ${i.site_name || ''}`,
}));
// Varatut IP:t
const taken = ips.filter(i => i.tila === 'varattu' && i.tyyppi === 'ip').sort((a, b) => (a.verkko || '').localeCompare(b.verkko || ''));
taken.forEach(i => items.push({
value: i.verkko,
label: i.verkko,
sub: [i.asiakas, i.nimi].filter(Boolean).join(' — '),
badge: 'varattu', badgeClass: 'taken',
group: 'Varatut IP:t',
searchStr: `${i.verkko} ${i.nimi || ''} ${i.asiakas || ''} ${i.site_name || ''}`,
}));
// Subnetit
const subnets = ips.filter(i => i.tyyppi === 'subnet').sort((a, b) => (a.verkko || '').localeCompare(b.verkko || ''));
subnets.forEach(i => items.push({
value: i.verkko,
label: i.verkko,
sub: [i.nimi, i.site_name].filter(Boolean).join(' — '),
badge: 'subnet', badgeClass: 'subnet',
group: 'Verkot',
searchStr: `${i.verkko} ${i.nimi || ''} ${i.site_name || ''}`,
}));
return items;
}
async function openNetadminDetail(connId) {
try {
const conn = await apiCall(`netadmin_connection&id=${connId}`);
document.getElementById('na-edit-id').value = conn.id;
document.getElementById('netadmin-detail-title').textContent = conn.asennusosoite || 'Liittymän tiedot';
document.getElementById('netadmin-detail-customer').textContent = '👤 ' + (conn.customer_name || '-');
document.getElementById('na-edit-osoite').value = conn.asennusosoite || '';
document.getElementById('na-edit-postinumero').value = conn.postinumero || '';
document.getElementById('na-edit-kaupunki').value = conn.kaupunki || '';
// Nopeus: aseta dropdown-arvo, tai lisää custom-optio jos ei löydy
const speedSel = document.getElementById('na-edit-nopeus');
const speed = conn.liittymanopeus || '';
if (speed && !Array.from(speedSel.options).some(o => o.value === speed)) {
const opt = document.createElement('option');
opt.value = speed;
opt.textContent = speed;
speedSel.insertBefore(opt, speedSel.lastElementChild);
}
speedSel.value = speed;
// Populoi VLAN, Laite ja IP hakukentät IPAM/Tekniikka-datasta
initCombo(document.getElementById('na-combo-vlan'), getVlanComboOptions(), conn.vlan || '');
initCombo(document.getElementById('na-combo-laite'), getDeviceComboOptions(), conn.laite || '');
initCombo(document.getElementById('na-combo-ip'), getIpComboOptions(), conn.ip || '');
document.getElementById('na-edit-portti').value = conn.portti || '';
document.getElementById('netadmin-detail-modal').style.display = '';
} catch (e) { alert('Liittymän avaus epäonnistui: ' + e.message); }
}
function closeNetadminDetail() {
document.getElementById('netadmin-detail-modal').style.display = 'none';
}
// Sulje modal klikkaamalla taustaa
document.getElementById('netadmin-detail-modal')?.addEventListener('click', (e) => {
if (e.target.id === 'netadmin-detail-modal') closeNetadminDetail();
});
// Tallenna liittymän muutokset
document.getElementById('netadmin-detail-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const connId = document.getElementById('na-edit-id').value;
try {
await apiCall('netadmin_connection_update', 'POST', {
id: parseInt(connId),
asennusosoite: document.getElementById('na-edit-osoite').value,
postinumero: document.getElementById('na-edit-postinumero').value,
kaupunki: document.getElementById('na-edit-kaupunki').value,
liittymanopeus: document.getElementById('na-edit-nopeus').value,
vlan: document.getElementById('na-edit-vlan').value,
laite: document.getElementById('na-edit-laite').value,
portti: document.getElementById('na-edit-portti').value,
ip: document.getElementById('na-edit-ip').value
});
closeNetadminDetail();
loadNetadmin();
} catch (e) { alert('Tallennus epäonnistui: ' + e.message); }
});
// ==================== FOOTER: KEHITYSEHDOTUS ====================
function openFeatureSuggestion() {
switchToTab('todo', 'features');
// Pieni viive jotta tab ehtii latautua
setTimeout(() => {
openFeatureEdit(null);
}, 200);
}
// ==================== DOKUMENTIT ====================
let allDocuments = [];
let currentDocument = null;
let allDocFolders = [];
let currentDocFolderId = null; // null = root (kaikki)
let docSubTabMode = 'docs-all'; // 'docs-all' | 'docs-kokoukset'
const docCategoryLabels = {
sopimus: 'Sopimus',
lasku: 'Lasku',
ohje: 'Ohje',
raportti: 'Raportti',
kuva: 'Kuva',
kokousmuistio: 'Kokousmuistio',
muu: 'Muu'
};
let currentDocCustomerId = null; // Valittu asiakaskansio
function showDocCustomerFoldersView() {
document.getElementById('docs-customer-folders-view').style.display = '';
document.getElementById('docs-list-view').style.display = 'none';
document.getElementById('doc-read-view').style.display = 'none';
document.getElementById('doc-edit-view').style.display = 'none';
}
function showDocsListView() {
document.getElementById('docs-customer-folders-view').style.display = 'none';
document.getElementById('docs-list-view').style.display = '';
document.getElementById('doc-read-view').style.display = 'none';
document.getElementById('doc-edit-view').style.display = 'none';
}
function showDocReadView() {
document.getElementById('docs-customer-folders-view').style.display = 'none';
document.getElementById('docs-list-view').style.display = 'none';
document.getElementById('doc-read-view').style.display = '';
document.getElementById('doc-edit-view').style.display = 'none';
}
function showDocEditView() {
document.getElementById('docs-customer-folders-view').style.display = 'none';
document.getElementById('docs-list-view').style.display = 'none';
document.getElementById('doc-read-view').style.display = 'none';
document.getElementById('doc-edit-view').style.display = '';
}
async function loadDocuments() {
try {
allDocuments = await apiCall('documents');
// Lataa kansiot asiakaskohtaisesti
if (currentDocCustomerId) {
try { allDocFolders = await apiCall('document_folders&customer_id=' + currentDocCustomerId); } catch (e2) { allDocFolders = []; }
renderDocFolderBar();
renderDocumentsList();
} else {
allDocFolders = [];
renderDocCustomerFolders();
}
} catch (e) { console.error('Dokumenttien lataus epäonnistui:', e); }
}
function renderDocCustomerFolders() {
const grid = document.getElementById('doc-customer-folders-grid');
const noFolders = document.getElementById('no-doc-folders');
const search = (document.getElementById('doc-folder-search')?.value || '').toLowerCase().trim();
// Hae asiakasnimien map
const customerNameMap = {};
if (typeof customers !== 'undefined') {
customers.forEach(c => { customerNameMap[c.id] = c.yritys; });
}
// Laske dokumenttien määrä per asiakas
const docCountMap = {};
allDocuments.forEach(d => {
if (d.customer_id) {
docCountMap[d.customer_id] = (docCountMap[d.customer_id] || 0) + 1;
}
});
// Näytä kaikki asiakkaat joilla on dokumentteja TAI kaikki aktiiviset asiakkaat
let folderList = [];
if (typeof customers !== 'undefined') {
customers.forEach(c => {
const count = docCountMap[c.id] || 0;
folderList.push({ id: c.id, name: c.yritys || c.id, count });
});
}
// Lisää asiakkaat jotka ovat dokumenteissa mutta eivät customers-listassa
Object.keys(docCountMap).forEach(custId => {
if (!folderList.find(f => f.id === custId)) {
folderList.push({ id: custId, name: customerNameMap[custId] || custId, count: docCountMap[custId] });
}
});
// Suodata hakusanalla
if (search) {
folderList = folderList.filter(f => f.name.toLowerCase().includes(search));
}
// Järjestä: ensin ne joilla on dokumentteja (count desc), sitten aakkosjärjestys
folderList.sort((a, b) => {
if (b.count !== a.count) return b.count - a.count;
return a.name.localeCompare(b.name, 'fi');
});
if (folderList.length === 0) {
grid.innerHTML = '';
noFolders.style.display = '';
return;
}
noFolders.style.display = 'none';
grid.innerHTML = folderList.map(f => `
<div class="doc-customer-folder${f.count === 0 ? ' empty' : ''}" onclick="openDocCustomerFolder('${f.id}')">
<div class="doc-customer-folder-icon">🏢</div>
<div class="doc-customer-folder-name">${esc(f.name)}</div>
<div class="doc-customer-folder-count">${f.count} ${f.count === 1 ? 'dokumentti' : 'dokumenttia'}</div>
</div>
`).join('');
}
function openDocCustomerFolder(customerId) {
currentDocCustomerId = customerId;
currentDocFolderId = null;
docSubTabMode = 'docs-all';
// Aseta otsikko
const customerNameMap = {};
if (typeof customers !== 'undefined') {
customers.forEach(c => { customerNameMap[c.id] = c.yritys; });
}
const name = customerNameMap[customerId] || customerId;
document.getElementById('docs-list-title').textContent = '📄 ' + name;
// Reset sub-tab
document.querySelectorAll('#doc-sub-tab-bar .sub-tab').forEach(t => t.classList.remove('active'));
const allBtn = document.querySelector('[data-doc-subtab="docs-all"]');
if (allBtn) allBtn.classList.add('active');
document.getElementById('btn-new-document').style.display = '';
document.getElementById('btn-new-meeting-note').style.display = 'none';
showDocsListView();
renderDocFolderBar();
renderDocumentsList();
window.location.hash = 'documents/' + customerId;
}
function backToDocCustomerFolders() {
currentDocCustomerId = null;
currentDocFolderId = null;
showDocCustomerFoldersView();
renderDocCustomerFolders();
window.location.hash = 'documents';
}
document.getElementById('btn-docs-back-to-folders')?.addEventListener('click', backToDocCustomerFolders);
document.getElementById('doc-folder-search')?.addEventListener('input', renderDocCustomerFolders);
// ---- Kansionavigointi ----
function renderDocFolderBar() {
const bc = document.getElementById('doc-breadcrumbs');
if (!bc) return;
// Piilotetaan kansiot kokoukset-subtabissa
const showFolders = docSubTabMode !== 'docs-kokoukset';
document.getElementById('doc-folder-bar').style.display = showFolders ? 'flex' : 'none';
document.getElementById('doc-folders-grid').style.display = showFolders ? 'flex' : 'none';
if (!showFolders) return;
let crumbs = `<a href="#" onclick="navigateDocFolder(null);return false;">📁 Kaikki</a>`;
if (currentDocFolderId) {
const path = getDocFolderPath(currentDocFolderId);
path.forEach(f => {
crumbs += `<span class="bc-sep">/</span><a href="#" onclick="navigateDocFolder('${f.id}');return false;">${esc(f.name)}</a>`;
});
}
bc.innerHTML = crumbs;
// Alikansiot
const subfolders = allDocFolders.filter(f => (f.parent_id || null) === currentDocFolderId);
const grid = document.getElementById('doc-folders-grid');
grid.innerHTML = subfolders.map(f =>
`<div class="doc-folder-item" onclick="navigateDocFolder('${f.id}')">📁 ${esc(f.name)}<button class="doc-folder-delete" onclick="event.stopPropagation();deleteDocFolder('${f.id}','${esc(f.name)}')" title="Poista kansio">🗑</button></div>`
).join('');
}
function getDocFolderPath(folderId) {
const path = [];
let current = folderId;
let safety = 20;
while (current && safety-- > 0) {
const folder = allDocFolders.find(f => f.id === current);
if (!folder) break;
path.unshift(folder);
current = folder.parent_id || null;
}
return path;
}
function navigateDocFolder(folderId) {
currentDocFolderId = folderId;
renderDocFolderBar();
renderDocumentsList();
}
async function deleteDocFolder(folderId, folderName) {
if (!confirm(`Poistetaanko kansio "${folderName}"?\n\nKansion dokumentit ja alikansiot siirretään ylätasolle.`)) return;
try {
await apiCall('document_folder_delete', 'POST', { id: folderId });
await loadDocuments();
} catch (e) { alert('Kansion poisto epäonnistui: ' + e.message); }
}
// ---- Sub-tabit ----
function switchDocSubTab(target) {
docSubTabMode = target;
document.querySelectorAll('#doc-sub-tab-bar .sub-tab').forEach(t => t.classList.remove('active'));
const btn = document.querySelector(`[data-doc-subtab="${target}"]`);
if (btn) btn.classList.add('active');
const isMeeting = target === 'docs-kokoukset';
document.getElementById('btn-new-document').style.display = isMeeting ? 'none' : '';
document.getElementById('btn-new-meeting-note').style.display = isMeeting ? '' : 'none';
// Päivitä otsikko asiakkaan nimellä
const customerNameMap = {};
if (typeof customers !== 'undefined') customers.forEach(c => { customerNameMap[c.id] = c.yritys; });
const custName = currentDocCustomerId ? (customerNameMap[currentDocCustomerId] || '') : '';
document.getElementById('docs-list-title').textContent = isMeeting
? '📝 ' + (custName ? custName + ' — Kokoukset' : 'Kokoukset')
: '📄 ' + (custName || 'Dokumentit');
// Nollaa kansionavigointi kokoukset-tilassa
if (isMeeting) currentDocFolderId = null;
renderDocFolderBar();
renderDocumentsList();
// URL hash
window.location.hash = isMeeting ? 'documents/kokoukset' : (currentDocCustomerId ? 'documents/' + currentDocCustomerId : 'documents');
}
document.querySelectorAll('#doc-sub-tab-bar .sub-tab').forEach(btn => {
btn.addEventListener('click', () => switchDocSubTab(btn.dataset.docSubtab));
});
// ---- Dokumenttilista ----
function renderDocumentsList() {
const query = (document.getElementById('doc-search')?.value || '').toLowerCase().trim();
let filtered = allDocuments;
// Suodata valitun asiakkaan perusteella
if (currentDocCustomerId) {
filtered = filtered.filter(d => d.customer_id === currentDocCustomerId);
}
// Sub-tab suodatus: kokoukset = vain kokousmuistiot, dokumentit = ei kokousmuistioita
if (docSubTabMode === 'docs-kokoukset') {
filtered = filtered.filter(d => d.category === 'kokousmuistio');
} else {
filtered = filtered.filter(d => d.category !== 'kokousmuistio');
}
// Kansiosuodatus (vain "Kaikki"-tilassa)
if (docSubTabMode !== 'docs-kokoukset') {
if (currentDocFolderId !== null) {
filtered = filtered.filter(d => d.folder_id === currentDocFolderId);
} else {
// Juuritasolla: näytä vain ilman kansiota olevat
filtered = filtered.filter(d => !d.folder_id);
}
}
if (query) {
filtered = filtered.filter(d =>
(d.title || '').toLowerCase().includes(query) ||
(d.description || '').toLowerCase().includes(query)
);
}
const tbody = document.getElementById('docs-tbody');
const noDocsEl = document.getElementById('no-docs');
if (filtered.length === 0) {
tbody.innerHTML = '';
noDocsEl.style.display = '';
return;
}
noDocsEl.style.display = 'none';
tbody.innerHTML = filtered.map(d => {
const version = d.current_version || 0;
const date = d.muokattu ? new Date(d.muokattu).toLocaleDateString('fi-FI') : '-';
const author = d.version_author || d.created_by || '-';
return `<tr onclick="openDocRead('${d.id}')" style="cursor:pointer;">
<td><strong>${esc(d.title)}</strong></td>
<td style="text-align:center;">v${version}</td>
<td>${date}</td>
<td>${esc(author)}</td>
</tr>`;
}).join('');
}
document.getElementById('doc-search')?.addEventListener('input', renderDocumentsList);
async function openDocRead(docId) {
try {
currentDocument = await apiCall(`document&id=${docId}`);
renderDocReadView();
showDocReadView();
} catch (e) { alert('Dokumentin avaus epäonnistui: ' + e.message); }
}
function renderDocReadView() {
const d = currentDocument;
if (!d) return;
// Asiakasnimen haku
let customerName = 'Ei asiakasta (yleinen)';
if (d.customer_id && typeof customers !== 'undefined') {
const c = customers.find(c => c.id === d.customer_id);
if (c) customerName = c.yritys;
}
document.getElementById('doc-read-title').textContent = d.title || '';
document.getElementById('doc-read-customer').textContent = '👤 ' + customerName;
document.getElementById('doc-read-category').innerHTML = `<span class="doc-category cat-${d.category || 'muu'}">${docCategoryLabels[d.category] || d.category || 'Muu'}</span>`;
const maxV = (d.max_versions && d.max_versions > 0) ? d.max_versions : '∞';
document.getElementById('doc-read-version').textContent = `📌 Versio ${d.current_version || 0} (max ${maxV})`;
document.getElementById('doc-read-date').textContent = d.muokattu ? '📅 ' + new Date(d.muokattu).toLocaleDateString('fi-FI') : '';
const isMeeting = d.category === 'kokousmuistio';
// Kuvaus: kokousmuistioille näytetään osallistujat
if (isMeeting && d.description) {
document.getElementById('doc-read-description').textContent = 'Osallistujat: ' + d.description;
} else {
document.getElementById('doc-read-description').textContent = d.description || '';
}
// Poista-nappi: näytetään adminille tai dokumentin luojalle
const isAdmin = isCurrentUserAdmin();
const isOwner = d.created_by === (currentUser?.username || '');
document.getElementById('btn-doc-delete').style.display = (isAdmin || isOwner) ? '' : 'none';
// Kokousmuistio vs tiedostopohjainen
const contentSection = document.getElementById('doc-read-content-section');
const inlineEditor = document.getElementById('doc-inline-editor');
const fileSection = document.getElementById('doc-file-section');
const uploadSection = document.getElementById('doc-upload-section');
if (isMeeting) {
// Näytä sisältö, piilota tiedosto-osiot
fileSection.style.display = 'none';
uploadSection.style.display = 'none';
inlineEditor.style.display = 'none';
contentSection.style.display = '';
const currentVersion = d.versions?.find(v => v.version_number === d.current_version);
document.getElementById('doc-read-content').textContent = currentVersion?.content || '(Tyhjä muistio)';
} else {
// Tiedostopohjainen: piilota kokousmuistio-osiot
contentSection.style.display = 'none';
inlineEditor.style.display = 'none';
fileSection.style.display = (d.current_version && d.current_version > 0) ? '' : 'none';
uploadSection.style.display = '';
}
// Versiohistoria
const vtbody = document.getElementById('doc-versions-tbody');
if (!d.versions || d.versions.length === 0) {
vtbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:#aaa;padding:1rem;">Ei versioita vielä.</td></tr>';
} else {
vtbody.innerHTML = d.versions.map(v => {
const date = v.luotu ? new Date(v.luotu).toLocaleDateString('fi-FI', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-';
const isCurrent = v.version_number === d.current_version;
const sizeDisplay = isMeeting ? (v.content ? v.content.length + ' merkkiä' : '-') : formatFileSize(v.file_size || 0);
const downloadLink = isMeeting
? `<button onclick="viewMeetingVersion('${v.id}', ${v.version_number})" title="Näytä versio" style="background:none;border:none;cursor:pointer;font-size:1rem;">👁️</button>`
: `<a href="${API}?action=document_download&id=${d.id}&version=${v.version_number}" target="_blank" title="Lataa">⬇️</a>`;
return `<tr${isCurrent ? ' style="background:#f0f7ff;"' : ''}>
<td style="font-weight:600;">v${v.version_number}${isCurrent ? ' ✓' : ''}</td>
<td>${date}</td>
<td>${esc(v.created_by || '-')}</td>
<td>${esc(v.change_notes || '-')}</td>
<td>${sizeDisplay}</td>
<td class="actions-cell">
${downloadLink}
${isAdmin && !isCurrent ? `<button onclick="restoreDocVersion('${d.id}', '${v.id}', ${v.version_number})" title="Palauta tämä versio" style="background:none;border:none;cursor:pointer;font-size:1rem;">🔄</button>` : ''}
</td>
</tr>`;
}).join('');
}
}
// Latausnappi
document.getElementById('btn-doc-download')?.addEventListener('click', () => {
if (!currentDocument || !currentDocument.current_version) return;
window.open(`${API}?action=document_download&id=${currentDocument.id}&version=${currentDocument.current_version}`, '_blank');
});
// Uusi versio
document.getElementById('btn-doc-upload-version')?.addEventListener('click', async () => {
const fileInput = document.getElementById('doc-version-file');
const notesInput = document.getElementById('doc-version-notes');
if (!fileInput.files.length) { alert('Valitse tiedosto'); return; }
if (!currentDocument) return;
const fd = new FormData();
fd.append('document_id', currentDocument.id);
fd.append('file', fileInput.files[0]);
fd.append('change_notes', notesInput.value || '');
try {
const res = await fetch(`${API}?action=document_upload`, { method: 'POST', credentials: 'include', body: fd });
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch (e) { throw new Error('Palvelin palautti virheellisen vastauksen'); }
if (!res.ok) throw new Error(data.error || 'Virhe');
currentDocument = data;
renderDocReadView();
fileInput.value = '';
notesInput.value = '';
} catch (e) { alert('Tiedoston lataus epäonnistui: ' + e.message); }
});
async function restoreDocVersion(docId, versionId, versionNum) {
if (!confirm(`Palautetaanko versio ${versionNum}? Siitä tulee uusi nykyinen versio.`)) return;
try {
currentDocument = await apiCall('document_restore', 'POST', { document_id: docId, version_id: versionId });
renderDocReadView();
} catch (e) { alert('Palautus epäonnistui: ' + e.message); }
}
// Poista dokumentti
document.getElementById('btn-doc-delete')?.addEventListener('click', async () => {
if (!currentDocument) return;
if (!confirm(`Poistetaanko dokumentti "${currentDocument.title}" ja kaikki sen versiot?`)) return;
try {
await apiCall('document_delete', 'POST', { id: currentDocument.id });
currentDocument = null;
showDocsListView();
loadDocuments();
} catch (e) { alert('Poisto epäonnistui: ' + e.message); }
});
// Navigaatio
document.getElementById('btn-doc-back')?.addEventListener('click', () => { showDocsListView(); });
document.getElementById('btn-doc-edit')?.addEventListener('click', () => { openDocEdit(currentDocument); });
document.getElementById('btn-doc-edit-back')?.addEventListener('click', () => {
if (currentDocument) showDocReadView();
else showDocsListView();
});
document.getElementById('btn-doc-edit-cancel')?.addEventListener('click', () => {
if (currentDocument) showDocReadView();
else showDocsListView();
});
// Uusi dokumentti
document.getElementById('btn-new-document')?.addEventListener('click', () => { openDocEdit(null); });
// Uusi kokousmuistio
document.getElementById('btn-new-meeting-note')?.addEventListener('click', () => { openDocEdit(null, 'kokousmuistio'); });
// Kokousmuistion inline-editori
document.getElementById('btn-doc-edit-content')?.addEventListener('click', () => {
const d = currentDocument;
if (!d) return;
const currentVersion = d.versions?.find(v => v.version_number === d.current_version);
document.getElementById('doc-inline-content').value = currentVersion?.content || '';
document.getElementById('doc-inline-notes').value = '';
document.getElementById('doc-inline-editor').style.display = '';
document.getElementById('doc-read-content-section').style.display = 'none';
});
document.getElementById('btn-doc-cancel-content')?.addEventListener('click', () => {
document.getElementById('doc-inline-editor').style.display = 'none';
document.getElementById('doc-read-content-section').style.display = '';
});
document.getElementById('btn-doc-save-content')?.addEventListener('click', async () => {
if (!currentDocument) return;
const content = document.getElementById('doc-inline-content').value;
const notes = document.getElementById('doc-inline-notes').value || 'Muistiota päivitetty';
try {
currentDocument = await apiCall('document_content_save', 'POST', {
document_id: currentDocument.id,
content,
change_notes: notes
});
renderDocReadView();
loadDocuments();
} catch (e) { alert('Tallennus epäonnistui: ' + e.message); }
});
// Katso kokousmuistion vanhaa versiota
window.viewMeetingVersion = function(versionId, versionNum) {
if (!currentDocument) return;
const v = currentDocument.versions?.find(x => x.id === versionId);
if (v) {
alert('Versio ' + versionNum + ':\n\n' + (v.content || '(Tyhjä)'));
}
};
// Uusi kansio
document.getElementById('btn-new-folder')?.addEventListener('click', async () => {
const name = prompt('Kansion nimi:');
if (!name || !name.trim()) return;
try {
await apiCall('document_folder_save', 'POST', {
name: name.trim(),
parent_id: currentDocFolderId || null,
customer_id: currentDocCustomerId || null
});
await loadDocuments();
} catch (e) { alert('Kansion luonti epäonnistui: ' + e.message); }
});
function openDocEdit(doc, forceCategory, forceCustomerId) {
document.getElementById('doc-edit-id').value = doc?.id || '';
document.getElementById('doc-edit-name').value = doc?.title || '';
document.getElementById('doc-edit-description').value = doc?.description || '';
const cat = forceCategory || doc?.category || 'muu';
document.getElementById('doc-edit-category').value = cat;
document.getElementById('doc-edit-folder-id').value = doc?.folder_id || currentDocFolderId || '';
document.getElementById('doc-edit-max-versions').value = doc?.max_versions ?? 10;
const isMeeting = cat === 'kokousmuistio';
document.getElementById('doc-edit-title').textContent = doc
? (isMeeting ? 'Muokkaa kokousmuistiota' : 'Muokkaa dokumenttia')
: (isMeeting ? 'Uusi kokousmuistio' : 'Uusi dokumentti');
// Aseta asiakas automaattisesti nykyisen kansion perusteella
const custSel = document.getElementById('doc-edit-customer');
custSel.innerHTML = '<option value="">Ei asiakasta (yleinen)</option>';
if (typeof customers !== 'undefined') {
customers.forEach(c => {
custSel.innerHTML += `<option value="${c.id}" ${(forceCustomerId === c.id || doc?.customer_id === c.id) ? 'selected' : ''}>${esc(c.yritys)}</option>`;
});
}
// Aseta customer_id: kansionäkymästä tai parametrista
const effectiveCustomerId = forceCustomerId || currentDocCustomerId;
if (effectiveCustomerId) custSel.value = effectiveCustomerId;
else if (doc?.customer_id) custSel.value = doc.customer_id;
// Piilota asiakas-dropdown kun ollaan asiakkaan kansiossa (automaattinen valinta)
const custGroup = custSel.closest('.form-group');
if (custGroup) custGroup.style.display = currentDocCustomerId ? 'none' : '';
// Toggle kokousmuistio vs tiedostokenttä
toggleDocMeetingFields(cat);
// Kokousmuistio-kentät
if (isMeeting) {
const currentVersion = doc?.versions?.find(v => v.version_number === doc.current_version);
document.getElementById('doc-edit-content').value = currentVersion?.content || '';
document.getElementById('doc-edit-participants').value = doc?.description || '';
} else {
document.getElementById('doc-edit-content').value = '';
document.getElementById('doc-edit-participants').value = '';
}
// Piilota tiedostokenttä muokkaustilassa (versiot hoidetaan read-viewissä)
if (!isMeeting) {
document.getElementById('doc-edit-file').parentElement.style.display = doc ? 'none' : '';
}
showDocEditView();
}
function toggleDocMeetingFields(category) {
const isMeeting = category === 'kokousmuistio';
document.getElementById('doc-edit-meeting-fields').style.display = isMeeting ? '' : 'none';
document.getElementById('doc-edit-file').parentElement.style.display = isMeeting ? 'none' : '';
document.getElementById('doc-edit-desc-group').style.display = isMeeting ? 'none' : '';
}
document.getElementById('doc-edit-category')?.addEventListener('change', (e) => {
toggleDocMeetingFields(e.target.value);
});
// Lomakkeen lähetys
document.getElementById('doc-edit-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('doc-edit-id').value;
const cat = document.getElementById('doc-edit-category').value;
const isMeeting = cat === 'kokousmuistio';
const docData = {
id: id || undefined,
title: document.getElementById('doc-edit-name').value.trim(),
description: isMeeting
? document.getElementById('doc-edit-participants').value.trim()
: document.getElementById('doc-edit-description').value.trim(),
category: cat,
customer_id: document.getElementById('doc-edit-customer').value || null,
folder_id: document.getElementById('doc-edit-folder-id').value || null,
max_versions: parseInt(document.getElementById('doc-edit-max-versions').value) || 10,
created_by: currentUser?.username || ''
};
if (!docData.title) { alert('Otsikko on pakollinen'); return; }
try {
const saved = await apiCall('document_save', 'POST', docData);
const docId = saved.id;
if (isMeeting) {
// Tallenna kokousmuistion sisältö ensimmäisenä versiona
const content = document.getElementById('doc-edit-content').value;
if (content || !id) {
await apiCall('document_content_save', 'POST', {
document_id: docId,
content: content,
change_notes: id ? 'Muistiota päivitetty' : 'Ensimmäinen versio'
});
}
} else {
// Jos uusi dokumentti ja tiedosto valittu → lataa ensimmäinen versio
const fileInput = document.getElementById('doc-edit-file');
if (!id && fileInput.files.length > 0) {
const fd = new FormData();
fd.append('document_id', docId);
fd.append('file', fileInput.files[0]);
fd.append('change_notes', 'Ensimmäinen versio');
const res = await fetch(`${API}?action=document_upload`, { method: 'POST', credentials: 'include', body: fd });
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch (err) { throw new Error('Tiedoston lataus epäonnistui'); }
if (!res.ok) throw new Error(data.error || 'Virhe');
}
}
currentDocument = await apiCall(`document&id=${docId}`);
renderDocReadView();
showDocReadView();
loadDocuments();
} catch (e) { alert('Tallennus epäonnistui: ' + e.message); }
});
// ==================== LAITETILAT ====================
let allLaitetilat = [];
let currentLaitetila = null;
function showLaitetilatListView() {
document.getElementById('laitetilat-list-view').style.display = '';
document.getElementById('laitetila-read-view').style.display = 'none';
document.getElementById('laitetila-edit-view').style.display = 'none';
}
function showLaitetilaReadView() {
document.getElementById('laitetilat-list-view').style.display = 'none';
document.getElementById('laitetila-read-view').style.display = '';
document.getElementById('laitetila-edit-view').style.display = 'none';
}
function showLaitetilaEditView() {
document.getElementById('laitetilat-list-view').style.display = 'none';
document.getElementById('laitetila-read-view').style.display = 'none';
document.getElementById('laitetila-edit-view').style.display = '';
}
async function loadLaitetilat() {
try {
allLaitetilat = await apiCall('laitetilat');
renderLaitetilatList();
} catch (e) { console.error('Laitetilojen lataus epäonnistui:', e); }
}
function renderLaitetilatList() {
const grid = document.getElementById('laitetilat-grid');
const noEl = document.getElementById('no-laitetilat');
if (allLaitetilat.length === 0) {
grid.innerHTML = '';
noEl.style.display = '';
return;
}
noEl.style.display = 'none';
grid.innerHTML = allLaitetilat.map(t => {
const dc = t.device_count || 0;
const devList = (t.devices || []).slice(0, 4).map(d => {
const ping = d.ping_status === 'up' ? '🟢' : d.ping_status === 'down' ? '🔴' : '⚪';
return `<span class="lt-dev-chip">${ping} ${esc(d.nimi)}</span>`;
}).join('');
const moreCount = dc > 4 ? `<span class="lt-dev-more">+${dc - 4} muuta</span>` : '';
return `<div class="laitetila-card" onclick="openLaitetilaRead('${t.id}')">
<h4>${esc(t.nimi)}</h4>
<p class="laitetila-osoite">${esc(t.osoite || '')}</p>
<div class="laitetila-stats">
<span>🖥 ${dc} laitetta</span>
<span>📁 ${t.file_count || 0} tiedostoa</span>
</div>
${dc > 0 ? `<div class="lt-dev-list">${devList}${moreCount}</div>` : ''}
</div>`;
}).join('');
}
async function openLaitetilaRead(tilaId) {
try {
// Lataa laitetiedot rinnakkain jos ei vielä ladattu
const [tila] = await Promise.all([
apiCall(`laitetila&id=${tilaId}`),
devicesData.length ? Promise.resolve() : apiCall('devices').then(d => { devicesData = d; })
]);
currentLaitetila = tila;
renderLaitetilaReadView();
showLaitetilaReadView();
} catch (e) { alert('Laitetilan avaus epäonnistui: ' + e.message); }
}
function renderLaitetilaReadView() {
const t = currentLaitetila;
if (!t) return;
document.getElementById('laitetila-read-nimi').textContent = t.nimi || '';
document.getElementById('laitetila-read-osoite').textContent = t.osoite ? '📍 ' + t.osoite : '';
document.getElementById('laitetila-read-kuvaus').textContent = t.kuvaus || '';
const isAdmin = isCurrentUserAdmin();
document.getElementById('btn-laitetila-delete').style.display = isAdmin ? '' : 'none';
// Erota kuvat ja muut tiedostot
const files = t.files || [];
const images = files.filter(f => (f.mime_type || '').startsWith('image/'));
const otherFiles = files.filter(f => !(f.mime_type || '').startsWith('image/'));
// Kuvagalleria
const gallerySection = document.getElementById('laitetila-gallery');
const galleryGrid = document.getElementById('laitetila-gallery-grid');
if (images.length > 0) {
gallerySection.style.display = '';
galleryGrid.innerHTML = images.map(f => {
const imgUrl = `${API}?action=laitetila_file_download&laitetila_id=${t.id}&file_id=${f.id}`;
return `<div class="gallery-item">
<img src="${imgUrl}" alt="${esc(f.original_name)}" onclick="window.open('${imgUrl}', '_blank')" title="Klikkaa avataksesi">
<div class="gallery-caption">
<span>${esc(f.original_name)}</span>
${isAdmin ? `<button onclick="deleteLaitetilaFile('${f.id}')" class="btn-icon" title="Poista">🗑</button>` : ''}
</div>
</div>`;
}).join('');
} else {
gallerySection.style.display = 'none';
}
// Muut tiedostot
const filesSection = document.getElementById('laitetila-files-section');
const filesList = document.getElementById('laitetila-files-list');
if (otherFiles.length > 0) {
filesSection.style.display = '';
filesList.innerHTML = otherFiles.map(f => {
const dlUrl = `${API}?action=laitetila_file_download&laitetila_id=${t.id}&file_id=${f.id}`;
return `<div class="laitetila-file-item">
<div>
<a href="${dlUrl}" target="_blank" class="file-name">${esc(f.original_name)}</a>
<span class="file-meta">${formatFileSize(f.file_size || 0)} · ${f.luotu ? new Date(f.luotu).toLocaleDateString('fi-FI') : ''}</span>
${f.description ? `<span class="file-desc">${esc(f.description)}</span>` : ''}
</div>
${isAdmin ? `<button onclick="deleteLaitetilaFile('${f.id}')" class="btn-icon" title="Poista">🗑</button>` : ''}
</div>`;
}).join('');
} else {
filesSection.style.display = 'none';
}
// Laitteet tässä tilassa
const devSection = document.getElementById('laitetila-devices-section');
const devList = document.getElementById('laitetila-devices-list');
// Hae laitteet jotka on linkitetty tähän laitetilaan
const tilaDevices = (devicesData || []).filter(d => d.laitetila_id === t.id);
if (tilaDevices.length > 0) {
devSection.style.display = '';
devList.innerHTML = `<table class="lt-devices-table"><thead><tr><th>Laite</th><th>Tyyppi</th><th>Malli</th><th>IP</th><th>Tila</th></tr></thead><tbody>${
tilaDevices.map(d => {
const ping = d.ping_status === 'up' ? '🟢' : d.ping_status === 'down' ? '🔴' : '⚪';
return `<tr>
<td><strong>${esc(d.nimi)}</strong></td>
<td>${esc(d.tyyppi || '-')}</td>
<td>${esc(d.malli || '-')}</td>
<td><code>${esc(d.hallintaosoite || '-')}</code></td>
<td>${ping}</td>
</tr>`;
}).join('')
}</tbody></table>`;
} else {
devSection.style.display = '';
devList.innerHTML = '<p style="color:#aaa;font-size:0.85rem;">Ei laitteita tässä tilassa. Linkitä laitteita Tekniikka → Laitteet -osiossa.</p>';
}
}
// Tiedoston lataus
document.getElementById('btn-laitetila-upload')?.addEventListener('click', async () => {
const fileInput = document.getElementById('laitetila-file-input');
const descInput = document.getElementById('laitetila-file-desc');
if (!fileInput.files.length) { alert('Valitse tiedosto'); return; }
if (!currentLaitetila) return;
for (const file of fileInput.files) {
const fd = new FormData();
fd.append('laitetila_id', currentLaitetila.id);
fd.append('file', file);
fd.append('description', descInput.value || '');
try {
const res = await fetch(`${API}?action=laitetila_file_upload`, { method: 'POST', credentials: 'include', body: fd });
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch (e) { throw new Error('Palvelin palautti virheellisen vastauksen'); }
if (!res.ok) throw new Error(data.error || 'Virhe');
currentLaitetila = data;
} catch (e) { alert('Tiedoston lataus epäonnistui: ' + e.message); }
}
renderLaitetilaReadView();
fileInput.value = '';
descInput.value = '';
});
async function deleteLaitetilaFile(fileId) {
if (!confirm('Poistetaanko tiedosto?')) return;
try {
await apiCall('laitetila_file_delete', 'POST', { id: fileId });
// Päivitä näkymä
currentLaitetila = await apiCall(`laitetila&id=${currentLaitetila.id}`);
renderLaitetilaReadView();
} catch (e) { alert('Poisto epäonnistui: ' + e.message); }
}
// Navigaatio
document.getElementById('btn-laitetila-back')?.addEventListener('click', () => { showLaitetilatListView(); });
document.getElementById('btn-laitetila-edit')?.addEventListener('click', () => { openLaitetilaEdit(currentLaitetila); });
document.getElementById('btn-laitetila-edit-back')?.addEventListener('click', () => {
if (currentLaitetila) showLaitetilaReadView();
else showLaitetilatListView();
});
document.getElementById('btn-laitetila-edit-cancel')?.addEventListener('click', () => {
if (currentLaitetila) showLaitetilaReadView();
else showLaitetilatListView();
});
// Poista laitetila
document.getElementById('btn-laitetila-delete')?.addEventListener('click', async () => {
if (!currentLaitetila) return;
if (!confirm(`Poistetaanko laitetila "${currentLaitetila.nimi}" ja kaikki sen tiedostot?`)) return;
try {
await apiCall('laitetila_delete', 'POST', { id: currentLaitetila.id });
currentLaitetila = null;
showLaitetilatListView();
loadLaitetilat();
} catch (e) { alert('Poisto epäonnistui: ' + e.message); }
});
// Uusi laitetila
document.getElementById('btn-new-laitetila')?.addEventListener('click', () => { openLaitetilaEdit(null); });
function openLaitetilaEdit(tila) {
document.getElementById('laitetila-edit-id').value = tila?.id || '';
document.getElementById('laitetila-edit-nimi').value = tila?.nimi || '';
document.getElementById('laitetila-edit-osoite').value = tila?.osoite || '';
document.getElementById('laitetila-edit-kuvaus').value = tila?.kuvaus || '';
document.getElementById('laitetila-edit-title').textContent = tila ? 'Muokkaa laitetilaa' : 'Uusi laitetila';
showLaitetilaEditView();
}
// Lomakkeen lähetys
document.getElementById('laitetila-edit-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('laitetila-edit-id').value;
const tilaData = {
id: id || undefined,
nimi: document.getElementById('laitetila-edit-nimi').value.trim(),
osoite: document.getElementById('laitetila-edit-osoite').value.trim(),
kuvaus: document.getElementById('laitetila-edit-kuvaus').value.trim()
};
if (!tilaData.nimi) { alert('Nimi on pakollinen'); return; }
try {
const saved = await apiCall('laitetila_save', 'POST', tilaData);
currentLaitetila = saved;
renderLaitetilaReadView();
showLaitetilaReadView();
loadLaitetilat();
} catch (e) { alert('Tallennus epäonnistui: ' + e.message); }
});
// ==================== MODUULIT ====================
const ALL_MODULES = ['customers', 'support', 'leads', 'tekniikka', 'ohjeet', 'todo', 'documents', 'laitetilat', 'netadmin', '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 = isCurrentUserAdmin();
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();
});