const API = 'api.php';
let customers = [];
let sortField = 'yritys';
let sortAsc = true;
let currentDetailId = null;
// 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');
// 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
async function checkAuth() {
try {
const data = await apiCall('check_auth');
if (data.authenticated) showDashboard();
} catch (e) { /* not logged in */ }
}
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('login-password').value;
try {
await apiCall('login', 'POST', { password });
loginError.style.display = 'none';
showDashboard();
} catch (err) {
loginError.textContent = err.message;
loginError.style.display = 'block';
}
});
document.getElementById('btn-logout').addEventListener('click', async () => {
await apiCall('logout');
dashboard.style.display = 'none';
loginScreen.style.display = 'flex';
document.getElementById('login-password').value = '';
});
async function showDashboard() {
loginScreen.style.display = 'none';
dashboard.style.display = 'block';
await loadCustomers();
}
// Customers
async function loadCustomers() {
customers = await apiCall('customers');
renderTable();
}
// Helper: flatten customers into rows (one row per liittymä)
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 inLiittymat = 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) ||
inLiittymat;
});
}
const rows = flattenRows(filtered);
// Sort
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 prevCustomerId = null;
tbody.innerHTML = rows.map(r => {
const c = r.customer;
const l = r.liittyma;
const isFirst = c.id !== prevCustomerId;
prevCustomerId = c.id;
const sopimus = l.sopimuskausi ? l.sopimuskausi + ' kk' : '';
const alkupvm = l.alkupvm ? ' (' + esc(l.alkupvm) + ')' : '';
return `
| ${isFirst ? '' + esc(c.yritys) + '' : '↳'} |
${esc(l.asennusosoite)}${l.postinumero ? ', ' + esc(l.postinumero) : ''} |
${esc(l.kaupunki)} |
${esc(l.liittymanopeus)} |
${formatPrice(l.hinta)} |
${sopimus}${alkupvm} |
${isFirst ? `
` : ''}
|
`;
}).join('');
}
updateSummary();
}
function getAllLiittymat() {
const all = [];
customers.forEach(c => (c.liittymat || []).forEach(l => all.push(l)));
return all;
}
function updateSummary() {
const liittymat = getAllLiittymat();
const count = customers.length;
const connCount = liittymat.length;
const total = liittymat.reduce((sum, l) => sum + (parseFloat(l.hinta) || 0), 0);
customerCount.textContent = `${count} asiakasta, ${connCount} liittymää`;
totalBilling.textContent = `Laskutus yhteensä: ${formatPrice(total)}/kk`;
setText('stat-count', count);
setText('stat-connections', connCount);
setText('stat-billing', formatPrice(total));
setText('stat-yearly', formatPrice(total * 12));
updateTrivia(liittymat, connCount);
}
function updateTrivia(liittymat, connCount) {
if (connCount === 0) {
setTrivia('stat-top-zip', '-', '');
setText('stat-avg-price', '-');
const st = document.getElementById('stat-speed-table');
if (st) st.innerHTML = '-';
return;
}
// Suosituin postinumero
const zipCounts = {};
liittymat.forEach(l => {
const zip = (l.postinumero || '').trim();
if (zip) zipCounts[zip] = (zipCounts[zip] || 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', '-', '');
}
// Nopeus-jakauma
const speedCounts = {};
liittymat.forEach(l => {
const speed = (l.liittymanopeus || '').trim();
if (speed) speedCounts[speed] = (speedCounts[speed] || 0) + 1;
});
const speedTable = document.getElementById('stat-speed-table');
if (speedTable) {
const sorted = Object.entries(speedCounts).sort((a, b) => b[1] - a[1]);
const maxCount = sorted.length > 0 ? sorted[0][1] : 0;
if (sorted.length === 0) {
speedTable.innerHTML = '-';
} else {
speedTable.innerHTML = sorted.map(([speed, cnt]) => {
const isTop = cnt === maxCount;
const barWidth = Math.max(15, (cnt / maxCount) * 50);
return `${esc(speed)} (${cnt})`;
}).join('');
}
}
// Keskihinta
const total = liittymat.reduce((sum, l) => sum + (parseFloat(l.hinta) || 0), 0);
setText('stat-avg-price', formatPrice(total / connCount));
}
function setTrivia(id, value, sub) {
const el = document.getElementById(id);
const subEl = document.getElementById(id + '-detail');
if (el) el.textContent = value;
if (subEl) subEl.textContent = sub;
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
function formatPrice(val) {
return parseFloat(val || 0).toFixed(2).replace('.', ',') + ' €';
}
function esc(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Search
searchInput.addEventListener('input', () => renderTable());
// Sort
document.querySelectorAll('th[data-sort]').forEach(th => {
th.addEventListener('click', () => {
const field = th.dataset.sort;
if (sortField === field) {
sortAsc = !sortAsc;
} else {
sortField = field;
sortAsc = true;
}
renderTable();
});
});
// Row click -> detail
tbody.addEventListener('click', (e) => {
const row = e.target.closest('tr');
if (!row) return;
showDetail(row.dataset.id);
});
function detailVal(val) {
return val ? esc(val) : '-';
}
function detailLink(val, type) {
if (!val) return '-';
if (type === 'tel') return `${esc(val)}`;
if (type === 'email') return `${esc(val)}`;
return esc(val);
}
function showDetail(id) {
const c = customers.find(x => x.id === id);
if (!c) return;
currentDetailId = id;
const liittymat = c.liittymat || [];
const fullBillingAddress = [c.laskutusosoite, c.laskutuspostinumero, c.laskutuskaupunki].filter(Boolean).join(', ');
const liittymatHtml = liittymat.map((l, i) => {
const fullAddr = [l.asennusosoite, l.postinumero, l.kaupunki].filter(Boolean).join(', ');
const sopimus = l.sopimuskausi ? l.sopimuskausi + ' kk' : '-';
const alku = l.alkupvm || '-';
return `
${liittymat.length > 1 ? `
Liittymä ${i + 1}
` : ''}
Osoite
${detailVal(fullAddr)}
Nopeus
${detailVal(l.liittymanopeus)}
Hinta / kk
${formatPrice(l.hinta)}
Alkaen
${detailVal(alku)}
`;
}).join('');
const totalHinta = liittymat.reduce((s, l) => s + (parseFloat(l.hinta) || 0), 0);
document.getElementById('detail-title').textContent = c.yritys;
document.getElementById('detail-body').innerHTML = `
Perustiedot
Yritys
${detailVal(c.yritys)}
Y-tunnus
${detailVal(c.ytunnus)}
Liittymät (${liittymat.length})
${liittymatHtml}
${liittymat.length > 1 ? `
Yhteensä: ${formatPrice(totalHinta)}/kk
` : ''}
Yhteystiedot
Yhteyshenkilö
${detailVal(c.yhteyshenkilö)}
Puhelin
${detailLink(c.puhelin, 'tel')}
Sähköposti
${detailLink(c.sahkoposti, 'email')}
Laskutustiedot
Laskutusosoite
${detailVal(fullBillingAddress)}
Laskutussähköposti
${detailLink(c.laskutussahkoposti, 'email')}
E-laskuosoite
${detailVal(c.elaskuosoite)}
E-laskuvälittäjä
${detailVal(c.elaskuvalittaja)}
${c.lisatiedot ? `
Lisätiedot
${esc(c.lisatiedot)}
` : ''}
`;
detailModal.style.display = 'flex';
loadFiles(id);
const fileInput = document.getElementById('file-upload-input');
fileInput.addEventListener('change', async () => {
for (const file of fileInput.files) {
const formData = new FormData();
formData.append('customer_id', id);
formData.append('file', file);
try {
const res = await fetch(`${API}?action=file_upload`, {
method: 'POST',
credentials: 'include',
body: formData,
});
const data = await res.json();
if (!res.ok) alert(data.error || 'Virhe');
} catch (e) {
alert('Tiedoston lähetys epäonnistui');
}
}
fileInput.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 = 'Ei tiedostoja.
';
return;
}
fileList.innerHTML = files.map(f => `
`).join('');
} catch (e) {
fileList.innerHTML = 'Virhe ladattaessa tiedostoja.
';
}
}
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 (add/remove rows) ============
let formLiittymat = [];
function createLiittymaRow(data = {}, index = 0) {
const div = document.createElement('div');
div.className = 'liittyma-row';
div.dataset.index = index;
div.innerHTML = `
`;
div.querySelector('.btn-remove-row').addEventListener('click', () => {
div.remove();
renumberLiittymaRows();
});
return div;
}
function renumberLiittymaRows() {
const container = document.getElementById('liittymat-container');
container.querySelectorAll('.liittyma-row').forEach((row, i) => {
row.dataset.index = i;
row.querySelector('.liittyma-row-title').textContent = `Liittymä ${i + 1}`;
});
}
function collectLiittymatFromForm() {
const container = document.getElementById('liittymat-container');
const rows = container.querySelectorAll('.liittyma-row');
return Array.from(rows).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');
const count = container.querySelectorAll('.liittyma-row').length;
container.appendChild(createLiittymaRow({}, count));
});
// Billing "same as" checkbox
document.getElementById('form-billing-same').addEventListener('change', function () {
const billingFields = document.getElementById('billing-fields');
if (this.checked) {
billingFields.style.display = 'none';
// Copy first liittymä address into billing fields
const firstRow = document.querySelector('.liittyma-row');
if (firstRow) {
document.getElementById('form-laskutusosoite').value = firstRow.querySelector('.l-asennusosoite').value;
document.getElementById('form-laskutuspostinumero').value = firstRow.querySelector('.l-postinumero').value;
document.getElementById('form-laskutuskaupunki').value = firstRow.querySelector('.l-kaupunki').value;
}
} else {
billingFields.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 || '') : '';
// Reset billing checkbox
document.getElementById('form-billing-same').checked = false;
document.getElementById('billing-fields').style.display = 'block';
// Liittymät
const container = document.getElementById('liittymat-container');
container.innerHTML = '';
const liittymat = c ? (c.liittymat || []) : [{}];
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(`Poistetaanko asiakas "${name}"?`)) return;
await apiCall('customer_delete', 'POST', { id });
await loadCustomers();
}
customerForm.addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('form-id').value;
// If "same as" checked, sync billing from first liittymä
if (document.getElementById('form-billing-same').checked) {
const firstRow = document.querySelector('.liittyma-row');
if (firstRow) {
document.getElementById('form-laskutusosoite').value = firstRow.querySelector('.l-asennusosoite').value;
document.getElementById('form-laskutuspostinumero').value = firstRow.querySelector('.l-postinumero').value;
document.getElementById('form-laskutuskaupunki').value = firstRow.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();
});
// Close modals on backdrop click
customerModal.addEventListener('click', (e) => {
if (e.target === customerModal) customerModal.style.display = 'none';
});
detailModal.addEventListener('click', (e) => {
if (e.target === detailModal) detailModal.style.display = 'none';
});
// ESC to close modals
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
customerModal.style.display = 'none';
detailModal.style.display = 'none';
}
});
// Init
checkAuth();