- File upload/download/delete per customer (max 20MB, stored in data/files/) - Files section shown in customer detail modal - Speed distribution chart replaces single "top speed" stat - Bar chart shows all speeds with count, top speed bolded - Customer delete also cleans up associated files - data/files/ added to .gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
523 lines
20 KiB
JavaScript
523 lines
20 KiB
JavaScript
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();
|
|
}
|
|
|
|
function renderTable() {
|
|
const query = searchInput.value.toLowerCase().trim();
|
|
let filtered = customers;
|
|
if (query) {
|
|
filtered = customers.filter(c =>
|
|
c.yritys.toLowerCase().includes(query) ||
|
|
(c.asennusosoite || '').toLowerCase().includes(query) ||
|
|
(c.postinumero || '').toLowerCase().includes(query) ||
|
|
(c.kaupunki || '').toLowerCase().includes(query) ||
|
|
(c.yhteyshenkilö || '').toLowerCase().includes(query) ||
|
|
(c.liittymanopeus || '').toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
// Sort
|
|
filtered.sort((a, b) => {
|
|
let va = a[sortField] ?? '';
|
|
let vb = b[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 (filtered.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';
|
|
tbody.innerHTML = filtered.map(c => `
|
|
<tr data-id="${c.id}">
|
|
<td><strong>${esc(c.yritys)}</strong></td>
|
|
<td>${esc(c.asennusosoite)}</td>
|
|
<td>${esc(c.postinumero)}</td>
|
|
<td>${esc(c.kaupunki)}</td>
|
|
<td>${esc(c.liittymanopeus)}</td>
|
|
<td class="price-cell">${formatPrice(c.hinta)}</td>
|
|
<td class="actions-cell">
|
|
<button onclick="event.stopPropagation(); editCustomer('${c.id}')" title="Muokkaa">✏️</button>
|
|
<button onclick="event.stopPropagation(); deleteCustomer('${c.id}', '${esc(c.yritys)}')" title="Poista">🗑️</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
updateSummary(filtered);
|
|
}
|
|
|
|
function updateSummary(filtered) {
|
|
const count = customers.length;
|
|
const total = customers.reduce((sum, c) => sum + (parseFloat(c.hinta) || 0), 0);
|
|
customerCount.textContent = `${count} asiakasta`;
|
|
totalBilling.textContent = `Laskutus yhteensä: ${formatPrice(total)}/kk`;
|
|
|
|
// Stat-kortit
|
|
const statCount = document.getElementById('stat-count');
|
|
const statBilling = document.getElementById('stat-billing');
|
|
const statYearly = document.getElementById('stat-yearly');
|
|
if (statCount) statCount.textContent = count;
|
|
if (statBilling) statBilling.textContent = formatPrice(total);
|
|
if (statYearly) statYearly.textContent = formatPrice(total * 12);
|
|
|
|
// Nippelitilastot
|
|
updateTrivia();
|
|
}
|
|
|
|
function updateTrivia() {
|
|
const count = customers.length;
|
|
if (count === 0) {
|
|
setTrivia('stat-top-zip', '-', '');
|
|
setTrivia('stat-top-speed', '-', '');
|
|
setText('stat-avg-price', '-');
|
|
return;
|
|
}
|
|
|
|
// Suosituin postinumero
|
|
const zipCounts = {};
|
|
customers.forEach(c => {
|
|
const zip = (c.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 = customers.find(c => (c.postinumero || '').trim() === topZip[0]);
|
|
setTrivia('stat-top-zip', topZip[0], `${topZip[1]} liittymää` + (city && city.kaupunki ? ` (${city.kaupunki})` : ''));
|
|
} else {
|
|
setTrivia('stat-top-zip', '-', 'ei postinumeroita');
|
|
}
|
|
|
|
// Nopeus-jakauma
|
|
const speedCounts = {};
|
|
customers.forEach(c => {
|
|
const speed = (c.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 = '<span style="color:#aaa; font-size:0.85rem;">-</span>';
|
|
} else {
|
|
speedTable.innerHTML = sorted.map(([speed, cnt]) => {
|
|
const isTop = cnt === maxCount;
|
|
const barWidth = Math.max(20, (cnt / maxCount) * 60);
|
|
return `<span class="speed-item ${isTop ? 'top' : ''}">${esc(speed)} (${cnt})<span class="speed-bar" style="width:${barWidth}px"></span></span>`;
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
// Keskihinta
|
|
const total = customers.reduce((sum, c) => sum + (parseFloat(c.hinta) || 0), 0);
|
|
setText('stat-avg-price', formatPrice(total / count));
|
|
}
|
|
|
|
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;
|
|
const id = row.dataset.id;
|
|
showDetail(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 fullAddress = [c.asennusosoite, c.postinumero, c.kaupunki].filter(Boolean).join(', ');
|
|
const fullBillingAddress = [c.laskutusosoite, c.laskutuspostinumero, c.laskutuskaupunki].filter(Boolean).join(', ');
|
|
|
|
document.getElementById('detail-title').textContent = c.yritys;
|
|
document.getElementById('detail-body').innerHTML = `
|
|
<div class="detail-section">
|
|
<h3>Yritys ja liittymä</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 class="detail-item">
|
|
<div class="detail-label">Asennusosoite</div>
|
|
<div class="detail-value">${detailVal(fullAddress)}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-label">Nopeus</div>
|
|
<div class="detail-value">${detailVal(c.liittymanopeus)}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="detail-label">Hinta / kk</div>
|
|
<div class="detail-value price-cell">${formatPrice(c.hinta)}</div>
|
|
</div>
|
|
</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(fullBillingAddress)}</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 class="file-upload-hint" 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';
|
|
|
|
// Lataa tiedostolista
|
|
loadFiles(id);
|
|
|
|
// Upload handler
|
|
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 = '<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);
|
|
}
|
|
});
|
|
|
|
// 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-asennusosoite').value = c ? c.asennusosoite : '';
|
|
document.getElementById('form-postinumero').value = c ? (c.postinumero || '') : '';
|
|
document.getElementById('form-kaupunki').value = c ? (c.kaupunki || '') : '';
|
|
document.getElementById('form-liittymanopeus').value = c ? c.liittymanopeus : '';
|
|
document.getElementById('form-hinta').value = c ? c.hinta : '';
|
|
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 : '';
|
|
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;
|
|
const data = {
|
|
yritys: document.getElementById('form-yritys').value,
|
|
ytunnus: document.getElementById('form-ytunnus').value,
|
|
asennusosoite: document.getElementById('form-asennusosoite').value,
|
|
postinumero: document.getElementById('form-postinumero').value,
|
|
kaupunki: document.getElementById('form-kaupunki').value,
|
|
liittymanopeus: document.getElementById('form-liittymanopeus').value,
|
|
hinta: document.getElementById('form-hinta').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,
|
|
};
|
|
|
|
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();
|