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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
data/customers.json
|
||||
data/users.json
|
||||
data/changelog.json
|
||||
data/archive.json
|
||||
data/backups/
|
||||
data/files/
|
||||
|
||||
455
api.php
455
api.php
@@ -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,21 +132,61 @@ 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) ?: [];
|
||||
}
|
||||
|
||||
function saveArchive(array $archive): void {
|
||||
file_put_contents(ARCHIVE_FILE, json_encode($archive, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
function parseLiittymat(array $input): array {
|
||||
$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' => ''];
|
||||
}
|
||||
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'] ?? '';
|
||||
if ($password === ADMIN_PASSWORD) {
|
||||
$_SESSION['authenticated'] = true;
|
||||
echo json_encode(['success' => true]);
|
||||
} else {
|
||||
$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ä salasana']);
|
||||
echo json_encode(['error' => 'Väärä käyttäjätunnus tai salasana']);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -92,14 +196,136 @@ switch ($action) {
|
||||
break;
|
||||
|
||||
case 'check_auth':
|
||||
echo json_encode(['authenticated' => isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true]);
|
||||
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') {
|
||||
$customers = loadCustomers();
|
||||
echo json_encode($customers);
|
||||
echo json_encode(loadCustomers());
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -108,21 +334,6 @@ 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'] ?? ''),
|
||||
@@ -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;
|
||||
|
||||
|
||||
258
index.html
258
index.html
@@ -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,85 +34,160 @@
|
||||
</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>
|
||||
|
||||
<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">🔍</span>
|
||||
<input type="text" id="search-input" placeholder="Hae yrityksen nimellä, osoitteella tai yhteyshenkilöllä...">
|
||||
<!-- 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">
|
||||
<div class="content-main">
|
||||
<div class="toolbar">
|
||||
<div class="search-bar">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input type="text" id="search-input" placeholder="Hae yrityksen nimellä, osoitteella tai yhteyshenkilöllä...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<table id="customer-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="yritys">Yritys ↕</th>
|
||||
<th data-sort="asennusosoite">Osoite ↕</th>
|
||||
<th data-sort="kaupunki">Kaupunki ↕</th>
|
||||
<th data-sort="liittymanopeus">Nopeus ↕</th>
|
||||
<th data-sort="hinta">Hinta/kk ↕</th>
|
||||
<th data-sort="sopimuskausi">Sopimus ↕</th>
|
||||
<th>Toiminnot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="customer-tbody"></tbody>
|
||||
</table>
|
||||
<div id="no-customers" class="empty-state" style="display:none">
|
||||
<div class="empty-icon">📋</div>
|
||||
<p>Ei asiakkaita vielä.</p>
|
||||
<p class="empty-hint">Klikkaa "+ Lisää asiakas" lisätäksesi ensimmäisen asiakkaan.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-bar">
|
||||
<span id="customer-count">0 asiakasta</span>
|
||||
<span id="total-billing">Laskutus yhteensä: 0,00 €/kk</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Taulukko -->
|
||||
<div class="table-card">
|
||||
<table id="customer-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="yritys">Yritys ↕</th>
|
||||
<th data-sort="asennusosoite">Osoite ↕</th>
|
||||
<th data-sort="kaupunki">Kaupunki ↕</th>
|
||||
<th data-sort="liittymanopeus">Nopeus ↕</th>
|
||||
<th data-sort="hinta">Hinta/kk ↕</th>
|
||||
<th data-sort="sopimuskausi">Sopimus ↕</th>
|
||||
<th>Toiminnot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="customer-tbody"></tbody>
|
||||
</table>
|
||||
<div id="no-customers" class="empty-state" style="display:none">
|
||||
<div class="empty-icon">📋</div>
|
||||
<p>Ei asiakkaita vielä.</p>
|
||||
<p class="empty-hint">Klikkaa "+ Lisää asiakas" lisätäksesi ensimmäisen asiakkaan.</p>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Yhteenveto -->
|
||||
<div class="summary-bar">
|
||||
<span id="customer-count">0 asiakasta</span>
|
||||
<span id="total-billing">Laskutus yhteensä: 0,00 €/kk</span>
|
||||
<!-- 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">🗃</div>
|
||||
<p>Arkisto on tyhjä.</p>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<!-- 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">📜</div>
|
||||
<p>Ei lokimerkintöjä.</p>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
@@ -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">×</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>
|
||||
|
||||
645
script.js
645
script.js
@@ -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">↳</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">✎</button>
|
||||
<button onclick="event.stopPropagation(); deleteCustomer('${c.id}', '${esc(c.yritys)}')" title="Poista">🗑</button>
|
||||
` : ''}
|
||||
</td>
|
||||
<td class="actions-cell">${isFirst ? `<button onclick="event.stopPropagation();editCustomer('${c.id}')" title="Muokkaa">✎</button><button onclick="event.stopPropagation();deleteCustomer('${c.id}','${esc(c.yritys)}')" title="Arkistoi">🗃</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>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">Y-tunnus</div>
|
||||
<div class="detail-value">${detailVal(c.ytunnus)}</div>
|
||||
</div>
|
||||
</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-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">
|
||||
<div class="file-info">
|
||||
<a href="${API}?action=file_download&customer_id=${customerId}&filename=${encodeURIComponent(f.filename)}"
|
||||
class="file-name" target="_blank">${esc(f.filename)}</a>
|
||||
<span class="file-meta">${formatFileSize(f.size)} · ${f.modified}</span>
|
||||
</div>
|
||||
<button class="file-delete-btn" onclick="deleteFile('${customerId}', '${esc(f.filename)}')" title="Poista">✕</button>
|
||||
if (files.length === 0) { fileList.innerHTML = '<p style="color:#aaa;font-size:0.85rem;">Ei tiedostoja.</p>'; return; }
|
||||
fileList.innerHTML = files.map(f => `<div class="file-item">
|
||||
<div class="file-info">
|
||||
<a href="${API}?action=file_download&customer_id=${customerId}&filename=${encodeURIComponent(f.filename)}" class="file-name" target="_blank">${esc(f.filename)}</a>
|
||||
<span class="file-meta">${formatFileSize(f.size)} · ${f.modified}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
fileList.innerHTML = '<p style="color:#e74c3c;font-size:0.85rem;">Virhe ladattaessa tiedostoja.</p>';
|
||||
}
|
||||
<button class="file-delete-btn" onclick="deleteFile('${customerId}','${esc(f.filename)}')" title="Poista">✕</button>
|
||||
</div>`).join('');
|
||||
} catch (e) { fileList.innerHTML = '<p style="color:#e74c3c;font-size:0.85rem;">Virhe ladattaessa tiedostoja.</p>'; }
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
@@ -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">
|
||||
<span class="liittyma-row-title">Liittymä ${index + 1}</span>
|
||||
<button type="button" class="btn-remove-row" title="Poista liittymä">✕</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();
|
||||
});
|
||||
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ä">✕</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) => {
|
||||
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;
|
||||
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 {
|
||||
billingFields.style.display = 'block';
|
||||
}
|
||||
} 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">↺ Palauta</button>
|
||||
${currentUser.role === 'admin' ? `<button onclick="permanentDelete('${c.id}','${esc(c.yritys)}')" class="btn-small btn-perm-delete" title="Poista pysyvästi">✕ 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">✎</button>
|
||||
${u.id !== '${currentUser.id}' ? `<button onclick="deleteUser('${u.id}','${esc(u.username)}')" title="Poista">🗑</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
111
style.css
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user