From c648c9311c4d7c62f998b5540f52b3a82d93bdba Mon Sep 17 00:00:00 2001 From: Jukka Lampikoski Date: Tue, 10 Mar 2026 00:05:21 +0200 Subject: [PATCH] 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 --- .gitignore | 1 + api.php | 120 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 7 ++-- script.js | 93 ++++++++++++++++++++++++++++++++++++++--- style.css | 102 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 313 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 6c2a999..66e397b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ data/customers.json data/backups/ +data/files/ diff --git a/api.php b/api.php index 7f9e1d3..ff8c60d 100644 --- a/api.php +++ b/api.php @@ -159,6 +159,120 @@ switch ($action) { saveCustomers($customers); break; + case 'file_upload': + requireAuth(); + if ($method !== 'POST') break; + $customerId = $_POST['customer_id'] ?? ''; + if (!$customerId || !preg_match('/^[a-f0-9]+$/', $customerId)) { + http_response_code(400); + echo json_encode(['error' => 'Virheellinen asiakas-ID']); + break; + } + if (empty($_FILES['file'])) { + http_response_code(400); + echo json_encode(['error' => 'Tiedosto puuttuu']); + break; + } + $file = $_FILES['file']; + if ($file['error'] !== UPLOAD_ERR_OK) { + http_response_code(400); + echo json_encode(['error' => 'Tiedoston lähetys epäonnistui']); + break; + } + // Max 20MB + if ($file['size'] > 20 * 1024 * 1024) { + http_response_code(400); + echo json_encode(['error' => 'Tiedosto on liian suuri (max 20 MB)']); + break; + } + $uploadDir = __DIR__ . '/data/files/' . $customerId; + if (!file_exists($uploadDir)) mkdir($uploadDir, 0755, true); + $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($file['name'])); + // Jos samanniminen tiedosto on jo olemassa, lisää aikaleima + $dest = $uploadDir . '/' . $safeName; + if (file_exists($dest)) { + $ext = pathinfo($safeName, PATHINFO_EXTENSION); + $base = pathinfo($safeName, PATHINFO_FILENAME); + $safeName = $base . '_' . date('His') . ($ext ? '.' . $ext : ''); + $dest = $uploadDir . '/' . $safeName; + } + if (move_uploaded_file($file['tmp_name'], $dest)) { + echo json_encode([ + 'success' => true, + 'filename' => $safeName, + 'size' => $file['size'], + ]); + } else { + http_response_code(500); + echo json_encode(['error' => 'Tallennusvirhe']); + } + break; + + case 'file_list': + requireAuth(); + $customerId = $_GET['customer_id'] ?? ''; + if (!$customerId || !preg_match('/^[a-f0-9]+$/', $customerId)) { + echo json_encode([]); + break; + } + $dir = __DIR__ . '/data/files/' . $customerId; + $files = []; + if (is_dir($dir)) { + foreach (scandir($dir) as $f) { + if ($f === '.' || $f === '..') continue; + $path = $dir . '/' . $f; + $files[] = [ + 'filename' => $f, + 'size' => filesize($path), + 'modified' => date('Y-m-d H:i', filemtime($path)), + ]; + } + } + usort($files, fn($a, $b) => strcmp($b['modified'], $a['modified'])); + echo json_encode($files); + break; + + case 'file_download': + requireAuth(); + $customerId = $_GET['customer_id'] ?? ''; + $filename = $_GET['filename'] ?? ''; + if (!$customerId || !preg_match('/^[a-f0-9]+$/', $customerId) || !$filename) { + http_response_code(400); + echo json_encode(['error' => 'Virheelliset parametrit']); + break; + } + $safeName = basename($filename); + $path = __DIR__ . '/data/files/' . $customerId . '/' . $safeName; + if (!file_exists($path)) { + http_response_code(404); + echo json_encode(['error' => 'Tiedostoa ei löydy']); + break; + } + header('Content-Type: application/octet-stream'); + header('Content-Disposition: attachment; filename="' . $safeName . '"'); + header('Content-Length: ' . filesize($path)); + readfile($path); + exit; + + case 'file_delete': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $customerId = $input['customer_id'] ?? ''; + $filename = $input['filename'] ?? ''; + if (!$customerId || !preg_match('/^[a-f0-9]+$/', $customerId) || !$filename) { + http_response_code(400); + echo json_encode(['error' => 'Virheelliset parametrit']); + break; + } + $safeName = basename($filename); + $path = __DIR__ . '/data/files/' . $customerId . '/' . $safeName; + if (file_exists($path)) { + unlink($path); + } + echo json_encode(['success' => true]); + break; + case 'customer_delete': requireAuth(); if ($method !== 'POST') break; @@ -167,6 +281,12 @@ switch ($action) { $customers = loadCustomers(); $customers = array_values(array_filter($customers, fn($c) => $c['id'] !== $id)); saveCustomers($customers); + // Poista asiakkaan tiedostot + $filesDir = __DIR__ . '/data/files/' . $id; + if (is_dir($filesDir)) { + array_map('unlink', glob($filesDir . '/*')); + rmdir($filesDir); + } echo json_encode(['success' => true]); break; diff --git a/index.html b/index.html index 2c3d147..135bf88 100644 --- a/index.html +++ b/index.html @@ -58,10 +58,9 @@
-
-
-
Suosituin nopeus
-
-
-
+
+
Nopeudet
+
Keskihinta / kk
diff --git a/script.js b/script.js index f83989b..c51b3ce 100644 --- a/script.js +++ b/script.js @@ -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 = '-'; + } else { + speedTable.innerHTML = sorted.map(([speed, cnt]) => { + const isTop = cnt === maxCount; + const barWidth = Math.max(20, (cnt / maxCount) * 60); + return `${esc(speed)} (${cnt})`; + }).join(''); + } } // Keskihinta @@ -325,9 +333,82 @@ function showDetail(id) {

