Files
intra.noxus.fi/script.js
Jukka Lampikoski 8ba925d3dc Add leads (liidit) tab for tracking potential customers
- New Liidit tab with table, search, add/edit/delete
- Lead fields: company, contact, phone, email, address, city, status, notes
- Status workflow: Uusi → Kontaktoitu → Kiinnostunut → Odottaa toimitusta
- Color-coded status badges
- Detail view with notes display
- "Muuta asiakkaaksi" converts lead to customer with pre-filled data
- Lead CRUD endpoints in api.php with changelog logging
- leads.json added to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 01:35:04 +02:00

940 lines
46 KiB
JavaScript

const API = 'api.php';
let customers = [];
let sortField = 'yritys';
let sortAsc = true;
let currentDetailId = null;
let currentUser = { username: '', nimi: '', role: '' };
// 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 data = await res.json();
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')) {
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 };
showDashboard();
}
} catch (e) { /* not logged in */ }
}
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 };
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();
});
async function showDashboard() {
loginScreen.style.display = 'none';
dashboard.style.display = 'block';
document.getElementById('user-info').textContent = currentUser.nimi || currentUser.username;
// Näytä Käyttäjät-tab vain adminille
document.getElementById('tab-users').style.display = currentUser.role === 'admin' ? '' : 'none';
await loadCustomers();
}
// ==================== TABS ====================
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
const target = tab.dataset.tab;
document.getElementById('tab-content-' + target).classList.add('active');
// Lataa sisältö tarvittaessa
if (target === 'leads') loadLeads();
if (target === 'archive') loadArchive();
if (target === 'changelog') loadChangelog();
if (target === 'users') loadUsers();
});
});
// ==================== 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)
);
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 sopimus = l.sopimuskausi ? l.sopimuskausi + ' kk' : '';
const alkupvm = l.alkupvm ? ' (' + esc(l.alkupvm) + ')' : '';
return `<tr data-id="${c.id}" class="${isFirst ? '' : 'sub-row'}">
<td>${isFirst ? '<strong>' + esc(c.yritys) + '</strong>' : '<span class="sub-marker">&#8627;</span>'}</td>
<td>${esc(l.asennusosoite)}${l.postinumero ? ', ' + esc(l.postinumero) : ''}</td>
<td>${esc(l.kaupunki)}</td>
<td>${esc(l.liittymanopeus)}</td>
<td class="price-cell">${formatPrice(l.hinta)}</td>
<td>${sopimus}${alkupvm}</td>
<td class="actions-cell">${isFirst ? `<button onclick="event.stopPropagation();editCustomer('${c.id}')" title="Muokkaa">&#9998;</button><button onclick="event.stopPropagation();deleteCustomer('${c.id}','${esc(c.yritys)}')" title="Arkistoi">&#128451;</button>` : ''}</td>
</tr>`;
}).join('');
}
updateSummary();
}
function getAllLiittymat() {
const all = [];
customers.forEach(c => (c.liittymat || []).forEach(l => all.push(l)));
return all;
}
function updateSummary() {
const liittymat = getAllLiittymat();
const count = customers.length;
const connCount = liittymat.length;
const total = liittymat.reduce((sum, l) => sum + (parseFloat(l.hinta) || 0), 0);
customerCount.textContent = `${count} asiakasta, ${connCount} liittymää`;
totalBilling.textContent = `Laskutus yhteensä: ${formatPrice(total)}/kk`;
setText('stat-count', count);
setText('stat-connections', connCount);
setText('stat-billing', formatPrice(total));
setText('stat-yearly', formatPrice(total * 12));
updateTrivia(liittymat, connCount);
}
function updateTrivia(liittymat, connCount) {
if (connCount === 0) {
setTrivia('stat-top-zip', '-', '');
setText('stat-avg-price', '-');
const st = document.getElementById('stat-speed-table');
if (st) st.innerHTML = '<span style="color:#aaa;font-size:0.85rem;">-</span>';
return;
}
// Postinumero
const zipCounts = {};
liittymat.forEach(l => { const z = (l.postinumero || '').trim(); if (z) zipCounts[z] = (zipCounts[z] || 0) + 1; });
const topZip = Object.entries(zipCounts).sort((a, b) => b[1] - a[1])[0];
if (topZip) {
const city = liittymat.find(l => (l.postinumero || '').trim() === topZip[0]);
setTrivia('stat-top-zip', topZip[0], `${topZip[1]} liittymää` + (city && city.kaupunki ? ` (${city.kaupunki})` : ''));
} else { setTrivia('stat-top-zip', '-', ''); }
// Nopeudet
const speedCounts = {};
liittymat.forEach(l => { const s = (l.liittymanopeus || '').trim(); if (s) speedCounts[s] = (speedCounts[s] || 0) + 1; });
const speedTable = document.getElementById('stat-speed-table');
if (speedTable) {
const sorted = Object.entries(speedCounts).sort((a, b) => b[1] - a[1]);
const maxC = sorted.length > 0 ? sorted[0][1] : 0;
speedTable.innerHTML = sorted.length === 0 ? '<span style="color:#aaa;font-size:0.85rem;">-</span>' :
sorted.map(([sp, cnt]) => {
const isTop = cnt === maxC;
const w = Math.max(15, (cnt / maxC) * 50);
return `<span class="speed-item ${isTop ? 'top' : ''}">${esc(sp)} (${cnt})<span class="speed-bar" style="width:${w}px"></span></span>`;
}).join('');
}
// Keskihinta
const total = liittymat.reduce((sum, l) => sum + (parseFloat(l.hinta) || 0), 0);
setText('stat-avg-price', formatPrice(total / connCount));
}
function setTrivia(id, value, sub) {
const el = document.getElementById(id);
const subEl = document.getElementById(id + '-detail');
if (el) el.textContent = value;
if (subEl) subEl.textContent = sub;
}
function setText(id, value) { const el = document.getElementById(id); if (el) el.textContent = value; }
function formatPrice(val) { return parseFloat(val || 0).toFixed(2).replace('.', ',') + ' €'; }
function esc(str) { if (!str) return ''; const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
// 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">${l.sopimuskausi ? l.sopimuskausi + ' kk' : '-'}</div></div>
<div class="detail-item"><div class="detail-label">Alkaen</div><div class="detail-value">${detailVal(l.alkupvm)}</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">Yhteensä: ${formatPrice(totalH)}/kk</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>`;
detailModal.style.display = 'flex';
loadFiles(id);
document.getElementById('file-upload-input').addEventListener('change', async function () {
for (const file of this.files) {
const fd = new FormData();
fd.append('customer_id', id);
fd.append('file', file);
try {
const res = await fetch(`${API}?action=file_upload`, { method: 'POST', credentials: 'include', body: fd });
const data = await res.json();
if (!res.ok) alert(data.error || 'Virhe');
} catch (e) { alert('Tiedoston lähetys epäonnistui'); }
}
this.value = '';
loadFiles(id);
});
}
async function loadFiles(customerId) {
const fileList = document.getElementById('file-list');
if (!fileList) return;
try {
const files = await apiCall(`file_list&customer_id=${customerId}`);
if (files.length === 0) { fileList.innerHTML = '<p style="color:#aaa;font-size:0.85rem;">Ei tiedostoja.</p>'; return; }
fileList.innerHTML = files.map(f => `<div class="file-item">
<div class="file-info">
<a href="${API}?action=file_download&customer_id=${customerId}&filename=${encodeURIComponent(f.filename)}" class="file-name" target="_blank">${esc(f.filename)}</a>
<span class="file-meta">${formatFileSize(f.size)} &middot; ${f.modified}</span>
</div>
<button class="file-delete-btn" onclick="deleteFile('${customerId}','${esc(f.filename)}')" title="Poista">&#10005;</button>
</div>`).join('');
} catch (e) { fileList.innerHTML = '<p style="color:#e74c3c;font-size:0.85rem;">Virhe ladattaessa tiedostoja.</p>'; }
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
async function deleteFile(customerId, filename) {
if (!confirm(`Poistetaanko tiedosto "${filename}"?`)) return;
await apiCall('file_delete', 'POST', { customer_id: customerId, filename });
loadFiles(customerId);
}
// Detail modal actions
document.getElementById('detail-close').addEventListener('click', () => detailModal.style.display = 'none');
document.getElementById('detail-cancel').addEventListener('click', () => detailModal.style.display = 'none');
document.getElementById('detail-edit').addEventListener('click', () => { detailModal.style.display = 'none'; editCustomer(currentDetailId); });
document.getElementById('detail-delete').addEventListener('click', () => {
const c = customers.find(x => x.id === currentDetailId);
if (c) { detailModal.style.display = 'none'; deleteCustomer(currentDetailId, c.yritys); }
});
// ==================== FORM: Liittymät ====================
function createLiittymaRow(data = {}, index = 0) {
const div = document.createElement('div');
div.className = 'liittyma-row';
div.dataset.index = index;
div.innerHTML = `<div class="liittyma-row-header">
<span class="liittyma-row-title">Liittymä ${index + 1}</span>
<button type="button" class="btn-remove-row" title="Poista liittymä">&#10005;</button>
</div>
<div class="form-grid form-grid-liittyma">
<div class="form-group"><label>Osoite</label><input type="text" class="l-asennusosoite" value="${esc(data.asennusosoite || '')}" placeholder="esim. Kauppakatu 5"></div>
<div class="form-group"><label>Postinumero</label><input type="text" class="l-postinumero" value="${esc(data.postinumero || '')}" placeholder="20100"></div>
<div class="form-group"><label>Kaupunki</label><input type="text" class="l-kaupunki" value="${esc(data.kaupunki || '')}" placeholder="Turku"></div>
<div class="form-group"><label>Nopeus</label><input type="text" class="l-liittymanopeus" value="${esc(data.liittymanopeus || '')}" placeholder="esim. 100/100"></div>
<div class="form-group"><label>Hinta €/kk</label><input type="number" class="l-hinta" step="0.01" min="0" value="${data.hinta || ''}"></div>
<div class="form-group"><label>Sopimuskausi</label><select class="l-sopimuskausi">
<option value="">- Valitse -</option>
<option value="1" ${data.sopimuskausi === '1' ? 'selected' : ''}>1 kk</option>
<option value="12" ${data.sopimuskausi === '12' ? 'selected' : ''}>12 kk</option>
<option value="24" ${data.sopimuskausi === '24' ? 'selected' : ''}>24 kk</option>
<option value="36" ${data.sopimuskausi === '36' ? 'selected' : ''}>36 kk</option>
</select></div>
<div class="form-group"><label>Alkaen</label><input type="date" class="l-alkupvm" value="${esc(data.alkupvm || '')}"></div>
</div>`;
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,
}));
}
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-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,
liittymat: collectLiittymatFromForm(),
};
if (id) { data.id = id; await apiCall('customer_update', 'POST', data); }
else { await apiCall('customer', 'POST', data); }
customerModal.style.display = 'none';
await loadCustomers();
});
// ==================== LEADS ====================
let leads = [];
let currentLeadId = null;
const leadModal = document.getElementById('lead-modal');
const leadDetailModal = document.getElementById('lead-detail-modal');
const leadStatusLabels = {
uusi: 'Uusi',
kontaktoitu: 'Kontaktoitu',
kiinnostunut: 'Kiinnostunut',
odottaa: 'Odottaa toimitusta',
ei_kiinnosta: 'Ei kiinnosta',
};
async function loadLeads() {
try {
leads = await apiCall('leads');
renderLeads();
} catch (e) { console.error(e); }
}
function renderLeads() {
const query = document.getElementById('lead-search-input').value.toLowerCase().trim();
let filtered = leads;
if (query) {
filtered = leads.filter(l =>
(l.yritys || '').toLowerCase().includes(query) ||
(l.yhteyshenkilo || '').toLowerCase().includes(query) ||
(l.kaupunki || '').toLowerCase().includes(query)
);
}
const ltbody = document.getElementById('leads-tbody');
const noLeads = document.getElementById('no-leads');
if (filtered.length === 0) {
ltbody.innerHTML = '';
noLeads.style.display = 'block';
document.getElementById('leads-table').style.display = 'none';
} else {
noLeads.style.display = 'none';
document.getElementById('leads-table').style.display = 'table';
ltbody.innerHTML = filtered.map(l => `<tr data-lead-id="${l.id}">
<td><strong>${esc(l.yritys)}</strong></td>
<td>${esc(l.yhteyshenkilo || '')}</td>
<td>${esc(l.kaupunki || '')}</td>
<td><span class="lead-status lead-status-${l.tila || 'uusi'}">${leadStatusLabels[l.tila] || l.tila || 'Uusi'}</span></td>
<td class="nowrap">${esc((l.luotu || '').substring(0, 10))}</td>
<td class="actions-cell">
<button onclick="event.stopPropagation();editLead('${l.id}')" title="Muokkaa">&#9998;</button>
<button onclick="event.stopPropagation();deleteLead('${l.id}','${esc(l.yritys)}')" title="Poista">&#128465;</button>
</td>
</tr>`).join('');
}
document.getElementById('lead-count').textContent = `${leads.length} liidiä`;
}
document.getElementById('lead-search-input').addEventListener('input', () => renderLeads());
document.getElementById('leads-tbody').addEventListener('click', (e) => {
const row = e.target.closest('tr');
if (row && row.dataset.leadId) showLeadDetail(row.dataset.leadId);
});
function showLeadDetail(id) {
const l = leads.find(x => x.id === id);
if (!l) return;
currentLeadId = id;
document.getElementById('lead-detail-title').textContent = l.yritys;
document.getElementById('lead-detail-body').innerHTML = `
<div style="padding:1.5rem;">
<div class="detail-grid">
<div class="detail-item"><div class="detail-label">Yritys</div><div class="detail-value">${detailVal(l.yritys)}</div></div>
<div class="detail-item"><div class="detail-label">Tila</div><div class="detail-value"><span class="lead-status lead-status-${l.tila || 'uusi'}">${leadStatusLabels[l.tila] || 'Uusi'}</span></div></div>
<div class="detail-item"><div class="detail-label">Yhteyshenkilö</div><div class="detail-value">${detailVal(l.yhteyshenkilo)}</div></div>
<div class="detail-item"><div class="detail-label">Puhelin</div><div class="detail-value">${detailLink(l.puhelin, 'tel')}</div></div>
<div class="detail-item"><div class="detail-label">Sähköposti</div><div class="detail-value">${detailLink(l.sahkoposti, 'email')}</div></div>
<div class="detail-item"><div class="detail-label">Osoite</div><div class="detail-value">${detailVal([l.osoite, l.kaupunki].filter(Boolean).join(', '))}</div></div>
<div class="detail-item"><div class="detail-label">Lisätty</div><div class="detail-value">${detailVal(l.luotu)} (${esc(l.luoja || '')})</div></div>
${l.muokattu ? `<div class="detail-item"><div class="detail-label">Muokattu</div><div class="detail-value">${esc(l.muokattu)} (${esc(l.muokkaaja || '')})</div></div>` : ''}
</div>
${l.muistiinpanot ? `<div style="margin-top:1.25rem;"><div class="detail-label" style="margin-bottom:0.5rem;">MUISTIINPANOT</div><div style="white-space:pre-wrap;color:#555;background:#f8f9fb;padding:1rem;border-radius:8px;border:1px solid #e8ebf0;font-size:0.9rem;">${esc(l.muistiinpanot)}</div></div>` : ''}
</div>`;
leadDetailModal.style.display = 'flex';
}
// Lead form
document.getElementById('btn-add-lead').addEventListener('click', () => openLeadForm());
document.getElementById('lead-modal-close').addEventListener('click', () => leadModal.style.display = 'none');
document.getElementById('lead-form-cancel').addEventListener('click', () => leadModal.style.display = 'none');
function openLeadForm(lead = null) {
document.getElementById('lead-modal-title').textContent = lead ? 'Muokkaa liidiä' : 'Lisää liidi';
document.getElementById('lead-form-submit').textContent = lead ? 'Päivitä' : 'Tallenna';
document.getElementById('lead-form-id').value = lead ? lead.id : '';
document.getElementById('lead-form-yritys').value = lead ? lead.yritys : '';
document.getElementById('lead-form-yhteyshenkilo').value = lead ? (lead.yhteyshenkilo || '') : '';
document.getElementById('lead-form-puhelin').value = lead ? (lead.puhelin || '') : '';
document.getElementById('lead-form-sahkoposti').value = lead ? (lead.sahkoposti || '') : '';
document.getElementById('lead-form-osoite').value = lead ? (lead.osoite || '') : '';
document.getElementById('lead-form-kaupunki').value = lead ? (lead.kaupunki || '') : '';
document.getElementById('lead-form-tila').value = lead ? (lead.tila || 'uusi') : 'uusi';
document.getElementById('lead-form-muistiinpanot').value = lead ? (lead.muistiinpanot || '') : '';
leadModal.style.display = 'flex';
document.getElementById('lead-form-yritys').focus();
}
function editLead(id) {
const l = leads.find(x => x.id === id);
if (l) { leadDetailModal.style.display = 'none'; openLeadForm(l); }
}
async function deleteLead(id, name) {
if (!confirm(`Poistetaanko liidi "${name}"?`)) return;
await apiCall('lead_delete', 'POST', { id });
leadDetailModal.style.display = 'none';
await loadLeads();
}
async function convertLeadToCustomer(id) {
const l = leads.find(x => x.id === id);
if (!l) return;
if (!confirm(`Muutetaanko "${l.yritys}" asiakkaaksi?\n\nLiidi poistetaan ja asiakas luodaan sen tiedoilla.`)) return;
await apiCall('lead_to_customer', 'POST', { id });
leadDetailModal.style.display = 'none';
await loadLeads();
await loadCustomers();
}
document.getElementById('lead-form').addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('lead-form-id').value;
const data = {
yritys: document.getElementById('lead-form-yritys').value,
yhteyshenkilo: document.getElementById('lead-form-yhteyshenkilo').value,
puhelin: document.getElementById('lead-form-puhelin').value,
sahkoposti: document.getElementById('lead-form-sahkoposti').value,
osoite: document.getElementById('lead-form-osoite').value,
kaupunki: document.getElementById('lead-form-kaupunki').value,
tila: document.getElementById('lead-form-tila').value,
muistiinpanot: document.getElementById('lead-form-muistiinpanot').value,
};
if (id) { data.id = id; await apiCall('lead_update', 'POST', data); }
else { await apiCall('lead_create', 'POST', data); }
leadModal.style.display = 'none';
await loadLeads();
});
// Lead detail actions
document.getElementById('lead-detail-close').addEventListener('click', () => leadDetailModal.style.display = 'none');
document.getElementById('lead-detail-cancel').addEventListener('click', () => leadDetailModal.style.display = 'none');
document.getElementById('lead-detail-edit').addEventListener('click', () => editLead(currentLeadId));
document.getElementById('lead-detail-delete').addEventListener('click', () => {
const l = leads.find(x => x.id === currentLeadId);
if (l) deleteLead(currentLeadId, l.yritys);
});
document.getElementById('lead-detail-convert').addEventListener('click', () => convertLeadToCustomer(currentLeadId));
// ==================== ARCHIVE ====================
async function loadArchive() {
try {
const archive = await apiCall('archived_customers');
const atbody = document.getElementById('archive-tbody');
const noArc = document.getElementById('no-archive');
if (archive.length === 0) {
atbody.innerHTML = '';
noArc.style.display = 'block';
document.getElementById('archive-table').style.display = 'none';
} else {
noArc.style.display = 'none';
document.getElementById('archive-table').style.display = 'table';
atbody.innerHTML = archive.map(c => `<tr>
<td><strong>${esc(c.yritys)}</strong></td>
<td>${(c.liittymat || []).length}</td>
<td>${esc(c.arkistoitu || '')}</td>
<td>${esc(c.arkistoija || '')}</td>
<td class="actions-cell">
<button onclick="restoreCustomer('${c.id}')" class="btn-small btn-restore" title="Palauta">&#8634; Palauta</button>
${currentUser.role === 'admin' ? `<button onclick="permanentDelete('${c.id}','${esc(c.yritys)}')" class="btn-small btn-perm-delete" title="Poista pysyvästi">&#10005; Poista</button>` : ''}
</td>
</tr>`).join('');
}
} catch (e) { console.error(e); }
}
async function restoreCustomer(id) {
if (!confirm('Palautetaanko asiakas arkistosta?')) return;
await apiCall('customer_restore', 'POST', { id });
loadArchive();
loadCustomers();
}
async function permanentDelete(id, name) {
if (!confirm(`Poistetaanko "${name}" PYSYVÄSTI?\n\nTätä ei voi perua!`)) return;
await apiCall('customer_permanent_delete', 'POST', { id });
loadArchive();
}
// ==================== CHANGELOG ====================
const actionLabels = {
customer_create: 'Lisäsi asiakkaan',
customer_update: 'Muokkasi asiakasta',
customer_archive: 'Arkistoi asiakkaan',
customer_restore: 'Palautti asiakkaan',
customer_permanent_delete: 'Poisti pysyvästi',
user_create: 'Lisäsi käyttäjän',
user_update: 'Muokkasi käyttäjää',
user_delete: 'Poisti käyttäjän',
lead_create: 'Lisäsi liidin',
lead_update: 'Muokkasi liidiä',
lead_delete: 'Poisti liidin',
lead_to_customer: 'Muutti liidin asiakkaaksi',
};
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 === 'admin' ? 'Ylläpitäjä' : 'Käyttäjä'}</span></td>
<td>${esc(u.luotu)}</td>
<td class="actions-cell">
<button onclick="editUser('${u.id}')" title="Muokkaa">&#9998;</button>
${u.id !== '${currentUser.id}' ? `<button onclick="deleteUser('${u.id}','${esc(u.username)}')" title="Poista">&#128465;</button>` : ''}
</td>
</tr>`).join('');
} catch (e) { console.error(e); }
}
let usersCache = [];
document.getElementById('btn-add-user').addEventListener('click', () => openUserForm());
document.getElementById('user-modal-close').addEventListener('click', () => userModal.style.display = 'none');
document.getElementById('user-form-cancel').addEventListener('click', () => userModal.style.display = 'none');
function openUserForm(user = null) {
document.getElementById('user-modal-title').textContent = user ? 'Muokkaa käyttäjää' : 'Lisää käyttäjä';
document.getElementById('user-form-id').value = user ? user.id : '';
document.getElementById('user-form-username').value = user ? user.username : '';
document.getElementById('user-form-username').disabled = !!user;
document.getElementById('user-form-nimi').value = user ? user.nimi : '';
document.getElementById('user-form-email').value = user ? (user.email || '') : '';
document.getElementById('user-form-password').value = '';
document.getElementById('user-pw-hint').textContent = user ? '(jätä tyhjäksi jos ei muuteta)' : '*';
document.getElementById('user-form-role').value = user ? user.role : 'user';
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 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,
};
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();
} catch (e) { alert(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';
}
});
// Init
loadCaptcha();
checkAuth();