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