Lisätiedot

${esc(c.lisatiedot)}

` : ''} +
+

Tiedostot

+
+ + Max 20 MB / tiedosto +
+
+
`; 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 = '

Ei tiedostoja.

'; + return; + } + fileList.innerHTML = files.map(f => ` +
+
+ ${esc(f.filename)} + ${formatFileSize(f.size)} · ${f.modified} +
+ +
+ `).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 diff --git a/style.css b/style.css index abfad6d..07d424b 100644 --- a/style.css +++ b/style.css @@ -178,6 +178,42 @@ header { margin-top: 2px; } +.stat-wide { + grid-column: span 2; +} + +.speed-table { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 0.75rem; + margin-top: 0.4rem; + justify-content: center; +} + +.speed-item { + font-size: 0.85rem; + color: #666; + white-space: nowrap; +} + +.speed-item.top { + font-weight: 700; + color: #0f3460; +} + +.speed-bar { + display: inline-block; + height: 6px; + background: #d5dbe5; + border-radius: 3px; + margin-left: 4px; + vertical-align: middle; +} + +.speed-item.top .speed-bar { + background: #0f3460; +} + /* Toolbar */ .toolbar { margin-bottom: 1rem; @@ -589,6 +625,72 @@ form h3:first-child { font-style: italic; } +/* Files */ +.file-upload-area { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.file-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.file-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 0.75rem; + background: #f8f9fb; + border-radius: 8px; + border: 1px solid #eee; +} + +.file-info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.file-name { + font-size: 0.9rem; + font-weight: 500; + color: #0f3460; + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-name:hover { + text-decoration: underline; +} + +.file-meta { + font-size: 0.75rem; + color: #999; +} + +.file-delete-btn { + background: none; + border: none; + color: #ccc; + font-size: 1rem; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + transition: all 0.15s; + flex-shrink: 0; +} + +.file-delete-btn:hover { + color: #e74c3c; + background: #fef2f2; +} + /* Responsive */ @media (max-width: 768px) { header {