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 || [], data.has_integrations);
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 ``;
}).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 || [], auth.has_integrations);
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();
const supportSubMap = { ohjeet: 'support-ohjeet', saannot: 'support-saannot', vastauspohjat: 'support-vastauspohjat', asetukset: 'support-asetukset' };
switchSupportSubTab(supportSubMap[subTab] || 'support-tickets');
}
if (target === 'documents') {
if (subTab && subTab !== 'kokoukset') {
// subTab on customer_id → avaa suoraan asiakkaan kansio
currentDocCustomerId = subTab;
loadDocuments().then(() => 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 `
| ${isFirst ? '' + esc(c.yritys) + '' : '↳'} |
${esc(l.asennusosoite)}${l.postinumero ? ', ' + esc(l.postinumero) : ''} |
${esc(l.kaupunki)} |
${esc(l.liittymanopeus)} |
${formatPrice(l.hinta)} |
${sopimusStr} |
${isFirst ? `` : ''} |
`;
}).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 = '-';
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 ? '-' :
sorted.map(([sp, cnt]) => {
const isTop = cnt === maxC;
const w = Math.max(15, (cnt / maxC) * 50);
return `${esc(sp)} (${cnt})`;
}).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 (jatkuva)`;
const remainMonths = Math.ceil(diffMs / (1000 * 60 * 60 * 24 * 30.44));
return `${months} kk (${remainMonths} kk jäljellä)`;
}
// 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 sanitizeHtml(str) {
if (!str) return '';
// Salli turvalliset tagit, poista kaikki muut
const allowed = ['br', 'p', 'div', 'a', 'b', 'i', 'strong', 'em', 'ul', 'ol', 'li', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'span', 'table', 'tr', 'td', 'th', 'thead', 'tbody'];
const tmp = document.createElement('div');
tmp.innerHTML = str;
// Poista script, style, iframe, object, embed, form tagit kokonaan
tmp.querySelectorAll('script,style,iframe,object,embed,form,input,textarea,button,link,meta').forEach(el => el.remove());
// Poista on*-attribuutit kaikista elementeistä
tmp.querySelectorAll('*').forEach(el => {
[...el.attributes].forEach(attr => {
if (attr.name.startsWith('on') || attr.name === 'srcdoc') el.removeAttribute(attr.name);
});
// Sanitoi href — vain http/https/mailto
if (el.hasAttribute('href')) {
const href = el.getAttribute('href') || '';
if (!/^(https?:|mailto:)/i.test(href)) el.removeAttribute('href');
}
});
return tmp.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) : '-'; }
function detailLink(val, type) {
if (!val) return '-';
if (type === 'tel') return `${esc(val)}`;
if (type === 'email') return `${esc(val)}`;
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 `
${liittymat.length > 1 ? `
Liittymä ${i + 1}
` : ''}
Nopeus
${detailVal(l.liittymanopeus)}
Hinta / kk
${formatPrice(l.hinta)}
Sopimuskausi
${contractRemaining(l.sopimuskausi, l.alkupvm) || '-'}
Alkaen
${detailVal(l.alkupvm)}
Laite
${detailVal(l.laite)}
Portti
${detailVal(l.portti)}
`;
}).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 = `
Perustiedot
Yritys
${detailVal(c.yritys)}
Y-tunnus
${detailVal(c.ytunnus)}
Liittymät (${liittymat.length})
${liittymatHtml}
${liittymat.length > 1 ? `
${formatPrice(totalH)}/kk
` : ''}
Yhteystiedot
Yhteyshenkilö
${detailVal(c.yhteyshenkilö)}
Puhelin
${detailLink(c.puhelin, 'tel')}
Sähköposti
${detailLink(c.sahkoposti, 'email')}
Laskutustiedot
Laskutusosoite
${detailVal(fullBilling)}
Laskutussähköposti
${detailLink(c.laskutussahkoposti, 'email')}
E-laskuosoite
${detailVal(c.elaskuosoite)}
E-laskuvälittäjä
${detailVal(c.elaskuvalittaja)}
${c.lisatiedot ? `Lisätiedot
${esc(c.lisatiedot)}
` : ''}
Dokumentit
Ladataan...
`;
// 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 = 'Ei dokumentteja.
';
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 ``;
}).join('');
} catch (e) {
container.innerHTML = 'Virhe ladattaessa dokumentteja.
';
}
}
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.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 => `
| ${esc(l.yritys)} |
${esc(l.yhteyshenkilo || '')} |
${esc(l.kaupunki || '')} |
${leadStatusLabels[l.tila] || l.tila || 'Uusi'} |
${esc((l.luotu || '').substring(0, 10))} |
|
`).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 = `
Yritys
${detailVal(l.yritys)}
Tila
${leadStatusLabels[l.tila] || 'Uusi'}
Yhteyshenkilö
${detailVal(l.yhteyshenkilo)}
Puhelin
${detailLink(l.puhelin, 'tel')}
Sähköposti
${detailLink(l.sahkoposti, 'email')}
Osoite
${detailVal([l.osoite, l.kaupunki].filter(Boolean).join(', '))}
Lisätty
${detailVal(l.luotu)} (${esc(l.luoja || '')})
${l.muokattu ? `
Muokattu
${timeAgo(l.muokattu)} (${esc(l.muokkaaja || '')})
` : ''}
${l.muistiinpanot ? `
MUISTIINPANOT
${esc(l.muistiinpanot)}
` : ''}
`;
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 => `
| ${esc(c.yritys)} |
${(c.liittymat || []).length} |
${esc(c.arkistoitu || '')} |
${esc(c.arkistoija || '')} |
${isCurrentUserAdmin() ? `` : ''}
|
`).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 => `
| ${esc(e.timestamp)} |
${esc(e.user)} |
${actionLabels[e.action] || esc(e.action)} |
${esc(e.customer_name)} |
${esc(e.details)} |
`).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 => `
| ${esc(u.username)} |
${esc(u.nimi)} |
${esc(u.email || '')} |
${u.role === 'superadmin' ? 'Pääkäyttäjä' :
(u.company_roles || {})[currentCompany?.id] === 'admin'
? 'Admin'
: 'Käyttäjä'} |
${esc(u.luotu)} |
${u.id !== '${currentUser.id}' ? `` : ''}
|
`).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 `
`;
}).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 =>
`
`
).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 currentTicketData = null;
let ticketReplyType = 'reply';
const ticketStatusLabels = {
uusi: 'Uusi',
kasittelyssa: 'Käsittelyssä',
odottaa: 'Odottaa vastausta',
suljettu: 'Suljettu',
};
let ticketTypeLabels = {
laskutus: 'Laskutus',
tekniikka: 'Tekniikka',
vika: 'Vika',
abuse: 'Abuse',
muu: 'Muu',
};
let ticketPage = 1;
let TICKETS_PER_PAGE = 100;
async function loadTickets() {
try {
// Hae kaikkien yritysten tiketit jos useampi yritys
const allParam = availableCompanies.length > 1 ? '&all=1' : '';
tickets = await apiCall('tickets' + allParam);
ticketPage = 1;
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;
const showMine = document.getElementById('ticket-show-mine').checked;
let filtered = tickets;
// Piilota piilotettujen postilaatikoiden ja Zammad-ryhmien tiketit
if (currentHiddenMailboxes.length > 0) {
filtered = filtered.filter(t => {
// Piilota mailbox-perusteisesti
if (t.mailbox_id && (currentHiddenMailboxes.includes(String(t.mailbox_id)) || currentHiddenMailboxes.includes(t.mailbox_id))) return false;
// Piilota Zammad-ryhmä-perusteisesti
if (t.source === 'zammad' && t.zammad_group && currentHiddenMailboxes.includes('zammad_group:' + t.zammad_group)) return false;
return true;
});
}
// 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);
}
// Vain omat (assigned_to === nykyinen käyttäjä)
if (showMine && currentUser) {
filtered = filtered.filter(t => t.assigned_to === currentUser.username);
}
// 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');
// Paginointi
const totalFiltered = filtered.length;
const totalPages = Math.max(1, Math.ceil(totalFiltered / TICKETS_PER_PAGE));
if (ticketPage > totalPages) ticketPage = totalPages;
const startIdx = (ticketPage - 1) * TICKETS_PER_PAGE;
const pageTickets = filtered.slice(startIdx, startIdx + TICKETS_PER_PAGE);
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 = pageTickets.map(t => {
const lastType = (t.last_message_type === 'reply_out' || t.last_message_type === 'outgoing') ? '→' : (t.last_message_type === 'note' ? '📝' : '←');
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 ? `${esc(t.company_name)} ` : '';
const prioBadge = t.priority === 'urgent' ? '🚨 ' : (t.priority === 'tärkeä' ? '⚠️ ' : '');
return `
|
${ticketStatusLabels[t.status] || t.status} |
${t.customer_name ? esc(t.customer_name) : '-'} |
${typeLabel} |
${prioBadge}${companyBadge}${t.ticket_number ? `#${t.ticket_number}` : ''}${esc(t.subject)} |
${esc(t.mailbox_name || t.from_name || t.from_email)} |
${lastType} ${t.message_count} |
${timeAgo(t.updated)} |
${t.assigned_to ? esc(t.assigned_to) : '—'} |
`;
}).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;
const closedCount = tickets.filter(t => t.status === 'suljettu').length;
let countText = `${openCount} avointa tikettiä`;
if (showClosed && closedCount > 0) countText += ` (${closedCount} suljettua)`;
document.getElementById('ticket-count').textContent = countText;
// 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(' · ');
// Paginointipalkki
renderTicketPagination(totalFiltered, totalPages);
}
function renderTicketPagination(totalFiltered, totalPages) {
let paginationEl = document.getElementById('ticket-pagination');
if (!paginationEl) {
paginationEl = document.createElement('div');
paginationEl.id = 'ticket-pagination';
paginationEl.style.cssText = 'display:flex;align-items:center;justify-content:center;gap:0.5rem;padding:0.75rem 0;flex-wrap:wrap;';
const table = document.getElementById('tickets-table');
table.parentNode.insertBefore(paginationEl, table.nextSibling);
}
if (totalPages <= 1) {
paginationEl.innerHTML = totalFiltered > 0 ? `${totalFiltered} tikettiä` : '';
return;
}
let html = '';
// Edellinen-nappi
html += ``;
html += ``;
// Sivunumerot
const maxShow = 5;
let startPage = Math.max(1, ticketPage - Math.floor(maxShow / 2));
let endPage = Math.min(totalPages, startPage + maxShow - 1);
if (endPage - startPage < maxShow - 1) startPage = Math.max(1, endPage - maxShow + 1);
if (startPage > 1) html += `...`;
for (let p = startPage; p <= endPage; p++) {
if (p === ticketPage) {
html += ``;
} else {
html += ``;
}
}
if (endPage < totalPages) html += `...`;
// Seuraava-nappi
html += ``;
html += ``;
// Näytetään sivuinfo
const startNum = (ticketPage - 1) * TICKETS_PER_PAGE + 1;
const endNum = Math.min(ticketPage * TICKETS_PER_PAGE, totalFiltered);
html += `${startNum}–${endNum} / ${totalFiltered}`;
paginationEl.innerHTML = html;
}
document.getElementById('ticket-search-input').addEventListener('input', () => { ticketPage = 1; renderTickets(); });
document.getElementById('ticket-status-filter').addEventListener('change', () => { ticketPage = 1; renderTickets(); });
document.getElementById('ticket-type-filter').addEventListener('change', () => { ticketPage = 1; renderTickets(); });
document.getElementById('ticket-tag-filter').addEventListener('input', () => { ticketPage = 1; renderTickets(); });
document.getElementById('ticket-sort').addEventListener('change', () => { ticketPage = 1; renderTickets(); });
document.getElementById('ticket-show-closed').addEventListener('change', () => { ticketPage = 1; renderTickets(); });
document.getElementById('ticket-show-mine').addEventListener('change', () => { ticketPage = 1; renderTickets(); });
document.getElementById('ticket-page-size').addEventListener('change', function() { TICKETS_PER_PAGE = parseInt(this.value); ticketPage = 1; 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();
// Näytä montako valittu kaikista sivuilta
const allCheckbox = document.getElementById('bulk-select-all');
if (bulkSelectedIds.size > checkboxes.length) {
allCheckbox.title = `${bulkSelectedIds.size} tikettiä valittu (myös muilta sivuilta)`;
}
});
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;
currentTicketData = ticket;
// Header
document.getElementById('ticket-detail-header').innerHTML = `
${ticket.ticket_number ? `#${ticket.ticket_number} ` : ''}${esc(ticket.subject)}
${esc(ticket.from_name)} <${esc(ticket.from_email)}> · Luotu ${esc(ticket.created)}
Tagit:
${(ticket.tags || []).map(tag => '#' + esc(tag) + ' ').join('')}
${ticket.auto_close_at ? '
⏰ Auto-close: ' + esc(ticket.auto_close_at.substring(0, 10)) + '' : ''}
${ticket.cc ? 'CC: ' + esc(ticket.cc) + '
' : ''}`;
// 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',
` ✓ ${esc(matchedCustomer.yritys)}`);
}
} else {
// Ei löytynyt → näytä "Lisää liidi" -nappi
if (senderLine) {
senderLine.insertAdjacentHTML('beforeend',
` `);
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',
` ✓ ${esc(linked.yritys)}`);
}
}
} 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' || m.type === 'outgoing';
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 ? '⚡ Automaattinen vastaus' : (isOut ? '→ Lähetetty' : (isNote ? '📝 Muistiinpano' : '← Saapunut'));
// Liitteet
let attachmentsHtml = '';
if (m.attachments && m.attachments.length > 0) {
const attItems = m.attachments.map(att => {
const sizeStr = att.size > 1048576 ? (att.size / 1048576).toFixed(1) + ' MB' : att.size > 1024 ? (att.size / 1024).toFixed(0) + ' KB' : att.size + ' B';
const icon = att.type && att.type.startsWith('image/') ? '🖼️' : att.type && att.type.includes('pdf') ? '📄' : '📎';
// Zammad-liite: lataa proxy-endpointin kautta
if (att.id && m.zammad_article_id) {
const dlUrl = `api.php?action=zammad_attachment&ticket_id=${encodeURIComponent(ticket.id)}&article_id=${m.zammad_article_id}&attachment_id=${att.id}&filename=${encodeURIComponent(att.filename)}`;
return `${icon} ${esc(att.filename)} (${sizeStr})`;
}
return `${icon} ${esc(att.filename)} (${sizeStr})`;
}).join('');
attachmentsHtml = `${attItems}
`;
}
return `
${m.type === 'email_in' || m.type === 'incoming' || m.type === 'outgoing' ? sanitizeHtml(m.body) : esc(m.body).replace(/\n/g, '
')}
${attachmentsHtml}
`;
}).join('');
// Show detail, hide list
document.getElementById('ticket-list-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ä — käytä tiketin tallennettua to_email:a, tai fallback from_email:iin
const toField = document.getElementById('reply-to');
if (toField) toField.value = ticket.to_email || ticket.from_email || '';
// CC-kenttä — täytetään tiketin CC:stä
const ccField = document.getElementById('reply-cc');
if (ccField) ccField.value = ticket.cc || '';
// BCC-kenttä — täytetään tiketin BCC:stä
const bccField = document.getElementById('reply-bcc');
if (bccField) bccField.value = ticket.bcc || '';
// Mailbox-valinta — Zammad-tiketit vastaa Zammadin kautta, muut SMTP:llä
const mbSelect = document.getElementById('reply-mailbox-select');
let replyMailboxes = []; // tallennetaan postilaatikkodata allekirjoitushakua varten
if (mbSelect) {
if (ticket.source === 'zammad' && ticket.zammad_ticket_id) {
// Zammad-tiketti: vastaus menee Zammadin kautta
const zTo = ticket.zammad_to_email || ticket.zammad_group || 'Zammad';
mbSelect.innerHTML = ``;
} else {
try {
replyMailboxes = await apiCall('all_mailboxes');
const visibleMailboxes = replyMailboxes.filter(mb =>
String(mb.id) === String(ticket.mailbox_id || '') ||
(!currentHiddenMailboxes.includes(String(mb.id)) && !currentHiddenMailboxes.includes(mb.id))
);
const ticketMbId = String(ticket.mailbox_id || '');
const hasMatch = ticketMbId && visibleMailboxes.some(mb => String(mb.id) === ticketMbId);
let optionsHtml = '';
if (!hasMatch) optionsHtml = '';
optionsHtml += visibleMailboxes.map(mb =>
``
).join('');
mbSelect.innerHTML = optionsHtml;
} catch (e) { mbSelect.innerHTML = ''; }
}
mbSelect.addEventListener('change', function() {
updateSignaturePreview(this.value);
});
}
// Allekirjoituksen esikatselu — generoi oikea oletus jos tallennettua ei löydy
const _sigUserName = currentUser?.nimi || currentUser?.username || '';
function _buildDefaultSig(companyName, email, phone) {
let sig = _sigUserName + '\n' + companyName + '\n' + email;
if (phone) sig += '\n' + phone;
return sig;
}
function updateSignaturePreview(mbId) {
const sigPreview = document.getElementById('signature-preview');
const useSigCheck = document.getElementById('reply-use-signature');
let sig = '';
if (mbId === 'zammad' && ticket.zammad_to_email) {
// Zammad-tiketti: hae allekirjoitus zammad:email -avaimella
sig = currentUserSignatures['zammad:' + ticket.zammad_to_email] || '';
if (!sig) {
// Generoi oletus Zammad-tiketin tiedoilla
const cName = currentCompany?.nimi || '';
const cPhone = currentCompany?.phone || '';
sig = _buildDefaultSig(cName, ticket.zammad_to_email, cPhone);
}
} else if (mbId) {
// SMTP-postilaatikko: hae tallennettu allekirjoitus
sig = currentUserSignatures[mbId] || currentUserSignatures[String(mbId)] || currentUserSignatures[Number(mbId)] || '';
if (!sig) {
// Generoi oletus postilaatikon tiedoilla
const mb = replyMailboxes.find(m => String(m.id) === String(mbId));
if (mb) {
sig = _buildDefaultSig(mb.company_nimi || '', mb.smtp_from_email || '', mb.company_phone || '');
}
}
}
if (sig && useSigCheck && useSigCheck.checked) {
sigPreview.textContent = '-- \n' + sig;
sigPreview.style.display = 'block';
} else {
sigPreview.style.display = 'none';
}
}
// Zammad-tiketeille käytä 'zammad' avaimena, muille mailbox_id
const initialSigKey = (ticket.source === 'zammad' && ticket.zammad_ticket_id) ? 'zammad' : (ticket.mailbox_id || '');
updateSignaturePreview(initialSigKey);
// 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 = '';
templates.forEach(t => {
tplSelect.innerHTML += ``;
});
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-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';
}
});
});
// ---- Liitetiedostot ----
let replyAttachments = []; // [{name, size, type, data (base64)}]
document.getElementById('reply-file-input').addEventListener('change', function() {
const files = Array.from(this.files);
files.forEach(file => {
if (file.size > 25 * 1024 * 1024) {
alert(`Tiedosto "${file.name}" on liian suuri (max 25 MB)`);
return;
}
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result.split(',')[1]; // poista data:...;base64, prefix
replyAttachments.push({ name: file.name, size: file.size, type: file.type || 'application/octet-stream', data: base64 });
renderReplyAttachments();
};
reader.readAsDataURL(file);
});
this.value = ''; // nollaa input jotta saman tiedoston voi lisätä uudelleen
});
function renderReplyAttachments() {
const list = document.getElementById('reply-attachments-list');
const count = document.getElementById('reply-attachments-count');
if (replyAttachments.length === 0) {
list.innerHTML = '';
count.textContent = '';
return;
}
count.textContent = `${replyAttachments.length} liite${replyAttachments.length > 1 ? 'ttä' : ''}`;
list.innerHTML = replyAttachments.map((a, i) => {
const sizeStr = a.size < 1024 ? a.size + ' B' : (a.size < 1048576 ? (a.size / 1024).toFixed(1) + ' KB' : (a.size / 1048576).toFixed(1) + ' MB');
return `
📄 ${esc(a.name)} (${sizeStr})
`;
}).join('');
}
function removeReplyAttachment(index) {
replyAttachments.splice(index, 1);
renderReplyAttachments();
}
// 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 {
// Tarkista onko Zammad-tiketti
const isZammadTicket = currentTicketData?.zammad_ticket_id;
if (isZammadTicket && ticketReplyType !== 'note') {
// Liitä allekirjoitus Zammad-vastaukseen
let zBody = body;
const useSig = document.getElementById('reply-use-signature');
if (useSig && useSig.checked && currentTicketData.zammad_to_email) {
let zSig = currentUserSignatures['zammad:' + currentTicketData.zammad_to_email] || '';
if (!zSig) {
// Generoi oletus allekirjoitus
const _un = currentUser?.nimi || currentUser?.username || '';
const _cn = currentCompany?.nimi || '';
const _cp = currentCompany?.phone || '';
zSig = _un + '\n' + _cn + '\n' + currentTicketData.zammad_to_email;
if (_cp) zSig += '\n' + _cp;
}
if (zSig) zBody += '\n\n-- \n' + zSig;
}
// Lähetä Zammad API:n kautta — välitä myös to/cc/bcc-kentät
const zPayload = { ticket_id: currentTicketId, body: zBody };
const zToFld = document.getElementById('reply-to');
const zCcFld = document.getElementById('reply-cc');
const zBccFld = document.getElementById('reply-bcc');
if (zToFld && zToFld.value.trim()) zPayload.to = zToFld.value.trim();
if (zCcFld && zCcFld.value.trim()) zPayload.cc = zCcFld.value.trim();
if (zBccFld && zBccFld.value.trim()) zPayload.bcc = zBccFld.value.trim();
if (replyAttachments.length > 0) zPayload.attachments = replyAttachments;
await apiCall('zammad_reply' + ticketCompanyParam(), 'POST', zPayload);
} else {
const action = ticketReplyType === 'note' ? 'ticket_note' : 'ticket_reply';
const payload = { id: currentTicketId, body };
if (ticketReplyType !== 'note') {
const mbSel = document.getElementById('reply-mailbox-select');
const toFld = document.getElementById('reply-to');
const ccFld = document.getElementById('reply-cc');
const bccFld = document.getElementById('reply-bcc');
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 (bccFld) payload.bcc = bccFld.value.trim();
if (useSig && !useSig.checked) payload.no_signature = true;
}
if (replyAttachments.length > 0) payload.attachments = replyAttachments;
await apiCall(action + ticketCompanyParam(), 'POST', payload);
}
// Tyhjennä liitteet
replyAttachments = [];
renderReplyAttachments();
// 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');
let statusMsg = `Valmis! ${result.new_tickets} uutta tikettiä, ${result.threaded} ketjutettu viestiä.`;
// Hae myös Zammadista (full sync)
let zammadMsg = '';
try {
status.textContent = 'Synkataan Zammad...';
const zResult = await apiCall('zammad_sync', 'POST', { full: true });
if (zResult.created || zResult.updated || zResult.messages_added) {
zammadMsg = ` Zammad: ${zResult.created} uutta, ${zResult.updated} päivitettyä, ${zResult.messages_added} viestiä.`;
}
} catch (ze) { /* Zammad ei käytössä tai virhe — ohitetaan */ }
status.style.background = '#eafaf1';
status.style.color = '#27ae60';
status.textContent = statusMsg + zammadMsg;
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(async () => {
// 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) {
// Synkkaa Zammad taustalla ennen tikettien latausta
try { await apiCall('zammad_sync', 'POST'); } catch (e) { /* Zammad ei käytössä */ }
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 = 'Ei sääntöjä vielä. Lisää ensimmäinen sääntö.
';
return;
}
const priorityLabels = { normaali: 'Normaali', 'tärkeä': 'Tärkeä', urgent: 'Kiireellinen' };
list.innerHTML = ticketRules.map(r => {
const conditions = [];
if (r.from_contains) conditions.push('Lähettäjä: ' + esc(r.from_contains) + '');
if (r.to_contains) conditions.push('Vastaanottaja: ' + esc(r.to_contains) + '');
if (r.subject_contains) conditions.push('Otsikko: ' + esc(r.subject_contains) + '');
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_priority) actions.push('Prioriteetti → ' + (priorityLabels[r.set_priority] || r.set_priority));
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 `
${esc(r.name)}
${conditions.length ? 'Ehdot: ' + conditions.join(', ') : 'Ei ehtoja'} → ${actions.length ? actions.join(', ') : 'Ei toimenpiteitä'}
`;
}).join('');
}
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-to').value = rule ? (rule.to_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-priority').value = rule ? (rule.set_priority || '') : '';
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-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(),
to_contains: document.getElementById('rule-form-to').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_priority: document.getElementById('rule-form-priority').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); }
}
// ==================== TIKETTITYYPIT ====================
async function loadTicketTypes() {
try {
const types = await apiCall('ticket_types');
ticketTypeLabels = {};
types.forEach(t => { ticketTypeLabels[t.value] = t.label; });
renderTicketTypes(types);
populateTypeDropdowns();
} catch (e) { console.error('loadTicketTypes:', e); }
}
function renderTicketTypes(types) {
const container = document.getElementById('ticket-types-list');
if (!container) return;
if (!types || types.length === 0) {
container.innerHTML = 'Ei tikettityyppejä.
';
return;
}
container.innerHTML = types.map(t =>
`
${esc(t.label)}
(${esc(t.value)})
`
).join('');
}
function populateTypeDropdowns() {
const options = Object.entries(ticketTypeLabels).map(
([val, label]) => ``
).join('');
// Tikettilistan suodatin
const filter = document.getElementById('ticket-type-filter');
if (filter) {
const current = filter.value;
filter.innerHTML = '' + options;
filter.value = current;
}
// Sääntölomakkeen tyyppi
const ruleType = document.getElementById('rule-form-type');
if (ruleType) {
const current = ruleType.value;
ruleType.innerHTML = '' + options;
ruleType.value = current;
}
// Tiketin detail-näkymän tyyppi-select
const detailType = document.getElementById('ticket-detail-type');
if (detailType) {
const current = detailType.value;
detailType.innerHTML = options;
detailType.value = current;
}
}
document.getElementById('btn-add-ticket-type')?.addEventListener('click', async () => {
const value = document.getElementById('new-ticket-type-value').value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '');
const label = document.getElementById('new-ticket-type-label').value.trim();
if (!value || !label) { alert('Täytä tunnus ja nimi'); return; }
try {
await apiCall('ticket_type_save', 'POST', { value, label });
document.getElementById('new-ticket-type-value').value = '';
document.getElementById('new-ticket-type-label').value = '';
await loadTicketTypes();
} catch (e) { alert(e.message); }
});
window.deleteTicketType = async function(value) {
if (!confirm(`Poistetaanko tikettityyppi "${value}"?`)) return;
try {
await apiCall('ticket_type_delete', 'POST', { value });
await loadTicketTypes();
} catch (e) { alert(e.message); }
};
// ==================== VASTAUSPOHJAT (TUKITABISSA) ====================
function renderTplList() {
const list = document.getElementById('tpl-list');
if (!list) return;
if (replyTemplates.length === 0) {
list.innerHTML = 'Ei vastauspohjia vielä. Lisää ensimmäinen klikkaamalla "+ Lisää pohja".
';
return;
}
list.innerHTML = replyTemplates.map(t =>
`
${esc(t.nimi)}
${esc(t.body.substring(0, 100))}
`
).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-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 initTicketSettings() {
const sigContainer = document.getElementById('ticket-settings-signatures');
const visContainer = document.getElementById('ticket-settings-mailbox-visibility');
sigContainer.innerHTML = 'Ladataan...
';
visContainer.innerHTML = '';
try {
const mailboxes = await apiCall('all_mailboxes');
if (mailboxes.length === 0) {
sigContainer.innerHTML = 'Ei postilaatikoita.
';
visContainer.innerHTML = 'Ei postilaatikoita.
';
return;
}
// Oletusallekirjoitus: Käyttäjänimi + Yritys + sähköposti + puhelin (jos asetettu)
const userName = currentUser?.nimi || currentUser?.username || '';
function defaultSig(companyName, email, phone) {
let sig = userName + '\n' + companyName + '\n' + email;
if (phone) sig += '\n' + phone;
return sig;
}
// Allekirjoitukset per postilaatikko
let sigHtml = mailboxes.map(mb => {
const sig = currentUserSignatures[mb.id] || defaultSig(mb.company_nimi, mb.smtp_from_email, mb.company_phone);
return `
`;
}).join('');
// Zammad-sähköpostien allekirjoitukset
try {
const zammadEmails = await apiCall('ticket_zammad_emails');
if (zammadEmails.length > 0) {
sigHtml += 'Zammad-sähköpostit
';
const companyName = currentCompany?.nimi || '';
const companyPhone = currentCompany?.phone || '';
zammadEmails.forEach(email => {
const key = 'zammad:' + email;
const sig = currentUserSignatures[key] || defaultSig(companyName, email, companyPhone);
sigHtml += `
`;
});
}
} catch (e) {}
sigContainer.innerHTML = sigHtml;
// Postilaatikoiden näkyvyys — checkbox per postilaatikko
let visHtml = mailboxes.map(mb => {
const isHidden = currentHiddenMailboxes.includes(String(mb.id)) || currentHiddenMailboxes.includes(mb.id);
return ``;
}).join('');
// Zammad-ryhmät näkyvyyteen (haetaan API:sta)
try {
const zammadGroups = await apiCall('ticket_zammad_groups');
if (zammadGroups.length > 0) {
visHtml += 'Zammad-ryhmät
';
zammadGroups.forEach(grp => {
const key = 'zammad_group:' + grp;
const isHidden = currentHiddenMailboxes.includes(key);
visHtml += ``;
});
}
} catch (e) {}
visContainer.innerHTML = visHtml;
} catch (e) {
sigContainer.innerHTML = 'Virhe ladattaessa postilaatikoita.
';
}
}
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;
// 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); }
// Lataa saatavuuskyselyt
loadAvailabilityQueries();
// Näytä API-sivun kortit integraatioiden perusteella
try {
const integs = await apiCall('integrations');
const saatavuusEnabled = integs.find(i => i.type === 'saatavuus_api')?.enabled;
const telegramEnabled = integs.find(i => i.type === 'telegram')?.enabled;
const zammadInteg = integs.find(i => i.type === 'zammad');
const zammadEnabled = zammadInteg?.enabled;
// Saatavuus-API kortti näkyy aina (perus API-asetukset)
const teleCard = document.getElementById('settings-telegram-card');
const zammadCard = document.getElementById('settings-zammad-card');
if (teleCard) teleCard.style.display = telegramEnabled ? '' : 'none';
if (zammadCard) zammadCard.style.display = zammadEnabled ? '' : 'none';
// Lataa Zammad-asetukset korttiin
if (zammadEnabled && zammadInteg?.config) {
document.getElementById('company-zammad-url').value = zammadInteg.config.url || '';
document.getElementById('company-zammad-token').value = zammadInteg.config.token || '';
if (zammadInteg.config.group_ids && zammadInteg.config.group_names) {
renderCompanyZammadGroups(
zammadInteg.config.group_names.map((name, i) => ({ id: zammadInteg.config.group_ids[i], name })),
zammadInteg.config.group_ids
);
}
}
} 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; }
});
// ==================== INTEGRAATIOT ====================
const INTEGRATION_TYPES = {
zammad: { name: 'Zammad', icon: '📧', desc: 'Synkronoi tiketit Zammad-helpdeskistä (O365-sähköpostit)' },
saatavuus_api: { name: 'Saatavuus-API', icon: '🌐', desc: 'Julkinen API saatavuustarkistukseen verkkosivuilla' },
telegram: { name: 'Telegram-hälytykset', icon: '🤖', desc: 'URGENT-tikettien hälytykset Telegram-bottiin' },
};
// (Integraatiot hallitaan nyt Yritykset-välilehdellä)
// ==================== 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 => `
${esc(c.id)} |
${esc(c.nimi)} |
- |
${esc((c.luotu || '').substring(0, 10))} |
${c.aktiivinen !== false ? 'Aktiivinen' : 'Ei aktiivinen'} |
${superAdmin ? `` : ''}
|
`).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 || '';
document.getElementById('company-edit-phone').value = comp?.phone || '';
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';
}
// Superadmin-osiot: moduulit, integraatiot, IP-rajoitukset
const isSA = currentUser?.role === 'superadmin';
const modulesSection = document.getElementById('company-modules-section');
const integrationsSection = document.getElementById('company-integrations-section');
const ipsSection = document.getElementById('company-allowed-ips-section');
if (modulesSection) modulesSection.style.display = isSA ? '' : 'none';
if (integrationsSection) integrationsSection.style.display = isSA ? '' : 'none';
if (ipsSection) ipsSection.style.display = isSA ? '' : '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 });
// Integraatiot — lataa tila (vain superadmin)
if (isSA) loadCompanyIntegrations();
// Lataa postilaatikot
loadMailboxes();
// Lataa käyttäjäoikeudet
loadCompanyUsers(id);
}
// ==================== YRITYKSEN INTEGRAATIOT ====================
async function loadCompanyIntegrations() {
try {
const integrations = await apiCall('integrations');
// Aseta vain checkboxit — konfiguraatio ladataan API-tabissa
['zammad', 'saatavuus_api', 'telegram'].forEach(type => {
const integ = integrations.find(i => i.type === type);
const cb = document.querySelector(`#integrations-checkboxes input[data-integration="${type}"]`);
if (cb) cb.checked = integ?.enabled || false;
});
} catch (e) { console.error('loadCompanyIntegrations:', e); }
}
function renderCompanyZammadGroups(groups, selectedIds = []) {
const container = document.getElementById('company-zammad-groups');
if (!groups.length) { container.innerHTML = 'Ei ryhmiä.'; return; }
container.innerHTML = groups.map(g => `
`).join('');
}
async function saveCompanyZammad() {
const url = document.getElementById('company-zammad-url').value.trim();
const token = document.getElementById('company-zammad-token').value.trim();
const groupCbs = document.querySelectorAll('.company-zammad-group-cb:checked');
const groupIds = Array.from(groupCbs).map(cb => cb.value);
const groupNames = Array.from(groupCbs).map(cb => cb.dataset.name);
await apiCall('integration_save', 'POST', {
type: 'zammad',
enabled: true, // Jos ollaan API-tabissa säätämässä, integraatio on päällä
config: { url, token, group_ids: groupIds, group_names: groupNames },
});
}
// Integraatio-toggle apufunktio (Saatavuus-API & Telegram)
async function saveSimpleIntegration(type, enabled) {
await apiCall('integration_save', 'POST', { type, enabled, config: {} });
}
// Zammad checkbox toggle (vain enabled päälle/pois, asetukset API-tabissa)
document.querySelector('#integrations-checkboxes input[data-integration="zammad"]')?.addEventListener('change', async function() {
try {
await saveSimpleIntegration('zammad', this.checked);
const card = document.getElementById('settings-zammad-card');
if (card) card.style.display = this.checked ? '' : 'none';
} catch (e) { console.error(e); }
});
// Saatavuus-API checkbox toggle (kortti näkyy aina API-tabissa)
document.querySelector('#integrations-checkboxes input[data-integration="saatavuus_api"]')?.addEventListener('change', async function() {
try { await saveSimpleIntegration('saatavuus_api', this.checked); } catch (e) { console.error(e); }
});
// Telegram checkbox toggle
document.querySelector('#integrations-checkboxes input[data-integration="telegram"]')?.addEventListener('change', async function() {
try {
await saveSimpleIntegration('telegram', this.checked);
// Päivitä API-sivun kortti
const card = document.getElementById('settings-telegram-card');
if (card) card.style.display = this.checked ? '' : 'none';
} catch (e) { console.error(e); }
});
// Lataa ryhmät
document.getElementById('btn-company-zammad-groups')?.addEventListener('click', async () => {
// Tallenna ensin URL ja token
try {
await saveCompanyZammad();
const groups = await apiCall('zammad_groups');
const activeGroups = groups.filter(g => g.active);
const integrations = await apiCall('integrations');
const zammad = integrations.find(i => i.type === 'zammad');
renderCompanyZammadGroups(activeGroups, zammad?.config?.group_ids || []);
} catch (e) {
alert('Virhe: ' + e.message);
}
});
// Testaa yhteys
document.getElementById('btn-company-zammad-test')?.addEventListener('click', async () => {
const result = document.getElementById('company-zammad-result');
result.style.display = 'block';
result.style.background = '#f8f9fb';
result.textContent = 'Testataan...';
try {
await saveCompanyZammad();
const res = await apiCall('integration_test', 'POST', { type: 'zammad' });
result.style.background = '#d4edda';
result.textContent = `✅ Yhteys OK! Käyttäjä: ${res.user}, Ryhmiä: ${res.groups}`;
} catch (e) {
result.style.background = '#f8d7da';
result.textContent = '❌ ' + e.message;
}
});
// Synkronoi
document.getElementById('btn-company-zammad-sync')?.addEventListener('click', async () => {
const result = document.getElementById('company-zammad-result');
result.style.display = 'block';
result.style.background = '#f8f9fb';
result.innerHTML = '⏳ Synkronoidaan...';
try {
await saveCompanyZammad();
const res = await apiCall('zammad_sync', 'POST', {});
result.style.background = '#d4edda';
result.innerHTML = `✅ Synkronointi valmis!
Tikettejä: ${res.tickets_found} | Uusia: ${res.created} | Päivitetty: ${res.updated} | Viestejä: ${res.messages_added}`;
} catch (e) {
result.style.background = '#f8d7da';
result.innerHTML = '❌ ' + e.message;
}
});
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 phone = document.getElementById('company-edit-phone').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, phone, 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.phone = phone; 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() : ''
});
// Tarkista integraatiot API-tabin näkyvyydelle
try {
const integs = await apiCall('integrations');
const hasIntegs = integs.some(i => i.enabled);
applyModules(enabled_modules, hasIntegs);
} catch (e2) {
applyModules(enabled_modules, false);
}
}
} 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 = 'Ei postilaatikoita. Lisää ensimmäinen postilaatikko.
';
return;
}
container.innerHTML = mailboxesData.map(mb => `
${esc(mb.nimi)}
${esc(mb.imap_user)}
${mb.aktiivinen !== false ? 'Aktiivinen' : 'Ei aktiivinen'}
`).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 ``;
}).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' ? '🔴' : '⚪')) : '—';
return `
| ${esc(d.nimi)} |
${esc(d.hallintaosoite || '-')} |
${esc(d.serial || '-')} |
${d.laitetila_name ? esc(d.laitetila_name) : '-'} |
${esc(d.tyyppi || '-')} |
${esc(d.funktio || '-')} |
${esc(d.malli || '-')} |
${pingIcon} |
|
`;
}).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 = '' +
tilat.map(t => ``).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');
// Lataa data tarvittaessa
const hashMap = {
'support-tickets': 'support',
'support-ohjeet': 'support/ohjeet',
'support-saannot': 'support/saannot',
'support-vastauspohjat': 'support/vastauspohjat',
'support-asetukset': 'support/asetukset',
};
if (target === 'support-ohjeet') loadGuides();
if (target === 'support-saannot') { loadRules(); loadTicketTypes(); }
if (target === 'support-vastauspohjat') loadTemplates();
if (target === 'support-asetukset') initTicketSettings();
window.location.hash = hashMap[target] || '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));
});
// ==================== SAATAVUUSKYSELYT ====================
let availabilityPage = 0;
const AVAILABILITY_PER_PAGE = 50;
async function loadAvailabilityQueries(page = 0) {
availabilityPage = page;
const offset = page * AVAILABILITY_PER_PAGE;
try {
const data = await apiCall(`availability_queries&limit=${AVAILABILITY_PER_PAGE}&offset=${offset}`);
const tbody = document.querySelector('#availability-queries-table tbody');
const countEl = document.getElementById('availability-query-count');
countEl.textContent = `Yhteensä ${data.total} kyselyä`;
if (data.queries.length === 0) {
tbody.innerHTML = '| Ei vielä kyselyjä |
';
} else {
tbody.innerHTML = data.queries.map(q => {
const date = q.created_at ? q.created_at.replace('T', ' ').substring(0, 16) : '';
const found = q.saatavilla == 1;
const badge = found
? 'Saatavilla'
: 'Ei saatavilla';
let source = '';
if (q.referer) {
try { source = new URL(q.referer).hostname; } catch(e) { source = q.referer.substring(0, 30); }
}
const ipInfo = q.hostname
? `${esc(q.ip_address)}
${esc(q.hostname)}`
: esc(q.ip_address || '');
return `
| ${esc(date)} |
${esc(q.osoite)} |
${esc(q.postinumero)} |
${esc(q.kaupunki)} |
${badge} |
${ipInfo} |
${esc(q.org || '')} |
${esc(source)} |
${esc(q.company_nimi || q.company_id || '')} |
`;
}).join('');
}
// Sivutus
const totalPages = Math.ceil(data.total / AVAILABILITY_PER_PAGE);
const pagination = document.getElementById('availability-pagination');
if (totalPages > 1) {
let pagHtml = '';
if (page > 0) pagHtml += ``;
pagHtml += `Sivu ${page + 1} / ${totalPages}`;
if (page < totalPages - 1) pagHtml += ``;
pagination.innerHTML = pagHtml;
} else {
pagination.innerHTML = '';
}
} catch (e) {
console.error('Saatavuuskyselyjen lataus epäonnistui:', e);
}
}
// ==================== 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 `${childCount}`;
// 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 `${childCount}`;
const totalSlots = 1 << slotBits; // 2^slotBits
const sameLevel = node.children.filter(c => c.prefix === commonPrefix).length;
const freeSlots = totalSlots - sameLevel;
return `${sameLevel}/${totalSlots}`;
}
// --- 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 = `Kaikki verkot` +
ipamDrillStack.map((s, i) =>
` › ${esc(s.label)}`
).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 `
|
Vapaa
|
${esc(e.verkko)} |
Klikkaa varataksesi |
`;
}
const toggleIcon = r.hasChildren
? `${r.expanded ? '▼' : '▶'} `
: ' ';
const typeTag = e.tyyppi === 'subnet'
? 'Subnet'
: 'IP';
const drillBtn = (e.tyyppi === 'subnet' && r.hasChildren)
? ` →`
: '';
return `
|
${toggleIcon}${typeTag}
|
${esc(e.verkko || '-')}${drillBtn} ${subnetUsageHtml(r.node)} |
${esc(e.nimi || '-')} |
${vlanRefHtml(e.vlan_id)} |
${e.site_name ? esc(e.site_name) : '—'} |
${tilaLabel[e.tila] || e.tila} |
|
`;
}).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 = '| Ei VLANeja vielä. |
';
} else {
tbody.innerHTML = vlans.map(e => `
| ${e.vlan_id || '-'} |
${esc(e.verkko || '-')} |
${esc(e.nimi || '-')} |
${e.site_name ? esc(e.site_name) : '—'} |
${tilaLabel[e.tila] || e.tila} |
|
`).join('');
}
document.getElementById('ipam-vlan-count').textContent = vlans.length + ' VLANia';
}
// --- VLAN-viite apufunktio ---
function vlanRefHtml(vlanId) {
if (!vlanId) return '—';
const vl = ipamData.find(v => v.tyyppi === 'vlan' && String(v.vlan_id) === String(vlanId));
const label = vl ? esc(vl.nimi) : '';
return `${vlanId}${label ? ` ${label}` : ''}`;
}
// --- 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 = '' +
_ipamLaitetilatCache.map(t => ``).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 = `
`;
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) => `${code}
`);
// Inline-koodi
html = html.replace(/`([^`]+)`/g, '$1');
// Otsikot
html = html.replace(/^### (.+)$/gm, '$1
');
html = html.replace(/^## (.+)$/gm, '$1
');
html = html.replace(/^# (.+)$/gm, '$1
');
// Lihavointi + kursiivi
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '$1');
html = html.replace(/\*\*(.+?)\*\*/g, '$1');
html = html.replace(/\*(.+?)\*/g, '$1');
// Kuvat (ennen linkkejä!) — klikkaa avataksesi isompana
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '
');
// Linkit
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
// Lainaukset
html = html.replace(/^> (.+)$/gm, '$1
');
// Vaakaviiva
html = html.replace(/^---$/gm, '
');
// Listat: kerätään peräkkäiset lista-rivit yhteen
html = html.replace(/(^[\-\*] .+\n?)+/gm, (match) => {
const items = match.trim().split('\n').map(l => '' + l.replace(/^[\-\*] /, '') + '').join('');
return '';
});
html = html.replace(/(^\d+\. .+\n?)+/gm, (match) => {
const items = match.trim().split('\n').map(l => '' + l.replace(/^\d+\. /, '') + '').join('');
return '' + items + '
';
});
// Kappalejaot
html = html.replace(/\n\n/g, '');
html = html.replace(/\n/g, '
');
return '
' + html + '
';
}
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 => ``).join('');
if (sel) sel.innerHTML = '' + opts;
if (formSel) formSel.innerHTML = '' + 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 `
${esc(g.title)}
${esc(preview)}${(g.content || '').length > 150 ? '...' : ''}
${tags.length > 0 ? `
${tags.map(t => `${esc(t.trim())}`).join('')}
` : ''}
`;
}).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 ? `📁 ${esc(guide.category_name)}` : '',
`✎ ${esc(guide.author || 'Tuntematon')}`,
`📅 ${esc((guide.luotu || '').substring(0, 10))}`,
guide.muokattu ? `Päivitetty: ${timeAgo(guide.muokattu)} (${esc(guide.muokkaaja || '')})` : ''
].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 => `${esc(t.trim())}`).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 = ``;
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 = 'Ei kategorioita.
';
return;
}
list.innerHTML = guideCategories.map(c => `
${esc(c.nimi)}
`).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 = '' + users.map(u => ``).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 => `
`).join('') : 'Ei osatehtäviä
';
}
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 `
| ${t.deadline ? `${t.deadline}` : '—'} |
${todoStatusLabels[t.status]||t.status} |
${todoPriorityLabels[t.priority]||t.priority} |
${t.category ? `${todoCategoryLabels[t.category]||t.category}` : '—'} |
${esc(t.title)}${t.subtask_count > 0 ? ` ☑ ${t.subtask_done}/${t.subtask_count}` : ''} |
${t.assigned_to ? esc(t.assigned_to) : '—'} |
${t.total_hours > 0 ? t.total_hours + 'h' : '—'} |
${t.comment_count > 0 ? t.comment_count : ''} |
`;
}).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)} | Luotu: ${(t.luotu||'').slice(0,10)} ${t.muokattu ? ' | Muokattu: ' + t.muokattu.slice(0,10) : ''}`;
document.getElementById('task-read-badges').innerHTML = `
${todoPriorityLabels[t.priority]||t.priority}
${todoStatusLabels[t.status]||t.status}`;
document.getElementById('task-read-fields').innerHTML = `
Status
${isAdmin ? `` : (todoStatusLabels[t.status]||t.status)}
Vastuuhenkilö
${isAdmin ? `` : esc(t.assigned_to || '—')}
Prioriteetti
${todoPriorityLabels[t.priority]||t.priority}
Tyyppi
${t.category ? (todoCategoryLabels[t.category]||t.category) : '—'}
Deadline
${t.deadline || '—'}
`;
// 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 => `
| ${e.work_date} | ${esc(e.user)} | ${e.hours}h | ${esc(e.description||'')} |
${(e.user === currentUser?.username || isAdmin) ? `` : ''} |
`).join('') : '| Ei kirjauksia |
';
// 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 ? `` : '';
// 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 = '';
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 `
| ${(t.luotu||'').slice(0,10)} |
${todoStatusLabels[t.status]||t.status} |
${esc(t.title)} |
${esc(t.created_by)} |
${t.comment_count > 0 ? t.comment_count : ''} |
`;
}).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)} | ${(t.luotu||'').slice(0,10)}`;
document.getElementById('feature-read-badges').innerHTML = isAdmin
? ``
: `${todoStatusLabels[t.status]||t.status}`;
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)
? `${isAdmin ? `` : ''}`
: '';
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 => ``).join('') : 'Ei kommentteja vielä.
';
}
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 = '' +
cities.map(c => ``).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 = '' +
speeds.map(s => ``).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 = '' +
devs.map(d => ``).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, c.gateway_name
].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 ? `${esc(c.laite)}` : '-';
// VLAN: näytä tallennettu arvo, tai IPAM:sta haetut
let vlanDisplay = esc(c.vlan || '');
if (!c.vlan && c.ipam_vlans && c.ipam_vlans.length > 0) {
vlanDisplay = c.ipam_vlans.map(v =>
`${esc(String(v.vlan_id))}`
).join(', ');
}
// IP: näytä tallennettu arvo, tai IPAM:sta haetut
let ipDisplay = c.ip ? `${esc(c.ip)}` : '';
if (!c.ip && c.ipam_ips && c.ipam_ips.length > 0) {
ipDisplay = c.ipam_ips.map(i =>
`${esc(i.verkko)}`
).join(', ');
}
// Gateway
const gwDisplay = c.gateway_name ? esc(c.gateway_name) : '-';
return `
| ${esc(c.customer_name || '-')} |
${esc(addr)} |
${esc(c.kaupunki || '-')} |
${esc(c.liittymanopeus || '-')} |
${vlanDisplay || '-'} |
${deviceDisplay} |
${esc(c.portti || '-')} |
${ipDisplay || '-'} |
${gwDisplay} |
`;
}).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 = 'Ei tuloksia
';
} else {
let lastGroup = null;
list.innerHTML = filtered.map(o => {
let grpHtml = '';
if (o.group && o.group !== lastGroup) {
lastGroup = o.group;
grpHtml = `${esc(o.group)}
`;
}
const badge = o.badge ? `${esc(o.badge)}` : '';
const sub = o.sub ? `${esc(o.sub)}` : '';
return grpHtml + `${badge}${esc(o.label)}${sub}
`;
}).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;
}
// Rakennetaan Gateway-combobox optiot (kaikki laitteet devices-listasta)
function getGatewayComboOptions() {
const devices = 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: String(d.id),
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 || ''}`,
};
});
}
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 — yhdistä IPAM-data asiakkaan tietoihin
// Jos asiakkaalla on IPAM VLANeja, näytä ne ensin
let vlanOptions = getVlanComboOptions();
if (conn.ipam_vlans && conn.ipam_vlans.length > 0) {
const ipamVlanOpts = conn.ipam_vlans.map(v => ({
value: String(v.vlan_id || ''),
label: String(v.vlan_id || '') + (v.nimi ? ` — ${v.nimi}` : ''),
sub: v.site_name || '',
badge: 'IPAM', badgeClass: 'free',
group: '📌 Asiakkaan VLANit (IPAM)',
searchStr: `${v.vlan_id} ${v.nimi || ''} ${v.site_name || ''}`,
}));
vlanOptions = [...ipamVlanOpts, ...vlanOptions];
}
initCombo(document.getElementById('na-combo-vlan'), vlanOptions, conn.vlan || '');
initCombo(document.getElementById('na-combo-laite'), getDeviceComboOptions(), conn.laite || '');
// Jos asiakkaalla on IPAM IP:itä, näytä ne ensin
let ipOptions = getIpComboOptions();
if (conn.ipam_ips && conn.ipam_ips.length > 0) {
const ipamIpOpts = conn.ipam_ips.map(i => ({
value: i.verkko,
label: i.verkko,
sub: [i.nimi, i.site_name].filter(Boolean).join(' — '),
badge: 'IPAM', badgeClass: 'free',
group: '📌 Asiakkaan IP:t (IPAM)',
searchStr: `${i.verkko} ${i.nimi || ''} ${i.site_name || ''}`,
}));
ipOptions = [...ipamIpOpts, ...ipOptions];
}
initCombo(document.getElementById('na-combo-ip'), ipOptions, conn.ip || '');
document.getElementById('na-edit-portti').value = conn.portti || '';
// Gateway-laitevalitsin
initCombo(document.getElementById('na-combo-gateway'), getGatewayComboOptions(), conn.gateway_device_id ? String(conn.gateway_device_id) : '');
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 {
const gwVal = document.getElementById('na-edit-gateway').value;
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,
gateway_device_id: gwVal ? parseInt(gwVal) : null
});
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 {
// Varmista että asiakkaat on ladattu (tarvitaan kansionäkymässä)
if (!customers || customers.length === 0) {
try { customers = await apiCall('customers'); } catch (e2) {}
}
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 ja kokousmuistioiden määrä per asiakas
const docCountMap = {};
const meetingCountMap = {};
allDocuments.forEach(d => {
if (d.customer_id) {
if (d.category === 'kokousmuistio') {
meetingCountMap[d.customer_id] = (meetingCountMap[d.customer_id] || 0) + 1;
} else {
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.length > 0) {
customers.forEach(c => {
const docs = docCountMap[c.id] || 0;
const meetings = meetingCountMap[c.id] || 0;
folderList.push({ id: c.id, name: c.yritys || c.id, count: docs, meetings });
});
}
// Lisää asiakkaat jotka ovat dokumenteissa mutta eivät customers-listassa
const allCustIds = new Set(folderList.map(f => f.id));
Object.keys(docCountMap).forEach(custId => {
if (!allCustIds.has(custId)) {
folderList.push({ id: custId, name: customerNameMap[custId] || custId, count: docCountMap[custId], meetings: meetingCountMap[custId] || 0 });
}
});
Object.keys(meetingCountMap).forEach(custId => {
if (!allCustIds.has(custId) && !docCountMap[custId]) {
folderList.push({ id: custId, name: customerNameMap[custId] || custId, count: 0, meetings: meetingCountMap[custId] });
}
});
// Suodata hakusanalla
if (search) {
folderList = folderList.filter(f => f.name.toLowerCase().includes(search));
}
// Järjestä: ensin ne joilla on sisältöä (count+meetings desc), sitten aakkosjärjestys
folderList.sort((a, b) => {
const totalA = (a.count || 0) + (a.meetings || 0);
const totalB = (b.count || 0) + (b.meetings || 0);
if (totalB !== totalA) return totalB - totalA;
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 => {
const total = (f.count || 0) + (f.meetings || 0);
const parts = [];
if (f.count > 0) parts.push(`${f.count} dok.`);
if (f.meetings > 0) parts.push(`${f.meetings} kok.`);
const desc = parts.length > 0 ? parts.join(', ') : 'Tyhjä';
return ``;
}).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 = `📁 Kaikki`;
if (currentDocFolderId) {
const path = getDocFolderPath(currentDocFolderId);
path.forEach(f => {
crumbs += `/${esc(f.name)}`;
});
}
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 =>
`📁 ${esc(f.name)}
`
).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 `
| ${esc(d.title)} |
v${version} |
${date} |
${esc(author)} |
`;
}).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 = `${docCategoryLabels[d.category] || d.category || 'Muu'}`;
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 = '| Ei versioita vielä. |
';
} 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
? ``
: `⬇️`;
return `
| v${v.version_number}${isCurrent ? ' ✓' : ''} |
${date} |
${esc(v.created_by || '-')} |
${esc(v.change_notes || '-')} |
${sizeDisplay} |
${downloadLink}
${isAdmin && !isCurrent ? `` : ''}
|
`;
}).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 = '';
if (typeof customers !== 'undefined') {
customers.forEach(c => {
custSel.innerHTML += ``;
});
}
// 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' : '';
// Piilota kategoria-kenttä kokousmuistiossa (asetetaan automaattisesti)
const catGroup = document.getElementById('doc-edit-category').closest('.form-group');
if (catGroup) catGroup.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); }
});
// ---- Drag & Drop multi-upload ----
function detectDocCategory(filename) {
const ext = (filename.split('.').pop() || '').toLowerCase();
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) return 'kuva';
return 'muu';
}
// Käy läpi kansiorakenne webkitGetAsEntry:n avulla
function traverseFileTree(entry, path) {
return new Promise((resolve) => {
if (entry.isFile) {
entry.file(file => {
file._relativePath = path + file.name;
resolve([file]);
});
} else if (entry.isDirectory) {
const reader = entry.createReader();
reader.readEntries(async (entries) => {
let files = [];
for (const e of entries) {
const sub = await traverseFileTree(e, path + entry.name + '/');
files = files.concat(sub);
}
// Merkitään kansion nimi
if (files.length > 0) {
files._folderName = entry.name;
}
resolve(files);
});
} else {
resolve([]);
}
});
}
async function handleDocFileDrop(dataTransfer) {
const progressEl = document.getElementById('doc-upload-progress');
const fillEl = document.getElementById('doc-upload-fill');
const statusEl = document.getElementById('doc-upload-status');
// Kerää tiedostot — tarkista kansiot webkitGetAsEntry:llä
let allFiles = [];
let folderName = null;
const items = dataTransfer.items;
if (items && items.length > 0 && items[0].webkitGetAsEntry) {
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry();
if (entry) {
if (entry.isDirectory) {
folderName = entry.name;
const files = await traverseFileTree(entry, '');
allFiles = allFiles.concat(files);
} else {
const files = await traverseFileTree(entry, '');
allFiles = allFiles.concat(files);
}
}
}
} else {
// Fallback: tavallinen files-lista
allFiles = Array.from(dataTransfer.files || []);
}
if (allFiles.length === 0) return;
// Näytä edistymispalkki
progressEl.style.display = '';
fillEl.style.width = '0%';
statusEl.textContent = `Ladataan 0 / ${allFiles.length} tiedostoa...`;
// Jos raahattiin kansio → luo kansio ensin
let targetFolderId = currentDocFolderId || null;
if (folderName) {
try {
const folder = await apiCall('document_folder_save', 'POST', {
name: folderName,
parent_id: currentDocFolderId || null,
customer_id: currentDocCustomerId || null
});
targetFolderId = folder.id;
} catch (e) {
console.error('Kansion luonti epäonnistui:', e);
}
}
let success = 0;
let failed = 0;
for (let i = 0; i < allFiles.length; i++) {
const file = allFiles[i];
const filename = file.name;
const pct = Math.round(((i) / allFiles.length) * 100);
fillEl.style.width = pct + '%';
statusEl.textContent = `Ladataan ${i + 1} / ${allFiles.length}: ${filename}`;
try {
// 1. Luo dokumentti
const saved = await apiCall('document_save', 'POST', {
title: filename.replace(/\.[^.]+$/, ''),
category: detectDocCategory(filename),
customer_id: currentDocCustomerId || null,
folder_id: targetFolderId,
max_versions: 10,
created_by: currentUser?.username || ''
});
// 2. Lataa tiedosto
const fd = new FormData();
fd.append('document_id', saved.id);
fd.append('file', file);
fd.append('change_notes', 'Ensimmäinen versio');
const res = await fetch(`${API}?action=document_upload`, {
method: 'POST',
credentials: 'include',
body: fd
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || 'Upload failed');
}
success++;
} catch (e) {
console.error(`Tiedoston "${filename}" lataus epäonnistui:`, e);
failed++;
}
}
// Valmis
fillEl.style.width = '100%';
const failText = failed > 0 ? ` (${failed} epäonnistui)` : '';
statusEl.textContent = `✓ ${success} tiedostoa ladattu${failText}`;
// Päivitä lista
await loadDocuments();
if (folderName && targetFolderId) {
navigateDocFolder(targetFolderId);
}
// Piilota edistymispalkki hetken kuluttua
setTimeout(() => {
progressEl.style.display = 'none';
fillEl.style.width = '0%';
}, 3000);
}
// Drop zone handlerit
const docDropzone = document.getElementById('doc-dropzone');
if (docDropzone) {
let dragCounter = 0;
docDropzone.addEventListener('dragenter', (e) => {
e.preventDefault();
dragCounter++;
docDropzone.classList.add('active');
});
docDropzone.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
docDropzone.addEventListener('dragleave', (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
docDropzone.classList.remove('active');
}
});
docDropzone.addEventListener('drop', async (e) => {
e.preventDefault();
dragCounter = 0;
docDropzone.classList.remove('active');
await handleDocFileDrop(e.dataTransfer);
});
// Klikkaa drop zonea → avaa file dialog
docDropzone.addEventListener('click', (e) => {
if (e.target.tagName !== 'LABEL' && e.target.tagName !== 'INPUT') {
document.getElementById('doc-multi-file')?.click();
}
});
}
// Multi-file input fallback
document.getElementById('doc-multi-file')?.addEventListener('change', async (e) => {
if (e.target.files.length > 0) {
// Luo pseudo-DataTransfer jossa on files
await handleDocFileDrop({ files: e.target.files, items: null });
e.target.value = '';
}
});
// ==================== 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 `${ping} ${esc(d.nimi)}`;
}).join('');
const moreCount = dc > 4 ? `+${dc - 4} muuta` : '';
return `
${esc(t.nimi)}
${esc(t.osoite || '')}
🖥 ${dc} laitetta
📁 ${t.file_count || 0} tiedostoa
${dc > 0 ? `
${devList}${moreCount}
` : ''}
`;
}).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 `
${esc(f.original_name)}
${isAdmin ? `` : ''}
`;
}).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 `
${esc(f.original_name)}
${formatFileSize(f.file_size || 0)} · ${f.luotu ? new Date(f.luotu).toLocaleDateString('fi-FI') : ''}
${f.description ? `
${esc(f.description)}` : ''}
${isAdmin ? `
` : ''}
`;
}).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 = `| Laite | Tyyppi | Malli | IP | Tila |
${
tilaDevices.map(d => {
const ping = d.ping_status === 'up' ? '🟢' : d.ping_status === 'down' ? '🔴' : '⚪';
return `
| ${esc(d.nimi)} |
${esc(d.tyyppi || '-')} |
${esc(d.malli || '-')} |
${esc(d.hallintaosoite || '-')} |
${ping} |
`;
}).join('')
}
`;
} else {
devSection.style.display = '';
devList.innerHTML = 'Ei laitteita tässä tilassa. Linkitä laitteita Tekniikka → Laitteet -osiossa.
';
}
}
// 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, hasIntegrations) {
// 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();
const isSuperAdmin = currentUser?.role === 'superadmin';
ALL_MODULES.forEach(mod => {
const tabBtn = document.querySelector(`.tab[data-tab="${mod}"]`);
if (tabBtn) {
// settings/API-tabi: adminille/superadminille, ja vain jos integraatioita on päällä (superadmin näkee aina)
if (mod === 'settings') {
const showSettings = enabled.includes(mod) && isAdminUser && (isSuperAdmin || hasIntegrations === true);
tabBtn.style.display = showSettings ? '' : '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 HUB';
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 HUB', primary_color: '#0f3460', subtitle: 'Hallintapaneeli', logo_url: '' });
}
}
// Init — branding ensin, sitten auth (luo session-cookien), sitten captcha (käyttää samaa sessiota)
loadBranding().then(async () => {
await checkAuth();
loadCaptcha();
});