feat: moduulijärjestelmä + käyttäjäroolit + suhteellinen aika

- Moduulijärjestelmä: yrityskohtaiset tabit (customers, support, leads, archive,
  changelog, settings) valittavissa checkboxeina yrityksen asetuksissa
- Käyttäjäroolit: superadmin (pääkäyttäjä), admin (yritysadmin), user (käyttäjä)
  - Superadmin: kaikki oikeudet kuten ennen
  - Yritysadmin: muokkaa oman yrityksen asetuksia, moduuleita, postilaatikoita
  - Käyttäjä: peruskäyttö ilman hallintaoikeuksia
- Päivitetty-kenttä näyttää suhteellista aikaa (15min sitten, 2h sitten, 3pv sitten)
- DB: enabled_modules sarake companies-tauluun, role ENUM laajennettu
- Automaattinen migraatio: vanhat admin → superadmin

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 18:42:07 +02:00
parent 86ffcc88de
commit a135aaaaef
5 changed files with 225 additions and 50 deletions

75
api.php
View File

@@ -38,13 +38,32 @@ function requireAuth() {
function requireAdmin() { function requireAdmin() {
requireAuth(); requireAuth();
if (($_SESSION['role'] ?? '') !== 'admin') { $role = $_SESSION['role'] ?? '';
if ($role !== 'admin' && $role !== 'superadmin') {
http_response_code(403); http_response_code(403);
echo json_encode(['error' => 'Vain ylläpitäjä voi tehdä tämän']); echo json_encode(['error' => 'Vain ylläpitäjä voi tehdä tämän']);
exit; exit;
} }
} }
function requireSuperAdmin() {
requireAuth();
if (($_SESSION['role'] ?? '') !== 'superadmin') {
http_response_code(403);
echo json_encode(['error' => 'Vain pääkäyttäjä voi tehdä tämän']);
exit;
}
}
function isSuperAdmin(): bool {
return ($_SESSION['role'] ?? '') === 'superadmin';
}
function isCompanyAdmin(): bool {
$role = $_SESSION['role'] ?? '';
return $role === 'admin' || $role === 'superadmin';
}
function currentUser(): string { function currentUser(): string {
return $_SESSION['username'] ?? 'tuntematon'; return $_SESSION['username'] ?? 'tuntematon';
} }
@@ -884,7 +903,7 @@ switch ($action) {
$domainCompany = dbGetCompanyByDomain($host); $domainCompany = dbGetCompanyByDomain($host);
$domainCompanyId = $domainCompany ? $domainCompany['id'] : ''; $domainCompanyId = $domainCompany ? $domainCompany['id'] : '';
// Jos domain kuuluu tietylle yritykselle, vain sen yrityksen käyttäjät + adminit pääsevät sisään // Jos domain kuuluu tietylle yritykselle, vain sen yrityksen käyttäjät + adminit pääsevät sisään
if ($domainCompanyId && $u['role'] !== 'admin' && !in_array($domainCompanyId, $userCompanies)) { if ($domainCompanyId && $u['role'] !== 'superadmin' && !in_array($domainCompanyId, $userCompanies)) {
dbRecordLoginAttempt($ip); dbRecordLoginAttempt($ip);
http_response_code(403); http_response_code(403);
echo json_encode(['error' => 'Sinulla ei ole oikeutta kirjautua tälle sivustolle.']); echo json_encode(['error' => 'Sinulla ei ole oikeutta kirjautua tälle sivustolle.']);
@@ -956,6 +975,15 @@ switch ($action) {
// Brändäystiedot domain-pohjaisesti (sama kuin branding-endpoint) // Brändäystiedot domain-pohjaisesti (sama kuin branding-endpoint)
$host = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]); $host = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
$branding = dbGetBranding($host); $branding = dbGetBranding($host);
// Aktiivisen yrityksen enabled_modules
$activeCompanyId = $_SESSION['company_id'] ?? '';
$enabledModules = [];
foreach ($allCompanies as $comp) {
if ($comp['id'] === $activeCompanyId) {
$enabledModules = $comp['enabled_modules'] ?? [];
break;
}
}
echo json_encode([ echo json_encode([
'authenticated' => true, 'authenticated' => true,
'user_id' => $_SESSION['user_id'], 'user_id' => $_SESSION['user_id'],
@@ -966,6 +994,7 @@ switch ($action) {
'company_id' => $_SESSION['company_id'] ?? '', 'company_id' => $_SESSION['company_id'] ?? '',
'signatures' => $userSignatures, 'signatures' => $userSignatures,
'branding' => $branding, 'branding' => $branding,
'enabled_modules' => $enabledModules,
]); ]);
} else { } else {
echo json_encode(['authenticated' => false]); echo json_encode(['authenticated' => false]);
@@ -1035,7 +1064,7 @@ switch ($action) {
// ---------- USERS ---------- // ---------- USERS ----------
case 'users': case 'users':
requireAdmin(); requireSuperAdmin();
$users = dbLoadUsers(); $users = dbLoadUsers();
$safe = array_map(function($u) { $safe = array_map(function($u) {
unset($u['password_hash']); unset($u['password_hash']);
@@ -1045,14 +1074,15 @@ switch ($action) {
break; break;
case 'user_create': case 'user_create':
requireAdmin(); requireSuperAdmin();
if ($method !== 'POST') break; if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true);
$username = trim($input['username'] ?? ''); $username = trim($input['username'] ?? '');
$password = $input['password'] ?? ''; $password = $input['password'] ?? '';
$nimi = trim($input['nimi'] ?? ''); $nimi = trim($input['nimi'] ?? '');
$email = trim($input['email'] ?? ''); $email = trim($input['email'] ?? '');
$role = ($input['role'] ?? 'user') === 'admin' ? 'admin' : 'user'; $validRoles = ['superadmin', 'admin', 'user'];
$role = in_array($input['role'] ?? '', $validRoles) ? $input['role'] : 'user';
if (empty($username) || empty($password)) { if (empty($username) || empty($password)) {
http_response_code(400); http_response_code(400);
echo json_encode(['error' => 'Käyttäjätunnus ja salasana vaaditaan']); echo json_encode(['error' => 'Käyttäjätunnus ja salasana vaaditaan']);
@@ -1099,7 +1129,7 @@ switch ($action) {
break; break;
case 'user_update': case 'user_update':
requireAdmin(); requireSuperAdmin();
if ($method !== 'POST') break; if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? ''; $id = $input['id'] ?? '';
@@ -1111,7 +1141,10 @@ switch ($action) {
} }
if (isset($input['nimi'])) $u['nimi'] = trim($input['nimi']); if (isset($input['nimi'])) $u['nimi'] = trim($input['nimi']);
if (isset($input['email'])) $u['email'] = trim($input['email']); if (isset($input['email'])) $u['email'] = trim($input['email']);
if (isset($input['role'])) $u['role'] = $input['role'] === 'admin' ? 'admin' : 'user'; if (isset($input['role'])) {
$validRoles = ['superadmin', 'admin', 'user'];
$u['role'] = in_array($input['role'], $validRoles) ? $input['role'] : 'user';
}
if (isset($input['companies'])) { if (isset($input['companies'])) {
$allCompanies = dbLoadCompanies(); $allCompanies = dbLoadCompanies();
$validIds = array_column($allCompanies, 'id'); $validIds = array_column($allCompanies, 'id');
@@ -1146,7 +1179,7 @@ switch ($action) {
break; break;
case 'user_delete': case 'user_delete':
requireAdmin(); requireSuperAdmin();
if ($method !== 'POST') break; if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? ''; $id = $input['id'] ?? '';
@@ -2297,7 +2330,15 @@ switch ($action) {
case 'companies_all': case 'companies_all':
requireAdmin(); requireAdmin();
echo json_encode(dbLoadCompanies()); $all = dbLoadCompanies();
if (isSuperAdmin()) {
echo json_encode($all);
} else {
// Yritysadmin näkee vain omat yrityksensä
$userCompanyIds = $_SESSION['companies'] ?? [];
$filtered = array_values(array_filter($all, fn($c) => in_array($c['id'], $userCompanyIds)));
echo json_encode($filtered);
}
break; break;
case 'all_mailboxes': case 'all_mailboxes':
@@ -2322,7 +2363,7 @@ switch ($action) {
break; break;
case 'company_create': case 'company_create':
requireAdmin(); requireSuperAdmin();
if ($method !== 'POST') break; if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true);
$id = preg_replace('/[^a-z0-9-]/', '', strtolower(trim($input['id'] ?? ''))); $id = preg_replace('/[^a-z0-9-]/', '', strtolower(trim($input['id'] ?? '')));
@@ -2374,6 +2415,15 @@ switch ($action) {
if ($method !== 'POST') break; if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? ''; $id = $input['id'] ?? '';
// Yritysadmin saa muokata vain omia yrityksiään
if (!isSuperAdmin()) {
$userCompanyIds = $_SESSION['companies'] ?? [];
if (!in_array($id, $userCompanyIds)) {
http_response_code(403);
echo json_encode(['error' => 'Ei oikeuksia muokata tätä yritystä']);
break;
}
}
$companies = dbLoadCompanies(); $companies = dbLoadCompanies();
$found = false; $found = false;
foreach ($companies as $c) { foreach ($companies as $c) {
@@ -2385,6 +2435,9 @@ switch ($action) {
} }
if (isset($input['primary_color'])) $c['primary_color'] = trim($input['primary_color']); if (isset($input['primary_color'])) $c['primary_color'] = trim($input['primary_color']);
if (isset($input['subtitle'])) $c['subtitle'] = trim($input['subtitle']); if (isset($input['subtitle'])) $c['subtitle'] = trim($input['subtitle']);
if (isset($input['enabled_modules']) && is_array($input['enabled_modules'])) {
$c['enabled_modules'] = array_values($input['enabled_modules']);
}
dbSaveCompany($c); dbSaveCompany($c);
$found = true; $found = true;
echo json_encode($c); echo json_encode($c);
@@ -2398,7 +2451,7 @@ switch ($action) {
break; break;
case 'company_delete': case 'company_delete':
requireAdmin(); requireSuperAdmin();
if ($method !== 'POST') break; if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? ''; $id = $input['id'] ?? '';

74
db.php
View File

@@ -137,7 +137,7 @@ function initDatabase(): void {
username VARCHAR(100) NOT NULL UNIQUE, username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
nimi VARCHAR(255) NOT NULL, nimi VARCHAR(255) NOT NULL,
role ENUM('admin','user') DEFAULT 'user', role ENUM('superadmin','admin','user') DEFAULT 'user',
email VARCHAR(255) DEFAULT '', email VARCHAR(255) DEFAULT '',
luotu DATETIME luotu DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
@@ -375,10 +375,21 @@ function initDatabase(): void {
"ALTER TABLE tickets ADD COLUMN cc TEXT DEFAULT '' AFTER mailbox_id", "ALTER TABLE tickets ADD COLUMN cc TEXT DEFAULT '' AFTER mailbox_id",
"ALTER TABLE tickets ADD COLUMN priority VARCHAR(20) DEFAULT 'normaali' AFTER cc", "ALTER TABLE tickets ADD COLUMN priority VARCHAR(20) DEFAULT 'normaali' AFTER cc",
"ALTER TABLE customers ADD COLUMN priority_emails TEXT DEFAULT '' AFTER lisatiedot", "ALTER TABLE customers ADD COLUMN priority_emails TEXT DEFAULT '' AFTER lisatiedot",
"ALTER TABLE companies ADD COLUMN enabled_modules TEXT DEFAULT '' AFTER cors_origins",
"ALTER TABLE users MODIFY COLUMN role ENUM('superadmin','admin','user') DEFAULT 'user'",
]; ];
foreach ($alters as $sql) { foreach ($alters as $sql) {
try { $db->query($sql); } catch (\Throwable $e) { /* sarake on jo olemassa */ } try { $db->query($sql); } catch (\Throwable $e) { /* sarake on jo olemassa / jo ajettu */ }
} }
// Kertaluontoinen migraatio: päivitä vanhat admin-käyttäjät superadminiksi
// (vain jos yhtään superadminia ei vielä ole)
try {
$result = $db->query("SELECT COUNT(*) AS cnt FROM users WHERE role = 'superadmin'");
$row = $result->fetch_assoc();
if ((int)($row['cnt'] ?? 0) === 0) {
$db->query("UPDATE users SET role = 'superadmin' WHERE role = 'admin'");
}
} catch (\Throwable $e) { /* ohitetaan */ }
} }
// ==================== YRITYKSET ==================== // ==================== YRITYKSET ====================
@@ -389,6 +400,9 @@ function dbLoadCompanies(): array {
foreach ($companies as &$c) { foreach ($companies as &$c) {
$c['domains'] = _dbFetchColumn("SELECT domain FROM company_domains WHERE company_id = ?", [$c['id']]); $c['domains'] = _dbFetchColumn("SELECT domain FROM company_domains WHERE company_id = ?", [$c['id']]);
$c['aktiivinen'] = (bool)$c['aktiivinen']; $c['aktiivinen'] = (bool)$c['aktiivinen'];
// enabled_modules: JSON-array tai tyhjä (= kaikki päällä)
$raw = $c['enabled_modules'] ?? '';
$c['enabled_modules'] = $raw ? (json_decode($raw, true) ?: []) : [];
} }
return $companies; return $companies;
} }
@@ -397,23 +411,27 @@ function dbSaveCompany(array $company): void {
$db = getDb(); $db = getDb();
$db->begin_transaction(); $db->begin_transaction();
try { try {
$enabledModules = $company['enabled_modules'] ?? [];
$enabledModulesJson = is_array($enabledModules) ? json_encode($enabledModules) : ($enabledModules ?: '');
_dbExecute(" _dbExecute("
INSERT INTO companies (id, nimi, luotu, aktiivinen, primary_color, subtitle, logo_file, api_key, cors_origins) INSERT INTO companies (id, nimi, luotu, aktiivinen, primary_color, subtitle, logo_file, api_key, cors_origins, enabled_modules)
VALUES (:id, :nimi, :luotu, :aktiivinen, :primary_color, :subtitle, :logo_file, :api_key, :cors_origins) VALUES (:id, :nimi, :luotu, :aktiivinen, :primary_color, :subtitle, :logo_file, :api_key, :cors_origins, :enabled_modules)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
nimi = VALUES(nimi), aktiivinen = VALUES(aktiivinen), nimi = VALUES(nimi), aktiivinen = VALUES(aktiivinen),
primary_color = VALUES(primary_color), subtitle = VALUES(subtitle), primary_color = VALUES(primary_color), subtitle = VALUES(subtitle),
logo_file = VALUES(logo_file), api_key = VALUES(api_key), cors_origins = VALUES(cors_origins) logo_file = VALUES(logo_file), api_key = VALUES(api_key), cors_origins = VALUES(cors_origins),
enabled_modules = VALUES(enabled_modules)
", [ ", [
'id' => $company['id'], 'id' => $company['id'],
'nimi' => $company['nimi'], 'nimi' => $company['nimi'],
'luotu' => $company['luotu'] ?? date('Y-m-d H:i:s'), 'luotu' => $company['luotu'] ?? date('Y-m-d H:i:s'),
'aktiivinen' => $company['aktiivinen'] ?? true, 'aktiivinen' => $company['aktiivinen'] ?? true,
'primary_color' => $company['primary_color'] ?? '#0f3460', 'primary_color' => $company['primary_color'] ?? '#0f3460',
'subtitle' => $company['subtitle'] ?? '', 'subtitle' => $company['subtitle'] ?? '',
'logo_file' => $company['logo_file'] ?? '', 'logo_file' => $company['logo_file'] ?? '',
'api_key' => $company['api_key'] ?? '', 'api_key' => $company['api_key'] ?? '',
'cors_origins' => $company['cors_origins'] ?? '', 'cors_origins' => $company['cors_origins'] ?? '',
'enabled_modules' => $enabledModulesJson,
]); ]);
// Päivitä domainit // Päivitä domainit
@@ -451,23 +469,27 @@ function dbGetBranding(string $host): array {
$logoUrl = !empty($company['logo_file']) $logoUrl = !empty($company['logo_file'])
? "api.php?action=company_logo&company_id=" . urlencode($company['id']) ? "api.php?action=company_logo&company_id=" . urlencode($company['id'])
: ''; : '';
$rawModules = $company['enabled_modules'] ?? '';
$enabledModules = $rawModules ? (json_decode($rawModules, true) ?: []) : [];
return [ return [
'found' => true, 'found' => true,
'company_id' => $company['id'], 'company_id' => $company['id'],
'nimi' => $company['nimi'], 'nimi' => $company['nimi'],
'primary_color' => $company['primary_color'] ?? '#0f3460', 'primary_color' => $company['primary_color'] ?? '#0f3460',
'subtitle' => $company['subtitle'] ?? '', 'subtitle' => $company['subtitle'] ?? '',
'logo_url' => $logoUrl, 'logo_url' => $logoUrl,
'enabled_modules' => $enabledModules,
]; ];
} }
return [ return [
'found' => false, 'found' => false,
'company_id' => '', 'company_id' => '',
'nimi' => 'Noxus Intra', 'nimi' => 'Noxus Intra',
'primary_color' => '#0f3460', 'primary_color' => '#0f3460',
'subtitle' => 'Hallintapaneeli', 'subtitle' => 'Hallintapaneeli',
'logo_url' => '', 'logo_url' => '',
'enabled_modules' => [],
]; ];
} }

View File

@@ -606,6 +606,31 @@
<textarea id="company-edit-domains" rows="3" placeholder="intra.yritys.fi&#10;intra.toinen.fi" style="font-family:monospace;font-size:0.85rem;"></textarea> <textarea id="company-edit-domains" rows="3" placeholder="intra.yritys.fi&#10;intra.toinen.fi" style="font-family:monospace;font-size:0.85rem;"></textarea>
</div> </div>
</div> </div>
<!-- Moduulit -->
<div style="margin-bottom:1.5rem;">
<h4 style="color:#0f3460;margin-bottom:0.5rem;font-size:0.95rem;">Käytössä olevat moduulit</h4>
<p style="color:#888;font-size:0.82rem;margin-bottom:0.75rem;">Valitse mitkä välilehdet ovat käytössä tässä yrityksessä.</p>
<div id="modules-checkboxes" style="display:flex;flex-direction:column;gap:0.4rem;">
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
<input type="checkbox" data-module="customers" checked> Asiakkaat
</label>
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
<input type="checkbox" data-module="support" checked> Asiakaspalvelu
</label>
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
<input type="checkbox" data-module="leads"> Liidit
</label>
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
<input type="checkbox" data-module="archive" checked> Arkisto
</label>
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
<input type="checkbox" data-module="changelog" checked> Muutosloki
</label>
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
<input type="checkbox" data-module="settings" checked> Asetukset / API
</label>
</div>
</div>
<button class="btn-primary" id="btn-save-company-settings" style="font-size:0.85rem;">Tallenna asetukset</button> <button class="btn-primary" id="btn-save-company-settings" style="font-size:0.85rem;">Tallenna asetukset</button>
</div> </div>
<!-- Postilaatikot --> <!-- Postilaatikot -->
@@ -816,7 +841,8 @@
<label for="user-form-role">Rooli</label> <label for="user-form-role">Rooli</label>
<select id="user-form-role"> <select id="user-form-role">
<option value="user">Käyttäjä</option> <option value="user">Käyttäjä</option>
<option value="admin">Ylläpitäjä</option> <option value="admin">Yritysadmin</option>
<option value="superadmin">Pääkäyttäjä</option>
</select> </select>
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">

View File

@@ -137,6 +137,7 @@ async function checkAuth() {
currentCompany = availableCompanies.find(c => c.id === data.company_id) || availableCompanies[0] || null; currentCompany = availableCompanies.find(c => c.id === data.company_id) || availableCompanies[0] || null;
currentUserSignatures = data.signatures || {}; currentUserSignatures = data.signatures || {};
if (data.branding) applyBranding(data.branding); if (data.branding) applyBranding(data.branding);
applyModules(data.enabled_modules || []);
showDashboard(); showDashboard();
} }
} catch (e) { /* not logged in */ } } catch (e) { /* not logged in */ }
@@ -179,10 +180,12 @@ async function showDashboard() {
loginScreen.style.display = 'none'; loginScreen.style.display = 'none';
dashboard.style.display = 'block'; dashboard.style.display = 'block';
document.getElementById('user-info').textContent = currentUser.nimi || currentUser.username; document.getElementById('user-info').textContent = currentUser.nimi || currentUser.username;
// Näytä admin-toiminnot vain adminille const isSuperAdmin = currentUser.role === 'superadmin';
document.getElementById('btn-users').style.display = currentUser.role === 'admin' ? '' : 'none'; const isAdmin = currentUser.role === 'admin' || isSuperAdmin;
document.getElementById('tab-settings').style.display = currentUser.role === 'admin' ? '' : 'none'; // Näytä admin-toiminnot roolin mukaan
document.getElementById('btn-companies').style.display = currentUser.role === 'admin' ? '' : 'none'; document.getElementById('btn-users').style.display = isSuperAdmin ? '' : 'none';
document.getElementById('tab-settings').style.display = isAdmin ? '' : 'none';
document.getElementById('btn-companies').style.display = isAdmin ? '' : 'none';
// Yritysvalitsin // Yritysvalitsin
populateCompanySelector(); populateCompanySelector();
// Avaa oikea tabi URL-hashin perusteella (tai customers oletuks) // Avaa oikea tabi URL-hashin perusteella (tai customers oletuks)
@@ -212,6 +215,7 @@ async function switchCompany(companyId) {
try { try {
const auth = await apiCall('check_auth'); const auth = await apiCall('check_auth');
if (auth.branding) applyBranding(auth.branding); if (auth.branding) applyBranding(auth.branding);
applyModules(auth.enabled_modules || []);
} catch (e2) {} } catch (e2) {}
// Lataa uudelleen aktiivinen tab // Lataa uudelleen aktiivinen tab
const hash = window.location.hash.replace('#', '') || 'customers'; const hash = window.location.hash.replace('#', '') || 'customers';
@@ -415,6 +419,25 @@ function setText(id, value) { const el = document.getElementById(id); if (el) el
function formatPrice(val) { return parseFloat(val || 0).toFixed(2).replace('.', ',') + ' €'; } 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 esc(str) { if (!str) return ''; const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
function timeAgo(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr.replace(' ', 'T'));
const now = new Date();
const diffMs = now - date;
if (diffMs < 0) return 'juuri nyt';
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return 'juuri nyt';
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return diffMin + ' min sitten';
const diffHours = Math.floor(diffMin / 60);
if (diffHours < 24) return diffHours + 'h sitten';
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) return diffDays + 'pv sitten';
if (diffDays < 30) return Math.floor(diffDays / 7) + 'vk sitten';
// Yli kuukausi → näytä päivämäärä
return dateStr.substring(0, 10);
}
// Search & Sort // Search & Sort
searchInput.addEventListener('input', () => renderTable()); searchInput.addEventListener('input', () => renderTable());
document.querySelectorAll('th[data-sort]').forEach(th => { document.querySelectorAll('th[data-sort]').forEach(th => {
@@ -764,7 +787,7 @@ function showLeadDetail(id) {
<div class="detail-item"><div class="detail-label">Sähköposti</div><div class="detail-value">${detailLink(l.sahkoposti, 'email')}</div></div> <div class="detail-item"><div class="detail-label">Sähköposti</div><div class="detail-value">${detailLink(l.sahkoposti, 'email')}</div></div>
<div class="detail-item"><div class="detail-label">Osoite</div><div class="detail-value">${detailVal([l.osoite, l.kaupunki].filter(Boolean).join(', '))}</div></div> <div class="detail-item"><div class="detail-label">Osoite</div><div class="detail-value">${detailVal([l.osoite, l.kaupunki].filter(Boolean).join(', '))}</div></div>
<div class="detail-item"><div class="detail-label">Lisätty</div><div class="detail-value">${detailVal(l.luotu)} (${esc(l.luoja || '')})</div></div> <div class="detail-item"><div class="detail-label">Lisätty</div><div class="detail-value">${detailVal(l.luotu)} (${esc(l.luoja || '')})</div></div>
${l.muokattu ? `<div class="detail-item"><div class="detail-label">Muokattu</div><div class="detail-value">${esc(l.muokattu)} (${esc(l.muokkaaja || '')})</div></div>` : ''} ${l.muokattu ? `<div class="detail-item"><div class="detail-label">Muokattu</div><div class="detail-value">${timeAgo(l.muokattu)} (${esc(l.muokkaaja || '')})</div></div>` : ''}
</div> </div>
${l.muistiinpanot ? `<div style="margin-top:1.25rem;"><div class="detail-label" style="margin-bottom:0.5rem;">MUISTIINPANOT</div><div style="white-space:pre-wrap;color:#555;background:#f8f9fb;padding:1rem;border-radius:8px;border:1px solid #e8ebf0;font-size:0.9rem;">${esc(l.muistiinpanot)}</div></div>` : ''} ${l.muistiinpanot ? `<div style="margin-top:1.25rem;"><div class="detail-label" style="margin-bottom:0.5rem;">MUISTIINPANOT</div><div style="white-space:pre-wrap;color:#555;background:#f8f9fb;padding:1rem;border-radius:8px;border:1px solid #e8ebf0;font-size:0.9rem;">${esc(l.muistiinpanot)}</div></div>` : ''}
</div>`; </div>`;
@@ -943,7 +966,7 @@ async function loadUsers() {
<td><strong>${esc(u.username)}</strong></td> <td><strong>${esc(u.username)}</strong></td>
<td>${esc(u.nimi)}</td> <td>${esc(u.nimi)}</td>
<td>${esc(u.email || '')}</td> <td>${esc(u.email || '')}</td>
<td><span class="role-badge role-${u.role}">${u.role === 'admin' ? 'Ylläpitäjä' : 'Käyttäjä'}</span></td> <td><span class="role-badge role-${u.role}">${u.role === 'superadmin' ? 'Pääkäyttäjä' : (u.role === 'admin' ? 'Yritysadmin' : 'Käyttäjä')}</span></td>
<td>${esc(u.luotu)}</td> <td>${esc(u.luotu)}</td>
<td class="actions-cell"> <td class="actions-cell">
<button onclick="editUser('${u.id}')" title="Muokkaa">&#9998;</button> <button onclick="editUser('${u.id}')" title="Muokkaa">&#9998;</button>
@@ -1178,7 +1201,7 @@ function renderTickets() {
<td>${esc(t.mailbox_name || t.from_name || t.from_email)}</td> <td>${esc(t.mailbox_name || t.from_name || t.from_email)}</td>
<td>${t.customer_name ? esc(t.customer_name) : '<span style="color:#ccc;">-</span>'}</td> <td>${t.customer_name ? esc(t.customer_name) : '<span style="color:#ccc;">-</span>'}</td>
<td style="text-align:center;">${lastType} ${t.message_count}</td> <td style="text-align:center;">${lastType} ${t.message_count}</td>
<td class="nowrap">${esc((t.updated || '').substring(0, 16))}</td> <td class="nowrap" title="${esc((t.updated || '').substring(0, 16))}">${timeAgo(t.updated)}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
// Re-attach checkbox listeners // Re-attach checkbox listeners
@@ -1965,6 +1988,7 @@ async function loadCompaniesTab() {
function renderCompaniesTable() { function renderCompaniesTable() {
const tbody = document.getElementById('companies-tbody'); const tbody = document.getElementById('companies-tbody');
const superAdmin = currentUser?.role === 'superadmin';
tbody.innerHTML = companiesTabData.map(c => `<tr> tbody.innerHTML = companiesTabData.map(c => `<tr>
<td><code>${esc(c.id)}</code></td> <td><code>${esc(c.id)}</code></td>
<td><strong>${esc(c.nimi)}</strong></td> <td><strong>${esc(c.nimi)}</strong></td>
@@ -1973,9 +1997,12 @@ function renderCompaniesTable() {
<td>${c.aktiivinen !== false ? '<span style="color:#22c55e;">Aktiivinen</span>' : '<span style="color:#888;">Ei aktiivinen</span>'}</td> <td>${c.aktiivinen !== false ? '<span style="color:#22c55e;">Aktiivinen</span>' : '<span style="color:#888;">Ei aktiivinen</span>'}</td>
<td> <td>
<button class="btn-link" onclick="showCompanyDetail('${c.id}')">Asetukset</button> <button class="btn-link" onclick="showCompanyDetail('${c.id}')">Asetukset</button>
<button class="btn-link" style="color:#dc2626;" onclick="deleteCompany('${c.id}','${esc(c.nimi)}')">Poista</button> ${superAdmin ? `<button class="btn-link" style="color:#dc2626;" onclick="deleteCompany('${c.id}','${esc(c.nimi)}')">Poista</button>` : ''}
</td> </td>
</tr>`).join(''); </tr>`).join('');
// Piilota "Lisää yritys" nappi jos ei superadmin
const addBtn = document.getElementById('btn-add-company');
if (addBtn) addBtn.style.display = superAdmin ? '' : 'none';
document.getElementById('companies-list-view').style.display = ''; document.getElementById('companies-list-view').style.display = '';
document.getElementById('company-detail-view').style.display = 'none'; document.getElementById('company-detail-view').style.display = 'none';
} }
@@ -2035,6 +2062,14 @@ async function showCompanyDetail(id) {
logoPreview.style.display = 'none'; logoPreview.style.display = 'none';
} }
// Moduuli-checkboxit
const enabledMods = comp?.enabled_modules || [];
document.querySelectorAll('#modules-checkboxes input[data-module]').forEach(cb => {
const mod = cb.dataset.module;
// Jos enabled_modules on tyhjä → kaikki päällä (oletus)
cb.checked = enabledMods.length === 0 ? DEFAULT_MODULES.includes(mod) : enabledMods.includes(mod);
});
// Vaihda aktiivinen yritys jotta API-kutsut kohdistuvat oikein // Vaihda aktiivinen yritys jotta API-kutsut kohdistuvat oikein
await apiCall('company_switch', 'POST', { company_id: id }); await apiCall('company_switch', 'POST', { company_id: id });
@@ -2160,21 +2195,27 @@ document.getElementById('btn-save-company-settings').addEventListener('click', a
const primary_color = document.getElementById('company-edit-color').value; const primary_color = document.getElementById('company-edit-color').value;
const domainsText = document.getElementById('company-edit-domains').value; const domainsText = document.getElementById('company-edit-domains').value;
const domains = domainsText.split('\n').map(d => d.trim()).filter(d => d); const domains = domainsText.split('\n').map(d => d.trim()).filter(d => d);
// Moduulit
const enabled_modules = [];
document.querySelectorAll('#modules-checkboxes input[data-module]:checked').forEach(cb => {
enabled_modules.push(cb.dataset.module);
});
try { try {
await apiCall('company_update', 'POST', { id: currentCompanyDetail, nimi, subtitle, primary_color, domains }); await apiCall('company_update', 'POST', { id: currentCompanyDetail, nimi, subtitle, primary_color, domains, enabled_modules });
alert('Asetukset tallennettu!'); alert('Asetukset tallennettu!');
// Päivitä paikalliset tiedot // Päivitä paikalliset tiedot
const comp = companiesTabData.find(c => c.id === currentCompanyDetail); const comp = companiesTabData.find(c => c.id === currentCompanyDetail);
if (comp) { comp.nimi = nimi; comp.subtitle = subtitle; comp.primary_color = primary_color; comp.domains = domains; } if (comp) { comp.nimi = nimi; comp.subtitle = subtitle; comp.primary_color = primary_color; comp.domains = domains; comp.enabled_modules = enabled_modules; }
const avail = availableCompanies.find(c => c.id === currentCompanyDetail); const avail = availableCompanies.find(c => c.id === currentCompanyDetail);
if (avail) avail.nimi = nimi; if (avail) avail.nimi = nimi;
populateCompanySelector(); populateCompanySelector();
// Jos tämä on aktiivinen yritys → päivitä brändäys heti // Jos tämä on aktiivinen yritys → päivitä brändäys ja moduulit heti
if (currentCompany && currentCompany.id === currentCompanyDetail) { if (currentCompany && currentCompany.id === currentCompanyDetail) {
applyBranding({ applyBranding({
nimi, subtitle, primary_color, nimi, subtitle, primary_color,
logo_url: comp?.logo_file ? 'api.php?action=company_logo&company_id=' + encodeURIComponent(currentCompanyDetail) + '&t=' + Date.now() : '' logo_url: comp?.logo_file ? 'api.php?action=company_logo&company_id=' + encodeURIComponent(currentCompanyDetail) + '&t=' + Date.now() : ''
}); });
applyModules(enabled_modules);
} }
} catch (e) { alert(e.message); } } catch (e) { alert(e.message); }
}); });
@@ -2275,7 +2316,7 @@ async function loadCompanyUsers(companyId) {
return `<label style="display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0;cursor:pointer;"> return `<label style="display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0;cursor:pointer;">
<input type="checkbox" class="company-user-cb" data-user-id="${u.id}" ${hasAccess ? 'checked' : ''} onchange="toggleCompanyUser('${u.id}','${companyId}',this.checked)"> <input type="checkbox" class="company-user-cb" data-user-id="${u.id}" ${hasAccess ? 'checked' : ''} onchange="toggleCompanyUser('${u.id}','${companyId}',this.checked)">
<strong>${esc(u.nimi || u.username)}</strong> <strong>${esc(u.nimi || u.username)}</strong>
<span style="color:#888;font-size:0.85rem;">(${u.username}) — ${u.role === 'admin' ? 'Ylläpitäjä' : 'Käyttäjä'}</span> <span style="color:#888;font-size:0.85rem;">(${u.username}) — ${u.role === 'superadmin' ? 'Pääkäyttäjä' : (u.role === 'admin' ? 'Yritysadmin' : 'Käyttäjä')}</span>
</label>`; </label>`;
}).join(''); }).join('');
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
@@ -2296,6 +2337,34 @@ async function toggleCompanyUser(userId, companyId, add) {
} catch (e) { alert(e.message); } } catch (e) { alert(e.message); }
} }
// ==================== MODUULIT ====================
const ALL_MODULES = ['customers', 'support', 'leads', 'archive', 'changelog', 'settings'];
const DEFAULT_MODULES = ['customers', 'support', 'archive', 'changelog', 'settings'];
function applyModules(modules) {
// Jos tyhjä array → kaikki moduulit päällä (fallback)
const enabled = (modules && modules.length > 0) ? modules : ALL_MODULES;
const isAdminUser = currentUser?.role === 'admin' || currentUser?.role === 'superadmin';
ALL_MODULES.forEach(mod => {
const tabBtn = document.querySelector(`.tab[data-tab="${mod}"]`);
if (tabBtn) {
// settings-tabi näkyy vain adminille/superadminille
if (mod === 'settings') {
tabBtn.style.display = (enabled.includes(mod) && isAdminUser) ? '' : 'none';
} else {
tabBtn.style.display = enabled.includes(mod) ? '' : 'none';
}
}
});
// Jos aktiivinen tabi on piilotettu → vaihda ensimmäiseen näkyvään
const activeTab = document.querySelector('.tab.active');
if (activeTab && activeTab.style.display === 'none') {
const firstVisible = document.querySelector('.tab[data-tab]:not([style*="display: none"])');
if (firstVisible) switchToTab(firstVisible.dataset.tab);
}
}
// ==================== BRANDING ==================== // ==================== BRANDING ====================
function applyBranding(branding) { function applyBranding(branding) {

View File

@@ -1033,6 +1033,11 @@ span.empty {
letter-spacing: 0.3px; letter-spacing: 0.3px;
} }
.role-superadmin {
background: #7c3aed;
color: #fff;
}
.role-admin { .role-admin {
background: var(--primary-color); background: var(--primary-color);
color: #fff; color: #fff;