Add file uploads and speed distribution chart

- 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>
This commit is contained in:
2026-03-10 00:05:21 +02:00
parent 127b581a69
commit c648c9311c
5 changed files with 313 additions and 10 deletions

View File

@@ -170,17 +170,25 @@ function updateTrivia() {
setTrivia('stat-top-zip', '-', 'ei postinumeroita');
}
// Suosituin nopeus
// Nopeus-jakauma
const speedCounts = {};
customers.forEach(c => {
const speed = (c.liittymanopeus || '').trim();
if (speed) speedCounts[speed] = (speedCounts[speed] || 0) + 1;
});
const topSpeed = Object.entries(speedCounts).sort((a, b) => b[1] - a[1])[0];
if (topSpeed) {
setTrivia('stat-top-speed', topSpeed[0], `${topSpeed[1]} liittymää`);
} else {
setTrivia('stat-top-speed', '-', '');
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
@@ -325,9 +333,82 @@ function showDetail(id) {
<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)} &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