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 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 00:01:43 +02:00
parent 297ba39c4f
commit 127b581a69
5 changed files with 595 additions and 202 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
data/customers.json data/customers.json
data/backups/

24
api.php
View File

@@ -31,6 +31,18 @@ function loadCustomers(): array {
} }
function saveCustomers(array $customers): void { 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)); file_put_contents(DATA_FILE, json_encode($customers, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
} }
@@ -78,13 +90,19 @@ switch ($action) {
'id' => generateId(), 'id' => generateId(),
'yritys' => trim($input['yritys'] ?? ''), 'yritys' => trim($input['yritys'] ?? ''),
'asennusosoite' => trim($input['asennusosoite'] ?? ''), 'asennusosoite' => trim($input['asennusosoite'] ?? ''),
'postinumero' => trim($input['postinumero'] ?? ''),
'kaupunki' => trim($input['kaupunki'] ?? ''),
'liittymanopeus' => trim($input['liittymanopeus'] ?? ''), 'liittymanopeus' => trim($input['liittymanopeus'] ?? ''),
'hinta' => floatval($input['hinta'] ?? 0), 'hinta' => floatval($input['hinta'] ?? 0),
'yhteyshenkilö' => trim($input['yhteyshenkilö'] ?? ''), 'yhteyshenkilö' => trim($input['yhteyshenkilö'] ?? ''),
'puhelin' => trim($input['puhelin'] ?? ''), 'puhelin' => trim($input['puhelin'] ?? ''),
'sahkoposti' => trim($input['sahkoposti'] ?? ''), 'sahkoposti' => trim($input['sahkoposti'] ?? ''),
'laskutusosoite' => trim($input['laskutusosoite'] ?? ''), 'laskutusosoite' => trim($input['laskutusosoite'] ?? ''),
'laskutuspostinumero' => trim($input['laskutuspostinumero'] ?? ''),
'laskutuskaupunki' => trim($input['laskutuskaupunki'] ?? ''),
'laskutussahkoposti' => trim($input['laskutussahkoposti'] ?? ''), 'laskutussahkoposti' => trim($input['laskutussahkoposti'] ?? ''),
'elaskuosoite' => trim($input['elaskuosoite'] ?? ''),
'elaskuvalittaja' => trim($input['elaskuvalittaja'] ?? ''),
'ytunnus' => trim($input['ytunnus'] ?? ''), 'ytunnus' => trim($input['ytunnus'] ?? ''),
'lisatiedot' => trim($input['lisatiedot'] ?? ''), 'lisatiedot' => trim($input['lisatiedot'] ?? ''),
'luotu' => date('Y-m-d H:i:s'), 'luotu' => date('Y-m-d H:i:s'),
@@ -111,13 +129,19 @@ switch ($action) {
if ($c['id'] === $id) { if ($c['id'] === $id) {
$c['yritys'] = trim($input['yritys'] ?? $c['yritys']); $c['yritys'] = trim($input['yritys'] ?? $c['yritys']);
$c['asennusosoite'] = trim($input['asennusosoite'] ?? $c['asennusosoite']); $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['liittymanopeus'] = trim($input['liittymanopeus'] ?? $c['liittymanopeus']);
$c['hinta'] = floatval($input['hinta'] ?? $c['hinta']); $c['hinta'] = floatval($input['hinta'] ?? $c['hinta']);
$c['yhteyshenkilö'] = trim($input['yhteyshenkilö'] ?? $c['yhteyshenkilö']); $c['yhteyshenkilö'] = trim($input['yhteyshenkilö'] ?? $c['yhteyshenkilö']);
$c['puhelin'] = trim($input['puhelin'] ?? $c['puhelin']); $c['puhelin'] = trim($input['puhelin'] ?? $c['puhelin']);
$c['sahkoposti'] = trim($input['sahkoposti'] ?? $c['sahkoposti']); $c['sahkoposti'] = trim($input['sahkoposti'] ?? $c['sahkoposti']);
$c['laskutusosoite'] = trim($input['laskutusosoite'] ?? $c['laskutusosoite']); $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['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['ytunnus'] = trim($input['ytunnus'] ?? $c['ytunnus']);
$c['lisatiedot'] = trim($input['lisatiedot'] ?? $c['lisatiedot']); $c['lisatiedot'] = trim($input['lisatiedot'] ?? $c['lisatiedot']);
$c['muokattu'] = date('Y-m-d H:i:s'); $c['muokattu'] = date('Y-m-d H:i:s');

View File

@@ -24,8 +24,13 @@
<div id="dashboard" style="display:none"> <div id="dashboard" style="display:none">
<header> <header>
<div class="header-left"> <div class="header-left">
<h1>CuituNet Intra</h1> <div class="header-brand">
<span class="subtitle">Asiakashallinta</span> <span class="brand-icon">&#9889;</span>
<div>
<h1>CuituNet Intra</h1>
<span class="subtitle">Kuituasiakkaiden hallinta</span>
</div>
</div>
</div> </div>
<div class="header-right"> <div class="header-right">
<button id="btn-add" class="btn-primary">+ Lisää asiakas</button> <button id="btn-add" class="btn-primary">+ Lisää asiakas</button>
@@ -33,35 +38,78 @@
</div> </div>
</header> </header>
<!-- Haku --> <div class="main-container">
<div class="search-bar"> <!-- Stat-kortit -->
<input type="text" id="search-input" placeholder="Hae yrityksen nimellä tai osoitteella..."> <div class="stats-row">
</div> <div class="stat-card">
<div class="stat-label">Asiakkaita</div>
<div class="stat-value" id="stat-count">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Laskutus / kk</div>
<div class="stat-value stat-highlight" id="stat-billing">0,00 €</div>
</div>
<div class="stat-card">
<div class="stat-label">Laskutus / vuosi</div>
<div class="stat-value" id="stat-yearly">0,00 €</div>
</div>
<div class="stat-card stat-trivia">
<div class="stat-label">Suosituin postinumero</div>
<div class="stat-value" id="stat-top-zip">-</div>
<div class="stat-sub" id="stat-top-zip-detail"></div>
</div>
<div class="stat-card stat-trivia">
<div class="stat-label">Suosituin nopeus</div>
<div class="stat-value" id="stat-top-speed">-</div>
<div class="stat-sub" id="stat-top-speed-detail"></div>
</div>
<div class="stat-card stat-trivia">
<div class="stat-label">Keskihinta / kk</div>
<div class="stat-value" id="stat-avg-price">-</div>
</div>
</div>
<!-- Taulukko --> <!-- Toolbar: haku + info -->
<div class="table-container"> <div class="toolbar">
<table id="customer-table"> <div class="search-bar">
<thead> <span class="search-icon">&#128269;</span>
<tr> <input type="text" id="search-input" placeholder="Hae yrityksen nimellä, osoitteella tai yhteyshenkilöllä...">
<th data-sort="yritys">Yritys ↕</th> </div>
<th data-sort="asennusosoite">Asennusosoite ↕</th> </div>
<th data-sort="liittymanopeus">Nopeus ↕</th>
<th data-sort="hinta">Hinta/kk ↕</th> <!-- Taulukko -->
<th>Toiminnot</th> <div class="table-card">
</tr> <table id="customer-table">
</thead> <thead>
<tbody id="customer-tbody"></tbody> <tr>
</table> <th data-sort="yritys">Yritys ↕</th>
<div id="no-customers" class="empty-state" style="display:none"> <th data-sort="asennusosoite">Osoite ↕</th>
<p>Ei asiakkaita vielä. Lisää ensimmäinen asiakas!</p> <th data-sort="postinumero">Postinro ↕</th>
<th data-sort="kaupunki">Kaupunki ↕</th>
<th data-sort="liittymanopeus">Nopeus ↕</th>
<th data-sort="hinta">Hinta/kk ↕</th>
<th>Toiminnot</th>
</tr>
</thead>
<tbody id="customer-tbody"></tbody>
</table>
<div id="no-customers" class="empty-state" style="display:none">
<div class="empty-icon">&#128203;</div>
<p>Ei asiakkaita vielä.</p>
<p class="empty-hint">Klikkaa "Lisää asiakas" lisätäksesi ensimmäisen asiakkaan.</p>
</div>
</div>
<!-- Yhteenveto -->
<div class="summary-bar">
<span id="customer-count">0 asiakasta</span>
<span id="total-billing">Laskutus yhteensä: 0,00 €/kk</span>
</div> </div>
</div> </div>
<!-- Yhteenveto --> <footer>
<div class="summary-bar"> <p>CuituNet Intra &mdash; Asiakashallintajärjestelmä</p>
<span id="customer-count">0 asiakasta</span> </footer>
<span id="total-billing">Laskutus yhteensä: 0,00 €/kk</span>
</div>
</div> </div>
<!-- Asiakas-modal --> <!-- Asiakas-modal -->
@@ -84,10 +132,26 @@
<label for="form-ytunnus">Y-tunnus</label> <label for="form-ytunnus">Y-tunnus</label>
<input type="text" id="form-ytunnus" placeholder="1234567-8"> <input type="text" id="form-ytunnus" placeholder="1234567-8">
</div> </div>
<div class="form-group"> </div>
<label for="form-asennusosoite">Asennusosoite</label>
<input type="text" id="form-asennusosoite"> <h3>Asennusosoite</h3>
<div class="form-grid">
<div class="form-group full-width">
<label for="form-asennusosoite">Osoite</label>
<input type="text" id="form-asennusosoite" placeholder="esim. Kauppakatu 5">
</div> </div>
<div class="form-group">
<label for="form-postinumero">Postinumero</label>
<input type="text" id="form-postinumero" placeholder="20100">
</div>
<div class="form-group">
<label for="form-kaupunki">Kaupunki</label>
<input type="text" id="form-kaupunki" placeholder="Turku">
</div>
</div>
<h3>Liittymä</h3>
<div class="form-grid">
<div class="form-group"> <div class="form-group">
<label for="form-liittymanopeus">Liittymänopeus</label> <label for="form-liittymanopeus">Liittymänopeus</label>
<input type="text" id="form-liittymanopeus" placeholder="esim. 100/100"> <input type="text" id="form-liittymanopeus" placeholder="esim. 100/100">
@@ -120,10 +184,26 @@
<label for="form-laskutusosoite">Laskutusosoite</label> <label for="form-laskutusosoite">Laskutusosoite</label>
<input type="text" id="form-laskutusosoite"> <input type="text" id="form-laskutusosoite">
</div> </div>
<div class="form-group">
<label for="form-laskutuspostinumero">Postinumero</label>
<input type="text" id="form-laskutuspostinumero" placeholder="20100">
</div>
<div class="form-group">
<label for="form-laskutuskaupunki">Kaupunki</label>
<input type="text" id="form-laskutuskaupunki">
</div>
<div class="form-group"> <div class="form-group">
<label for="form-laskutussahkoposti">Laskutussähköposti</label> <label for="form-laskutussahkoposti">Laskutussähköposti</label>
<input type="email" id="form-laskutussahkoposti"> <input type="email" id="form-laskutussahkoposti">
</div> </div>
<div class="form-group">
<label for="form-elaskuosoite">E-laskuosoite</label>
<input type="text" id="form-elaskuosoite" placeholder="esim. 003712345678">
</div>
<div class="form-group">
<label for="form-elaskuvalittaja">E-laskuvälittäjä</label>
<input type="text" id="form-elaskuvalittaja" placeholder="esim. DABAFIHH">
</div>
</div> </div>
<h3>Lisätiedot</h3> <h3>Lisätiedot</h3>

160
script.js
View File

@@ -79,8 +79,11 @@ function renderTable() {
if (query) { if (query) {
filtered = customers.filter(c => filtered = customers.filter(c =>
c.yritys.toLowerCase().includes(query) || c.yritys.toLowerCase().includes(query) ||
c.asennusosoite.toLowerCase().includes(query) || (c.asennusosoite || '').toLowerCase().includes(query) ||
(c.yhteyshenkilö && c.yhteyshenkilö.toLowerCase().includes(query)) (c.postinumero || '').toLowerCase().includes(query) ||
(c.kaupunki || '').toLowerCase().includes(query) ||
(c.yhteyshenkilö || '').toLowerCase().includes(query) ||
(c.liittymanopeus || '').toLowerCase().includes(query)
); );
} }
@@ -109,8 +112,10 @@ function renderTable() {
document.getElementById('customer-table').style.display = 'table'; document.getElementById('customer-table').style.display = 'table';
tbody.innerHTML = filtered.map(c => ` tbody.innerHTML = filtered.map(c => `
<tr data-id="${c.id}"> <tr data-id="${c.id}">
<td>${esc(c.yritys)}</td> <td><strong>${esc(c.yritys)}</strong></td>
<td>${esc(c.asennusosoite)}</td> <td>${esc(c.asennusosoite)}</td>
<td>${esc(c.postinumero)}</td>
<td>${esc(c.kaupunki)}</td>
<td>${esc(c.liittymanopeus)}</td> <td>${esc(c.liittymanopeus)}</td>
<td class="price-cell">${formatPrice(c.hinta)}</td> <td class="price-cell">${formatPrice(c.hinta)}</td>
<td class="actions-cell"> <td class="actions-cell">
@@ -129,6 +134,70 @@ function updateSummary(filtered) {
const total = customers.reduce((sum, c) => sum + (parseFloat(c.hinta) || 0), 0); const total = customers.reduce((sum, c) => sum + (parseFloat(c.hinta) || 0), 0);
customerCount.textContent = `${count} asiakasta`; customerCount.textContent = `${count} asiakasta`;
totalBilling.textContent = `Laskutus yhteensä: ${formatPrice(total)}/kk`; 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');
}
// Suosituin nopeus
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', '-', '');
}
// 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) { function formatPrice(val) {
@@ -167,34 +236,48 @@ tbody.addEventListener('click', (e) => {
showDetail(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) { function showDetail(id) {
const c = customers.find(x => x.id === id); const c = customers.find(x => x.id === id);
if (!c) return; if (!c) return;
currentDetailId = id; 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-title').textContent = c.yritys;
document.getElementById('detail-body').innerHTML = ` document.getElementById('detail-body').innerHTML = `
<div class="detail-section"> <div class="detail-section">
<h3>Liittymätiedot</h3> <h3>Yritys ja liittymä</h3>
<div class="detail-grid"> <div class="detail-grid">
<div class="detail-item"> <div class="detail-item">
<div class="detail-label">Yritys</div> <div class="detail-label">Yritys</div>
<div class="detail-value">${esc(c.yritys) || '<span class="empty">-</span>'}</div> <div class="detail-value">${detailVal(c.yritys)}</div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="detail-label">Y-tunnus</div> <div class="detail-label">Y-tunnus</div>
<div class="detail-value">${esc(c.ytunnus) || '<span class="empty">-</span>'}</div> <div class="detail-value">${detailVal(c.ytunnus)}</div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="detail-label">Asennusosoite</div> <div class="detail-label">Asennusosoite</div>
<div class="detail-value">${esc(c.asennusosoite) || '<span class="empty">-</span>'}</div> <div class="detail-value">${detailVal(fullAddress)}</div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="detail-label">Nopeus</div> <div class="detail-label">Nopeus</div>
<div class="detail-value">${esc(c.liittymanopeus) || '<span class="empty">-</span>'}</div> <div class="detail-value">${detailVal(c.liittymanopeus)}</div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="detail-label">Hinta</div> <div class="detail-label">Hinta / kk</div>
<div class="detail-value price-cell">${formatPrice(c.hinta)}</div> <div class="detail-value price-cell">${formatPrice(c.hinta)}</div>
</div> </div>
</div> </div>
@@ -204,15 +287,15 @@ function showDetail(id) {
<div class="detail-grid"> <div class="detail-grid">
<div class="detail-item"> <div class="detail-item">
<div class="detail-label">Yhteyshenkilö</div> <div class="detail-label">Yhteyshenkilö</div>
<div class="detail-value">${esc(c.yhteyshenkilö) || '<span class="empty">-</span>'}</div> <div class="detail-value">${detailVal(c.yhteyshenkilö)}</div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="detail-label">Puhelin</div> <div class="detail-label">Puhelin</div>
<div class="detail-value">${c.puhelin ? `<a href="tel:${esc(c.puhelin)}">${esc(c.puhelin)}</a>` : '<span class="empty">-</span>'}</div> <div class="detail-value">${detailLink(c.puhelin, 'tel')}</div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="detail-label">Sähköposti</div> <div class="detail-label">Sähköposti</div>
<div class="detail-value">${c.sahkoposti ? `<a href="mailto:${esc(c.sahkoposti)}">${esc(c.sahkoposti)}</a>` : '<span class="empty">-</span>'}</div> <div class="detail-value">${detailLink(c.sahkoposti, 'email')}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -221,11 +304,19 @@ function showDetail(id) {
<div class="detail-grid"> <div class="detail-grid">
<div class="detail-item"> <div class="detail-item">
<div class="detail-label">Laskutusosoite</div> <div class="detail-label">Laskutusosoite</div>
<div class="detail-value">${esc(c.laskutusosoite) || '<span class="empty">-</span>'}</div> <div class="detail-value">${detailVal(fullBillingAddress)}</div>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<div class="detail-label">Laskutussähköposti</div> <div class="detail-label">Laskutussähköposti</div>
<div class="detail-value">${c.laskutussahkoposti ? `<a href="mailto:${esc(c.laskutussahkoposti)}">${esc(c.laskutussahkoposti)}</a>` : '<span class="empty">-</span>'}</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> </div>
</div> </div>
@@ -260,20 +351,27 @@ document.getElementById('modal-close').addEventListener('click', () => customerM
document.getElementById('form-cancel').addEventListener('click', () => customerModal.style.display = 'none'); document.getElementById('form-cancel').addEventListener('click', () => customerModal.style.display = 'none');
function openCustomerForm(customer = null) { function openCustomerForm(customer = null) {
document.getElementById('modal-title').textContent = customer ? 'Muokkaa asiakasta' : 'Lisää asiakas'; const c = customer;
document.getElementById('form-submit').textContent = customer ? 'Päivitä' : 'Tallenna'; document.getElementById('modal-title').textContent = c ? 'Muokkaa asiakasta' : 'Lisää asiakas';
document.getElementById('form-id').value = customer ? customer.id : ''; document.getElementById('form-submit').textContent = c ? 'Päivitä' : 'Tallenna';
document.getElementById('form-yritys').value = customer ? customer.yritys : ''; document.getElementById('form-id').value = c ? c.id : '';
document.getElementById('form-ytunnus').value = customer ? customer.ytunnus : ''; document.getElementById('form-yritys').value = c ? c.yritys : '';
document.getElementById('form-asennusosoite').value = customer ? customer.asennusosoite : ''; document.getElementById('form-ytunnus').value = c ? c.ytunnus : '';
document.getElementById('form-liittymanopeus').value = customer ? customer.liittymanopeus : ''; document.getElementById('form-asennusosoite').value = c ? c.asennusosoite : '';
document.getElementById('form-hinta').value = customer ? customer.hinta : ''; document.getElementById('form-postinumero').value = c ? (c.postinumero || '') : '';
document.getElementById('form-yhteyshenkilo').value = customer ? customer.yhteyshenkilö : ''; document.getElementById('form-kaupunki').value = c ? (c.kaupunki || '') : '';
document.getElementById('form-puhelin').value = customer ? customer.puhelin : ''; document.getElementById('form-liittymanopeus').value = c ? c.liittymanopeus : '';
document.getElementById('form-sahkoposti').value = customer ? customer.sahkoposti : ''; document.getElementById('form-hinta').value = c ? c.hinta : '';
document.getElementById('form-laskutusosoite').value = customer ? customer.laskutusosoite : ''; document.getElementById('form-yhteyshenkilo').value = c ? c.yhteyshenkilö : '';
document.getElementById('form-laskutussahkoposti').value = customer ? customer.laskutussahkoposti : ''; document.getElementById('form-puhelin').value = c ? c.puhelin : '';
document.getElementById('form-lisatiedot').value = customer ? customer.lisatiedot : ''; 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'; customerModal.style.display = 'flex';
document.getElementById('form-yritys').focus(); document.getElementById('form-yritys').focus();
} }
@@ -296,13 +394,19 @@ customerForm.addEventListener('submit', async (e) => {
yritys: document.getElementById('form-yritys').value, yritys: document.getElementById('form-yritys').value,
ytunnus: document.getElementById('form-ytunnus').value, ytunnus: document.getElementById('form-ytunnus').value,
asennusosoite: document.getElementById('form-asennusosoite').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, liittymanopeus: document.getElementById('form-liittymanopeus').value,
hinta: document.getElementById('form-hinta').value, hinta: document.getElementById('form-hinta').value,
yhteyshenkilö: document.getElementById('form-yhteyshenkilo').value, yhteyshenkilö: document.getElementById('form-yhteyshenkilo').value,
puhelin: document.getElementById('form-puhelin').value, puhelin: document.getElementById('form-puhelin').value,
sahkoposti: document.getElementById('form-sahkoposti').value, sahkoposti: document.getElementById('form-sahkoposti').value,
laskutusosoite: document.getElementById('form-laskutusosoite').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, laskutussahkoposti: document.getElementById('form-laskutussahkoposti').value,
elaskuosoite: document.getElementById('form-elaskuosoite').value,
elaskuvalittaja: document.getElementById('form-elaskuvalittaja').value,
lisatiedot: document.getElementById('form-lisatiedot').value, lisatiedot: document.getElementById('form-lisatiedot').value,
}; };

472
style.css
View File

@@ -28,6 +28,7 @@ body {
text-align: center; text-align: center;
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
margin: 1rem;
} }
.login-box h1 { .login-box h1 {
@@ -80,27 +81,260 @@ body {
/* Header */ /* Header */
header { header {
background: #0f3460; background: linear-gradient(135deg, #0f3460, #16213e);
color: #fff; color: #fff;
padding: 1rem 2rem; padding: 1rem 2rem;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; 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 { .header-left h1 {
font-size: 1.4rem; font-size: 1.3rem;
letter-spacing: -0.3px;
} }
.subtitle { .subtitle {
font-size: 0.85rem; font-size: 0.8rem;
opacity: 0.8; opacity: 0.7;
} }
.header-right { .header-right {
display: flex; display: flex;
gap: 0.75rem; 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 */ /* Buttons */
@@ -113,13 +347,17 @@ header {
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
transition: background 0.2s; transition: background 0.2s, transform 0.1s;
} }
.btn-primary:hover { .btn-primary:hover {
background: #27ae60; background: #27ae60;
} }
.btn-primary:active {
transform: scale(0.97);
}
.btn-secondary { .btn-secondary {
background: transparent; background: transparent;
color: #fff; color: #fff;
@@ -151,120 +389,6 @@ header {
background: #c0392b; 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 */
.modal { .modal {
position: fixed; position: fixed;
@@ -278,6 +402,7 @@ tbody td {
justify-content: center; justify-content: center;
z-index: 1000; z-index: 1000;
padding: 1rem; padding: 1rem;
backdrop-filter: blur(2px);
} }
.modal-content { .modal-content {
@@ -288,6 +413,12 @@ tbody td {
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.3); 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 { .modal-header {
@@ -310,10 +441,18 @@ tbody td {
cursor: pointer; cursor: pointer;
color: #999; color: #999;
line-height: 1; 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 { .modal-close:hover {
color: #333; color: #333;
background: #f0f0f0;
} }
/* Form */ /* Form */
@@ -322,7 +461,7 @@ form {
} }
form h3 { form h3 {
font-size: 1rem; font-size: 0.95rem;
color: #0f3460; color: #0f3460;
margin: 1.25rem 0 0.75rem; margin: 1.25rem 0 0.75rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
@@ -349,7 +488,7 @@ form h3:first-child {
} }
.form-group label { .form-group label {
font-size: 0.85rem; font-size: 0.82rem;
font-weight: 600; font-weight: 600;
color: #555; color: #555;
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
@@ -360,8 +499,8 @@ form h3:first-child {
padding: 10px 12px; padding: 10px 12px;
border: 2px solid #e0e0e0; border: 2px solid #e0e0e0;
border-radius: 8px; border-radius: 8px;
font-size: 0.95rem; font-size: 0.93rem;
transition: border-color 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
font-family: inherit; font-family: inherit;
} }
@@ -369,6 +508,7 @@ form h3:first-child {
.form-group textarea:focus { .form-group textarea:focus {
outline: none; outline: none;
border-color: #0f3460; border-color: #0f3460;
box-shadow: 0 0 0 3px rgba(15,52,96,0.1);
} }
.form-actions { .form-actions {
@@ -398,8 +538,12 @@ form h3:first-child {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.detail-section:last-child {
margin-bottom: 0;
}
.detail-section h3 { .detail-section h3 {
font-size: 1rem; font-size: 0.95rem;
color: #0f3460; color: #0f3460;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
@@ -409,18 +553,19 @@ form h3:first-child {
.detail-grid { .detail-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 0.5rem 2rem; gap: 0.75rem 2rem;
} }
.detail-item { .detail-item {
padding: 0.4rem 0; padding: 0.3rem 0;
} }
.detail-label { .detail-label {
font-size: 0.8rem; font-size: 0.75rem;
color: #888; color: #999;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-bottom: 2px;
} }
.detail-value { .detail-value {
@@ -430,6 +575,15 @@ form h3:first-child {
word-break: break-word; word-break: break-word;
} }
.detail-value a {
color: #0f3460;
text-decoration: none;
}
.detail-value a:hover {
text-decoration: underline;
}
.detail-value.empty { .detail-value.empty {
color: #ccc; color: #ccc;
font-style: italic; font-style: italic;
@@ -439,10 +593,39 @@ form h3:first-child {
@media (max-width: 768px) { @media (max-width: 768px) {
header { header {
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 0.75rem;
padding: 1rem;
text-align: center; 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, .form-grid,
.detail-grid { .detail-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -450,25 +633,26 @@ form h3:first-child {
.summary-bar { .summary-bar {
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.25rem;
text-align: center; text-align: center;
} }
.table-container {
padding: 0 1rem;
}
.search-bar {
padding: 1rem;
}
.summary-bar {
margin: 1rem;
}
thead th, thead th,
tbody td { tbody td {
padding: 10px 8px; padding: 10px 10px;
font-size: 0.85rem; 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;
}
}