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

View File

@@ -137,6 +137,7 @@ async function checkAuth() {
currentCompany = availableCompanies.find(c => c.id === data.company_id) || availableCompanies[0] || null;
currentUserSignatures = data.signatures || {};
if (data.branding) applyBranding(data.branding);
applyModules(data.enabled_modules || []);
showDashboard();
}
} catch (e) { /* not logged in */ }
@@ -179,10 +180,12 @@ async function showDashboard() {
loginScreen.style.display = 'none';
dashboard.style.display = 'block';
document.getElementById('user-info').textContent = currentUser.nimi || currentUser.username;
// Näytä admin-toiminnot vain adminille
document.getElementById('btn-users').style.display = currentUser.role === 'admin' ? '' : 'none';
document.getElementById('tab-settings').style.display = currentUser.role === 'admin' ? '' : 'none';
document.getElementById('btn-companies').style.display = currentUser.role === 'admin' ? '' : 'none';
const isSuperAdmin = currentUser.role === 'superadmin';
const isAdmin = currentUser.role === 'admin' || isSuperAdmin;
// Näytä admin-toiminnot roolin mukaan
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
populateCompanySelector();
// Avaa oikea tabi URL-hashin perusteella (tai customers oletuks)
@@ -212,6 +215,7 @@ async function switchCompany(companyId) {
try {
const auth = await apiCall('check_auth');
if (auth.branding) applyBranding(auth.branding);
applyModules(auth.enabled_modules || []);
} catch (e2) {}
// Lataa uudelleen aktiivinen tab
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 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
searchInput.addEventListener('input', () => renderTable());
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">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>
${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>
${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>`;
@@ -943,7 +966,7 @@ async function loadUsers() {
<td><strong>${esc(u.username)}</strong></td>
<td>${esc(u.nimi)}</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 class="actions-cell">
<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>${t.customer_name ? esc(t.customer_name) : '<span style="color:#ccc;">-</span>'}</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>`;
}).join('');
// Re-attach checkbox listeners
@@ -1965,6 +1988,7 @@ async function loadCompaniesTab() {
function renderCompaniesTable() {
const tbody = document.getElementById('companies-tbody');
const superAdmin = currentUser?.role === 'superadmin';
tbody.innerHTML = companiesTabData.map(c => `<tr>
<td><code>${esc(c.id)}</code></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>
<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>
</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('company-detail-view').style.display = 'none';
}
@@ -2035,6 +2062,14 @@ async function showCompanyDetail(id) {
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
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 domainsText = document.getElementById('company-edit-domains').value;
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 {
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!');
// Päivitä paikalliset tiedot
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);
if (avail) avail.nimi = nimi;
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) {
applyBranding({
nimi, subtitle, primary_color,
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); }
});
@@ -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;">
<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>
<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>`;
}).join('');
} catch (e) { console.error(e); }
@@ -2296,6 +2337,34 @@ async function toggleCompanyUser(userId, companyId, add) {
} 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 ====================
function applyBranding(branding) {