Add multiple connections per customer, contract period, and layout redesign

- Refactor data model: each customer now has a liittymat array (auto-migration from old format)
- Add sopimuskausi (1/12/24/36 kk) and alkupvm fields per connection
- Form supports adding/removing multiple connection rows per company
- Add "use same as installation address" checkbox for billing address
- Move stat cards to compact sidebar on the right
- Place search bar above customer table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 00:21:31 +02:00
parent c648c9311c
commit 695d8c6545
4 changed files with 650 additions and 287 deletions

65
api.php
View File

@@ -27,7 +27,29 @@ function requireAuth() {
function loadCustomers(): array {
$data = file_get_contents(DATA_FILE);
return json_decode($data, true) ?: [];
$customers = json_decode($data, true) ?: [];
// Migroi vanha data: jos asiakkaalla ei ole liittymat-arrayta, luo se
$migrated = false;
foreach ($customers as &$c) {
if (!isset($c['liittymat'])) {
$c['liittymat'] = [[
'asennusosoite' => $c['asennusosoite'] ?? '',
'postinumero' => $c['postinumero'] ?? '',
'kaupunki' => $c['kaupunki'] ?? '',
'liittymanopeus' => $c['liittymanopeus'] ?? '',
'hinta' => floatval($c['hinta'] ?? 0),
'sopimuskausi' => '',
'alkupvm' => '',
]];
unset($c['asennusosoite'], $c['postinumero'], $c['kaupunki'], $c['liittymanopeus'], $c['hinta']);
$migrated = true;
}
}
unset($c);
if ($migrated) {
file_put_contents(DATA_FILE, json_encode($customers, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
return $customers;
}
function saveCustomers(array $customers): void {
@@ -86,14 +108,24 @@ switch ($action) {
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$customers = loadCustomers();
$liittymat = [];
foreach (($input['liittymat'] ?? []) as $l) {
$liittymat[] = [
'asennusosoite' => trim($l['asennusosoite'] ?? ''),
'postinumero' => trim($l['postinumero'] ?? ''),
'kaupunki' => trim($l['kaupunki'] ?? ''),
'liittymanopeus' => trim($l['liittymanopeus'] ?? ''),
'hinta' => floatval($l['hinta'] ?? 0),
'sopimuskausi' => trim($l['sopimuskausi'] ?? ''),
'alkupvm' => trim($l['alkupvm'] ?? ''),
];
}
if (empty($liittymat)) {
$liittymat[] = ['asennusosoite' => '', 'postinumero' => '', 'kaupunki' => '', 'liittymanopeus' => '', 'hinta' => 0, 'sopimuskausi' => '', 'alkupvm' => ''];
}
$customer = [
'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'] ?? ''),
@@ -105,6 +137,7 @@ switch ($action) {
'elaskuvalittaja' => trim($input['elaskuvalittaja'] ?? ''),
'ytunnus' => trim($input['ytunnus'] ?? ''),
'lisatiedot' => trim($input['lisatiedot'] ?? ''),
'liittymat' => $liittymat,
'luotu' => date('Y-m-d H:i:s'),
];
if (empty($customer['yritys'])) {
@@ -128,11 +161,6 @@ switch ($action) {
foreach ($customers as &$c) {
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']);
@@ -144,6 +172,21 @@ switch ($action) {
$c['elaskuvalittaja'] = trim($input['elaskuvalittaja'] ?? ($c['elaskuvalittaja'] ?? ''));
$c['ytunnus'] = trim($input['ytunnus'] ?? $c['ytunnus']);
$c['lisatiedot'] = trim($input['lisatiedot'] ?? $c['lisatiedot']);
if (isset($input['liittymat'])) {
$liittymat = [];
foreach ($input['liittymat'] as $l) {
$liittymat[] = [
'asennusosoite' => trim($l['asennusosoite'] ?? ''),
'postinumero' => trim($l['postinumero'] ?? ''),
'kaupunki' => trim($l['kaupunki'] ?? ''),
'liittymanopeus' => trim($l['liittymanopeus'] ?? ''),
'hinta' => floatval($l['hinta'] ?? 0),
'sopimuskausi' => trim($l['sopimuskausi'] ?? ''),
'alkupvm' => trim($l['alkupvm'] ?? ''),
];
}
$c['liittymat'] = $liittymat;
}
$c['muokattu'] = date('Y-m-d H:i:s');
$found = true;
echo json_encode($c);

View File

@@ -10,7 +10,7 @@
<!-- Login -->
<div id="login-screen" class="login-screen">
<div class="login-box">
<h1>🔒 CuituNet Intra</h1>
<h1>CuituNet Intra</h1>
<p>Kirjaudu sisään</p>
<form id="login-form">
<input type="password" id="login-password" placeholder="Salasana" required autofocus>
@@ -39,36 +39,10 @@
</header>
<div class="main-container">
<!-- Stat-kortit -->
<div class="stats-row">
<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 stat-wide">
<div class="stat-label">Nopeudet</div>
<div id="stat-speed-table" class="speed-table"></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>
<!-- Toolbar: haku + info -->
<div class="content-layout">
<!-- Vasen: taulukko -->
<div class="content-main">
<!-- Toolbar: haku -->
<div class="toolbar">
<div class="search-bar">
<span class="search-icon">&#128269;</span>
@@ -81,12 +55,12 @@
<table id="customer-table">
<thead>
<tr>
<th data-sort="yritys">Yritys </th>
<th data-sort="asennusosoite">Osoite </th>
<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 data-sort="yritys">Yritys &#8597;</th>
<th data-sort="asennusosoite">Osoite &#8597;</th>
<th data-sort="kaupunki">Kaupunki &#8597;</th>
<th data-sort="liittymanopeus">Nopeus &#8597;</th>
<th data-sort="hinta">Hinta/kk &#8597;</th>
<th data-sort="sopimuskausi">Sopimus &#8597;</th>
<th>Toiminnot</th>
</tr>
</thead>
@@ -95,7 +69,7 @@
<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>
<p class="empty-hint">Klikkaa "+ Lisää asiakas" lisätäksesi ensimmäisen asiakkaan.</p>
</div>
</div>
@@ -106,6 +80,41 @@
</div>
</div>
<!-- Oikea: tilastot -->
<aside class="sidebar-stats">
<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">Liittymiä</div>
<div class="stat-value" id="stat-connections">0</div>
</div>
<div class="stat-card highlight">
<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 trivia">
<div class="stat-label">Keskihinta / kk</div>
<div class="stat-value" id="stat-avg-price">-</div>
</div>
<div class="stat-card 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 trivia">
<div class="stat-label">Nopeudet</div>
<div id="stat-speed-table" class="speed-table"></div>
</div>
</aside>
</div>
</div>
<footer>
<p>CuituNet Intra &mdash; Asiakashallintajärjestelmä</p>
</footer>
@@ -113,7 +122,7 @@
<!-- Asiakas-modal -->
<div id="customer-modal" class="modal" style="display:none">
<div class="modal-content">
<div class="modal-content modal-wide">
<div class="modal-header">
<h2 id="modal-title">Lisää asiakas</h2>
<button class="modal-close" id="modal-close">&times;</button>
@@ -133,33 +142,8 @@
</div>
</div>
<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 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">
<label for="form-liittymanopeus">Liittymänopeus</label>
<input type="text" id="form-liittymanopeus" placeholder="esim. 100/100">
</div>
<div class="form-group">
<label for="form-hinta">Hinta €/kk</label>
<input type="number" id="form-hinta" step="0.01" min="0">
</div>
</div>
<h3>Liittymät <button type="button" class="btn-add-row" id="btn-add-liittyma">+ Lisää liittymä</button></h3>
<div id="liittymat-container"></div>
<h3>Yhteystiedot</h3>
<div class="form-grid">
@@ -178,6 +162,13 @@
</div>
<h3>Laskutustiedot</h3>
<div class="form-group" style="margin-bottom:0.75rem;">
<label class="checkbox-label">
<input type="checkbox" id="form-billing-same">
Käytä samoja kuin ensimmäisen liittymän asennusosoite
</label>
</div>
<div id="billing-fields">
<div class="form-grid">
<div class="form-group full-width">
<label for="form-laskutusosoite">Laskutusosoite</label>
@@ -191,6 +182,9 @@
<label for="form-laskutuskaupunki">Kaupunki</label>
<input type="text" id="form-laskutuskaupunki">
</div>
</div>
</div>
<div class="form-grid" style="margin-top:0.75rem;">
<div class="form-group">
<label for="form-laskutussahkoposti">Laskutussähköposti</label>
<input type="email" id="form-laskutussahkoposti">
@@ -220,7 +214,7 @@
<!-- Tiedot-modal (klikkaa riviä) -->
<div id="detail-modal" class="modal" style="display:none">
<div class="modal-content">
<div class="modal-content modal-wide">
<div class="modal-header">
<h2 id="detail-title">Asiakkaan tiedot</h2>
<button class="modal-close" id="detail-close">&times;</button>

365
script.js
View File

@@ -35,9 +35,7 @@ async function apiCall(action, method = 'GET', body = null) {
async function checkAuth() {
try {
const data = await apiCall('check_auth');
if (data.authenticated) {
showDashboard();
}
if (data.authenticated) showDashboard();
} catch (e) { /* not logged in */ }
}
@@ -73,24 +71,52 @@ async function loadCustomers() {
renderTable();
}
// Helper: flatten customers into rows (one row per liittymä)
function flattenRows(customerList) {
const rows = [];
customerList.forEach(c => {
const liittymat = c.liittymat || [];
if (liittymat.length === 0) {
rows.push({ customer: c, liittyma: { asennusosoite: '', postinumero: '', kaupunki: '', liittymanopeus: '', hinta: 0, sopimuskausi: '', alkupvm: '' }, index: 0 });
} else {
liittymat.forEach((l, i) => {
rows.push({ customer: c, liittyma: l, index: i });
});
}
});
return rows;
}
function renderTable() {
const query = searchInput.value.toLowerCase().trim();
let filtered = customers;
if (query) {
filtered = customers.filter(c =>
c.yritys.toLowerCase().includes(query) ||
(c.asennusosoite || '').toLowerCase().includes(query) ||
(c.postinumero || '').toLowerCase().includes(query) ||
(c.kaupunki || '').toLowerCase().includes(query) ||
(c.yhteyshenki || '').toLowerCase().includes(query) ||
(c.liittymanopeus || '').toLowerCase().includes(query)
filtered = customers.filter(c => {
const liittymat = c.liittymat || [];
const inLiittymat = liittymat.some(l =>
(l.asennusosoite || '').toLowerCase().includes(query) ||
(l.postinumero || '').toLowerCase().includes(query) ||
(l.kaupunki || '').toLowerCase().includes(query) ||
(l.liittymanopeus || '').toLowerCase().includes(query)
);
return c.yritys.toLowerCase().includes(query) ||
(c.yhteyshenkilö || '').toLowerCase().includes(query) ||
inLiittymat;
});
}
const rows = flattenRows(filtered);
// Sort
filtered.sort((a, b) => {
let va = a[sortField] ?? '';
let vb = b[sortField] ?? '';
rows.sort((a, b) => {
let va, vb;
if (['asennusosoite', 'postinumero', 'kaupunki', 'liittymanopeus', 'hinta', 'sopimuskausi'].includes(sortField)) {
va = a.liittyma[sortField] ?? '';
vb = b.liittyma[sortField] ?? '';
} else {
va = a.customer[sortField] ?? '';
vb = b.customer[sortField] ?? '';
}
if (sortField === 'hinta') {
va = parseFloat(va) || 0;
vb = parseFloat(vb) || 0;
@@ -103,77 +129,92 @@ function renderTable() {
return 0;
});
if (filtered.length === 0) {
if (rows.length === 0) {
tbody.innerHTML = '';
noCustomers.style.display = 'block';
document.getElementById('customer-table').style.display = 'none';
} else {
noCustomers.style.display = 'none';
document.getElementById('customer-table').style.display = 'table';
tbody.innerHTML = filtered.map(c => `
<tr data-id="${c.id}">
<td><strong>${esc(c.yritys)}</strong></td>
<td>${esc(c.asennusosoite)}</td>
<td>${esc(c.postinumero)}</td>
<td>${esc(c.kaupunki)}</td>
<td>${esc(c.liittymanopeus)}</td>
<td class="price-cell">${formatPrice(c.hinta)}</td>
let prevCustomerId = null;
tbody.innerHTML = rows.map(r => {
const c = r.customer;
const l = r.liittyma;
const isFirst = c.id !== prevCustomerId;
prevCustomerId = c.id;
const sopimus = l.sopimuskausi ? l.sopimuskausi + ' kk' : '';
const alkupvm = l.alkupvm ? ' (' + esc(l.alkupvm) + ')' : '';
return `
<tr data-id="${c.id}" class="${isFirst ? '' : 'sub-row'}">
<td>${isFirst ? '<strong>' + esc(c.yritys) + '</strong>' : '<span class="sub-marker">↳</span>'}</td>
<td>${esc(l.asennusosoite)}${l.postinumero ? ', ' + esc(l.postinumero) : ''}</td>
<td>${esc(l.kaupunki)}</td>
<td>${esc(l.liittymanopeus)}</td>
<td class="price-cell">${formatPrice(l.hinta)}</td>
<td>${sopimus}${alkupvm}</td>
<td class="actions-cell">
<button onclick="event.stopPropagation(); editCustomer('${c.id}')" title="Muokkaa">✏️</button>
<button onclick="event.stopPropagation(); deleteCustomer('${c.id}', '${esc(c.yritys)}')" title="Poista">🗑️</button>
${isFirst ? `
<button onclick="event.stopPropagation(); editCustomer('${c.id}')" title="Muokkaa">&#9998;</button>
<button onclick="event.stopPropagation(); deleteCustomer('${c.id}', '${esc(c.yritys)}')" title="Poista">&#128465;</button>
` : ''}
</td>
</tr>
`).join('');
</tr>`;
}).join('');
}
updateSummary(filtered);
updateSummary();
}
function updateSummary(filtered) {
function getAllLiittymat() {
const all = [];
customers.forEach(c => (c.liittymat || []).forEach(l => all.push(l)));
return all;
}
function updateSummary() {
const liittymat = getAllLiittymat();
const count = customers.length;
const total = customers.reduce((sum, c) => sum + (parseFloat(c.hinta) || 0), 0);
customerCount.textContent = `${count} asiakasta`;
const connCount = liittymat.length;
const total = liittymat.reduce((sum, l) => sum + (parseFloat(l.hinta) || 0), 0);
customerCount.textContent = `${count} asiakasta, ${connCount} liittymää`;
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);
setText('stat-count', count);
setText('stat-connections', connCount);
setText('stat-billing', formatPrice(total));
setText('stat-yearly', formatPrice(total * 12));
// Nippelitilastot
updateTrivia();
updateTrivia(liittymat, connCount);
}
function updateTrivia() {
const count = customers.length;
if (count === 0) {
function updateTrivia(liittymat, connCount) {
if (connCount === 0) {
setTrivia('stat-top-zip', '-', '');
setTrivia('stat-top-speed', '-', '');
setText('stat-avg-price', '-');
const st = document.getElementById('stat-speed-table');
if (st) st.innerHTML = '<span style="color:#aaa;font-size:0.85rem;">-</span>';
return;
}
// Suosituin postinumero
const zipCounts = {};
customers.forEach(c => {
const zip = (c.postinumero || '').trim();
liittymat.forEach(l => {
const zip = (l.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]);
const city = liittymat.find(l => (l.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');
setTrivia('stat-top-zip', '-', '');
}
// Nopeus-jakauma
const speedCounts = {};
customers.forEach(c => {
const speed = (c.liittymanopeus || '').trim();
liittymat.forEach(l => {
const speed = (l.liittymanopeus || '').trim();
if (speed) speedCounts[speed] = (speedCounts[speed] || 0) + 1;
});
const speedTable = document.getElementById('stat-speed-table');
@@ -181,19 +222,19 @@ function updateTrivia() {
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>';
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);
const barWidth = Math.max(15, (cnt / maxCount) * 50);
return `<span class="speed-item ${isTop ? 'top' : ''}">${esc(speed)} (${cnt})<span class="speed-bar" style="width:${barWidth}px"></span></span>`;
}).join('');
}
}
// Keskihinta
const total = customers.reduce((sum, c) => sum + (parseFloat(c.hinta) || 0), 0);
setText('stat-avg-price', formatPrice(total / count));
const total = liittymat.reduce((sum, l) => sum + (parseFloat(l.hinta) || 0), 0);
setText('stat-avg-price', formatPrice(total / connCount));
}
function setTrivia(id, value, sub) {
@@ -240,8 +281,7 @@ document.querySelectorAll('th[data-sort]').forEach(th => {
tbody.addEventListener('click', (e) => {
const row = e.target.closest('tr');
if (!row) return;
const id = row.dataset.id;
showDetail(id);
showDetail(row.dataset.id);
});
function detailVal(val) {
@@ -260,13 +300,47 @@ function showDetail(id) {
if (!c) return;
currentDetailId = id;
const fullAddress = [c.asennusosoite, c.postinumero, c.kaupunki].filter(Boolean).join(', ');
const liittymat = c.liittymat || [];
const fullBillingAddress = [c.laskutusosoite, c.laskutuspostinumero, c.laskutuskaupunki].filter(Boolean).join(', ');
const liittymatHtml = liittymat.map((l, i) => {
const fullAddr = [l.asennusosoite, l.postinumero, l.kaupunki].filter(Boolean).join(', ');
const sopimus = l.sopimuskausi ? l.sopimuskausi + ' kk' : '-';
const alku = l.alkupvm || '-';
return `
<div class="liittyma-card">
${liittymat.length > 1 ? `<div class="liittyma-num">Liittymä ${i + 1}</div>` : ''}
<div class="detail-grid">
<div class="detail-item">
<div class="detail-label">Osoite</div>
<div class="detail-value">${detailVal(fullAddr)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Nopeus</div>
<div class="detail-value">${detailVal(l.liittymanopeus)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Hinta / kk</div>
<div class="detail-value price-cell">${formatPrice(l.hinta)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Sopimuskausi</div>
<div class="detail-value">${sopimus}</div>
</div>
<div class="detail-item">
<div class="detail-label">Alkaen</div>
<div class="detail-value">${detailVal(alku)}</div>
</div>
</div>
</div>`;
}).join('');
const totalHinta = liittymat.reduce((s, l) => s + (parseFloat(l.hinta) || 0), 0);
document.getElementById('detail-title').textContent = c.yritys;
document.getElementById('detail-body').innerHTML = `
<div class="detail-section">
<h3>Yritys ja liittymä</h3>
<h3>Perustiedot</h3>
<div class="detail-grid">
<div class="detail-item">
<div class="detail-label">Yritys</div>
@@ -276,19 +350,12 @@ function showDetail(id) {
<div class="detail-label">Y-tunnus</div>
<div class="detail-value">${detailVal(c.ytunnus)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Asennusosoite</div>
<div class="detail-value">${detailVal(fullAddress)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Nopeus</div>
<div class="detail-value">${detailVal(c.liittymanopeus)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Hinta / kk</div>
<div class="detail-value price-cell">${formatPrice(c.hinta)}</div>
</div>
</div>
<div class="detail-section">
<h3>Liittymät (${liittymat.length})</h3>
${liittymatHtml}
${liittymat.length > 1 ? `<div class="liittyma-total">Yhteensä: ${formatPrice(totalHinta)}/kk</div>` : ''}
</div>
<div class="detail-section">
<h3>Yhteystiedot</h3>
@@ -331,27 +398,24 @@ function showDetail(id) {
${c.lisatiedot ? `
<div class="detail-section">
<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 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;">
<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>
<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) {
@@ -381,7 +445,7 @@ async function loadFiles(customerId) {
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>';
fileList.innerHTML = '<p style="color:#aaa;font-size:0.85rem;">Ei tiedostoja.</p>';
return;
}
fileList.innerHTML = files.map(f => `
@@ -395,7 +459,7 @@ async function loadFiles(customerId) {
</div>
`).join('');
} catch (e) {
fileList.innerHTML = '<p style="color:#e74c3c; font-size:0.85rem;">Virhe ladattaessa tiedostoja.</p>';
fileList.innerHTML = '<p style="color:#e74c3c;font-size:0.85rem;">Virhe ladattaessa tiedostoja.</p>';
}
}
@@ -426,6 +490,108 @@ document.getElementById('detail-delete').addEventListener('click', () => {
}
});
// ============ FORM: Liittymät (add/remove rows) ============
let formLiittymat = [];
function createLiittymaRow(data = {}, index = 0) {
const div = document.createElement('div');
div.className = 'liittyma-row';
div.dataset.index = index;
div.innerHTML = `
<div class="liittyma-row-header">
<span class="liittyma-row-title">Liittymä ${index + 1}</span>
<button type="button" class="btn-remove-row" title="Poista liittymä">&#10005;</button>
</div>
<div class="form-grid form-grid-liittyma">
<div class="form-group">
<label>Osoite</label>
<input type="text" class="l-asennusosoite" value="${esc(data.asennusosoite || '')}" placeholder="esim. Kauppakatu 5">
</div>
<div class="form-group">
<label>Postinumero</label>
<input type="text" class="l-postinumero" value="${esc(data.postinumero || '')}" placeholder="20100">
</div>
<div class="form-group">
<label>Kaupunki</label>
<input type="text" class="l-kaupunki" value="${esc(data.kaupunki || '')}" placeholder="Turku">
</div>
<div class="form-group">
<label>Nopeus</label>
<input type="text" class="l-liittymanopeus" value="${esc(data.liittymanopeus || '')}" placeholder="esim. 100/100">
</div>
<div class="form-group">
<label>Hinta €/kk</label>
<input type="number" class="l-hinta" step="0.01" min="0" value="${data.hinta || ''}">
</div>
<div class="form-group">
<label>Sopimuskausi</label>
<select class="l-sopimuskausi">
<option value="">- Valitse -</option>
<option value="1" ${data.sopimuskausi === '1' ? 'selected' : ''}>1 kk</option>
<option value="12" ${data.sopimuskausi === '12' ? 'selected' : ''}>12 kk</option>
<option value="24" ${data.sopimuskausi === '24' ? 'selected' : ''}>24 kk</option>
<option value="36" ${data.sopimuskausi === '36' ? 'selected' : ''}>36 kk</option>
</select>
</div>
<div class="form-group">
<label>Alkaen</label>
<input type="date" class="l-alkupvm" value="${esc(data.alkupvm || '')}">
</div>
</div>
`;
div.querySelector('.btn-remove-row').addEventListener('click', () => {
div.remove();
renumberLiittymaRows();
});
return div;
}
function renumberLiittymaRows() {
const container = document.getElementById('liittymat-container');
container.querySelectorAll('.liittyma-row').forEach((row, i) => {
row.dataset.index = i;
row.querySelector('.liittyma-row-title').textContent = `Liittymä ${i + 1}`;
});
}
function collectLiittymatFromForm() {
const container = document.getElementById('liittymat-container');
const rows = container.querySelectorAll('.liittyma-row');
return Array.from(rows).map(row => ({
asennusosoite: row.querySelector('.l-asennusosoite').value,
postinumero: row.querySelector('.l-postinumero').value,
kaupunki: row.querySelector('.l-kaupunki').value,
liittymanopeus: row.querySelector('.l-liittymanopeus').value,
hinta: row.querySelector('.l-hinta').value,
sopimuskausi: row.querySelector('.l-sopimuskausi').value,
alkupvm: row.querySelector('.l-alkupvm').value,
}));
}
document.getElementById('btn-add-liittyma').addEventListener('click', () => {
const container = document.getElementById('liittymat-container');
const count = container.querySelectorAll('.liittyma-row').length;
container.appendChild(createLiittymaRow({}, count));
});
// Billing "same as" checkbox
document.getElementById('form-billing-same').addEventListener('change', function () {
const billingFields = document.getElementById('billing-fields');
if (this.checked) {
billingFields.style.display = 'none';
// Copy first liittymä address into billing fields
const firstRow = document.querySelector('.liittyma-row');
if (firstRow) {
document.getElementById('form-laskutusosoite').value = firstRow.querySelector('.l-asennusosoite').value;
document.getElementById('form-laskutuspostinumero').value = firstRow.querySelector('.l-postinumero').value;
document.getElementById('form-laskutuskaupunki').value = firstRow.querySelector('.l-kaupunki').value;
}
} else {
billingFields.style.display = 'block';
}
});
// Add/Edit modal
document.getElementById('btn-add').addEventListener('click', () => openCustomerForm());
document.getElementById('modal-close').addEventListener('click', () => customerModal.style.display = 'none');
@@ -437,22 +603,28 @@ function openCustomerForm(customer = null) {
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-ytunnus').value = c ? (c.ytunnus || '') : '';
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-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 : '';
document.getElementById('form-lisatiedot').value = c ? (c.lisatiedot || '') : '';
// Reset billing checkbox
document.getElementById('form-billing-same').checked = false;
document.getElementById('billing-fields').style.display = 'block';
// Liittymät
const container = document.getElementById('liittymat-container');
container.innerHTML = '';
const liittymat = c ? (c.liittymat || []) : [{}];
liittymat.forEach((l, i) => container.appendChild(createLiittymaRow(l, i)));
customerModal.style.display = 'flex';
document.getElementById('form-yritys').focus();
}
@@ -471,14 +643,20 @@ async function deleteCustomer(id, name) {
customerForm.addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('form-id').value;
// If "same as" checked, sync billing from first liittymä
if (document.getElementById('form-billing-same').checked) {
const firstRow = document.querySelector('.liittyma-row');
if (firstRow) {
document.getElementById('form-laskutusosoite').value = firstRow.querySelector('.l-asennusosoite').value;
document.getElementById('form-laskutuspostinumero').value = firstRow.querySelector('.l-postinumero').value;
document.getElementById('form-laskutuskaupunki').value = firstRow.querySelector('.l-kaupunki').value;
}
}
const data = {
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,
@@ -489,6 +667,7 @@ customerForm.addEventListener('submit', async (e) => {
elaskuosoite: document.getElementById('form-elaskuosoite').value,
elaskuvalittaja: document.getElementById('form-elaskuvalittaja').value,
lisatiedot: document.getElementById('form-lisatiedot').value,
liittymat: collectLiittymatFromForm(),
};
if (id) {

305
style.css
View File

@@ -122,38 +122,69 @@ header {
/* Main container */
.main-container {
max-width: 1200px;
max-width: 1400px;
margin: 0 auto;
padding: 1.5rem;
min-height: calc(100vh - 120px);
}
/* Stat cards */
.stats-row {
/* Content layout: table left, stats right */
.content-layout {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1.25rem;
grid-template-columns: 1fr 220px;
gap: 1.25rem;
align-items: start;
}
.stat-card {
.content-main {
min-width: 0;
}
/* Sidebar stats */
.sidebar-stats {
display: flex;
flex-direction: column;
gap: 0.6rem;
position: sticky;
top: 80px;
}
.sidebar-stats .stat-card {
background: #fff;
border-radius: 12px;
padding: 1.25rem 1.5rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
border-radius: 10px;
padding: 0.8rem 1rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
text-align: center;
}
.sidebar-stats .stat-card.highlight {
background: #0f3460;
color: #fff;
}
.sidebar-stats .stat-card.highlight .stat-label {
color: rgba(255,255,255,0.7);
}
.sidebar-stats .stat-card.highlight .stat-value {
color: #fff;
}
.sidebar-stats .stat-card.trivia {
background: #f8f9fb;
border: 1px dashed #d5dbe5;
}
.stat-label {
font-size: 0.8rem;
font-size: 0.7rem;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.4rem;
margin-bottom: 0.2rem;
}
.stat-value {
font-size: 1.6rem;
font-size: 1.2rem;
font-weight: 700;
color: #1a1a2e;
}
@@ -162,38 +193,31 @@ header {
color: #0f3460;
}
.stat-trivia {
background: #f8f9fb;
border: 1px dashed #d5dbe5;
}
.stat-trivia .stat-value {
font-size: 1.3rem;
.sidebar-stats .stat-card.trivia .stat-value {
font-size: 1rem;
color: #555;
}
.stat-sub {
font-size: 0.78rem;
font-size: 0.7rem;
color: #999;
margin-top: 2px;
}
.stat-wide {
grid-column: span 2;
margin-top: 1px;
}
.speed-table {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.75rem;
margin-top: 0.4rem;
justify-content: center;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.3rem;
}
.speed-item {
font-size: 0.85rem;
font-size: 0.78rem;
color: #666;
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
}
.speed-item.top {
@@ -203,11 +227,10 @@ header {
.speed-bar {
display: inline-block;
height: 6px;
height: 5px;
background: #d5dbe5;
border-radius: 3px;
margin-left: 4px;
vertical-align: middle;
flex-shrink: 0;
}
.speed-item.top .speed-bar {
@@ -235,10 +258,10 @@ header {
.search-bar input {
width: 100%;
padding: 12px 16px 12px 40px;
padding: 11px 16px 11px 40px;
border: 2px solid #e0e0e0;
border-radius: 10px;
font-size: 0.95rem;
font-size: 0.93rem;
background: #fff;
transition: border-color 0.2s, box-shadow 0.2s;
}
@@ -266,10 +289,10 @@ table {
thead th {
background: #16213e;
color: #fff;
padding: 13px 16px;
padding: 11px 14px;
text-align: left;
font-weight: 600;
font-size: 0.85rem;
font-size: 0.82rem;
cursor: pointer;
user-select: none;
white-space: nowrap;
@@ -294,9 +317,23 @@ tbody tr:hover {
background: #f4f7fb;
}
tbody tr.sub-row {
background: #fafbfc;
}
tbody tr.sub-row:hover {
background: #f0f3f7;
}
.sub-marker {
color: #bbb;
font-size: 0.85rem;
margin-left: 0.5rem;
}
tbody td {
padding: 13px 16px;
font-size: 0.93rem;
padding: 11px 14px;
font-size: 0.9rem;
}
.price-cell {
@@ -312,8 +349,8 @@ tbody td {
background: none;
border: none;
cursor: pointer;
padding: 6px 8px;
font-size: 1rem;
padding: 5px 7px;
font-size: 0.95rem;
border-radius: 6px;
transition: background 0.15s;
}
@@ -349,17 +386,17 @@ tbody td {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
padding: 0.8rem 1.25rem;
background: #fff;
border-radius: 12px;
border-radius: 10px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
font-weight: 600;
font-size: 0.9rem;
font-size: 0.85rem;
color: #555;
}
#total-billing {
font-size: 1rem;
font-size: 0.95rem;
color: #0f3460;
}
@@ -425,6 +462,39 @@ footer {
background: #c0392b;
}
.btn-add-row {
background: #3498db;
color: #fff;
border: none;
padding: 4px 12px;
border-radius: 6px;
font-size: 0.78rem;
cursor: pointer;
margin-left: 0.75rem;
vertical-align: middle;
transition: background 0.2s;
}
.btn-add-row:hover {
background: #2980b9;
}
.btn-remove-row {
background: none;
border: none;
color: #ccc;
font-size: 1rem;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
}
.btn-remove-row:hover {
color: #e74c3c;
background: #fef2f2;
}
/* Modal */
.modal {
position: fixed;
@@ -452,6 +522,10 @@ footer {
animation: modalIn 0.2s ease-out;
}
.modal-content.modal-wide {
max-width: 800px;
}
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
@@ -502,16 +576,22 @@ form h3 {
margin: 1.25rem 0 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #f0f2f5;
display: flex;
align-items: center;
}
form h3:first-child {
form h3:first-of-type {
margin-top: 0;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
gap: 0.85rem;
}
.form-grid-liittyma {
grid-template-columns: repeat(3, 1fr);
}
.form-group {
@@ -524,29 +604,46 @@ form h3:first-child {
}
.form-group label {
font-size: 0.82rem;
font-size: 0.78rem;
font-weight: 600;
color: #555;
margin-bottom: 0.3rem;
margin-bottom: 0.25rem;
}
.form-group input,
.form-group textarea {
padding: 10px 12px;
.form-group textarea,
.form-group select {
padding: 9px 11px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 0.93rem;
font-size: 0.9rem;
transition: border-color 0.2s, box-shadow 0.2s;
font-family: inherit;
}
.form-group input:focus,
.form-group textarea:focus {
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #0f3460;
box-shadow: 0 0 0 3px rgba(15,52,96,0.1);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #555;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #0f3460;
}
.form-actions {
display: flex;
gap: 0.75rem;
@@ -565,6 +662,28 @@ form h3:first-child {
border-color: #999;
}
/* Liittymä rows in form */
.liittyma-row {
background: #f8f9fb;
border: 1px solid #e8ebf0;
border-radius: 10px;
padding: 0.75rem 1rem;
margin-bottom: 0.6rem;
}
.liittyma-row-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.liittyma-row-title {
font-size: 0.8rem;
font-weight: 600;
color: #0f3460;
}
/* Detail view */
#detail-body {
padding: 1.5rem;
@@ -597,7 +716,7 @@ form h3:first-child {
}
.detail-label {
font-size: 0.75rem;
font-size: 0.72rem;
color: #999;
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -605,7 +724,7 @@ form h3:first-child {
}
.detail-value {
font-size: 0.95rem;
font-size: 0.93rem;
font-weight: 500;
color: #1a1a2e;
word-break: break-word;
@@ -620,11 +739,40 @@ form h3:first-child {
text-decoration: underline;
}
.detail-value.empty {
.detail-value.empty,
span.empty {
color: #ccc;
font-style: italic;
}
/* Liittymä card in detail view */
.liittyma-card {
background: #f8f9fb;
border: 1px solid #e8ebf0;
border-radius: 10px;
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
}
.liittyma-num {
font-size: 0.75rem;
font-weight: 700;
color: #0f3460;
margin-bottom: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.liittyma-total {
text-align: right;
font-weight: 700;
font-size: 0.95rem;
color: #0f3460;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 2px solid #e8ebf0;
}
/* Files */
.file-upload-area {
display: flex;
@@ -692,6 +840,24 @@ form h3:first-child {
}
/* Responsive */
@media (max-width: 1024px) {
.content-layout {
grid-template-columns: 1fr;
}
.sidebar-stats {
position: static;
flex-direction: row;
flex-wrap: wrap;
gap: 0.5rem;
}
.sidebar-stats .stat-card {
flex: 1;
min-width: 120px;
}
}
@media (max-width: 768px) {
header {
flex-direction: column;
@@ -708,27 +874,8 @@ form h3:first-child {
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-liittyma,
.detail-grid {
grid-template-columns: 1fr;
}
@@ -741,8 +888,8 @@ form h3:first-child {
thead th,
tbody td {
padding: 10px 10px;
font-size: 0.85rem;
padding: 9px 10px;
font-size: 0.83rem;
}
}