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 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}
` : ''}
Osoite
${detailVal(addr)}
Nopeus
${detailVal(l.liittymanopeus)}
Hinta / kk
${formatPrice(l.hinta)}
Sopimuskausi
${contractRemaining(l.sopimuskausi, l.alkupvm) || '-'}
Alkaen
${detailVal(l.alkupvm)}
VLAN
${detailVal(l.vlan)}
Laite
${detailVal(l.laite)}
Portti
${detailVal(l.portti)}
IP
${detailVal(l.ip)}
`; }).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 `
${esc(d.title)} ${catLabel} ${date}
`; }).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 = `
Liittymä ${index + 1}
`; 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 === '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'; 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 ? '→ Vastaus' : (isNote ? '📝 Muistiinpano' : '← Saapunut')); return `
${typeIcon} ${esc(m.from_name || m.from)} ${esc(m.timestamp)}
${esc(m.body)}
`; }).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ä — tiketin alkuperäinen lähettäjä const toField = document.getElementById('reply-to'); if (toField) toField.value = ticket.from_email || ''; // CC-kenttä — täytetään tiketin CC:stä const ccField = document.getElementById('reply-cc'); if (ccField) ccField.value = ticket.cc || ''; // Mailbox-valinta — täytetään yrityksen postilaatikoista const mbSelect = document.getElementById('reply-mailbox-select'); if (mbSelect) { try { const mailboxes = await apiCall('all_mailboxes'); // Suodata pois piilotetut postilaatikot (paitsi jos tiketin oma mailbox on piilotettu — se näytetään silti) const visibleMailboxes = mailboxes.filter(mb => mb.id === (ticket.mailbox_id || '') || (!currentHiddenMailboxes.includes(String(mb.id)) && !currentHiddenMailboxes.includes(mb.id)) ); mbSelect.innerHTML = visibleMailboxes.map(mb => `` ).join(''); // Vaihda allekirjoitusta kun mailbox vaihtuu mbSelect.addEventListener('change', function() { updateSignaturePreview(this.value); }); } catch (e) { mbSelect.innerHTML = ''; } } // Allekirjoituksen esikatselu function updateSignaturePreview(mbId) { const sigPreview = document.getElementById('signature-preview'); const useSigCheck = document.getElementById('reply-use-signature'); // Etsi allekirjoitus: ensin suoraan mailbox-id:llä, sitten fallback ensimmäiseen löytyvään let sig = currentUserSignatures[mbId] || ''; if (!sig && mbId) { // Kokeile myös string/number-konversiota sig = currentUserSignatures[String(mbId)] || currentUserSignatures[Number(mbId)] || ''; } if (!sig) { // Fallback: käytä ensimmäistä löytyvää allekirjoitusta const keys = Object.keys(currentUserSignatures); if (keys.length > 0) sig = currentUserSignatures[keys[0]] || ''; } if (sig && useSigCheck && useSigCheck.checked) { sigPreview.textContent = '-- \n' + sig; sigPreview.style.display = 'block'; } else { sigPreview.style.display = 'none'; } } updateSignaturePreview(ticket.mailbox_id || ''); // Allekirjoitus-checkbox: päivitä esikatselu vaihdettaessa const useSigCheckbox = document.getElementById('reply-use-signature'); if (useSigCheckbox) { useSigCheckbox.addEventListener('change', () => { const mbSelect = document.getElementById('reply-mailbox-select'); updateSignaturePreview(mbSelect ? mbSelect.value : ''); }); } // Vastauspohjat — lataa dropdown try { const templates = await apiCall('reply_templates'); const tplSelect = document.getElementById('reply-template-select'); tplSelect.innerHTML = ''; 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'; } }); }); // 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') { // Lähetä Zammad API:n kautta await apiCall('zammad_reply' + ticketCompanyParam(), 'POST', { ticket_id: currentTicketId, body }); } else { const action = ticketReplyType === 'note' ? 'ticket_note' : 'ticket_reply'; const payload = { id: currentTicketId, body }; if (ticketReplyType !== 'note') { const mbSel = document.getElementById('reply-mailbox-select'); const toFld = document.getElementById('reply-to'); const ccFld = document.getElementById('reply-cc'); const useSig = document.getElementById('reply-use-signature'); if (mbSel) payload.mailbox_id = mbSel.value; if (toFld && toFld.value.trim()) payload.to = toFld.value.trim(); if (ccFld) payload.cc = ccFld.value.trim(); if (useSig && !useSig.checked) payload.no_signature = true; } await apiCall(action + ticketCompanyParam(), 'POST', payload); } // Reload the detail view await showTicketDetail(currentTicketId, currentTicketCompanyId); } catch (e) { alert(e.message); } finally { btn.disabled = false; btn.textContent = ticketReplyType === 'note' ? 'Tallenna muistiinpano' : 'Lähetä vastaus'; } }); // Fetch emails document.getElementById('btn-fetch-emails').addEventListener('click', async () => { const btn = document.getElementById('btn-fetch-emails'); const status = document.getElementById('ticket-fetch-status'); btn.disabled = true; btn.textContent = '⏳ Haetaan...'; status.style.display = 'block'; status.className = ''; status.style.background = '#f0f7ff'; status.style.color = '#0f3460'; status.textContent = 'Yhdistetään sähköpostipalvelimeen...'; try { const result = await apiCall('ticket_fetch', 'POST'); status.style.background = '#eafaf1'; status.style.color = '#27ae60'; status.textContent = `Valmis! ${result.new_tickets} uutta tikettiä, ${result.threaded} ketjutettu viestiä. Yhteensä ${result.total} tikettiä.`; await loadTickets(); } catch (e) { status.style.background = '#fef2f2'; status.style.color = '#e74c3c'; status.textContent = 'Virhe: ' + e.message; } finally { btn.disabled = false; btn.textContent = '📧 Hae postit'; setTimeout(() => { status.style.display = 'none'; }, 8000); } }); // ==================== TICKET AUTO-REFRESH ==================== let ticketAutoRefreshTimer = null; function startTicketAutoRefresh() { stopTicketAutoRefresh(); const seconds = parseInt(document.getElementById('ticket-refresh-interval').value) || 60; ticketAutoRefreshTimer = setInterval(() => { // Vain jos support-tabi on aktiivinen ja listanäkymä näkyy const supportActive = document.getElementById('tab-content-support').classList.contains('active'); const listVisible = document.getElementById('ticket-list-view').style.display !== 'none'; if (supportActive && listVisible) { loadTickets(); } }, seconds * 1000); } function stopTicketAutoRefresh() { if (ticketAutoRefreshTimer) { clearInterval(ticketAutoRefreshTimer); ticketAutoRefreshTimer = null; } } document.getElementById('ticket-auto-refresh').addEventListener('change', function() { if (this.checked) { startTicketAutoRefresh(); } else { stopTicketAutoRefresh(); } }); document.getElementById('ticket-refresh-interval').addEventListener('change', function() { if (document.getElementById('ticket-auto-refresh').checked) { startTicketAutoRefresh(); // Käynnistä uudelleen uudella intervallilla } }); // ==================== TICKET RULES (AUTOMAATTISÄÄNNÖT) ==================== let ticketRules = []; let editingRuleId = null; async function loadRules() { try { ticketRules = await apiCall('ticket_rules'); renderRules(); } catch (e) { console.error(e); } } function renderRules() { const list = document.getElementById('rules-list'); if (ticketRules.length === 0) { list.innerHTML = '
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; } // Allekirjoitukset per postilaatikko sigContainer.innerHTML = mailboxes.map(mb => `
` ).join(''); // 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); } // 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 || ''; 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 primary_color = document.getElementById('company-edit-color').value; const domainsText = document.getElementById('company-edit-domains').value; const domains = domainsText.split('\n').map(d => d.trim()).filter(d => d); // Moduulit const enabled_modules = []; document.querySelectorAll('#modules-checkboxes input[data-module]:checked').forEach(cb => { enabled_modules.push(cb.dataset.module); }); const allowed_ips = document.getElementById('company-edit-allowed-ips').value.trim(); try { await apiCall('company_update', 'POST', { id: currentCompanyDetail, nimi, subtitle, primary_color, domains, enabled_modules, allowed_ips }); alert('Asetukset tallennettu!'); // Päivitä paikalliset tiedot const comp = companiesTabData.find(c => c.id === currentCompanyDetail); if (comp) { comp.nimi = nimi; comp.subtitle = subtitle; comp.primary_color = primary_color; comp.domains = domains; comp.enabled_modules = enabled_modules; comp.allowed_ips = allowed_ips; } const avail = availableCompanies.find(c => c.id === currentCompanyDetail); if (avail) avail.nimi = nimi; populateCompanySelector(); // Jos tämä on aktiivinen yritys → päivitä brändäys ja moduulit heti if (currentCompany && currentCompany.id === currentCompanyDetail) { applyBranding({ nimi, subtitle, primary_color, logo_url: comp?.logo_file ? 'api.php?action=company_logo&company_id=' + encodeURIComponent(currentCompanyDetail) + '&t=' + Date.now() : '' }); // 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)); }); // ==================== 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 = `${alt}`; 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, '$1'); // 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 `
    ${g.pinned ? '📌' : ''} ${g.category_name ? `${esc(g.category_name)}` : ''}

    ${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 = `![${file.name}](${result.url})`; ta.value = ta.value.replace(placeholder, mdImg); } catch (err) { ta.value = ta.value.replace(placeholder, ''); alert('Kuvan lataus epäonnistui: ' + err.message); } } // Toolbar-nappi document.getElementById('btn-guide-image')?.addEventListener('click', () => { document.getElementById('guide-image-input')?.click(); }); document.getElementById('guide-image-input')?.addEventListener('change', async (e) => { const file = e.target.files[0]; if (file) await guideUploadImage(file); e.target.value = ''; }); // Paste screenshot leikepöydältä (Ctrl+V / Cmd+V) document.getElementById('guide-form-content')?.addEventListener('paste', async (e) => { const items = e.clipboardData?.items; if (!items) return; for (const item of items) { if (item.type.startsWith('image/')) { e.preventDefault(); const file = item.getAsFile(); if (file) { // Anna tiedostolle nimi aikaleimalla const ext = file.type.split('/')[1] || 'png'; const named = new File([file], `screenshot-${Date.now()}.${ext}`, { type: file.type }); await guideUploadImage(named); } return; } } }); // Drag & drop kuvat editoriin const guideTA = document.getElementById('guide-form-content'); if (guideTA) { guideTA.addEventListener('dragover', (e) => { if (e.dataTransfer?.types?.includes('Files')) { e.preventDefault(); guideTA.style.borderColor = 'var(--primary-color)'; guideTA.style.background = '#f0f7ff'; } }); guideTA.addEventListener('dragleave', () => { guideTA.style.borderColor = ''; guideTA.style.background = ''; }); guideTA.addEventListener('drop', async (e) => { guideTA.style.borderColor = ''; guideTA.style.background = ''; const files = e.dataTransfer?.files; if (!files?.length) return; for (const file of files) { if (file.type.startsWith('image/')) { e.preventDefault(); await guideUploadImage(file); } } }); } // Kategorianhallinta document.getElementById('btn-manage-guide-cats')?.addEventListener('click', () => { renderGuideCatList(); document.getElementById('guide-cat-modal').style.display = 'flex'; }); document.getElementById('guide-cat-modal-close')?.addEventListener('click', () => { document.getElementById('guide-cat-modal').style.display = 'none'; }); function renderGuideCatList() { const list = document.getElementById('guide-cat-list'); if (!list) return; if (guideCategories.length === 0) { list.innerHTML = '

    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 => `
    ${esc(c.author)}  ·  ${(c.luotu||'').replace('T',' ').slice(0,16)}
    ${esc(c.body)}
    ${(c.author === currentUser?.username || isAdmin) ? `` : ''}
    `).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 `
    🏢
    ${esc(f.name)}
    ${desc}
    `; }).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 ``; }).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 = `${ tilaDevices.map(d => { const ping = d.ping_status === 'up' ? '🟢' : d.ping_status === 'down' ? '🔴' : '⚪'; return ``; }).join('') }
    LaiteTyyppiMalliIPTila
    ${esc(d.nimi)} ${esc(d.tyyppi || '-')} ${esc(d.malli || '-')} ${esc(d.hallintaosoite || '-')} ${ping}
    `; } 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(); });