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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
data/customers.json
|
data/customers.json
|
||||||
data/backups/
|
data/backups/
|
||||||
|
data/files/
|
||||||
|
|||||||
120
api.php
120
api.php
@@ -159,6 +159,120 @@ switch ($action) {
|
|||||||
saveCustomers($customers);
|
saveCustomers($customers);
|
||||||
break;
|
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':
|
case 'customer_delete':
|
||||||
requireAuth();
|
requireAuth();
|
||||||
if ($method !== 'POST') break;
|
if ($method !== 'POST') break;
|
||||||
@@ -167,6 +281,12 @@ switch ($action) {
|
|||||||
$customers = loadCustomers();
|
$customers = loadCustomers();
|
||||||
$customers = array_values(array_filter($customers, fn($c) => $c['id'] !== $id));
|
$customers = array_values(array_filter($customers, fn($c) => $c['id'] !== $id));
|
||||||
saveCustomers($customers);
|
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]);
|
echo json_encode(['success' => true]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -58,10 +58,9 @@
|
|||||||
<div class="stat-value" id="stat-top-zip">-</div>
|
<div class="stat-value" id="stat-top-zip">-</div>
|
||||||
<div class="stat-sub" id="stat-top-zip-detail"></div>
|
<div class="stat-sub" id="stat-top-zip-detail"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-trivia">
|
<div class="stat-card stat-trivia stat-wide">
|
||||||
<div class="stat-label">Suosituin nopeus</div>
|
<div class="stat-label">Nopeudet</div>
|
||||||
<div class="stat-value" id="stat-top-speed">-</div>
|
<div id="stat-speed-table" class="speed-table"></div>
|
||||||
<div class="stat-sub" id="stat-top-speed-detail"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-trivia">
|
<div class="stat-card stat-trivia">
|
||||||
<div class="stat-label">Keskihinta / kk</div>
|
<div class="stat-label">Keskihinta / kk</div>
|
||||||
|
|||||||
93
script.js
93
script.js
@@ -170,17 +170,25 @@ function updateTrivia() {
|
|||||||
setTrivia('stat-top-zip', '-', 'ei postinumeroita');
|
setTrivia('stat-top-zip', '-', 'ei postinumeroita');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suosituin nopeus
|
// Nopeus-jakauma
|
||||||
const speedCounts = {};
|
const speedCounts = {};
|
||||||
customers.forEach(c => {
|
customers.forEach(c => {
|
||||||
const speed = (c.liittymanopeus || '').trim();
|
const speed = (c.liittymanopeus || '').trim();
|
||||||
if (speed) speedCounts[speed] = (speedCounts[speed] || 0) + 1;
|
if (speed) speedCounts[speed] = (speedCounts[speed] || 0) + 1;
|
||||||
});
|
});
|
||||||
const topSpeed = Object.entries(speedCounts).sort((a, b) => b[1] - a[1])[0];
|
const speedTable = document.getElementById('stat-speed-table');
|
||||||
if (topSpeed) {
|
if (speedTable) {
|
||||||
setTrivia('stat-top-speed', topSpeed[0], `${topSpeed[1]} liittymää`);
|
const sorted = Object.entries(speedCounts).sort((a, b) => b[1] - a[1]);
|
||||||
} else {
|
const maxCount = sorted.length > 0 ? sorted[0][1] : 0;
|
||||||
setTrivia('stat-top-speed', '-', '');
|
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
|
// Keskihinta
|
||||||
@@ -325,9 +333,82 @@ function showDetail(id) {
|
|||||||
<h3>Lisätiedot</h3>
|
<h3>Lisätiedot</h3>
|
||||||
<p style="white-space:pre-wrap; color:#555;">${esc(c.lisatiedot)}</p>
|
<p style="white-space:pre-wrap; color:#555;">${esc(c.lisatiedot)}</p>
|
||||||
</div>` : ''}
|
</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';
|
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
|
// Detail modal actions
|
||||||
|
|||||||
102
style.css
102
style.css
@@ -178,6 +178,42 @@ header {
|
|||||||
margin-top: 2px;
|
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 */
|
||||||
.toolbar {
|
.toolbar {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
@@ -589,6 +625,72 @@ form h3:first-child {
|
|||||||
font-style: italic;
|
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 */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
header {
|
header {
|
||||||
|
|||||||
Reference in New Issue
Block a user