feat: Tekniikka-moduuli sub-tabeilla (Laitteet + Sijainnit + IPAM)
- Laitteet-tabi → Tekniikka (sub-tabit: Laitteet, Sijainnit, IPAM) - Sijainnit siirretty omaksi taulukkonäkymäksi (+ "Lisää sijainti" laitteiden yhteydessä) - Uusi IPAM-näkymä: IP-osoitteet, subnetit ja VLANit hallintaan - IPAM: tyyppi (subnet/vlan/ip), verkko, VLAN-nro, sijainti, tila, asiakas - Sub-tab-tyylit ja logiikka - Yhteensopivuus: vanha 'devices' moduuli → 'tekniikka' Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
47
api.php
47
api.php
@@ -1408,6 +1408,53 @@ switch ($action) {
|
||||
echo json_encode(['success' => true]);
|
||||
break;
|
||||
|
||||
// ---------- IPAM ----------
|
||||
case 'ipam':
|
||||
requireAuth();
|
||||
$companyId = requireCompany();
|
||||
echo json_encode(dbLoadIpam($companyId));
|
||||
break;
|
||||
|
||||
case 'ipam_save':
|
||||
requireAuth();
|
||||
$companyId = requireCompany();
|
||||
if ($method !== 'POST') break;
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$entry = [
|
||||
'id' => $input['id'] ?? generateId(),
|
||||
'tyyppi' => $input['tyyppi'] ?? 'ip',
|
||||
'nimi' => trim($input['nimi'] ?? ''),
|
||||
'verkko' => trim($input['verkko'] ?? ''),
|
||||
'vlan_id' => $input['vlan_id'] ?? null,
|
||||
'site_id' => $input['site_id'] ?? null,
|
||||
'tila' => $input['tila'] ?? 'vapaa',
|
||||
'asiakas' => trim($input['asiakas'] ?? ''),
|
||||
'lisatiedot' => trim($input['lisatiedot'] ?? ''),
|
||||
'luotu' => $input['luotu'] ?? date('Y-m-d H:i:s'),
|
||||
'muokattu' => date('Y-m-d H:i:s'),
|
||||
'muokkaaja' => currentUser(),
|
||||
];
|
||||
dbSaveIpam($companyId, $entry);
|
||||
$action_label = isset($input['id']) && !empty($input['id']) ? 'ipam_update' : 'ipam_create';
|
||||
$desc = ($entry['tyyppi'] === 'vlan' ? 'VLAN ' . ($entry['vlan_id'] ?? '') : $entry['verkko']) . ' ' . $entry['nimi'];
|
||||
dbAddLog($companyId, currentUser(), $action_label, $entry['id'], $desc, $action_label === 'ipam_create' ? 'Lisäsi IPAM-merkinnän' : 'Muokkasi IPAM-merkintää');
|
||||
echo json_encode($entry);
|
||||
break;
|
||||
|
||||
case 'ipam_delete':
|
||||
requireAuth();
|
||||
$companyId = requireCompany();
|
||||
if ($method !== 'POST') break;
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = $input['id'] ?? '';
|
||||
$all = dbLoadIpam($companyId);
|
||||
$entryName = '';
|
||||
foreach ($all as $e) { if ($e['id'] === $id) { $entryName = ($e['tyyppi'] === 'vlan' ? 'VLAN ' . $e['vlan_id'] : $e['verkko']) . ' ' . $e['nimi']; break; } }
|
||||
dbDeleteIpam($id);
|
||||
dbAddLog($companyId, currentUser(), 'ipam_delete', $id, $entryName, 'Poisti IPAM-merkinnän');
|
||||
echo json_encode(['success' => true]);
|
||||
break;
|
||||
|
||||
// ---------- ARCHIVE ----------
|
||||
case 'archived_customers':
|
||||
requireAuth();
|
||||
|
||||
64
db.php
64
db.php
@@ -381,6 +381,24 @@ function initDatabase(): void {
|
||||
INDEX idx_company (company_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS ipam (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
company_id VARCHAR(50) NOT NULL,
|
||||
tyyppi VARCHAR(20) NOT NULL DEFAULT 'ip',
|
||||
nimi VARCHAR(255) DEFAULT '',
|
||||
verkko VARCHAR(50) DEFAULT '',
|
||||
vlan_id INT DEFAULT NULL,
|
||||
site_id VARCHAR(20) NULL,
|
||||
tila VARCHAR(20) DEFAULT 'vapaa',
|
||||
asiakas VARCHAR(255) DEFAULT '',
|
||||
lisatiedot TEXT,
|
||||
luotu DATETIME,
|
||||
muokattu DATETIME NULL,
|
||||
muokkaaja VARCHAR(100) DEFAULT '',
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
|
||||
INDEX idx_company (company_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS files (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
company_id VARCHAR(50) NOT NULL,
|
||||
@@ -868,6 +886,52 @@ function dbDeleteDevice(string $deviceId): void {
|
||||
_dbExecute("DELETE FROM devices WHERE id = ?", [$deviceId]);
|
||||
}
|
||||
|
||||
// ==================== IPAM ====================
|
||||
|
||||
function dbLoadIpam(string $companyId): array {
|
||||
$rows = _dbFetchAll("
|
||||
SELECT i.*, s.nimi AS site_name
|
||||
FROM ipam i
|
||||
LEFT JOIN sites s ON i.site_id = s.id
|
||||
WHERE i.company_id = ?
|
||||
ORDER BY i.tyyppi, i.vlan_id, i.verkko
|
||||
", [$companyId]);
|
||||
foreach ($rows as &$r) {
|
||||
unset($r['company_id']);
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
function dbSaveIpam(string $companyId, array $entry): void {
|
||||
_dbExecute("
|
||||
INSERT INTO ipam (id, company_id, tyyppi, nimi, verkko, vlan_id, site_id, tila, asiakas, lisatiedot, luotu, muokattu, muokkaaja)
|
||||
VALUES (:id, :company_id, :tyyppi, :nimi, :verkko, :vlan_id, :site_id, :tila, :asiakas, :lisatiedot, :luotu, :muokattu, :muokkaaja)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
tyyppi = VALUES(tyyppi), nimi = VALUES(nimi), verkko = VALUES(verkko),
|
||||
vlan_id = VALUES(vlan_id), site_id = VALUES(site_id), tila = VALUES(tila),
|
||||
asiakas = VALUES(asiakas), lisatiedot = VALUES(lisatiedot),
|
||||
muokattu = VALUES(muokattu), muokkaaja = VALUES(muokkaaja)
|
||||
", [
|
||||
'id' => $entry['id'],
|
||||
'company_id' => $companyId,
|
||||
'tyyppi' => $entry['tyyppi'] ?? 'ip',
|
||||
'nimi' => $entry['nimi'] ?? '',
|
||||
'verkko' => $entry['verkko'] ?? '',
|
||||
'vlan_id' => !empty($entry['vlan_id']) ? (int)$entry['vlan_id'] : null,
|
||||
'site_id' => !empty($entry['site_id']) ? $entry['site_id'] : null,
|
||||
'tila' => $entry['tila'] ?? 'vapaa',
|
||||
'asiakas' => $entry['asiakas'] ?? '',
|
||||
'lisatiedot' => $entry['lisatiedot'] ?? '',
|
||||
'luotu' => $entry['luotu'] ?? date('Y-m-d H:i:s'),
|
||||
'muokattu' => $entry['muokattu'] ?? null,
|
||||
'muokkaaja' => $entry['muokkaaja'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
function dbDeleteIpam(string $id): void {
|
||||
_dbExecute("DELETE FROM ipam WHERE id = ?", [$id]);
|
||||
}
|
||||
|
||||
// ==================== LIIDIT ====================
|
||||
|
||||
function dbLoadLeads(string $companyId): array {
|
||||
|
||||
192
index.html
192
index.html
@@ -79,7 +79,7 @@
|
||||
<button class="tab" data-tab="support">Asiakaspalvelu</button>
|
||||
<button class="tab active" data-tab="customers">Asiakkaat</button>
|
||||
<button class="tab" data-tab="leads">Liidit</button>
|
||||
<button class="tab" data-tab="devices">Laitteet</button>
|
||||
<button class="tab" data-tab="tekniikka">Tekniikka</button>
|
||||
<button class="tab" data-tab="archive">Arkisto</button>
|
||||
<button class="tab" data-tab="changelog">Muutosloki</button>
|
||||
<button class="tab" data-tab="settings" id="tab-settings" style="display:none">API</button>
|
||||
@@ -194,35 +194,108 @@
|
||||
</div>
|
||||
|
||||
<!-- Tab: Laitteet -->
|
||||
<div class="tab-content" id="tab-content-devices">
|
||||
<div class="main-container">
|
||||
<div class="search-bar" style="display:flex;gap:0.5rem;align-items:center;">
|
||||
<input type="text" id="device-search-input" placeholder="Hae laitteita..." style="flex:1;">
|
||||
<button class="btn-primary" id="btn-add-device" style="white-space:nowrap;">+ Lisää laite</button>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<table id="device-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="nimi">Nimi ↕</th>
|
||||
<th>Hallintaosoite</th>
|
||||
<th>Serial</th>
|
||||
<th>Sijainti</th>
|
||||
<th>Funktio</th>
|
||||
<th>Tyyppi</th>
|
||||
<th>Malli</th>
|
||||
<th>Ping</th>
|
||||
<th>Toiminnot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="device-tbody"></tbody>
|
||||
</table>
|
||||
<div id="no-devices" class="empty-state" style="display:none;">
|
||||
<p>Ei laitteita vielä. Lisää ensimmäinen laite.</p>
|
||||
<div class="tab-content" id="tab-content-tekniikka">
|
||||
<div class="sub-tab-bar">
|
||||
<button class="sub-tab active" data-subtab="devices">Laitteet</button>
|
||||
<button class="sub-tab" data-subtab="sites">Sijainnit</button>
|
||||
<button class="sub-tab" data-subtab="ipam">IPAM</button>
|
||||
</div>
|
||||
|
||||
<!-- Sub-tab: Laitteet -->
|
||||
<div class="sub-tab-content active" id="subtab-devices">
|
||||
<div class="main-container">
|
||||
<div class="search-bar" style="display:flex;gap:0.5rem;align-items:center;">
|
||||
<input type="text" id="device-search-input" placeholder="Hae laitteita..." style="flex:1;">
|
||||
<button class="btn-primary" id="btn-add-device" style="white-space:nowrap;">+ Lisää laite</button>
|
||||
<button class="btn-secondary" id="btn-add-site-quick" style="white-space:nowrap;">+ Lisää sijainti</button>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<table id="device-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="nimi">Nimi ↕</th>
|
||||
<th>Hallintaosoite</th>
|
||||
<th>Serial</th>
|
||||
<th>Sijainti</th>
|
||||
<th>Funktio</th>
|
||||
<th>Tyyppi</th>
|
||||
<th>Malli</th>
|
||||
<th>Ping</th>
|
||||
<th>Toiminnot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="device-tbody"></tbody>
|
||||
</table>
|
||||
<div id="no-devices" class="empty-state" style="display:none;">
|
||||
<p>Ei laitteita vielä. Lisää ensimmäinen laite.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-bar">
|
||||
<span id="device-count">0 laitetta</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-bar">
|
||||
<span id="device-count">0 laitetta</span>
|
||||
</div>
|
||||
|
||||
<!-- Sub-tab: Sijainnit -->
|
||||
<div class="sub-tab-content" id="subtab-sites">
|
||||
<div class="main-container">
|
||||
<div class="search-bar" style="display:flex;gap:0.5rem;align-items:center;">
|
||||
<input type="text" id="site-search-input" placeholder="Hae sijainteja..." style="flex:1;">
|
||||
<button class="btn-primary" id="btn-add-site-tab" style="white-space:nowrap;">+ Lisää sijainti</button>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<table id="site-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nimi</th>
|
||||
<th>Osoite</th>
|
||||
<th>Kaupunki</th>
|
||||
<th>Laitteita</th>
|
||||
<th>Toiminnot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="site-tbody"></tbody>
|
||||
</table>
|
||||
<div id="no-sites-tab" class="empty-state" style="display:none;">
|
||||
<p>Ei sijainteja vielä. Lisää ensimmäinen sijainti.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-bar">
|
||||
<span id="site-count">0 sijaintia</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub-tab: IPAM -->
|
||||
<div class="sub-tab-content" id="subtab-ipam">
|
||||
<div class="main-container">
|
||||
<div class="search-bar" style="display:flex;gap:0.5rem;align-items:center;">
|
||||
<input type="text" id="ipam-search-input" placeholder="Hae IP, VLAN, verkko..." style="flex:1;">
|
||||
<button class="btn-primary" id="btn-add-ipam" style="white-space:nowrap;">+ Lisää merkintä</button>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<table id="ipam-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tyyppi</th>
|
||||
<th>Verkko / IP</th>
|
||||
<th>VLAN</th>
|
||||
<th>Nimi / Kuvaus</th>
|
||||
<th>Sijainti</th>
|
||||
<th>Tila</th>
|
||||
<th>Asiakas</th>
|
||||
<th>Toiminnot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ipam-tbody"></tbody>
|
||||
</table>
|
||||
<div id="no-ipam" class="empty-state" style="display:none;">
|
||||
<p>Ei IPAM-merkintöjä vielä.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-bar">
|
||||
<span id="ipam-count">0 merkintää</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -656,7 +729,7 @@
|
||||
<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="devices"> Laitteet
|
||||
<input type="checkbox" data-module="tekniikka"> Tekniikka
|
||||
</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
|
||||
@@ -834,6 +907,67 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IPAM Modal -->
|
||||
<div id="ipam-modal" class="modal" style="display:none">
|
||||
<div class="modal-content" style="max-width:560px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="ipam-modal-title">Lisää IPAM-merkintä</h2>
|
||||
<button class="modal-close" id="ipam-modal-close">×</button>
|
||||
</div>
|
||||
<form id="ipam-form">
|
||||
<input type="hidden" id="ipam-form-id">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="ipam-form-tyyppi">Tyyppi *</label>
|
||||
<select id="ipam-form-tyyppi">
|
||||
<option value="subnet">Subnet</option>
|
||||
<option value="vlan">VLAN</option>
|
||||
<option value="ip">IP-osoite</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ipam-form-verkko">Verkko / IP</label>
|
||||
<input type="text" id="ipam-form-verkko" placeholder="esim. 10.0.0.0/24 tai 192.168.1.5">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ipam-form-vlan">VLAN-numero</label>
|
||||
<input type="number" id="ipam-form-vlan" min="1" max="4094" placeholder="esim. 100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ipam-form-nimi">Nimi / Kuvaus</label>
|
||||
<input type="text" id="ipam-form-nimi" placeholder="esim. Asiakasverkko">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ipam-form-site">Sijainti</label>
|
||||
<select id="ipam-form-site">
|
||||
<option value="">— Ei sijaintia —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ipam-form-tila">Tila</label>
|
||||
<select id="ipam-form-tila">
|
||||
<option value="vapaa">Vapaa</option>
|
||||
<option value="varattu">Varattu</option>
|
||||
<option value="reserved">Reserved</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ipam-form-asiakas">Asiakas</label>
|
||||
<input type="text" id="ipam-form-asiakas" placeholder="Kenelle varattu">
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label for="ipam-form-lisatiedot">Lisätiedot</label>
|
||||
<textarea id="ipam-form-lisatiedot" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions" style="display:flex;gap:0.5rem;margin-top:1rem;">
|
||||
<button type="submit" class="btn-primary">Tallenna</button>
|
||||
<button type="button" class="btn-secondary" id="ipam-form-cancel">Peruuta</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="customer-modal" class="modal" style="display:none">
|
||||
<div class="modal-content modal-wide">
|
||||
<div class="modal-header">
|
||||
|
||||
271
script.js
271
script.js
@@ -194,7 +194,7 @@ async function showDashboard() {
|
||||
populateCompanySelector();
|
||||
// Avaa oikea tabi URL-hashin perusteella (tai customers oletuks)
|
||||
const hash = window.location.hash.replace('#', '');
|
||||
const validTabs = ['customers', 'leads', 'devices', 'archive', 'changelog', 'support', 'users', 'settings', 'companies'];
|
||||
const validTabs = ['customers', 'leads', 'tekniikka', 'archive', 'changelog', 'support', 'users', 'settings', 'companies'];
|
||||
const startTab = validTabs.includes(hash) ? hash : 'customers';
|
||||
switchToTab(startTab);
|
||||
}
|
||||
@@ -241,7 +241,7 @@ function switchToTab(target) {
|
||||
// Lataa sisältö tarvittaessa
|
||||
if (target === 'customers') loadCustomers();
|
||||
if (target === 'leads') loadLeads();
|
||||
if (target === 'devices') loadDevices();
|
||||
if (target === 'tekniikka') { loadDevices(); loadSitesTab(); loadIpam(); }
|
||||
if (target === 'archive') loadArchive();
|
||||
if (target === 'changelog') loadChangelog();
|
||||
if (target === 'support') { loadTickets(); showTicketListView(); if (document.getElementById('ticket-auto-refresh').checked) startTicketAutoRefresh(); }
|
||||
@@ -2083,8 +2083,11 @@ async function showCompanyDetail(id) {
|
||||
logoPreview.style.display = 'none';
|
||||
}
|
||||
|
||||
// Moduuli-checkboxit
|
||||
const enabledMods = comp?.enabled_modules || [];
|
||||
// Moduuli-checkboxit (yhteensopivuus: vanha 'devices' → 'tekniikka')
|
||||
let enabledMods = comp?.enabled_modules || [];
|
||||
if (enabledMods.includes('devices') && !enabledMods.includes('tekniikka')) {
|
||||
enabledMods = enabledMods.map(m => m === 'devices' ? 'tekniikka' : m);
|
||||
}
|
||||
document.querySelectorAll('#modules-checkboxes input[data-module]').forEach(cb => {
|
||||
const mod = cb.dataset.module;
|
||||
// Jos enabled_modules on tyhjä → kaikki päällä (oletus)
|
||||
@@ -2488,16 +2491,93 @@ document.getElementById('device-form')?.addEventListener('submit', async (e) =>
|
||||
|
||||
document.getElementById('device-search-input')?.addEventListener('input', () => renderDevices());
|
||||
|
||||
// ==================== SIJAINNIT (SITES) HALLINTA ====================
|
||||
// ==================== TEKNIIKKA SUB-TABS ====================
|
||||
|
||||
async function loadSites() {
|
||||
function switchSubTab(target) {
|
||||
document.querySelectorAll('#tab-content-tekniikka .sub-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('#tab-content-tekniikka .sub-tab-content').forEach(c => c.classList.remove('active'));
|
||||
const btn = document.querySelector(`.sub-tab[data-subtab="${target}"]`);
|
||||
if (btn) btn.classList.add('active');
|
||||
const content = document.getElementById('subtab-' + target);
|
||||
if (content) content.classList.add('active');
|
||||
}
|
||||
|
||||
document.querySelectorAll('#tab-content-tekniikka .sub-tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => switchSubTab(btn.dataset.subtab));
|
||||
});
|
||||
|
||||
// ==================== SIJAINNIT (SITES) — TEKNIIKKA TAB ====================
|
||||
|
||||
let sitesTabData = [];
|
||||
|
||||
async function loadSitesTab() {
|
||||
try {
|
||||
sitesData = await apiCall('sites');
|
||||
renderSites();
|
||||
sitesTabData = sitesData;
|
||||
renderSitesTab();
|
||||
renderSitesSettings(); // Päivitä myös asetuksissa
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function renderSites() {
|
||||
function renderSitesTab() {
|
||||
const query = (document.getElementById('site-search-input')?.value || '').toLowerCase().trim();
|
||||
let filtered = sitesTabData;
|
||||
if (query) {
|
||||
filtered = sitesTabData.filter(s =>
|
||||
(s.nimi || '').toLowerCase().includes(query) ||
|
||||
(s.osoite || '').toLowerCase().includes(query) ||
|
||||
(s.kaupunki || '').toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
const tbody = document.getElementById('site-tbody');
|
||||
const noSites = document.getElementById('no-sites-tab');
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
if (noSites) noSites.style.display = 'block';
|
||||
} else {
|
||||
if (noSites) noSites.style.display = 'none';
|
||||
tbody.innerHTML = filtered.map(s => {
|
||||
const deviceCount = devicesData.filter(d => d.site_id === s.id).length;
|
||||
return `<tr>
|
||||
<td><strong>${esc(s.nimi)}</strong></td>
|
||||
<td>${esc(s.osoite || '-')}</td>
|
||||
<td>${esc(s.kaupunki || '-')}</td>
|
||||
<td style="text-align:center;">${deviceCount}</td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn-link" onclick="editSiteTab('${s.id}')">✎</button>
|
||||
<button class="btn-link" style="color:#dc2626;" onclick="deleteSite('${s.id}','${esc(s.nimi)}')">🗑</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
document.getElementById('site-count').textContent = filtered.length + ' sijaintia';
|
||||
}
|
||||
|
||||
function editSiteTab(id) {
|
||||
const s = sitesData.find(x => x.id === id);
|
||||
if (!s) return;
|
||||
document.getElementById('site-form-id').value = s.id;
|
||||
document.getElementById('site-form-nimi').value = s.nimi || '';
|
||||
document.getElementById('site-form-osoite').value = s.osoite || '';
|
||||
document.getElementById('site-form-kaupunki').value = s.kaupunki || '';
|
||||
document.getElementById('site-form-title').textContent = 'Muokkaa sijaintia';
|
||||
document.getElementById('site-form-container').style.display = '';
|
||||
}
|
||||
|
||||
// Alias vanhalle editSite-funktiolle
|
||||
function editSite(id) { editSiteTab(id); }
|
||||
|
||||
async function deleteSite(id, name) {
|
||||
if (!confirm(`Poistetaanko sijainti "${name}"? Laitteet joissa tämä sijainti on menettävät sijainti-viittauksen.`)) return;
|
||||
try {
|
||||
await apiCall('site_delete', 'POST', { id });
|
||||
loadSitesTab();
|
||||
loadDevices();
|
||||
} catch (e) { alert(e.message); }
|
||||
}
|
||||
|
||||
// Renderöi sijainnit myös asetuksissa (company detail)
|
||||
function renderSitesSettings() {
|
||||
const container = document.getElementById('sites-list');
|
||||
if (!container) return;
|
||||
if (sitesData.length === 0) {
|
||||
@@ -2517,24 +2597,33 @@ function renderSites() {
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function editSite(id) {
|
||||
const s = sitesData.find(x => x.id === id);
|
||||
if (!s) return;
|
||||
document.getElementById('site-form-id').value = s.id;
|
||||
document.getElementById('site-form-nimi').value = s.nimi || '';
|
||||
document.getElementById('site-form-osoite').value = s.osoite || '';
|
||||
document.getElementById('site-form-kaupunki').value = s.kaupunki || '';
|
||||
document.getElementById('site-form-title').textContent = 'Muokkaa sijaintia';
|
||||
document.getElementById('site-form-container').style.display = '';
|
||||
}
|
||||
// Alias loadSites asetuksista kutsuun
|
||||
async function loadSites() { await loadSitesTab(); }
|
||||
|
||||
async function deleteSite(id, name) {
|
||||
if (!confirm(`Poistetaanko sijainti "${name}"? Laitteet joissa tämä sijainti on menettävät sijainti-viittauksen.`)) return;
|
||||
try {
|
||||
await apiCall('site_delete', 'POST', { id });
|
||||
loadSites();
|
||||
} catch (e) { alert(e.message); }
|
||||
}
|
||||
function renderSites() { renderSitesSettings(); }
|
||||
|
||||
document.getElementById('site-search-input')?.addEventListener('input', () => renderSitesTab());
|
||||
|
||||
// Lisää sijainti -napit (tekniikka-tab + laitteet-sivun quick-nappi)
|
||||
document.getElementById('btn-add-site-tab')?.addEventListener('click', () => {
|
||||
document.getElementById('site-form-id').value = '';
|
||||
document.getElementById('site-form-nimi').value = '';
|
||||
document.getElementById('site-form-osoite').value = '';
|
||||
document.getElementById('site-form-kaupunki').value = '';
|
||||
document.getElementById('site-form-title').textContent = 'Uusi sijainti';
|
||||
document.getElementById('site-form-container').style.display = '';
|
||||
switchSubTab('sites');
|
||||
});
|
||||
|
||||
document.getElementById('btn-add-site-quick')?.addEventListener('click', () => {
|
||||
document.getElementById('site-form-id').value = '';
|
||||
document.getElementById('site-form-nimi').value = '';
|
||||
document.getElementById('site-form-osoite').value = '';
|
||||
document.getElementById('site-form-kaupunki').value = '';
|
||||
document.getElementById('site-form-title').textContent = 'Uusi sijainti';
|
||||
document.getElementById('site-form-container').style.display = '';
|
||||
switchSubTab('sites');
|
||||
});
|
||||
|
||||
document.getElementById('btn-add-site')?.addEventListener('click', () => {
|
||||
document.getElementById('site-form-id').value = '';
|
||||
@@ -2558,7 +2647,7 @@ document.getElementById('btn-save-site')?.addEventListener('click', async () =>
|
||||
try {
|
||||
await apiCall('site_save', 'POST', data);
|
||||
document.getElementById('site-form-container').style.display = 'none';
|
||||
loadSites();
|
||||
loadSitesTab();
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
|
||||
@@ -2566,12 +2655,140 @@ document.getElementById('btn-cancel-site')?.addEventListener('click', () => {
|
||||
document.getElementById('site-form-container').style.display = 'none';
|
||||
});
|
||||
|
||||
// ==================== IPAM ====================
|
||||
|
||||
let ipamData = [];
|
||||
|
||||
async function loadIpam() {
|
||||
try {
|
||||
ipamData = await apiCall('ipam');
|
||||
renderIpam();
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function renderIpam() {
|
||||
const query = (document.getElementById('ipam-search-input')?.value || '').toLowerCase().trim();
|
||||
let filtered = ipamData;
|
||||
if (query) {
|
||||
filtered = ipamData.filter(e =>
|
||||
(e.tyyppi || '').toLowerCase().includes(query) ||
|
||||
(e.verkko || '').toLowerCase().includes(query) ||
|
||||
(e.nimi || '').toLowerCase().includes(query) ||
|
||||
(e.site_name || '').toLowerCase().includes(query) ||
|
||||
(e.asiakas || '').toLowerCase().includes(query) ||
|
||||
(e.lisatiedot || '').toLowerCase().includes(query) ||
|
||||
String(e.vlan_id || '').includes(query)
|
||||
);
|
||||
}
|
||||
const tbody = document.getElementById('ipam-tbody');
|
||||
const noIpam = document.getElementById('no-ipam');
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
if (noIpam) noIpam.style.display = 'block';
|
||||
} else {
|
||||
if (noIpam) noIpam.style.display = 'none';
|
||||
const tyyppiLabel = { subnet: 'Subnet', vlan: 'VLAN', ip: 'IP' };
|
||||
const tilaClass = { vapaa: 'ipam-tila-vapaa', varattu: 'ipam-tila-varattu', reserved: 'ipam-tila-reserved' };
|
||||
const tilaLabel = { vapaa: 'Vapaa', varattu: 'Varattu', reserved: 'Reserved' };
|
||||
tbody.innerHTML = filtered.map(e => `<tr>
|
||||
<td><span style="font-weight:600;">${tyyppiLabel[e.tyyppi] || e.tyyppi}</span></td>
|
||||
<td><code style="font-size:0.85rem;">${esc(e.verkko || '-')}</code></td>
|
||||
<td>${e.vlan_id ? '<strong>' + e.vlan_id + '</strong>' : '-'}</td>
|
||||
<td>${esc(e.nimi || '-')}</td>
|
||||
<td>${e.site_name ? esc(e.site_name) : '<span style="color:#ccc;">-</span>'}</td>
|
||||
<td><span class="ipam-tila ${tilaClass[e.tila] || ''}">${tilaLabel[e.tila] || e.tila}</span></td>
|
||||
<td>${esc(e.asiakas || '-')}</td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn-link" onclick="editIpam('${e.id}')">✎</button>
|
||||
<button class="btn-link" style="color:#dc2626;" onclick="deleteIpam('${e.id}','${esc(e.nimi || e.verkko)}')">🗑</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
document.getElementById('ipam-count').textContent = filtered.length + ' merkintää' + (query ? ` (${ipamData.length} yhteensä)` : '');
|
||||
}
|
||||
|
||||
async function loadIpamSitesDropdown() {
|
||||
try {
|
||||
if (!sitesData || sitesData.length === 0) sitesData = await apiCall('sites');
|
||||
const sel = document.getElementById('ipam-form-site');
|
||||
sel.innerHTML = '<option value="">— Ei sijaintia —</option>' +
|
||||
sitesData.map(s => `<option value="${s.id}">${esc(s.nimi)}${s.kaupunki ? ' (' + esc(s.kaupunki) + ')' : ''}</option>`).join('');
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function editIpam(id) {
|
||||
const e = ipamData.find(x => x.id === id);
|
||||
if (!e) return;
|
||||
document.getElementById('ipam-form-id').value = e.id;
|
||||
document.getElementById('ipam-form-tyyppi').value = e.tyyppi || 'ip';
|
||||
document.getElementById('ipam-form-verkko').value = e.verkko || '';
|
||||
document.getElementById('ipam-form-vlan').value = e.vlan_id || '';
|
||||
document.getElementById('ipam-form-nimi').value = e.nimi || '';
|
||||
document.getElementById('ipam-form-tila').value = e.tila || 'vapaa';
|
||||
document.getElementById('ipam-form-asiakas').value = e.asiakas || '';
|
||||
document.getElementById('ipam-form-lisatiedot').value = e.lisatiedot || '';
|
||||
await loadIpamSitesDropdown();
|
||||
document.getElementById('ipam-form-site').value = e.site_id || '';
|
||||
document.getElementById('ipam-modal-title').textContent = 'Muokkaa IPAM-merkintää';
|
||||
document.getElementById('ipam-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
async function deleteIpam(id, name) {
|
||||
if (!confirm(`Poistetaanko IPAM-merkintä "${name}"?`)) return;
|
||||
try {
|
||||
await apiCall('ipam_delete', 'POST', { id });
|
||||
loadIpam();
|
||||
} catch (e) { alert(e.message); }
|
||||
}
|
||||
|
||||
document.getElementById('btn-add-ipam')?.addEventListener('click', async () => {
|
||||
document.getElementById('ipam-form-id').value = '';
|
||||
document.getElementById('ipam-form').reset();
|
||||
await loadIpamSitesDropdown();
|
||||
document.getElementById('ipam-modal-title').textContent = 'Lisää IPAM-merkintä';
|
||||
document.getElementById('ipam-modal').style.display = 'flex';
|
||||
});
|
||||
|
||||
document.getElementById('ipam-modal-close')?.addEventListener('click', () => {
|
||||
document.getElementById('ipam-modal').style.display = 'none';
|
||||
});
|
||||
document.getElementById('ipam-form-cancel')?.addEventListener('click', () => {
|
||||
document.getElementById('ipam-modal').style.display = 'none';
|
||||
});
|
||||
|
||||
document.getElementById('ipam-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const id = document.getElementById('ipam-form-id').value;
|
||||
const data = {
|
||||
tyyppi: document.getElementById('ipam-form-tyyppi').value,
|
||||
verkko: document.getElementById('ipam-form-verkko').value.trim(),
|
||||
vlan_id: document.getElementById('ipam-form-vlan').value || null,
|
||||
nimi: document.getElementById('ipam-form-nimi').value.trim(),
|
||||
site_id: document.getElementById('ipam-form-site').value || null,
|
||||
tila: document.getElementById('ipam-form-tila').value,
|
||||
asiakas: document.getElementById('ipam-form-asiakas').value.trim(),
|
||||
lisatiedot: document.getElementById('ipam-form-lisatiedot').value.trim(),
|
||||
};
|
||||
if (id) data.id = id;
|
||||
try {
|
||||
await apiCall('ipam_save', 'POST', data);
|
||||
document.getElementById('ipam-modal').style.display = 'none';
|
||||
loadIpam();
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
|
||||
document.getElementById('ipam-search-input')?.addEventListener('input', () => renderIpam());
|
||||
|
||||
// ==================== MODUULIT ====================
|
||||
|
||||
const ALL_MODULES = ['customers', 'support', 'leads', 'devices', 'archive', 'changelog', 'settings'];
|
||||
const ALL_MODULES = ['customers', 'support', 'leads', 'tekniikka', 'archive', 'changelog', 'settings'];
|
||||
const DEFAULT_MODULES = ['customers', 'support', 'archive', 'changelog', 'settings'];
|
||||
|
||||
function applyModules(modules) {
|
||||
// Yhteensopivuus: vanha 'devices' → 'tekniikka'
|
||||
if (modules && modules.includes('devices') && !modules.includes('tekniikka')) {
|
||||
modules = modules.map(m => m === 'devices' ? 'tekniikka' : m);
|
||||
}
|
||||
// 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';
|
||||
|
||||
40
style.css
40
style.css
@@ -1022,6 +1022,46 @@ span.empty {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Sub-tabs (Tekniikka yms.) */
|
||||
.sub-tab-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
background: #f0f2f5;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.sub-tab {
|
||||
padding: 0.6rem 1.1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.sub-tab:hover {
|
||||
color: var(--primary-color);
|
||||
background: rgba(0,0,0,0.03);
|
||||
}
|
||||
.sub-tab.active {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
.sub-tab-content {
|
||||
display: none;
|
||||
}
|
||||
.sub-tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* IPAM tila-badget */
|
||||
.ipam-tila { display:inline-block; padding:2px 10px; border-radius:12px; font-size:0.8rem; font-weight:600; }
|
||||
.ipam-tila-vapaa { background:#e6f9ee; color:#1a7d42; }
|
||||
.ipam-tila-varattu { background:#fef3cd; color:#856404; }
|
||||
.ipam-tila-reserved { background:#e2e3e5; color:#495057; }
|
||||
|
||||
/* Role badge */
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
|
||||
Reference in New Issue
Block a user