From 127b581a69c43d1f6534725a5362cd41fb08dcb7 Mon Sep 17 00:00:00 2001 From: Jukka Lampikoski Date: Tue, 10 Mar 2026 00:01:43 +0200 Subject: [PATCH] Add address fields, e-invoice, stats and auto-backup - Split address into street, postal code, city (sortable) - Add billing postal code/city fields - Add e-invoice address and operator fields - Add trivia stats (top postal code, top speed, avg price) - Improved layout with stat cards grid and max-width container - Sticky header, modal animations, search icon - Auto-backup on every save (keeps last 30 backups) - Footer added Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + api.php | 24 +++ index.html | 140 ++++++++++++---- script.js | 160 ++++++++++++++---- style.css | 472 +++++++++++++++++++++++++++++++++++++---------------- 5 files changed, 595 insertions(+), 202 deletions(-) diff --git a/.gitignore b/.gitignore index fe48ed2..6c2a999 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ data/customers.json +data/backups/ diff --git a/api.php b/api.php index 42107cc..7f9e1d3 100644 --- a/api.php +++ b/api.php @@ -31,6 +31,18 @@ function loadCustomers(): array { } function saveCustomers(array $customers): void { + // Automaattinen backup ennen tallennusta + if (file_exists(DATA_FILE) && filesize(DATA_FILE) > 2) { + $backupDir = __DIR__ . '/data/backups'; + if (!file_exists($backupDir)) mkdir($backupDir, 0755, true); + copy(DATA_FILE, $backupDir . '/customers_' . date('Y-m-d_His') . '.json'); + // Säilytä vain 30 viimeisintä backuppia + $backups = glob($backupDir . '/customers_*.json'); + if (count($backups) > 30) { + sort($backups); + array_map('unlink', array_slice($backups, 0, count($backups) - 30)); + } + } file_put_contents(DATA_FILE, json_encode($customers, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); } @@ -78,13 +90,19 @@ switch ($action) { 'id' => generateId(), 'yritys' => trim($input['yritys'] ?? ''), 'asennusosoite' => trim($input['asennusosoite'] ?? ''), + 'postinumero' => trim($input['postinumero'] ?? ''), + 'kaupunki' => trim($input['kaupunki'] ?? ''), 'liittymanopeus' => trim($input['liittymanopeus'] ?? ''), 'hinta' => floatval($input['hinta'] ?? 0), 'yhteyshenkilö' => trim($input['yhteyshenkilö'] ?? ''), 'puhelin' => trim($input['puhelin'] ?? ''), 'sahkoposti' => trim($input['sahkoposti'] ?? ''), 'laskutusosoite' => trim($input['laskutusosoite'] ?? ''), + 'laskutuspostinumero' => trim($input['laskutuspostinumero'] ?? ''), + 'laskutuskaupunki' => trim($input['laskutuskaupunki'] ?? ''), 'laskutussahkoposti' => trim($input['laskutussahkoposti'] ?? ''), + 'elaskuosoite' => trim($input['elaskuosoite'] ?? ''), + 'elaskuvalittaja' => trim($input['elaskuvalittaja'] ?? ''), 'ytunnus' => trim($input['ytunnus'] ?? ''), 'lisatiedot' => trim($input['lisatiedot'] ?? ''), 'luotu' => date('Y-m-d H:i:s'), @@ -111,13 +129,19 @@ switch ($action) { if ($c['id'] === $id) { $c['yritys'] = trim($input['yritys'] ?? $c['yritys']); $c['asennusosoite'] = trim($input['asennusosoite'] ?? $c['asennusosoite']); + $c['postinumero'] = trim($input['postinumero'] ?? ($c['postinumero'] ?? '')); + $c['kaupunki'] = trim($input['kaupunki'] ?? ($c['kaupunki'] ?? '')); $c['liittymanopeus'] = trim($input['liittymanopeus'] ?? $c['liittymanopeus']); $c['hinta'] = floatval($input['hinta'] ?? $c['hinta']); $c['yhteyshenkilö'] = trim($input['yhteyshenkilö'] ?? $c['yhteyshenkilö']); $c['puhelin'] = trim($input['puhelin'] ?? $c['puhelin']); $c['sahkoposti'] = trim($input['sahkoposti'] ?? $c['sahkoposti']); $c['laskutusosoite'] = trim($input['laskutusosoite'] ?? $c['laskutusosoite']); + $c['laskutuspostinumero'] = trim($input['laskutuspostinumero'] ?? ($c['laskutuspostinumero'] ?? '')); + $c['laskutuskaupunki'] = trim($input['laskutuskaupunki'] ?? ($c['laskutuskaupunki'] ?? '')); $c['laskutussahkoposti'] = trim($input['laskutussahkoposti'] ?? $c['laskutussahkoposti']); + $c['elaskuosoite'] = trim($input['elaskuosoite'] ?? ($c['elaskuosoite'] ?? '')); + $c['elaskuvalittaja'] = trim($input['elaskuvalittaja'] ?? ($c['elaskuvalittaja'] ?? '')); $c['ytunnus'] = trim($input['ytunnus'] ?? $c['ytunnus']); $c['lisatiedot'] = trim($input['lisatiedot'] ?? $c['lisatiedot']); $c['muokattu'] = date('Y-m-d H:i:s'); diff --git a/index.html b/index.html index 8e51d48..2c3d147 100644 --- a/index.html +++ b/index.html @@ -24,8 +24,13 @@ @@ -260,20 +351,27 @@ document.getElementById('modal-close').addEventListener('click', () => customerM document.getElementById('form-cancel').addEventListener('click', () => customerModal.style.display = 'none'); function openCustomerForm(customer = null) { - document.getElementById('modal-title').textContent = customer ? 'Muokkaa asiakasta' : 'Lisää asiakas'; - document.getElementById('form-submit').textContent = customer ? 'Päivitä' : 'Tallenna'; - document.getElementById('form-id').value = customer ? customer.id : ''; - document.getElementById('form-yritys').value = customer ? customer.yritys : ''; - document.getElementById('form-ytunnus').value = customer ? customer.ytunnus : ''; - document.getElementById('form-asennusosoite').value = customer ? customer.asennusosoite : ''; - document.getElementById('form-liittymanopeus').value = customer ? customer.liittymanopeus : ''; - document.getElementById('form-hinta').value = customer ? customer.hinta : ''; - document.getElementById('form-yhteyshenkilo').value = customer ? customer.yhteyshenkilö : ''; - document.getElementById('form-puhelin').value = customer ? customer.puhelin : ''; - document.getElementById('form-sahkoposti').value = customer ? customer.sahkoposti : ''; - document.getElementById('form-laskutusosoite').value = customer ? customer.laskutusosoite : ''; - document.getElementById('form-laskutussahkoposti').value = customer ? customer.laskutussahkoposti : ''; - document.getElementById('form-lisatiedot').value = customer ? customer.lisatiedot : ''; + 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(); } @@ -296,13 +394,19 @@ customerForm.addEventListener('submit', async (e) => { 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, }; diff --git a/style.css b/style.css index e9357c9..abfad6d 100644 --- a/style.css +++ b/style.css @@ -28,6 +28,7 @@ body { text-align: center; width: 100%; max-width: 400px; + margin: 1rem; } .login-box h1 { @@ -80,27 +81,260 @@ body { /* Header */ header { - background: #0f3460; + background: linear-gradient(135deg, #0f3460, #16213e); color: #fff; padding: 1rem 2rem; display: flex; justify-content: space-between; align-items: center; - box-shadow: 0 2px 10px rgba(0,0,0,0.1); + box-shadow: 0 2px 12px rgba(0,0,0,0.15); + position: sticky; + top: 0; + z-index: 100; +} + +.header-brand { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.brand-icon { + font-size: 1.8rem; + line-height: 1; } .header-left h1 { - font-size: 1.4rem; + font-size: 1.3rem; + letter-spacing: -0.3px; } .subtitle { - font-size: 0.85rem; - opacity: 0.8; + font-size: 0.8rem; + opacity: 0.7; } .header-right { display: flex; gap: 0.75rem; + align-items: center; +} + +/* Main container */ +.main-container { + max-width: 1200px; + margin: 0 auto; + padding: 1.5rem; + min-height: calc(100vh - 120px); +} + +/* Stat cards */ +.stats-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 1.25rem; +} + +.stat-card { + background: #fff; + border-radius: 12px; + padding: 1.25rem 1.5rem; + box-shadow: 0 1px 4px rgba(0,0,0,0.06); + text-align: center; +} + +.stat-label { + font-size: 0.8rem; + color: #888; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 0.4rem; +} + +.stat-value { + font-size: 1.6rem; + font-weight: 700; + color: #1a1a2e; +} + +.stat-highlight { + color: #0f3460; +} + +.stat-trivia { + background: #f8f9fb; + border: 1px dashed #d5dbe5; +} + +.stat-trivia .stat-value { + font-size: 1.3rem; + color: #555; +} + +.stat-sub { + font-size: 0.78rem; + color: #999; + margin-top: 2px; +} + +/* Toolbar */ +.toolbar { + margin-bottom: 1rem; +} + +.search-bar { + position: relative; +} + +.search-icon { + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + font-size: 1rem; + opacity: 0.4; + pointer-events: none; +} + +.search-bar input { + width: 100%; + padding: 12px 16px 12px 40px; + border: 2px solid #e0e0e0; + border-radius: 10px; + font-size: 0.95rem; + background: #fff; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.search-bar input:focus { + outline: none; + border-color: #0f3460; + box-shadow: 0 0 0 3px rgba(15,52,96,0.1); +} + +/* Table card */ +.table-card { + background: #fff; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 1px 4px rgba(0,0,0,0.06); + margin-bottom: 1rem; +} + +table { + width: 100%; + border-collapse: collapse; +} + +thead th { + background: #16213e; + color: #fff; + padding: 13px 16px; + text-align: left; + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + user-select: none; + white-space: nowrap; + letter-spacing: 0.2px; +} + +thead th:hover { + background: #1a2744; +} + +tbody tr { + border-bottom: 1px solid #f0f0f0; + cursor: pointer; + transition: background 0.15s; +} + +tbody tr:last-child { + border-bottom: none; +} + +tbody tr:hover { + background: #f4f7fb; +} + +tbody td { + padding: 13px 16px; + font-size: 0.93rem; +} + +.price-cell { + font-weight: 700; + color: #0f3460; +} + +.actions-cell { + white-space: nowrap; +} + +.actions-cell button { + background: none; + border: none; + cursor: pointer; + padding: 6px 8px; + font-size: 1rem; + border-radius: 6px; + transition: background 0.15s; +} + +.actions-cell button:hover { + background: #eef1f6; +} + +.empty-state { + text-align: center; + padding: 3rem 2rem; + color: #888; +} + +.empty-icon { + font-size: 3rem; + margin-bottom: 0.75rem; + opacity: 0.4; +} + +.empty-state p { + font-size: 1.05rem; + margin-bottom: 0.25rem; +} + +.empty-hint { + font-size: 0.9rem !important; + color: #aaa; +} + +/* Summary */ +.summary-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + background: #fff; + border-radius: 12px; + box-shadow: 0 1px 4px rgba(0,0,0,0.06); + font-weight: 600; + font-size: 0.9rem; + color: #555; +} + +#total-billing { + font-size: 1rem; + color: #0f3460; +} + +/* Footer */ +footer { + text-align: center; + padding: 1.5rem; + color: #aaa; + font-size: 0.8rem; + border-top: 1px solid #e5e7eb; + margin-top: 1rem; } /* Buttons */ @@ -113,13 +347,17 @@ header { cursor: pointer; font-size: 0.9rem; font-weight: 600; - transition: background 0.2s; + transition: background 0.2s, transform 0.1s; } .btn-primary:hover { background: #27ae60; } +.btn-primary:active { + transform: scale(0.97); +} + .btn-secondary { background: transparent; color: #fff; @@ -151,120 +389,6 @@ header { background: #c0392b; } -/* Search */ -.search-bar { - padding: 1rem 2rem; -} - -.search-bar input { - width: 100%; - padding: 12px 16px; - border: 2px solid #e0e0e0; - border-radius: 8px; - font-size: 1rem; - background: #fff; - transition: border-color 0.2s; -} - -.search-bar input:focus { - outline: none; - border-color: #0f3460; -} - -/* Table */ -.table-container { - padding: 0 2rem; - overflow-x: auto; -} - -table { - width: 100%; - border-collapse: collapse; - background: #fff; - border-radius: 12px; - overflow: hidden; - box-shadow: 0 2px 10px rgba(0,0,0,0.06); -} - -thead th { - background: #16213e; - color: #fff; - padding: 14px 16px; - text-align: left; - font-weight: 600; - font-size: 0.9rem; - cursor: pointer; - user-select: none; - white-space: nowrap; -} - -thead th:hover { - background: #1a2744; -} - -tbody tr { - border-bottom: 1px solid #f0f0f0; - cursor: pointer; - transition: background 0.15s; -} - -tbody tr:hover { - background: #f7f9fc; -} - -tbody td { - padding: 12px 16px; - font-size: 0.95rem; -} - -.price-cell { - font-weight: 600; - color: #0f3460; -} - -.actions-cell { - white-space: nowrap; -} - -.actions-cell button { - background: none; - border: none; - cursor: pointer; - padding: 4px 8px; - font-size: 1.1rem; - border-radius: 4px; - transition: background 0.15s; -} - -.actions-cell button:hover { - background: #f0f0f0; -} - -.empty-state { - text-align: center; - padding: 3rem; - color: #888; - font-size: 1.1rem; -} - -/* Summary */ -.summary-bar { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1.25rem 2rem; - margin: 1rem 2rem 2rem; - background: #fff; - border-radius: 12px; - box-shadow: 0 2px 10px rgba(0,0,0,0.06); - font-weight: 600; -} - -#total-billing { - font-size: 1.15rem; - color: #0f3460; -} - /* Modal */ .modal { position: fixed; @@ -278,6 +402,7 @@ tbody td { justify-content: center; z-index: 1000; padding: 1rem; + backdrop-filter: blur(2px); } .modal-content { @@ -288,6 +413,12 @@ tbody td { max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.3); + animation: modalIn 0.2s ease-out; +} + +@keyframes modalIn { + from { opacity: 0; transform: scale(0.95) translateY(10px); } + to { opacity: 1; transform: scale(1) translateY(0); } } .modal-header { @@ -310,10 +441,18 @@ tbody td { cursor: pointer; color: #999; line-height: 1; + width: 36px; + height: 36px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; } .modal-close:hover { color: #333; + background: #f0f0f0; } /* Form */ @@ -322,7 +461,7 @@ form { } form h3 { - font-size: 1rem; + font-size: 0.95rem; color: #0f3460; margin: 1.25rem 0 0.75rem; padding-bottom: 0.5rem; @@ -349,7 +488,7 @@ form h3:first-child { } .form-group label { - font-size: 0.85rem; + font-size: 0.82rem; font-weight: 600; color: #555; margin-bottom: 0.3rem; @@ -360,8 +499,8 @@ form h3:first-child { padding: 10px 12px; border: 2px solid #e0e0e0; border-radius: 8px; - font-size: 0.95rem; - transition: border-color 0.2s; + font-size: 0.93rem; + transition: border-color 0.2s, box-shadow 0.2s; font-family: inherit; } @@ -369,6 +508,7 @@ form h3:first-child { .form-group textarea:focus { outline: none; border-color: #0f3460; + box-shadow: 0 0 0 3px rgba(15,52,96,0.1); } .form-actions { @@ -398,8 +538,12 @@ form h3:first-child { margin-bottom: 1.5rem; } +.detail-section:last-child { + margin-bottom: 0; +} + .detail-section h3 { - font-size: 1rem; + font-size: 0.95rem; color: #0f3460; margin-bottom: 0.75rem; padding-bottom: 0.5rem; @@ -409,18 +553,19 @@ form h3:first-child { .detail-grid { display: grid; grid-template-columns: 1fr 1fr; - gap: 0.5rem 2rem; + gap: 0.75rem 2rem; } .detail-item { - padding: 0.4rem 0; + padding: 0.3rem 0; } .detail-label { - font-size: 0.8rem; - color: #888; + font-size: 0.75rem; + color: #999; text-transform: uppercase; letter-spacing: 0.5px; + margin-bottom: 2px; } .detail-value { @@ -430,6 +575,15 @@ form h3:first-child { word-break: break-word; } +.detail-value a { + color: #0f3460; + text-decoration: none; +} + +.detail-value a:hover { + text-decoration: underline; +} + .detail-value.empty { color: #ccc; font-style: italic; @@ -439,10 +593,39 @@ form h3:first-child { @media (max-width: 768px) { header { flex-direction: column; - gap: 1rem; + gap: 0.75rem; + padding: 1rem; text-align: center; } + .header-brand { + justify-content: center; + } + + .main-container { + padding: 1rem; + } + + .stats-row { + grid-template-columns: 1fr; + gap: 0.75rem; + } + + .stat-card { + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + } + + .stat-label { + margin-bottom: 0; + } + + .stat-value { + font-size: 1.3rem; + } + .form-grid, .detail-grid { grid-template-columns: 1fr; @@ -450,25 +633,26 @@ form h3:first-child { .summary-bar { flex-direction: column; - gap: 0.5rem; + gap: 0.25rem; text-align: center; } - .table-container { - padding: 0 1rem; - } - - .search-bar { - padding: 1rem; - } - - .summary-bar { - margin: 1rem; - } - thead th, tbody td { - padding: 10px 8px; + padding: 10px 10px; font-size: 0.85rem; } } + +@media (max-width: 480px) { + .header-right { + flex-direction: column; + width: 100%; + } + + .header-right .btn-primary, + .header-right .btn-secondary { + width: 100%; + text-align: center; + } +}