Add user management, change log and customer archive

- Multi-user auth with username/password (replaces single password)
- Default admin account created automatically (admin/cuitunet2024)
- User CRUD with admin/user roles (only admin can manage users)
- All customer changes logged with timestamp, user and details
- Customer deletion now archives instead of permanently removing
- Archive view with restore and permanent delete options
- Tab navigation: Asiakkaat, Arkisto, Muutosloki, Käyttäjät
- Protect users.json, changelog.json and archive.json in .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 00:41:40 +02:00
parent 695d8c6545
commit e4914e9edb
5 changed files with 960 additions and 512 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
data/customers.json
data/users.json
data/changelog.json
data/archive.json
data/backups/
data/files/

477
api.php
View File

@@ -2,33 +2,99 @@
session_start();
header('Content-Type: application/json');
define('ADMIN_PASSWORD', 'cuitunet2024');
define('DATA_FILE', __DIR__ . '/data/customers.json');
define('DATA_DIR', __DIR__ . '/data');
define('DATA_FILE', DATA_DIR . '/customers.json');
define('USERS_FILE', DATA_DIR . '/users.json');
define('CHANGELOG_FILE', DATA_DIR . '/changelog.json');
define('ARCHIVE_FILE', DATA_DIR . '/archive.json');
// Varmista data-kansio
if (!file_exists(__DIR__ . '/data')) {
mkdir(__DIR__ . '/data', 0755, true);
}
if (!file_exists(DATA_FILE)) {
file_put_contents(DATA_FILE, '[]');
// Varmista data-kansio ja tiedostot
if (!file_exists(DATA_DIR)) mkdir(DATA_DIR, 0755, true);
foreach ([DATA_FILE, USERS_FILE, CHANGELOG_FILE, ARCHIVE_FILE] as $f) {
if (!file_exists($f)) file_put_contents($f, '[]');
}
// Luo oletuskäyttäjä jos users.json on tyhjä
initUsers();
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
// Auth-tarkistus (paitsi login)
// ==================== HELPERS ====================
function requireAuth() {
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['error' => 'Kirjaudu sisään']);
exit;
}
}
function requireAdmin() {
requireAuth();
if (($_SESSION['role'] ?? '') !== 'admin') {
http_response_code(403);
echo json_encode(['error' => 'Vain ylläpitäjä voi tehdä tämän']);
exit;
}
}
function currentUser(): string {
return $_SESSION['username'] ?? 'tuntematon';
}
function generateId(): string {
return bin2hex(random_bytes(8));
}
// ==================== USERS ====================
function initUsers(): void {
$users = json_decode(file_get_contents(USERS_FILE), true) ?: [];
if (empty($users)) {
$users[] = [
'id' => generateId(),
'username' => 'admin',
'password_hash' => password_hash('cuitunet2024', PASSWORD_DEFAULT),
'nimi' => 'Ylläpitäjä',
'role' => 'admin',
'luotu' => date('Y-m-d H:i:s'),
];
file_put_contents(USERS_FILE, json_encode($users, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
}
function loadUsers(): array {
return json_decode(file_get_contents(USERS_FILE), true) ?: [];
}
function saveUsers(array $users): void {
file_put_contents(USERS_FILE, json_encode($users, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
// ==================== CHANGELOG ====================
function addLog(string $action, string $customerId = '', string $customerName = '', string $details = ''): void {
$log = json_decode(file_get_contents(CHANGELOG_FILE), true) ?: [];
array_unshift($log, [
'id' => generateId(),
'timestamp' => date('Y-m-d H:i:s'),
'user' => currentUser(),
'action' => $action,
'customer_id' => $customerId,
'customer_name' => $customerName,
'details' => $details,
]);
// Säilytä max 500 lokimerkintää
if (count($log) > 500) $log = array_slice($log, 0, 500);
file_put_contents(CHANGELOG_FILE, json_encode($log, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
// ==================== CUSTOMERS ====================
function loadCustomers(): array {
$data = file_get_contents(DATA_FILE);
$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'])) {
@@ -53,12 +119,10 @@ 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';
$backupDir = DATA_DIR . '/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);
@@ -68,46 +132,17 @@ function saveCustomers(array $customers): void {
file_put_contents(DATA_FILE, json_encode($customers, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
function generateId(): string {
return bin2hex(random_bytes(8));
// ==================== ARCHIVE ====================
function loadArchive(): array {
return json_decode(file_get_contents(ARCHIVE_FILE), true) ?: [];
}
switch ($action) {
case 'login':
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$password = $input['password'] ?? '';
if ($password === ADMIN_PASSWORD) {
$_SESSION['authenticated'] = true;
echo json_encode(['success' => true]);
} else {
http_response_code(401);
echo json_encode(['error' => 'Väärä salasana']);
function saveArchive(array $archive): void {
file_put_contents(ARCHIVE_FILE, json_encode($archive, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
break;
case 'logout':
session_destroy();
echo json_encode(['success' => true]);
break;
case 'check_auth':
echo json_encode(['authenticated' => isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true]);
break;
case 'customers':
requireAuth();
if ($method === 'GET') {
$customers = loadCustomers();
echo json_encode($customers);
}
break;
case 'customer':
requireAuth();
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$customers = loadCustomers();
function parseLiittymat(array $input): array {
$liittymat = [];
foreach (($input['liittymat'] ?? []) as $l) {
$liittymat[] = [
@@ -123,6 +158,182 @@ switch ($action) {
if (empty($liittymat)) {
$liittymat[] = ['asennusosoite' => '', 'postinumero' => '', 'kaupunki' => '', 'liittymanopeus' => '', 'hinta' => 0, 'sopimuskausi' => '', 'alkupvm' => ''];
}
return $liittymat;
}
// ==================== ROUTES ====================
switch ($action) {
// ---------- AUTH ----------
case 'login':
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$username = trim($input['username'] ?? '');
$password = $input['password'] ?? '';
$users = loadUsers();
$found = false;
foreach ($users as $u) {
if ($u['username'] === $username && password_verify($password, $u['password_hash'])) {
$_SESSION['user_id'] = $u['id'];
$_SESSION['username'] = $u['username'];
$_SESSION['nimi'] = $u['nimi'];
$_SESSION['role'] = $u['role'];
echo json_encode(['success' => true, 'username' => $u['username'], 'nimi' => $u['nimi'], 'role' => $u['role']]);
$found = true;
break;
}
}
if (!$found) {
http_response_code(401);
echo json_encode(['error' => 'Väärä käyttäjätunnus tai salasana']);
}
break;
case 'logout':
session_destroy();
echo json_encode(['success' => true]);
break;
case 'check_auth':
if (isset($_SESSION['user_id'])) {
echo json_encode([
'authenticated' => true,
'username' => $_SESSION['username'],
'nimi' => $_SESSION['nimi'],
'role' => $_SESSION['role'],
]);
} else {
echo json_encode(['authenticated' => false]);
}
break;
// ---------- USERS ----------
case 'users':
requireAdmin();
$users = loadUsers();
// Älä palauta salasanoja
$safe = array_map(function($u) {
unset($u['password_hash']);
return $u;
}, $users);
echo json_encode(array_values($safe));
break;
case 'user_create':
requireAdmin();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$username = trim($input['username'] ?? '');
$password = $input['password'] ?? '';
$nimi = trim($input['nimi'] ?? '');
$role = ($input['role'] ?? 'user') === 'admin' ? 'admin' : 'user';
if (empty($username) || empty($password)) {
http_response_code(400);
echo json_encode(['error' => 'Käyttäjätunnus ja salasana vaaditaan']);
break;
}
if (strlen($password) < 4) {
http_response_code(400);
echo json_encode(['error' => 'Salasanan pitää olla vähintään 4 merkkiä']);
break;
}
$users = loadUsers();
foreach ($users as $u) {
if ($u['username'] === $username) {
http_response_code(400);
echo json_encode(['error' => 'Käyttäjätunnus on jo käytössä']);
break 2;
}
}
$newUser = [
'id' => generateId(),
'username' => $username,
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'nimi' => $nimi ?: $username,
'role' => $role,
'luotu' => date('Y-m-d H:i:s'),
];
$users[] = $newUser;
saveUsers($users);
addLog('user_create', '', '', "Lisäsi käyttäjän: {$username} ({$role})");
unset($newUser['password_hash']);
echo json_encode($newUser);
break;
case 'user_update':
requireAdmin();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? '';
$users = loadUsers();
$found = false;
foreach ($users as &$u) {
if ($u['id'] === $id) {
if (isset($input['nimi'])) $u['nimi'] = trim($input['nimi']);
if (isset($input['role'])) $u['role'] = $input['role'] === 'admin' ? 'admin' : 'user';
if (!empty($input['password'])) {
$u['password_hash'] = password_hash($input['password'], PASSWORD_DEFAULT);
}
$found = true;
addLog('user_update', '', '', "Muokkasi käyttäjää: {$u['username']}");
$safe = $u;
unset($safe['password_hash']);
echo json_encode($safe);
break;
}
}
unset($u);
if (!$found) {
http_response_code(404);
echo json_encode(['error' => 'Käyttäjää ei löydy']);
break;
}
saveUsers($users);
break;
case 'user_delete':
requireAdmin();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? '';
if ($id === $_SESSION['user_id']) {
http_response_code(400);
echo json_encode(['error' => 'Et voi poistaa itseäsi']);
break;
}
$users = loadUsers();
$deleted = null;
foreach ($users as $u) {
if ($u['id'] === $id) { $deleted = $u; break; }
}
$users = array_values(array_filter($users, fn($u) => $u['id'] !== $id));
saveUsers($users);
if ($deleted) addLog('user_delete', '', '', "Poisti käyttäjän: {$deleted['username']}");
echo json_encode(['success' => true]);
break;
// ---------- CHANGELOG ----------
case 'changelog':
requireAuth();
$log = json_decode(file_get_contents(CHANGELOG_FILE), true) ?: [];
$limit = intval($_GET['limit'] ?? 100);
echo json_encode(array_slice($log, 0, $limit));
break;
// ---------- CUSTOMERS ----------
case 'customers':
requireAuth();
if ($method === 'GET') {
echo json_encode(loadCustomers());
}
break;
case 'customer':
requireAuth();
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$customers = loadCustomers();
$customer = [
'id' => generateId(),
'yritys' => trim($input['yritys'] ?? ''),
@@ -137,7 +348,7 @@ switch ($action) {
'elaskuvalittaja' => trim($input['elaskuvalittaja'] ?? ''),
'ytunnus' => trim($input['ytunnus'] ?? ''),
'lisatiedot' => trim($input['lisatiedot'] ?? ''),
'liittymat' => $liittymat,
'liittymat' => parseLiittymat($input),
'luotu' => date('Y-m-d H:i:s'),
];
if (empty($customer['yritys'])) {
@@ -147,6 +358,7 @@ switch ($action) {
}
$customers[] = $customer;
saveCustomers($customers);
addLog('customer_create', $customer['id'], $customer['yritys'], 'Lisäsi asiakkaan');
echo json_encode($customer);
}
break;
@@ -160,35 +372,23 @@ switch ($action) {
$found = false;
foreach ($customers as &$c) {
if ($c['id'] === $id) {
$c['yritys'] = trim($input['yritys'] ?? $c['yritys']);
$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']);
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'] ?? ''),
];
$changes = [];
$fields = ['yritys','yhteyshenkilö','puhelin','sahkoposti','laskutusosoite','laskutuspostinumero','laskutuskaupunki','laskutussahkoposti','elaskuosoite','elaskuvalittaja','ytunnus','lisatiedot'];
foreach ($fields as $f) {
if (isset($input[$f])) {
$old = $c[$f] ?? '';
$new = trim($input[$f]);
if ($old !== $new) $changes[] = $f;
$c[$f] = $new;
}
$c['liittymat'] = $liittymat;
}
if (isset($input['liittymat'])) {
$c['liittymat'] = parseLiittymat($input);
$changes[] = 'liittymat';
}
$c['muokattu'] = date('Y-m-d H:i:s');
$found = true;
addLog('customer_update', $c['id'], $c['yritys'], 'Muokkasi: ' . implode(', ', $changes));
echo json_encode($c);
break;
}
@@ -202,6 +402,88 @@ switch ($action) {
saveCustomers($customers);
break;
case 'customer_delete':
requireAuth();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? '';
$customers = loadCustomers();
$archived = null;
$remaining = [];
foreach ($customers as $c) {
if ($c['id'] === $id) {
$c['arkistoitu'] = date('Y-m-d H:i:s');
$c['arkistoija'] = currentUser();
$archived = $c;
} else {
$remaining[] = $c;
}
}
if ($archived) {
$archive = loadArchive();
$archive[] = $archived;
saveArchive($archive);
saveCustomers($remaining);
addLog('customer_archive', $archived['id'], $archived['yritys'], 'Arkistoi asiakkaan');
}
echo json_encode(['success' => true]);
break;
// ---------- ARCHIVE ----------
case 'archived_customers':
requireAuth();
echo json_encode(loadArchive());
break;
case 'customer_restore':
requireAuth();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? '';
$archive = loadArchive();
$restored = null;
$remaining = [];
foreach ($archive as $c) {
if ($c['id'] === $id) {
unset($c['arkistoitu'], $c['arkistoija']);
$restored = $c;
} else {
$remaining[] = $c;
}
}
if ($restored) {
$customers = loadCustomers();
$customers[] = $restored;
saveCustomers($customers);
saveArchive($remaining);
addLog('customer_restore', $restored['id'], $restored['yritys'], 'Palautti asiakkaan arkistosta');
}
echo json_encode(['success' => true]);
break;
case 'customer_permanent_delete':
requireAdmin();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? '';
$archive = loadArchive();
$deleted = null;
foreach ($archive as $c) {
if ($c['id'] === $id) { $deleted = $c; break; }
}
$archive = array_values(array_filter($archive, fn($c) => $c['id'] !== $id));
saveArchive($archive);
// Poista tiedostot
$filesDir = DATA_DIR . '/files/' . $id;
if (is_dir($filesDir)) {
array_map('unlink', glob($filesDir . '/*'));
rmdir($filesDir);
}
if ($deleted) addLog('customer_permanent_delete', $id, $deleted['yritys'] ?? '', 'Poisti pysyvästi');
echo json_encode(['success' => true]);
break;
// ---------- FILES ----------
case 'file_upload':
requireAuth();
if ($method !== 'POST') break;
@@ -222,16 +504,14 @@ switch ($action) {
echo json_encode(['error' => 'Tiedoston lähetys epäonnistui']);
break;
}
// Max 20MB
if ($file['size'] > 20 * 1024 * 1024) {
http_response_code(400);
echo json_encode(['error' => 'Tiedosto on liian suuri (max 20 MB)']);
break;
}
$uploadDir = __DIR__ . '/data/files/' . $customerId;
$uploadDir = DATA_DIR . '/files/' . $customerId;
if (!file_exists($uploadDir)) mkdir($uploadDir, 0755, true);
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($file['name']));
// Jos samanniminen tiedosto on jo olemassa, lisää aikaleima
$dest = $uploadDir . '/' . $safeName;
if (file_exists($dest)) {
$ext = pathinfo($safeName, PATHINFO_EXTENSION);
@@ -240,11 +520,7 @@ switch ($action) {
$dest = $uploadDir . '/' . $safeName;
}
if (move_uploaded_file($file['tmp_name'], $dest)) {
echo json_encode([
'success' => true,
'filename' => $safeName,
'size' => $file['size'],
]);
echo json_encode(['success' => true, 'filename' => $safeName, 'size' => $file['size']]);
} else {
http_response_code(500);
echo json_encode(['error' => 'Tallennusvirhe']);
@@ -258,17 +534,13 @@ switch ($action) {
echo json_encode([]);
break;
}
$dir = __DIR__ . '/data/files/' . $customerId;
$dir = DATA_DIR . '/files/' . $customerId;
$files = [];
if (is_dir($dir)) {
foreach (scandir($dir) as $f) {
if ($f === '.' || $f === '..') continue;
$path = $dir . '/' . $f;
$files[] = [
'filename' => $f,
'size' => filesize($path),
'modified' => date('Y-m-d H:i', filemtime($path)),
];
$files[] = ['filename' => $f, 'size' => filesize($path), 'modified' => date('Y-m-d H:i', filemtime($path))];
}
}
usort($files, fn($a, $b) => strcmp($b['modified'], $a['modified']));
@@ -285,7 +557,7 @@ switch ($action) {
break;
}
$safeName = basename($filename);
$path = __DIR__ . '/data/files/' . $customerId . '/' . $safeName;
$path = DATA_DIR . '/files/' . $customerId . '/' . $safeName;
if (!file_exists($path)) {
http_response_code(404);
echo json_encode(['error' => 'Tiedostoa ei löydy']);
@@ -309,27 +581,8 @@ switch ($action) {
break;
}
$safeName = basename($filename);
$path = __DIR__ . '/data/files/' . $customerId . '/' . $safeName;
if (file_exists($path)) {
unlink($path);
}
echo json_encode(['success' => true]);
break;
case 'customer_delete':
requireAuth();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? '';
$customers = loadCustomers();
$customers = array_values(array_filter($customers, fn($c) => $c['id'] !== $id));
saveCustomers($customers);
// Poista asiakkaan tiedostot
$filesDir = __DIR__ . '/data/files/' . $id;
if (is_dir($filesDir)) {
array_map('unlink', glob($filesDir . '/*'));
rmdir($filesDir);
}
$path = DATA_DIR . '/files/' . $customerId . '/' . $safeName;
if (file_exists($path)) unlink($path);
echo json_encode(['success' => true]);
break;

View File

@@ -13,7 +13,8 @@
<h1>CuituNet Intra</h1>
<p>Kirjaudu sisään</p>
<form id="login-form">
<input type="password" id="login-password" placeholder="Salasana" required autofocus>
<input type="text" id="login-username" placeholder="Käyttäjätunnus" required autofocus>
<input type="password" id="login-password" placeholder="Salasana" required>
<button type="submit">Kirjaudu</button>
</form>
<div id="login-error" class="error" style="display:none"></div>
@@ -33,24 +34,31 @@
</div>
</div>
<div class="header-right">
<span id="user-info" class="user-info"></span>
<button id="btn-add" class="btn-primary">+ Lisää asiakas</button>
<button id="btn-logout" class="btn-secondary">Kirjaudu ulos</button>
</div>
</header>
<!-- Tabs -->
<div class="tab-bar">
<button class="tab active" data-tab="customers">Asiakkaat</button>
<button class="tab" data-tab="archive">Arkisto</button>
<button class="tab" data-tab="changelog">Muutosloki</button>
<button class="tab" data-tab="users" id="tab-users" style="display:none">Käyttäjät</button>
</div>
<!-- Tab: Asiakkaat -->
<div class="tab-content active" id="tab-content-customers">
<div class="main-container">
<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>
<input type="text" id="search-input" placeholder="Hae yrityksen nimellä, osoitteella tai yhteyshenkilöllä...">
</div>
</div>
<!-- Taulukko -->
<div class="table-card">
<table id="customer-table">
<thead>
@@ -72,15 +80,11 @@
<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>
<!-- Oikea: tilastot -->
<aside class="sidebar-stats">
<div class="stat-card">
<div class="stat-label">Asiakkaita</div>
@@ -114,6 +118,78 @@
</aside>
</div>
</div>
</div>
<!-- Tab: Arkisto -->
<div class="tab-content" id="tab-content-archive">
<div class="main-container">
<div class="table-card">
<table id="archive-table">
<thead>
<tr>
<th>Yritys</th>
<th>Liittymiä</th>
<th>Arkistoitu</th>
<th>Arkistoija</th>
<th>Toiminnot</th>
</tr>
</thead>
<tbody id="archive-tbody"></tbody>
</table>
<div id="no-archive" class="empty-state" style="display:none">
<div class="empty-icon">&#128451;</div>
<p>Arkisto on tyhjä.</p>
</div>
</div>
</div>
</div>
<!-- Tab: Muutosloki -->
<div class="tab-content" id="tab-content-changelog">
<div class="main-container">
<div class="table-card">
<table id="changelog-table">
<thead>
<tr>
<th>Aika</th>
<th>Käyttäjä</th>
<th>Toiminto</th>
<th>Asiakas</th>
<th>Lisätiedot</th>
</tr>
</thead>
<tbody id="changelog-tbody"></tbody>
</table>
<div id="no-changelog" class="empty-state" style="display:none">
<div class="empty-icon">&#128220;</div>
<p>Ei lokimerkintöjä.</p>
</div>
</div>
</div>
</div>
<!-- Tab: Käyttäjät (vain admin) -->
<div class="tab-content" id="tab-content-users">
<div class="main-container">
<div style="margin-bottom:1rem;">
<button class="btn-primary" id="btn-add-user">+ Lisää käyttäjä</button>
</div>
<div class="table-card">
<table id="users-table">
<thead>
<tr>
<th>Käyttäjätunnus</th>
<th>Nimi</th>
<th>Rooli</th>
<th>Luotu</th>
<th>Toiminnot</th>
</tr>
</thead>
<tbody id="users-tbody"></tbody>
</table>
</div>
</div>
</div>
<footer>
<p>CuituNet Intra &mdash; Asiakashallintajärjestelmä</p>
@@ -129,7 +205,6 @@
</div>
<form id="customer-form">
<input type="hidden" id="form-id">
<h3>Perustiedot</h3>
<div class="form-grid">
<div class="form-group">
@@ -141,10 +216,8 @@
<input type="text" id="form-ytunnus" placeholder="1234567-8">
</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">
<div class="form-group">
@@ -160,7 +233,6 @@
<input type="email" id="form-sahkoposti">
</div>
</div>
<h3>Laskutustiedot</h3>
<div class="form-group" style="margin-bottom:0.75rem;">
<label class="checkbox-label">
@@ -198,12 +270,10 @@
<input type="text" id="form-elaskuvalittaja" placeholder="esim. DABAFIHH">
</div>
</div>
<h3>Lisätiedot</h3>
<div class="form-group full-width">
<textarea id="form-lisatiedot" rows="3" placeholder="Vapaamuotoiset muistiinpanot..."></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" id="form-submit">Tallenna</button>
<button type="button" class="btn-secondary" id="form-cancel">Peruuta</button>
@@ -212,7 +282,7 @@
</div>
</div>
<!-- Tiedot-modal (klikkaa riviä) -->
<!-- Tiedot-modal -->
<div id="detail-modal" class="modal" style="display:none">
<div class="modal-content modal-wide">
<div class="modal-header">
@@ -222,12 +292,50 @@
<div id="detail-body"></div>
<div class="form-actions">
<button class="btn-primary" id="detail-edit">Muokkaa</button>
<button class="btn-danger" id="detail-delete">Poista</button>
<button class="btn-danger" id="detail-delete">Arkistoi</button>
<button class="btn-secondary" id="detail-cancel">Sulje</button>
</div>
</div>
</div>
<!-- Käyttäjä-modal -->
<div id="user-modal" class="modal" style="display:none">
<div class="modal-content">
<div class="modal-header">
<h2 id="user-modal-title">Lisää käyttäjä</h2>
<button class="modal-close" id="user-modal-close">&times;</button>
</div>
<form id="user-form">
<input type="hidden" id="user-form-id">
<div class="form-grid">
<div class="form-group">
<label for="user-form-username">Käyttäjätunnus *</label>
<input type="text" id="user-form-username" required>
</div>
<div class="form-group">
<label for="user-form-nimi">Nimi</label>
<input type="text" id="user-form-nimi">
</div>
<div class="form-group">
<label for="user-form-password">Salasana <span id="user-pw-hint"></span></label>
<input type="password" id="user-form-password">
</div>
<div class="form-group">
<label for="user-form-role">Rooli</label>
<select id="user-form-role">
<option value="user">Käyttäjä</option>
<option value="admin">Ylläpitäjä</option>
</select>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Tallenna</button>
<button type="button" class="btn-secondary" id="user-form-cancel">Peruuta</button>
</div>
</form>
</div>
</div>
<script src="script.js"></script>
</body>
</html>

621
script.js
View File

@@ -3,6 +3,7 @@ let customers = [];
let sortField = 'yritys';
let sortAsc = true;
let currentDetailId = null;
let currentUser = { username: '', nimi: '', role: '' };
// Elements
const loginScreen = document.getElementById('login-screen');
@@ -17,6 +18,7 @@ const totalBilling = document.getElementById('total-billing');
const customerModal = document.getElementById('customer-modal');
const detailModal = document.getElementById('detail-modal');
const customerForm = document.getElementById('customer-form');
const userModal = document.getElementById('user-modal');
// API helpers
async function apiCall(action, method = 'GET', body = null) {
@@ -31,20 +33,26 @@ async function apiCall(action, method = 'GET', body = null) {
return data;
}
// Auth
// ==================== AUTH ====================
async function checkAuth() {
try {
const data = await apiCall('check_auth');
if (data.authenticated) showDashboard();
if (data.authenticated) {
currentUser = { username: data.username, nimi: data.nimi, role: data.role };
showDashboard();
}
} catch (e) { /* not logged in */ }
}
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
try {
await apiCall('login', 'POST', { password });
const data = await apiCall('login', 'POST', { username, password });
loginError.style.display = 'none';
currentUser = { username: data.username, nimi: data.nimi, role: data.role };
showDashboard();
} catch (err) {
loginError.textContent = err.message;
@@ -56,22 +64,42 @@ document.getElementById('btn-logout').addEventListener('click', async () => {
await apiCall('logout');
dashboard.style.display = 'none';
loginScreen.style.display = 'flex';
document.getElementById('login-username').value = '';
document.getElementById('login-password').value = '';
});
async function showDashboard() {
loginScreen.style.display = 'none';
dashboard.style.display = 'block';
document.getElementById('user-info').textContent = currentUser.nimi || currentUser.username;
// Näytä Käyttäjät-tab vain adminille
document.getElementById('tab-users').style.display = currentUser.role === 'admin' ? '' : 'none';
await loadCustomers();
}
// Customers
// ==================== TABS ====================
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
const target = tab.dataset.tab;
document.getElementById('tab-content-' + target).classList.add('active');
// Lataa sisältö tarvittaessa
if (target === 'archive') loadArchive();
if (target === 'changelog') loadChangelog();
if (target === 'users') loadUsers();
});
});
// ==================== CUSTOMERS ====================
async function loadCustomers() {
customers = await apiCall('customers');
renderTable();
}
// Helper: flatten customers into rows (one row per liittymä)
function flattenRows(customerList) {
const rows = [];
customerList.forEach(c => {
@@ -79,9 +107,7 @@ function flattenRows(customerList) {
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 });
});
liittymat.forEach((l, i) => rows.push({ customer: c, liittyma: l, index: i }));
}
});
return rows;
@@ -93,21 +119,18 @@ function renderTable() {
if (query) {
filtered = customers.filter(c => {
const liittymat = c.liittymat || [];
const inLiittymat = liittymat.some(l =>
const inL = 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;
(c.yhteyshenkilö || '').toLowerCase().includes(query) || inL;
});
}
const rows = flattenRows(filtered);
// Sort
rows.sort((a, b) => {
let va, vb;
if (['asennusosoite', 'postinumero', 'kaupunki', 'liittymanopeus', 'hinta', 'sopimuskausi'].includes(sortField)) {
@@ -117,13 +140,8 @@ function renderTable() {
va = a.customer[sortField] ?? '';
vb = b.customer[sortField] ?? '';
}
if (sortField === 'hinta') {
va = parseFloat(va) || 0;
vb = parseFloat(vb) || 0;
} else {
va = String(va).toLowerCase();
vb = String(vb).toLowerCase();
}
if (sortField === 'hinta') { va = parseFloat(va) || 0; vb = parseFloat(vb) || 0; }
else { va = String(va).toLowerCase(); vb = String(vb).toLowerCase(); }
if (va < vb) return sortAsc ? -1 : 1;
if (va > vb) return sortAsc ? 1 : -1;
return 0;
@@ -136,32 +154,24 @@ function renderTable() {
} else {
noCustomers.style.display = 'none';
document.getElementById('customer-table').style.display = 'table';
let prevCustomerId = null;
let prevId = null;
tbody.innerHTML = rows.map(r => {
const c = r.customer;
const l = r.liittyma;
const isFirst = c.id !== prevCustomerId;
prevCustomerId = c.id;
const c = r.customer, l = r.liittyma;
const isFirst = c.id !== prevId;
prevId = 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>
return `<tr data-id="${c.id}" class="${isFirst ? '' : 'sub-row'}">
<td>${isFirst ? '<strong>' + esc(c.yritys) + '</strong>' : '<span class="sub-marker">&#8627;</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">
${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>
<td class="actions-cell">${isFirst ? `<button onclick="event.stopPropagation();editCustomer('${c.id}')" title="Muokkaa">&#9998;</button><button onclick="event.stopPropagation();deleteCustomer('${c.id}','${esc(c.yritys)}')" title="Arkistoi">&#128451;</button>` : ''}</td>
</tr>`;
}).join('');
}
updateSummary();
}
@@ -176,15 +186,12 @@ function updateSummary() {
const count = customers.length;
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`;
setText('stat-count', count);
setText('stat-connections', connCount);
setText('stat-billing', formatPrice(total));
setText('stat-yearly', formatPrice(total * 12));
updateTrivia(liittymat, connCount);
}
@@ -196,42 +203,28 @@ function updateTrivia(liittymat, connCount) {
if (st) st.innerHTML = '<span style="color:#aaa;font-size:0.85rem;">-</span>';
return;
}
// Suosituin postinumero
// Postinumero
const zipCounts = {};
liittymat.forEach(l => {
const zip = (l.postinumero || '').trim();
if (zip) zipCounts[zip] = (zipCounts[zip] || 0) + 1;
});
liittymat.forEach(l => { const z = (l.postinumero || '').trim(); if (z) zipCounts[z] = (zipCounts[z] || 0) + 1; });
const topZip = Object.entries(zipCounts).sort((a, b) => b[1] - a[1])[0];
if (topZip) {
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', '-', '');
}
// Nopeus-jakauma
} else { setTrivia('stat-top-zip', '-', ''); }
// Nopeudet
const speedCounts = {};
liittymat.forEach(l => {
const speed = (l.liittymanopeus || '').trim();
if (speed) speedCounts[speed] = (speedCounts[speed] || 0) + 1;
});
liittymat.forEach(l => { const s = (l.liittymanopeus || '').trim(); if (s) speedCounts[s] = (speedCounts[s] || 0) + 1; });
const speedTable = document.getElementById('stat-speed-table');
if (speedTable) {
const sorted = Object.entries(speedCounts).sort((a, b) => b[1] - a[1]);
const maxCount = sorted.length > 0 ? sorted[0][1] : 0;
if (sorted.length === 0) {
speedTable.innerHTML = '<span style="color:#aaa;font-size:0.85rem;">-</span>';
} else {
speedTable.innerHTML = sorted.map(([speed, cnt]) => {
const isTop = cnt === maxCount;
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>`;
const maxC = sorted.length > 0 ? sorted[0][1] : 0;
speedTable.innerHTML = sorted.length === 0 ? '<span style="color:#aaa;font-size:0.85rem;">-</span>' :
sorted.map(([sp, cnt]) => {
const isTop = cnt === maxC;
const w = Math.max(15, (cnt / maxC) * 50);
return `<span class="speed-item ${isTop ? 'top' : ''}">${esc(sp)} (${cnt})<span class="speed-bar" style="width:${w}px"></span></span>`;
}).join('');
}
}
// Keskihinta
const total = liittymat.reduce((sum, l) => sum + (parseFloat(l.hinta) || 0), 0);
setText('stat-avg-price', formatPrice(total / connCount));
@@ -243,51 +236,28 @@ function setTrivia(id, value, sub) {
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) { return parseFloat(val || 0).toFixed(2).replace('.', ',') + ' €'; }
function esc(str) { if (!str) return ''; const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
function formatPrice(val) {
return parseFloat(val || 0).toFixed(2).replace('.', ',') + ' €';
}
function esc(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Search
// Search & Sort
searchInput.addEventListener('input', () => renderTable());
// Sort
document.querySelectorAll('th[data-sort]').forEach(th => {
th.addEventListener('click', () => {
const field = th.dataset.sort;
if (sortField === field) {
sortAsc = !sortAsc;
} else {
sortField = field;
sortAsc = true;
}
const f = th.dataset.sort;
if (sortField === f) sortAsc = !sortAsc;
else { sortField = f; sortAsc = true; }
renderTable();
});
});
// Row click -> detail
// Row click
tbody.addEventListener('click', (e) => {
const row = e.target.closest('tr');
if (!row) return;
showDetail(row.dataset.id);
if (row) showDetail(row.dataset.id);
});
function detailVal(val) {
return val ? esc(val) : '<span class="empty">-</span>';
}
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>`;
@@ -299,142 +269,68 @@ function showDetail(id) {
const c = customers.find(x => x.id === id);
if (!c) return;
currentDetailId = id;
const liittymat = c.liittymat || [];
const fullBillingAddress = [c.laskutusosoite, c.laskutuspostinumero, c.laskutuskaupunki].filter(Boolean).join(', ');
const fullBilling = [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">
const addr = [l.asennusosoite, l.postinumero, l.kaupunki].filter(Boolean).join(', ');
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 class="detail-item"><div class="detail-label">Osoite</div><div class="detail-value">${detailVal(addr)}</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">${l.sopimuskausi ? l.sopimuskausi + ' kk' : '-'}</div></div>
<div class="detail-item"><div class="detail-label">Alkaen</div><div class="detail-value">${detailVal(l.alkupvm)}</div></div>
</div>
</div>`;
}).join('');
const totalHinta = liittymat.reduce((s, l) => s + (parseFloat(l.hinta) || 0), 0);
const totalH = 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>Perustiedot</h3>
<div class="detail-grid">
<div class="detail-item">
<div class="detail-label">Yritys</div>
<div class="detail-value">${detailVal(c.yritys)}</div>
<div class="detail-section"><h3>Perustiedot</h3><div class="detail-grid">
<div class="detail-item"><div class="detail-label">Yritys</div><div class="detail-value">${detailVal(c.yritys)}</div></div>
<div class="detail-item"><div class="detail-label">Y-tunnus</div><div class="detail-value">${detailVal(c.ytunnus)}</div></div>
</div></div>
<div class="detail-section"><h3>Liittymät (${liittymat.length})</h3>${liittymatHtml}
${liittymat.length > 1 ? `<div class="liittyma-total">Yhteensä: ${formatPrice(totalH)}/kk</div>` : ''}
</div>
<div class="detail-item">
<div class="detail-label">Y-tunnus</div>
<div class="detail-value">${detailVal(c.ytunnus)}</div>
</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>
<div class="detail-grid">
<div class="detail-item">
<div class="detail-label">Yhteyshenkilö</div>
<div class="detail-value">${detailVal(c.yhteyshenkilö)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Puhelin</div>
<div class="detail-value">${detailLink(c.puhelin, 'tel')}</div>
</div>
<div class="detail-item">
<div class="detail-label">Sähköposti</div>
<div class="detail-value">${detailLink(c.sahkoposti, 'email')}</div>
</div>
</div>
</div>
<div class="detail-section">
<h3>Laskutustiedot</h3>
<div class="detail-grid">
<div class="detail-item">
<div class="detail-label">Laskutusosoite</div>
<div class="detail-value">${detailVal(fullBillingAddress)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Laskutussähköposti</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>
${c.lisatiedot ? `
<div class="detail-section">
<h3>Lisätiedot</h3>
<p style="white-space:pre-wrap;color:#555;">${esc(c.lisatiedot)}</p>
</div>` : ''}
<div class="detail-section">
<h3>Tiedostot</h3>
<div class="detail-section"><h3>Yhteystiedot</h3><div class="detail-grid">
<div class="detail-item"><div class="detail-label">Yhteyshenkilö</div><div class="detail-value">${detailVal(c.yhteyshenkilö)}</div></div>
<div class="detail-item"><div class="detail-label">Puhelin</div><div class="detail-value">${detailLink(c.puhelin, 'tel')}</div></div>
<div class="detail-item"><div class="detail-label">Sähköposti</div><div class="detail-value">${detailLink(c.sahkoposti, 'email')}</div></div>
</div></div>
<div class="detail-section"><h3>Laskutustiedot</h3><div class="detail-grid">
<div class="detail-item"><div class="detail-label">Laskutusosoite</div><div class="detail-value">${detailVal(fullBilling)}</div></div>
<div class="detail-item"><div class="detail-label">Laskutussähköposti</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>
${c.lisatiedot ? `<div class="detail-section"><h3>Lisätiedot</h3><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;">
+ Lisää tiedosto
<input type="file" id="file-upload-input" style="display:none" multiple>
+ 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 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>
`;
</div>`;
detailModal.style.display = 'flex';
loadFiles(id);
const fileInput = document.getElementById('file-upload-input');
fileInput.addEventListener('change', async () => {
for (const file of fileInput.files) {
const formData = new FormData();
formData.append('customer_id', id);
formData.append('file', file);
document.getElementById('file-upload-input').addEventListener('change', async function () {
for (const file of this.files) {
const fd = new FormData();
fd.append('customer_id', id);
fd.append('file', file);
try {
const res = await fetch(`${API}?action=file_upload`, {
method: 'POST',
credentials: 'include',
body: formData,
});
const res = await fetch(`${API}?action=file_upload`, { method: 'POST', credentials: 'include', body: fd });
const data = await res.json();
if (!res.ok) alert(data.error || 'Virhe');
} catch (e) {
alert('Tiedoston lähetys epäonnistui');
} catch (e) { alert('Tiedoston lähetys epäonnistui'); }
}
}
fileInput.value = '';
this.value = '';
loadFiles(id);
});
}
@@ -444,23 +340,15 @@ async function loadFiles(customerId) {
if (!fileList) return;
try {
const files = await apiCall(`file_list&customer_id=${customerId}`);
if (files.length === 0) {
fileList.innerHTML = '<p style="color:#aaa;font-size:0.85rem;">Ei tiedostoja.</p>';
return;
}
fileList.innerHTML = files.map(f => `
<div class="file-item">
if (files.length === 0) { fileList.innerHTML = '<p style="color:#aaa;font-size:0.85rem;">Ei tiedostoja.</p>'; return; }
fileList.innerHTML = files.map(f => `<div class="file-item">
<div class="file-info">
<a href="${API}?action=file_download&customer_id=${customerId}&filename=${encodeURIComponent(f.filename)}"
class="file-name" target="_blank">${esc(f.filename)}</a>
<a href="${API}?action=file_download&customer_id=${customerId}&filename=${encodeURIComponent(f.filename)}" class="file-name" target="_blank">${esc(f.filename)}</a>
<span class="file-meta">${formatFileSize(f.size)} &middot; ${f.modified}</span>
</div>
<button class="file-delete-btn" onclick="deleteFile('${customerId}','${esc(f.filename)}')" title="Poista">&#10005;</button>
</div>
`).join('');
} catch (e) {
fileList.innerHTML = '<p style="color:#e74c3c;font-size:0.85rem;">Virhe ladattaessa tiedostoja.</p>';
}
</div>`).join('');
} catch (e) { fileList.innerHTML = '<p style="color:#e74c3c;font-size:0.85rem;">Virhe ladattaessa tiedostoja.</p>'; }
}
function formatFileSize(bytes) {
@@ -478,87 +366,50 @@ async function deleteFile(customerId, filename) {
// Detail modal actions
document.getElementById('detail-close').addEventListener('click', () => detailModal.style.display = 'none');
document.getElementById('detail-cancel').addEventListener('click', () => detailModal.style.display = 'none');
document.getElementById('detail-edit').addEventListener('click', () => {
detailModal.style.display = 'none';
editCustomer(currentDetailId);
});
document.getElementById('detail-edit').addEventListener('click', () => { detailModal.style.display = 'none'; editCustomer(currentDetailId); });
document.getElementById('detail-delete').addEventListener('click', () => {
const c = customers.find(x => x.id === currentDetailId);
if (c) {
detailModal.style.display = 'none';
deleteCustomer(currentDetailId, c.yritys);
}
if (c) { detailModal.style.display = 'none'; deleteCustomer(currentDetailId, c.yritys); }
});
// ============ FORM: Liittymät (add/remove rows) ============
let formLiittymat = [];
// ==================== FORM: Liittymät ====================
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">
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">
<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();
});
</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) => {
document.getElementById('liittymat-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 => ({
return Array.from(document.getElementById('liittymat-container').querySelectorAll('.liittyma-row')).map(row => ({
asennusosoite: row.querySelector('.l-asennusosoite').value,
postinumero: row.querySelector('.l-postinumero').value,
kaupunki: row.querySelector('.l-kaupunki').value,
@@ -571,25 +422,20 @@ function collectLiittymatFromForm() {
document.getElementById('btn-add-liittyma').addEventListener('click', () => {
const container = document.getElementById('liittymat-container');
const count = container.querySelectorAll('.liittyma-row').length;
container.appendChild(createLiittymaRow({}, count));
container.appendChild(createLiittymaRow({}, container.querySelectorAll('.liittyma-row').length));
});
// Billing "same as" checkbox
document.getElementById('form-billing-same').addEventListener('change', function () {
const billingFields = document.getElementById('billing-fields');
const bf = 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';
bf.style.display = 'none';
const first = document.querySelector('.liittyma-row');
if (first) {
document.getElementById('form-laskutusosoite').value = first.querySelector('.l-asennusosoite').value;
document.getElementById('form-laskutuspostinumero').value = first.querySelector('.l-postinumero').value;
document.getElementById('form-laskutuskaupunki').value = first.querySelector('.l-kaupunki').value;
}
} else { bf.style.display = 'block'; }
});
// Add/Edit modal
@@ -614,28 +460,19 @@ function openCustomerForm(customer = null) {
document.getElementById('form-elaskuosoite').value = c ? (c.elaskuosoite || '') : '';
document.getElementById('form-elaskuvalittaja').value = c ? (c.elaskuvalittaja || '') : '';
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)));
(c ? (c.liittymat || []) : [{}]).forEach((l, i) => container.appendChild(createLiittymaRow(l, i)));
customerModal.style.display = 'flex';
document.getElementById('form-yritys').focus();
}
function editCustomer(id) {
const c = customers.find(x => x.id === id);
if (c) openCustomerForm(c);
}
function editCustomer(id) { const c = customers.find(x => x.id === id); if (c) openCustomerForm(c); }
async function deleteCustomer(id, name) {
if (!confirm(`Poistetaanko asiakas "${name}"?`)) return;
if (!confirm(`Arkistoidaanko asiakas "${name}"?\n\nAsiakas siirretään arkistoon, josta sen voi palauttaa.`)) return;
await apiCall('customer_delete', 'POST', { id });
await loadCustomers();
}
@@ -643,17 +480,14 @@ 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 first = document.querySelector('.liittyma-row');
if (first) {
document.getElementById('form-laskutusosoite').value = first.querySelector('.l-asennusosoite').value;
document.getElementById('form-laskutuspostinumero').value = first.querySelector('.l-postinumero').value;
document.getElementById('form-laskutuskaupunki').value = first.querySelector('.l-kaupunki').value;
}
}
const data = {
yritys: document.getElementById('form-yritys').value,
ytunnus: document.getElementById('form-ytunnus').value,
@@ -669,31 +503,170 @@ customerForm.addEventListener('submit', async (e) => {
lisatiedot: document.getElementById('form-lisatiedot').value,
liittymat: collectLiittymatFromForm(),
};
if (id) {
data.id = id;
await apiCall('customer_update', 'POST', data);
} else {
await apiCall('customer', 'POST', data);
}
if (id) { data.id = id; await apiCall('customer_update', 'POST', data); }
else { await apiCall('customer', 'POST', data); }
customerModal.style.display = 'none';
await loadCustomers();
});
// Close modals on backdrop click
customerModal.addEventListener('click', (e) => {
if (e.target === customerModal) customerModal.style.display = 'none';
});
detailModal.addEventListener('click', (e) => {
if (e.target === detailModal) detailModal.style.display = 'none';
// ==================== ARCHIVE ====================
async function loadArchive() {
try {
const archive = await apiCall('archived_customers');
const atbody = document.getElementById('archive-tbody');
const noArc = document.getElementById('no-archive');
if (archive.length === 0) {
atbody.innerHTML = '';
noArc.style.display = 'block';
document.getElementById('archive-table').style.display = 'none';
} else {
noArc.style.display = 'none';
document.getElementById('archive-table').style.display = 'table';
atbody.innerHTML = archive.map(c => `<tr>
<td><strong>${esc(c.yritys)}</strong></td>
<td>${(c.liittymat || []).length}</td>
<td>${esc(c.arkistoitu || '')}</td>
<td>${esc(c.arkistoija || '')}</td>
<td class="actions-cell">
<button onclick="restoreCustomer('${c.id}')" class="btn-small btn-restore" title="Palauta">&#8634; Palauta</button>
${currentUser.role === 'admin' ? `<button onclick="permanentDelete('${c.id}','${esc(c.yritys)}')" class="btn-small btn-perm-delete" title="Poista pysyvästi">&#10005; Poista</button>` : ''}
</td>
</tr>`).join('');
}
} catch (e) { console.error(e); }
}
async function restoreCustomer(id) {
if (!confirm('Palautetaanko asiakas arkistosta?')) return;
await apiCall('customer_restore', 'POST', { id });
loadArchive();
loadCustomers();
}
async function permanentDelete(id, name) {
if (!confirm(`Poistetaanko "${name}" PYSYVÄSTI?\n\nTätä ei voi perua!`)) return;
await apiCall('customer_permanent_delete', 'POST', { id });
loadArchive();
}
// ==================== CHANGELOG ====================
const actionLabels = {
customer_create: 'Lisäsi asiakkaan',
customer_update: 'Muokkasi asiakasta',
customer_archive: 'Arkistoi asiakkaan',
customer_restore: 'Palautti asiakkaan',
customer_permanent_delete: 'Poisti pysyvästi',
user_create: 'Lisäsi käyttäjän',
user_update: 'Muokkasi käyttäjää',
user_delete: 'Poisti käyttäjän',
};
async function loadChangelog() {
try {
const log = await apiCall('changelog&limit=200');
const ctbody = document.getElementById('changelog-tbody');
const noLog = document.getElementById('no-changelog');
if (log.length === 0) {
ctbody.innerHTML = '';
noLog.style.display = 'block';
document.getElementById('changelog-table').style.display = 'none';
} else {
noLog.style.display = 'none';
document.getElementById('changelog-table').style.display = 'table';
ctbody.innerHTML = log.map(e => `<tr>
<td class="nowrap">${esc(e.timestamp)}</td>
<td><strong>${esc(e.user)}</strong></td>
<td>${actionLabels[e.action] || esc(e.action)}</td>
<td>${esc(e.customer_name)}</td>
<td class="text-muted">${esc(e.details)}</td>
</tr>`).join('');
}
} catch (e) { console.error(e); }
}
// ==================== USERS ====================
async function loadUsers() {
try {
const users = await apiCall('users');
const utbody = document.getElementById('users-tbody');
utbody.innerHTML = users.map(u => `<tr>
<td><strong>${esc(u.username)}</strong></td>
<td>${esc(u.nimi)}</td>
<td><span class="role-badge role-${u.role}">${u.role === 'admin' ? 'Ylläpitäjä' : 'Käyttäjä'}</span></td>
<td>${esc(u.luotu)}</td>
<td class="actions-cell">
<button onclick="editUser('${u.id}')" title="Muokkaa">&#9998;</button>
${u.id !== '${currentUser.id}' ? `<button onclick="deleteUser('${u.id}','${esc(u.username)}')" title="Poista">&#128465;</button>` : ''}
</td>
</tr>`).join('');
} catch (e) { console.error(e); }
}
let usersCache = [];
document.getElementById('btn-add-user').addEventListener('click', () => openUserForm());
document.getElementById('user-modal-close').addEventListener('click', () => userModal.style.display = 'none');
document.getElementById('user-form-cancel').addEventListener('click', () => userModal.style.display = 'none');
function openUserForm(user = null) {
document.getElementById('user-modal-title').textContent = user ? 'Muokkaa käyttäjää' : 'Lisää käyttäjä';
document.getElementById('user-form-id').value = user ? user.id : '';
document.getElementById('user-form-username').value = user ? user.username : '';
document.getElementById('user-form-username').disabled = !!user;
document.getElementById('user-form-nimi').value = user ? user.nimi : '';
document.getElementById('user-form-password').value = '';
document.getElementById('user-pw-hint').textContent = user ? '(jätä tyhjäksi jos ei muuteta)' : '*';
document.getElementById('user-form-role').value = user ? user.role : 'user';
userModal.style.display = 'flex';
}
async function editUser(id) {
try {
const users = await apiCall('users');
const u = users.find(x => x.id === id);
if (u) openUserForm(u);
} catch (e) { alert(e.message); }
}
async function deleteUser(id, username) {
if (!confirm(`Poistetaanko käyttäjä "${username}"?`)) return;
try {
await apiCall('user_delete', 'POST', { id });
loadUsers();
} catch (e) { alert(e.message); }
}
document.getElementById('user-form').addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('user-form-id').value;
const data = {
username: document.getElementById('user-form-username').value,
nimi: document.getElementById('user-form-nimi').value,
role: document.getElementById('user-form-role').value,
};
const pw = document.getElementById('user-form-password').value;
if (pw) data.password = pw;
else if (!id) { alert('Salasana vaaditaan uudelle käyttäjälle'); return; }
try {
if (id) { data.id = id; await apiCall('user_update', 'POST', data); }
else { await apiCall('user_create', 'POST', data); }
userModal.style.display = 'none';
loadUsers();
} catch (e) { alert(e.message); }
});
// ESC to close modals
// ==================== MODALS ====================
customerModal.addEventListener('click', (e) => { if (e.target === customerModal) customerModal.style.display = 'none'; });
detailModal.addEventListener('click', (e) => { if (e.target === detailModal) detailModal.style.display = 'none'; });
userModal.addEventListener('click', (e) => { if (e.target === userModal) userModal.style.display = 'none'; });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
customerModal.style.display = 'none';
detailModal.style.display = 'none';
userModal.style.display = 'none';
}
});

111
style.css
View File

@@ -120,6 +120,13 @@ header {
align-items: center;
}
.user-info {
font-size: 0.85rem;
opacity: 0.8;
padding-right: 0.5rem;
border-right: 1px solid rgba(255,255,255,0.2);
}
/* Main container */
.main-container {
max-width: 1400px;
@@ -905,3 +912,107 @@ span.empty {
text-align: center;
}
}
/* Tab bar */
.tab-bar {
display: flex;
gap: 0;
background: #fff;
border-bottom: 2px solid #e5e7eb;
padding: 0 2rem;
position: sticky;
top: 56px;
z-index: 99;
}
.tab {
padding: 0.75rem 1.25rem;
background: none;
border: none;
border-bottom: 3px solid transparent;
font-size: 0.88rem;
font-weight: 600;
color: #888;
cursor: pointer;
transition: all 0.2s;
margin-bottom: -2px;
}
.tab:hover {
color: #0f3460;
}
.tab.active {
color: #0f3460;
border-bottom-color: #0f3460;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Role badge */
.role-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.role-admin {
background: #0f3460;
color: #fff;
}
.role-user {
background: #e8ebf0;
color: #555;
}
/* Small buttons */
.btn-small {
display: inline-block;
padding: 4px 10px;
border: none;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.btn-restore {
background: #2ecc71;
color: #fff;
}
.btn-restore:hover {
background: #27ae60;
}
.btn-perm-delete {
background: #e74c3c;
color: #fff;
margin-left: 4px;
}
.btn-perm-delete:hover {
background: #c0392b;
}
/* Changelog */
.nowrap {
white-space: nowrap;
}
.text-muted {
color: #999;
font-size: 0.85rem;
}