Dokumentit: kokousmuistiot, kansiorakenne, sub-tabit, asiakaslinkkaus

- Uusi dokumenttityyppi "kokousmuistio" jolla inline tekstieditori (ei tiedostopohjainen)
- document_versions.content -sarake kokousmuistioiden tekstin tallennukseen
- Sub-tabit Dokumentit-välilehdelle (Kaikki / Kokoukset) Tekniikka-mallin mukaan
- Kansiorakenne: document_folders-taulu, kansionavigaatio breadcrumbsilla
- Uudet API-endpointit: document_folders, document_folder_save/delete, document_content_save, document_move
- Asiakasprofiilin Dokumentit-osio: näyttää linkitetyt dokumentit + pikanapit luontiin
- Asiakasprofiilista voi avata dokumentin suoraan tai luoda uuden linkitettynä asiakkaaseen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 23:19:05 +02:00
parent 150c774bb3
commit f40b387383
5 changed files with 549 additions and 38 deletions

84
api.php
View File

@@ -4310,6 +4310,90 @@ switch ($action) {
} }
break; break;
// ==================== DOKUMENTTIKANSIOT ====================
case 'document_folders':
requireAuth();
$companyId = requireCompany();
echo json_encode(dbLoadFolders($companyId));
break;
case 'document_folder_save':
requireAuth();
$companyId = requireCompany();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
if (empty($input['name'])) {
http_response_code(400);
echo json_encode(['error' => 'Kansion nimi puuttuu']);
break;
}
$input['created_by'] = currentUser();
$id = dbSaveFolder($companyId, $input);
echo json_encode(['id' => $id, 'name' => $input['name']]);
break;
case 'document_folder_delete':
requireAdmin();
$companyId = requireCompany();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$result = dbDeleteFolder($companyId, $input['id'] ?? '');
echo json_encode(['ok' => $result]);
break;
case 'document_content_save':
requireAuth();
$companyId = requireCompany();
if ($method !== 'POST') break;
try {
$input = json_decode(file_get_contents('php://input'), true);
$docId = $input['document_id'] ?? '';
$doc = dbLoadDocument($docId);
if (!$doc || $doc['company_id'] !== $companyId) {
http_response_code(404);
echo json_encode(['error' => 'Dokumenttia ei löytynyt']);
break;
}
dbAddDocumentVersion($docId, [
'content' => $input['content'] ?? '',
'change_notes' => $input['change_notes'] ?? '',
'created_by' => currentUser(),
'filename' => '',
'original_name' => '',
'file_size' => strlen($input['content'] ?? ''),
'mime_type' => 'text/plain'
]);
$updated = dbLoadDocument($docId);
echo json_encode($updated);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => 'Tallennus epäonnistui: ' . $e->getMessage()]);
}
break;
case 'document_move':
requireAuth();
$companyId = requireCompany();
if ($method !== 'POST') break;
try {
$input = json_decode(file_get_contents('php://input'), true);
$docId = $input['document_id'] ?? '';
$folderId = $input['folder_id'] ?? null;
$doc = dbLoadDocument($docId);
if (!$doc || $doc['company_id'] !== $companyId) {
http_response_code(404);
echo json_encode(['error' => 'Dokumenttia ei löytynyt']);
break;
}
_dbExecute("UPDATE documents SET folder_id = ? WHERE id = ?", [$folderId ?: null, $docId]);
echo json_encode(['ok' => true]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => 'Siirto epäonnistui: ' . $e->getMessage()]);
}
break;
// ==================== NETADMIN ==================== // ==================== NETADMIN ====================
case 'netadmin_connections': case 'netadmin_connections':

63
db.php
View File

@@ -542,6 +542,18 @@ function initDatabase(): void {
INDEX idx_document (document_id) INDEX idx_document (document_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"CREATE TABLE IF NOT EXISTS document_folders (
id VARCHAR(20) PRIMARY KEY,
company_id VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL,
parent_id VARCHAR(20) DEFAULT NULL,
created_by VARCHAR(100) DEFAULT '',
luotu DATETIME,
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
INDEX idx_company (company_id),
INDEX idx_parent (parent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"CREATE TABLE IF NOT EXISTS laitetilat ( "CREATE TABLE IF NOT EXISTS laitetilat (
id VARCHAR(20) PRIMARY KEY, id VARCHAR(20) PRIMARY KEY,
company_id VARCHAR(50) NOT NULL, company_id VARCHAR(50) NOT NULL,
@@ -599,6 +611,8 @@ function initDatabase(): void {
"ALTER TABLE companies ADD COLUMN allowed_ips TEXT DEFAULT '' AFTER enabled_modules", "ALTER TABLE companies ADD COLUMN allowed_ips TEXT DEFAULT '' AFTER enabled_modules",
"ALTER TABLE todos ADD COLUMN category VARCHAR(30) DEFAULT '' AFTER priority", "ALTER TABLE todos ADD COLUMN category VARCHAR(30) DEFAULT '' AFTER priority",
"ALTER TABLE user_companies ADD COLUMN role VARCHAR(20) DEFAULT 'user' AFTER company_id", "ALTER TABLE user_companies ADD COLUMN role VARCHAR(20) DEFAULT 'user' AFTER company_id",
"ALTER TABLE documents ADD COLUMN folder_id VARCHAR(20) DEFAULT NULL AFTER customer_id",
"ALTER TABLE document_versions ADD COLUMN content MEDIUMTEXT DEFAULT NULL AFTER mime_type",
]; ];
foreach ($alters as $sql) { foreach ($alters as $sql) {
try { $db->query($sql); } catch (\Throwable $e) { /* sarake on jo olemassa / jo ajettu */ } try { $db->query($sql); } catch (\Throwable $e) { /* sarake on jo olemassa / jo ajettu */ }
@@ -1736,6 +1750,43 @@ function dbDeleteTodoSubtask(string $subtaskId): void {
_dbExecute("DELETE FROM todo_subtasks WHERE id = ?", [$subtaskId]); _dbExecute("DELETE FROM todo_subtasks WHERE id = ?", [$subtaskId]);
} }
// ==================== DOKUMENTTIKANSIOT ====================
function dbLoadFolders(string $companyId): array {
return _dbFetchAll("SELECT * FROM document_folders WHERE company_id = ? ORDER BY name", [$companyId]);
}
function dbSaveFolder(string $companyId, array $folder): string {
$id = $folder['id'] ?? generateId();
$now = date('Y-m-d H:i:s');
_dbExecute("
INSERT INTO document_folders (id, company_id, name, parent_id, created_by, luotu)
VALUES (:id, :companyId, :name, :parentId, :createdBy, :luotu)
ON DUPLICATE KEY UPDATE name = VALUES(name), parent_id = VALUES(parent_id)
", [
'id' => $id,
'companyId' => $companyId,
'name' => $folder['name'] ?? '',
'parentId' => !empty($folder['parent_id']) ? $folder['parent_id'] : null,
'createdBy' => $folder['created_by'] ?? '',
'luotu' => $folder['luotu'] ?? $now
]);
return $id;
}
function dbDeleteFolder(string $companyId, string $folderId): bool {
$folder = _dbFetchOne("SELECT parent_id FROM document_folders WHERE id = ? AND company_id = ?", [$folderId, $companyId]);
if (!$folder) return false;
// Siirrä kansion dokumentit ylätasolle
_dbExecute("UPDATE documents SET folder_id = ? WHERE folder_id = ? AND company_id = ?",
[$folder['parent_id'], $folderId, $companyId]);
// Siirrä alikansiot ylätasolle
_dbExecute("UPDATE document_folders SET parent_id = ? WHERE parent_id = ? AND company_id = ?",
[$folder['parent_id'], $folderId, $companyId]);
_dbExecute("DELETE FROM document_folders WHERE id = ? AND company_id = ?", [$folderId, $companyId]);
return true;
}
// ==================== DOKUMENTIT ==================== // ==================== DOKUMENTIT ====================
function dbLoadDocuments(string $companyId, ?string $customerId = null): array { function dbLoadDocuments(string $companyId, ?string $customerId = null): array {
@@ -1767,19 +1818,21 @@ function dbSaveDocument(string $companyId, array $doc): string {
$id = $doc['id'] ?? generateId(); $id = $doc['id'] ?? generateId();
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
_dbExecute(" _dbExecute("
INSERT INTO documents (id, company_id, customer_id, title, description, category, current_version, created_by, luotu, muokattu, muokkaaja) INSERT INTO documents (id, company_id, customer_id, folder_id, title, description, category, current_version, created_by, luotu, muokattu, muokkaaja)
VALUES (:id, :companyId, :customerId, :title, :description, :category, :currentVersion, :createdBy, :luotu, :muokattu, :muokkaaja) VALUES (:id, :companyId, :customerId, :folderId, :title, :description, :category, :currentVersion, :createdBy, :luotu, :muokattu, :muokkaaja)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
title = VALUES(title), title = VALUES(title),
description = VALUES(description), description = VALUES(description),
category = VALUES(category), category = VALUES(category),
customer_id = VALUES(customer_id), customer_id = VALUES(customer_id),
folder_id = VALUES(folder_id),
muokattu = VALUES(muokattu), muokattu = VALUES(muokattu),
muokkaaja = VALUES(muokkaaja) muokkaaja = VALUES(muokkaaja)
", [ ", [
'id' => $id, 'id' => $id,
'companyId' => $companyId, 'companyId' => $companyId,
'customerId' => !empty($doc['customer_id']) ? $doc['customer_id'] : null, 'customerId' => !empty($doc['customer_id']) ? $doc['customer_id'] : null,
'folderId' => !empty($doc['folder_id']) ? $doc['folder_id'] : null,
'title' => $doc['title'] ?? '', 'title' => $doc['title'] ?? '',
'description' => $doc['description'] ?? '', 'description' => $doc['description'] ?? '',
'category' => $doc['category'] ?? 'muu', 'category' => $doc['category'] ?? 'muu',
@@ -1807,7 +1860,7 @@ function dbAddDocumentVersion(string $documentId, array $version): void {
$maxVersion = _dbFetchScalar("SELECT COALESCE(MAX(version_number), 0) FROM document_versions WHERE document_id = ?", [$documentId]); $maxVersion = _dbFetchScalar("SELECT COALESCE(MAX(version_number), 0) FROM document_versions WHERE document_id = ?", [$documentId]);
$nextVersion = (int)$maxVersion + 1; $nextVersion = (int)$maxVersion + 1;
_dbExecute("INSERT INTO document_versions (id, document_id, version_number, filename, original_name, file_size, mime_type, change_notes, created_by, luotu) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [ _dbExecute("INSERT INTO document_versions (id, document_id, version_number, filename, original_name, file_size, mime_type, content, change_notes, created_by, luotu) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [
$version['id'] ?? generateId(), $version['id'] ?? generateId(),
$documentId, $documentId,
$nextVersion, $nextVersion,
@@ -1815,6 +1868,7 @@ function dbAddDocumentVersion(string $documentId, array $version): void {
$version['original_name'] ?? '', $version['original_name'] ?? '',
$version['file_size'] ?? 0, $version['file_size'] ?? 0,
$version['mime_type'] ?? '', $version['mime_type'] ?? '',
$version['content'] ?? null,
$version['change_notes'] ?? '', $version['change_notes'] ?? '',
$version['created_by'] ?? '', $version['created_by'] ?? '',
$now $now
@@ -1836,7 +1890,7 @@ function dbRestoreDocumentVersion(string $documentId, string $versionId, string
$nextVersion = (int)$maxVersion + 1; $nextVersion = (int)$maxVersion + 1;
$newId = generateId(); $newId = generateId();
_dbExecute("INSERT INTO document_versions (id, document_id, version_number, filename, original_name, file_size, mime_type, change_notes, created_by, luotu) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [ _dbExecute("INSERT INTO document_versions (id, document_id, version_number, filename, original_name, file_size, mime_type, content, change_notes, created_by, luotu) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [
$newId, $newId,
$documentId, $documentId,
$nextVersion, $nextVersion,
@@ -1844,6 +1898,7 @@ function dbRestoreDocumentVersion(string $documentId, string $versionId, string
$oldVersion['original_name'], $oldVersion['original_name'],
$oldVersion['file_size'], $oldVersion['file_size'],
$oldVersion['mime_type'], $oldVersion['mime_type'],
$oldVersion['content'] ?? null,
'Palautettu versiosta ' . $oldVersion['version_number'], 'Palautettu versiosta ' . $oldVersion['version_number'],
$user, $user,
$now $now

View File

@@ -730,13 +730,26 @@
<!-- Tab: Dokumentit --> <!-- Tab: Dokumentit -->
<div class="tab-content" id="tab-content-documents"> <div class="tab-content" id="tab-content-documents">
<div class="sub-tab-bar" id="doc-sub-tab-bar">
<button class="sub-tab active" data-doc-subtab="docs-all">Kaikki</button>
<button class="sub-tab" data-doc-subtab="docs-kokoukset">Kokoukset</button>
</div>
<div class="main-container"> <div class="main-container">
<!-- Listanäkymä --> <!-- Listanäkymä -->
<div id="docs-list-view"> <div id="docs-list-view">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem;">
<h3 style="color:var(--primary-dark);margin:0;">📄 Dokumentit</h3> <h3 style="color:var(--primary-dark);margin:0;" id="docs-list-title">📄 Dokumentit</h3>
<button class="btn-primary" id="btn-new-document">+ Uusi dokumentti</button> <div style="display:flex;gap:0.5rem;">
<button class="btn-primary" id="btn-new-document">+ Uusi dokumentti</button>
<button class="btn-primary" id="btn-new-meeting-note" style="display:none;">+ Uusi kokousmuistio</button>
</div>
</div> </div>
<!-- Kansionavigointi -->
<div id="doc-folder-bar" style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;flex-wrap:wrap;">
<span id="doc-breadcrumbs" style="font-size:0.9rem;color:#555;"></span>
<button class="btn-secondary" id="btn-new-folder" style="font-size:0.8rem;padding:0.25rem 0.6rem;">+ Kansio</button>
</div>
<div id="doc-folders-grid" style="display:flex;gap:0.5rem;margin-bottom:0.75rem;flex-wrap:wrap;"></div>
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap;"> <div style="display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap;">
<input type="text" id="doc-search" placeholder="Hae dokumentteja..." style="flex:1;min-width:150px;"> <input type="text" id="doc-search" placeholder="Hae dokumentteja..." style="flex:1;min-width:150px;">
<select id="doc-filter-customer" style="min-width:140px;"> <select id="doc-filter-customer" style="min-width:140px;">
@@ -748,6 +761,7 @@
<option value="lasku">Lasku</option> <option value="lasku">Lasku</option>
<option value="ohje">Ohje</option> <option value="ohje">Ohje</option>
<option value="raportti">Raportti</option> <option value="raportti">Raportti</option>
<option value="kokousmuistio">Kokousmuistio</option>
<option value="muu">Muu</option> <option value="muu">Muu</option>
</select> </select>
</div> </div>
@@ -792,13 +806,31 @@
</div> </div>
<p id="doc-read-description" style="color:#555;margin-bottom:1.5rem;"></p> <p id="doc-read-description" style="color:#555;margin-bottom:1.5rem;"></p>
<!-- Nykyisen version lataus --> <!-- Kokousmuistion sisältö -->
<div style="margin-bottom:1.5rem;"> <div id="doc-read-content-section" style="display:none;margin-bottom:1.5rem;">
<div id="doc-read-content" style="white-space:pre-wrap;background:#fefefe;border:1px solid #e5e7eb;padding:1rem;border-radius:8px;min-height:100px;line-height:1.6;font-size:0.95rem;"></div>
<div style="margin-top:0.75rem;">
<button class="btn-primary" id="btn-doc-edit-content">✏️ Muokkaa muistiota</button>
</div>
</div>
<!-- Kokousmuistion inline-editori -->
<div id="doc-inline-editor" style="display:none;margin-bottom:1.5rem;">
<textarea id="doc-inline-content" rows="15" placeholder="Kirjoita kokousmuistio tähän..."></textarea>
<div style="margin-top:0.5rem;display:flex;gap:0.5rem;flex-wrap:wrap;">
<input type="text" id="doc-inline-notes" placeholder="Muistiinpanot versiosta (valinnainen)" style="flex:1;min-width:200px;">
<button class="btn-primary" id="btn-doc-save-content">Tallenna</button>
<button class="btn-secondary" id="btn-doc-cancel-content">Peruuta</button>
</div>
</div>
<!-- Nykyisen version lataus (tiedostopohjaisille) -->
<div id="doc-file-section" style="margin-bottom:1.5rem;">
<button class="btn-primary" id="btn-doc-download" style="font-size:1rem;padding:0.7rem 1.5rem;">⬇️ Lataa tiedosto</button> <button class="btn-primary" id="btn-doc-download" style="font-size:1rem;padding:0.7rem 1.5rem;">⬇️ Lataa tiedosto</button>
</div> </div>
<!-- Uusi versio --> <!-- Uusi versio (tiedostopohjaisille) -->
<div style="background:#f8f9fb;padding:1rem;border-radius:10px;margin-bottom:1.5rem;"> <div id="doc-upload-section" style="background:#f8f9fb;padding:1rem;border-radius:10px;margin-bottom:1.5rem;">
<h4 style="margin:0 0 0.75rem;color:var(--primary-dark);">Lataa uusi versio</h4> <h4 style="margin:0 0 0.75rem;color:var(--primary-dark);">Lataa uusi versio</h4>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:end;"> <div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:end;">
<input type="file" id="doc-version-file" style="flex:1;min-width:200px;"> <input type="file" id="doc-version-file" style="flex:1;min-width:200px;">
@@ -848,6 +880,7 @@
<option value="lasku">Lasku</option> <option value="lasku">Lasku</option>
<option value="ohje">Ohje</option> <option value="ohje">Ohje</option>
<option value="raportti">Raportti</option> <option value="raportti">Raportti</option>
<option value="kokousmuistio">Kokousmuistio</option>
<option value="muu">Muu</option> <option value="muu">Muu</option>
</select> </select>
</div> </div>
@@ -861,11 +894,23 @@
<label>Tiedosto</label> <label>Tiedosto</label>
<input type="file" id="doc-edit-file"> <input type="file" id="doc-edit-file">
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width" id="doc-edit-desc-group">
<label>Kuvaus</label> <label>Kuvaus</label>
<textarea id="doc-edit-description" rows="3" placeholder="Dokumentin kuvaus..."></textarea> <textarea id="doc-edit-description" rows="3" placeholder="Dokumentin kuvaus..."></textarea>
</div> </div>
</div> </div>
<!-- Kokousmuistion kentät -->
<div id="doc-edit-meeting-fields" style="display:none;margin-top:1rem;">
<div class="form-group" style="margin-bottom:0.75rem;">
<label>Osallistujat</label>
<input type="text" id="doc-edit-participants" placeholder="Nimi1, Nimi2, Nimi3">
</div>
<div class="form-group">
<label>Muistion sisältö</label>
<textarea id="doc-edit-content" rows="15" placeholder="Kirjoita kokousmuistio tähän..." style="font-family:inherit;line-height:1.6;"></textarea>
</div>
</div>
<input type="hidden" id="doc-edit-folder-id">
<div style="margin-top:1rem;display:flex;gap:0.5rem;"> <div style="margin-top:1rem;display:flex;gap:0.5rem;">
<button type="submit" class="btn-primary">Tallenna</button> <button type="submit" class="btn-primary">Tallenna</button>
<button type="button" class="btn-secondary" id="btn-doc-edit-cancel">Peruuta</button> <button type="button" class="btn-secondary" id="btn-doc-edit-cancel">Peruuta</button>

335
script.js
View File

@@ -284,7 +284,7 @@ function switchToTab(target, subTab) {
if (target === 'ohjeet') loadGuides(); if (target === 'ohjeet') loadGuides();
if (target === 'todo') { loadTodos(); if (subTab) switchTodoSubTab(subTab); } if (target === 'todo') { loadTodos(); if (subTab) switchTodoSubTab(subTab); }
if (target === 'support') { loadTickets(); showTicketListView(); if (document.getElementById('ticket-auto-refresh').checked) startTicketAutoRefresh(); } if (target === 'support') { loadTickets(); showTicketListView(); if (document.getElementById('ticket-auto-refresh').checked) startTicketAutoRefresh(); }
if (target === 'documents') { loadDocuments(); showDocsListView(); } if (target === 'documents') { loadDocuments(); showDocsListView(); if (subTab === 'kokoukset') switchDocSubTab('docs-kokoukset'); else switchDocSubTab('docs-all'); }
if (target === 'laitetilat') { loadLaitetilat(); showLaitetilatListView(); } if (target === 'laitetilat') { loadLaitetilat(); showLaitetilatListView(); }
if (target === 'netadmin') loadNetadmin(); if (target === 'netadmin') loadNetadmin();
if (target === 'users') loadUsers(); if (target === 'users') loadUsers();
@@ -597,6 +597,11 @@ function showDetail(id) {
<span 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>
<div id="file-list" class="file-list" style="margin-top:0.75rem;"></div> <div id="file-list" class="file-list" style="margin-top:0.75rem;"></div>
</div>
<div class="detail-section"><h3>Dokumentit</h3>
<div id="customer-docs-list" style="margin-bottom:0.75rem;"><span style="color:#aaa;font-size:0.85rem;">Ladataan...</span></div>
<button class="btn-primary" style="font-size:0.85rem;padding:6px 14px;" onclick="openDocEditForCustomer('${id}')">+ Lisää dokumentti</button>
<button class="btn-primary" style="font-size:0.85rem;padding:6px 14px;margin-left:0.25rem;" onclick="openDocEditForCustomer('${id}', 'kokousmuistio')">+ Kokousmuistio</button>
</div>`; </div>`;
// Synkronoi prices-hidden tila detail-modaliin // Synkronoi prices-hidden tila detail-modaliin
@@ -604,6 +609,7 @@ function showDetail(id) {
detailModal.querySelector('.modal-content')?.classList.toggle('prices-hidden', !!pricesHidden); detailModal.querySelector('.modal-content')?.classList.toggle('prices-hidden', !!pricesHidden);
detailModal.style.display = 'flex'; detailModal.style.display = 'flex';
loadFiles(id); loadFiles(id);
loadCustomerDocuments(id);
document.getElementById('file-upload-input').addEventListener('change', async function () { document.getElementById('file-upload-input').addEventListener('change', async function () {
for (const file of this.files) { for (const file of this.files) {
const fd = new FormData(); const fd = new FormData();
@@ -620,6 +626,44 @@ function showDetail(id) {
}); });
} }
async function loadCustomerDocuments(customerId) {
const container = document.getElementById('customer-docs-list');
if (!container) return;
try {
const docs = await apiCall(`documents&customer_id=${customerId}`);
if (docs.length === 0) {
container.innerHTML = '<p style="color:#aaa;font-size:0.85rem;">Ei dokumentteja.</p>';
return;
}
container.innerHTML = docs.map(d => {
const catLabel = docCategoryLabels[d.category] || d.category || 'Muu';
const date = d.muokattu ? new Date(d.muokattu).toLocaleDateString('fi-FI') : '';
return `<div class="customer-doc-item">
<a href="#" onclick="openDocFromCustomer('${d.id}');return false;">${esc(d.title)}</a>
<span><span class="doc-category cat-${d.category || 'muu'}" style="font-size:0.75rem;">${catLabel}</span> <span style="color:#aaa;font-size:0.8rem;">${date}</span></span>
</div>`;
}).join('');
} catch (e) {
container.innerHTML = '<p style="color:red;font-size:0.85rem;">Virhe ladattaessa dokumentteja.</p>';
}
}
window.openDocFromCustomer = async function(docId) {
detailModal.style.display = 'none';
switchToTab('documents');
try {
currentDocument = await apiCall(`document&id=${docId}`);
renderDocReadView();
showDocReadView();
} catch (e) { alert('Dokumentin avaus epäonnistui: ' + e.message); }
};
window.openDocEditForCustomer = function(customerId, forceCategory) {
detailModal.style.display = 'none';
switchToTab('documents');
openDocEdit(null, forceCategory || null, customerId);
};
async function loadFiles(customerId) { async function loadFiles(customerId) {
const fileList = document.getElementById('file-list'); const fileList = document.getElementById('file-list');
if (!fileList) return; if (!fileList) return;
@@ -4688,12 +4732,16 @@ function openFeatureSuggestion() {
let allDocuments = []; let allDocuments = [];
let currentDocument = null; let currentDocument = null;
let allDocFolders = [];
let currentDocFolderId = null; // null = root (kaikki)
let docSubTabMode = 'docs-all'; // 'docs-all' | 'docs-kokoukset'
const docCategoryLabels = { const docCategoryLabels = {
sopimus: 'Sopimus', sopimus: 'Sopimus',
lasku: 'Lasku', lasku: 'Lasku',
ohje: 'Ohje', ohje: 'Ohje',
raportti: 'Raportti', raportti: 'Raportti',
kokousmuistio: 'Kokousmuistio',
muu: 'Muu' muu: 'Muu'
}; };
@@ -4718,7 +4766,9 @@ function showDocEditView() {
async function loadDocuments() { async function loadDocuments() {
try { try {
allDocuments = await apiCall('documents'); allDocuments = await apiCall('documents');
try { allDocFolders = await apiCall('document_folders'); } catch (e2) { allDocFolders = []; }
populateDocCustomerFilter(); populateDocCustomerFilter();
renderDocFolderBar();
renderDocumentsList(); renderDocumentsList();
} catch (e) { console.error('Dokumenttien lataus epäonnistui:', e); } } catch (e) { console.error('Dokumenttien lataus epäonnistui:', e); }
} }
@@ -4747,12 +4797,107 @@ function populateDocCustomerFilter() {
sel.value = existing || ''; sel.value = existing || '';
} }
// ---- Kansionavigointi ----
function renderDocFolderBar() {
const bc = document.getElementById('doc-breadcrumbs');
if (!bc) return;
// Piilotetaan kansiot kokoukset-subtabissa
const showFolders = docSubTabMode !== 'docs-kokoukset';
document.getElementById('doc-folder-bar').style.display = showFolders ? 'flex' : 'none';
document.getElementById('doc-folders-grid').style.display = showFolders ? 'flex' : 'none';
if (!showFolders) return;
let crumbs = `<a href="#" onclick="navigateDocFolder(null);return false;">📁 Kaikki</a>`;
if (currentDocFolderId) {
const path = getDocFolderPath(currentDocFolderId);
path.forEach(f => {
crumbs += `<span class="bc-sep">/</span><a href="#" onclick="navigateDocFolder('${f.id}');return false;">${esc(f.name)}</a>`;
});
}
bc.innerHTML = crumbs;
// Alikansiot
const subfolders = allDocFolders.filter(f => (f.parent_id || null) === currentDocFolderId);
const grid = document.getElementById('doc-folders-grid');
grid.innerHTML = subfolders.map(f =>
`<div class="doc-folder-item" onclick="navigateDocFolder('${f.id}')">📁 ${esc(f.name)}</div>`
).join('');
}
function getDocFolderPath(folderId) {
const path = [];
let current = folderId;
let safety = 20;
while (current && safety-- > 0) {
const folder = allDocFolders.find(f => f.id === current);
if (!folder) break;
path.unshift(folder);
current = folder.parent_id || null;
}
return path;
}
function navigateDocFolder(folderId) {
currentDocFolderId = folderId;
renderDocFolderBar();
renderDocumentsList();
}
// ---- Sub-tabit ----
function switchDocSubTab(target) {
docSubTabMode = target;
document.querySelectorAll('#doc-sub-tab-bar .sub-tab').forEach(t => t.classList.remove('active'));
const btn = document.querySelector(`[data-doc-subtab="${target}"]`);
if (btn) btn.classList.add('active');
const isMeeting = target === 'docs-kokoukset';
document.getElementById('btn-new-document').style.display = isMeeting ? 'none' : '';
document.getElementById('btn-new-meeting-note').style.display = isMeeting ? '' : 'none';
document.getElementById('docs-list-title').textContent = isMeeting ? '📝 Kokoukset' : '📄 Dokumentit';
// Nollaa kansionavigointi kokoukset-tilassa
if (isMeeting) currentDocFolderId = null;
// Piilota suodattimet kokoukset-tilassa
document.getElementById('doc-filter-category').style.display = isMeeting ? 'none' : '';
renderDocFolderBar();
renderDocumentsList();
// URL hash
window.location.hash = isMeeting ? 'documents/kokoukset' : 'documents';
}
document.querySelectorAll('#doc-sub-tab-bar .sub-tab').forEach(btn => {
btn.addEventListener('click', () => switchDocSubTab(btn.dataset.docSubtab));
});
// ---- Dokumenttilista ----
function renderDocumentsList() { function renderDocumentsList() {
const query = (document.getElementById('doc-search')?.value || '').toLowerCase().trim(); const query = (document.getElementById('doc-search')?.value || '').toLowerCase().trim();
const filterCustomer = document.getElementById('doc-filter-customer')?.value || ''; const filterCustomer = document.getElementById('doc-filter-customer')?.value || '';
const filterCategory = document.getElementById('doc-filter-category')?.value || ''; const filterCategory = document.getElementById('doc-filter-category')?.value || '';
let filtered = allDocuments; let filtered = allDocuments;
// Sub-tab suodatus: kokoukset = vain kokousmuistiot
if (docSubTabMode === 'docs-kokoukset') {
filtered = filtered.filter(d => d.category === 'kokousmuistio');
}
// Kansiosuodatus (vain "Kaikki"-tilassa)
if (docSubTabMode !== 'docs-kokoukset') {
if (currentDocFolderId !== null) {
filtered = filtered.filter(d => d.folder_id === currentDocFolderId);
} else {
// Juuritasolla: näytä vain ilman kansiota olevat
filtered = filtered.filter(d => !d.folder_id);
}
}
if (query) { if (query) {
filtered = filtered.filter(d => filtered = filtered.filter(d =>
(d.title || '').toLowerCase().includes(query) || (d.title || '').toLowerCase().includes(query) ||
@@ -4762,7 +4907,7 @@ function renderDocumentsList() {
if (filterCustomer) { if (filterCustomer) {
filtered = filtered.filter(d => d.customer_id === filterCustomer); filtered = filtered.filter(d => d.customer_id === filterCustomer);
} }
if (filterCategory) { if (filterCategory && docSubTabMode !== 'docs-kokoukset') {
filtered = filtered.filter(d => d.category === filterCategory); filtered = filtered.filter(d => d.category === filterCategory);
} }
@@ -4827,14 +4972,40 @@ function renderDocReadView() {
document.getElementById('doc-read-category').innerHTML = `<span class="doc-category cat-${d.category || 'muu'}">${docCategoryLabels[d.category] || d.category || 'Muu'}</span>`; document.getElementById('doc-read-category').innerHTML = `<span class="doc-category cat-${d.category || 'muu'}">${docCategoryLabels[d.category] || d.category || 'Muu'}</span>`;
document.getElementById('doc-read-version').textContent = `📌 Versio ${d.current_version || 0}`; document.getElementById('doc-read-version').textContent = `📌 Versio ${d.current_version || 0}`;
document.getElementById('doc-read-date').textContent = d.muokattu ? '📅 ' + new Date(d.muokattu).toLocaleDateString('fi-FI') : ''; document.getElementById('doc-read-date').textContent = d.muokattu ? '📅 ' + new Date(d.muokattu).toLocaleDateString('fi-FI') : '';
document.getElementById('doc-read-description').textContent = d.description || ''; const isMeeting = d.category === 'kokousmuistio';
// Kuvaus: kokousmuistioille näytetään osallistujat
if (isMeeting && d.description) {
document.getElementById('doc-read-description').textContent = 'Osallistujat: ' + d.description;
} else {
document.getElementById('doc-read-description').textContent = d.description || '';
}
// Admin-napit // Admin-napit
const isAdmin = isCurrentUserAdmin(); const isAdmin = isCurrentUserAdmin();
document.getElementById('btn-doc-delete').style.display = isAdmin ? '' : 'none'; document.getElementById('btn-doc-delete').style.display = isAdmin ? '' : 'none';
// Latausnappi - piilota jos ei versioita // Kokousmuistio vs tiedostopohjainen
document.getElementById('btn-doc-download').style.display = (d.current_version && d.current_version > 0) ? '' : 'none'; const contentSection = document.getElementById('doc-read-content-section');
const inlineEditor = document.getElementById('doc-inline-editor');
const fileSection = document.getElementById('doc-file-section');
const uploadSection = document.getElementById('doc-upload-section');
if (isMeeting) {
// Näytä sisältö, piilota tiedosto-osiot
fileSection.style.display = 'none';
uploadSection.style.display = 'none';
inlineEditor.style.display = 'none';
contentSection.style.display = '';
const currentVersion = d.versions?.find(v => v.version_number === d.current_version);
document.getElementById('doc-read-content').textContent = currentVersion?.content || '(Tyhjä muistio)';
} else {
// Tiedostopohjainen: piilota kokousmuistio-osiot
contentSection.style.display = 'none';
inlineEditor.style.display = 'none';
fileSection.style.display = (d.current_version && d.current_version > 0) ? '' : 'none';
uploadSection.style.display = '';
}
// Versiohistoria // Versiohistoria
const vtbody = document.getElementById('doc-versions-tbody'); const vtbody = document.getElementById('doc-versions-tbody');
@@ -4844,14 +5015,18 @@ function renderDocReadView() {
vtbody.innerHTML = d.versions.map(v => { vtbody.innerHTML = d.versions.map(v => {
const date = v.luotu ? new Date(v.luotu).toLocaleDateString('fi-FI', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-'; const date = v.luotu ? new Date(v.luotu).toLocaleDateString('fi-FI', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-';
const isCurrent = v.version_number === d.current_version; const isCurrent = v.version_number === d.current_version;
const sizeDisplay = isMeeting ? (v.content ? v.content.length + ' merkkiä' : '-') : formatFileSize(v.file_size || 0);
const downloadLink = isMeeting
? `<button onclick="viewMeetingVersion('${v.id}', ${v.version_number})" title="Näytä versio" style="background:none;border:none;cursor:pointer;font-size:1rem;">👁️</button>`
: `<a href="${API}?action=document_download&id=${d.id}&version=${v.version_number}" target="_blank" title="Lataa">⬇️</a>`;
return `<tr${isCurrent ? ' style="background:#f0f7ff;"' : ''}> return `<tr${isCurrent ? ' style="background:#f0f7ff;"' : ''}>
<td style="font-weight:600;">v${v.version_number}${isCurrent ? ' ✓' : ''}</td> <td style="font-weight:600;">v${v.version_number}${isCurrent ? ' ✓' : ''}</td>
<td>${date}</td> <td>${date}</td>
<td>${esc(v.created_by || '-')}</td> <td>${esc(v.created_by || '-')}</td>
<td>${esc(v.change_notes || '-')}</td> <td>${esc(v.change_notes || '-')}</td>
<td>${formatFileSize(v.file_size || 0)}</td> <td>${sizeDisplay}</td>
<td class="actions-cell"> <td class="actions-cell">
<a href="${API}?action=document_download&id=${d.id}&version=${v.version_number}" target="_blank" title="Lataa">⬇️</a> ${downloadLink}
${isAdmin && !isCurrent ? `<button onclick="restoreDocVersion('${d.id}', '${v.id}', ${v.version_number})" title="Palauta tämä versio" style="background:none;border:none;cursor:pointer;font-size:1rem;">🔄</button>` : ''} ${isAdmin && !isCurrent ? `<button onclick="restoreDocVersion('${d.id}', '${v.id}', ${v.version_number})" title="Palauta tämä versio" style="background:none;border:none;cursor:pointer;font-size:1rem;">🔄</button>` : ''}
</td> </td>
</tr>`; </tr>`;
@@ -4924,40 +5099,134 @@ document.getElementById('btn-doc-edit-cancel')?.addEventListener('click', () =>
// Uusi dokumentti // Uusi dokumentti
document.getElementById('btn-new-document')?.addEventListener('click', () => { openDocEdit(null); }); document.getElementById('btn-new-document')?.addEventListener('click', () => { openDocEdit(null); });
// Uusi kokousmuistio
document.getElementById('btn-new-meeting-note')?.addEventListener('click', () => { openDocEdit(null, 'kokousmuistio'); });
function openDocEdit(doc) { // Kokousmuistion inline-editori
document.getElementById('btn-doc-edit-content')?.addEventListener('click', () => {
const d = currentDocument;
if (!d) return;
const currentVersion = d.versions?.find(v => v.version_number === d.current_version);
document.getElementById('doc-inline-content').value = currentVersion?.content || '';
document.getElementById('doc-inline-notes').value = '';
document.getElementById('doc-inline-editor').style.display = '';
document.getElementById('doc-read-content-section').style.display = 'none';
});
document.getElementById('btn-doc-cancel-content')?.addEventListener('click', () => {
document.getElementById('doc-inline-editor').style.display = 'none';
document.getElementById('doc-read-content-section').style.display = '';
});
document.getElementById('btn-doc-save-content')?.addEventListener('click', async () => {
if (!currentDocument) return;
const content = document.getElementById('doc-inline-content').value;
const notes = document.getElementById('doc-inline-notes').value || 'Muistiota päivitetty';
try {
currentDocument = await apiCall('document_content_save', 'POST', {
document_id: currentDocument.id,
content,
change_notes: notes
});
renderDocReadView();
loadDocuments();
} catch (e) { alert('Tallennus epäonnistui: ' + e.message); }
});
// Katso kokousmuistion vanhaa versiota
window.viewMeetingVersion = function(versionId, versionNum) {
if (!currentDocument) return;
const v = currentDocument.versions?.find(x => x.id === versionId);
if (v) {
alert('Versio ' + versionNum + ':\n\n' + (v.content || '(Tyhjä)'));
}
};
// Uusi kansio
document.getElementById('btn-new-folder')?.addEventListener('click', async () => {
const name = prompt('Kansion nimi:');
if (!name || !name.trim()) return;
try {
await apiCall('document_folder_save', 'POST', {
name: name.trim(),
parent_id: currentDocFolderId || null
});
await loadDocuments();
} catch (e) { alert('Kansion luonti epäonnistui: ' + e.message); }
});
function openDocEdit(doc, forceCategory, forceCustomerId) {
document.getElementById('doc-edit-id').value = doc?.id || ''; document.getElementById('doc-edit-id').value = doc?.id || '';
document.getElementById('doc-edit-name').value = doc?.title || ''; document.getElementById('doc-edit-name').value = doc?.title || '';
document.getElementById('doc-edit-description').value = doc?.description || ''; document.getElementById('doc-edit-description').value = doc?.description || '';
document.getElementById('doc-edit-category').value = doc?.category || 'muu'; const cat = forceCategory || doc?.category || 'muu';
document.getElementById('doc-edit-title').textContent = doc ? 'Muokkaa dokumenttia' : 'Uusi dokumentti'; document.getElementById('doc-edit-category').value = cat;
document.getElementById('doc-edit-folder-id').value = doc?.folder_id || currentDocFolderId || '';
const isMeeting = cat === 'kokousmuistio';
document.getElementById('doc-edit-title').textContent = doc
? (isMeeting ? 'Muokkaa kokousmuistiota' : 'Muokkaa dokumenttia')
: (isMeeting ? 'Uusi kokousmuistio' : 'Uusi dokumentti');
// Täytä asiakas-dropdown // Täytä asiakas-dropdown
const custSel = document.getElementById('doc-edit-customer'); const custSel = document.getElementById('doc-edit-customer');
custSel.innerHTML = '<option value="">Ei asiakasta (yleinen)</option>'; custSel.innerHTML = '<option value="">Ei asiakasta (yleinen)</option>';
if (typeof customers !== 'undefined') { if (typeof customers !== 'undefined') {
customers.forEach(c => { customers.forEach(c => {
custSel.innerHTML += `<option value="${c.id}" ${doc?.customer_id === c.id ? 'selected' : ''}>${esc(c.yritys)}</option>`; custSel.innerHTML += `<option value="${c.id}" ${(forceCustomerId === c.id || doc?.customer_id === c.id) ? 'selected' : ''}>${esc(c.yritys)}</option>`;
}); });
} }
if (doc?.customer_id) custSel.value = doc.customer_id; if (forceCustomerId) custSel.value = forceCustomerId;
else if (doc?.customer_id) custSel.value = doc.customer_id;
// Toggle kokousmuistio vs tiedostokenttä
toggleDocMeetingFields(cat);
// Kokousmuistio-kentät
if (isMeeting) {
const currentVersion = doc?.versions?.find(v => v.version_number === doc.current_version);
document.getElementById('doc-edit-content').value = currentVersion?.content || '';
document.getElementById('doc-edit-participants').value = doc?.description || '';
} else {
document.getElementById('doc-edit-content').value = '';
document.getElementById('doc-edit-participants').value = '';
}
// Piilota tiedostokenttä muokkaustilassa (versiot hoidetaan read-viewissä) // Piilota tiedostokenttä muokkaustilassa (versiot hoidetaan read-viewissä)
document.getElementById('doc-edit-file').parentElement.style.display = doc ? 'none' : ''; if (!isMeeting) {
document.getElementById('doc-edit-file').parentElement.style.display = doc ? 'none' : '';
}
showDocEditView(); showDocEditView();
} }
function toggleDocMeetingFields(category) {
const isMeeting = category === 'kokousmuistio';
document.getElementById('doc-edit-meeting-fields').style.display = isMeeting ? '' : 'none';
document.getElementById('doc-edit-file').parentElement.style.display = isMeeting ? 'none' : '';
document.getElementById('doc-edit-desc-group').style.display = isMeeting ? 'none' : '';
}
document.getElementById('doc-edit-category')?.addEventListener('change', (e) => {
toggleDocMeetingFields(e.target.value);
});
// Lomakkeen lähetys // Lomakkeen lähetys
document.getElementById('doc-edit-form')?.addEventListener('submit', async (e) => { document.getElementById('doc-edit-form')?.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const id = document.getElementById('doc-edit-id').value; const id = document.getElementById('doc-edit-id').value;
const cat = document.getElementById('doc-edit-category').value;
const isMeeting = cat === 'kokousmuistio';
const docData = { const docData = {
id: id || undefined, id: id || undefined,
title: document.getElementById('doc-edit-name').value.trim(), title: document.getElementById('doc-edit-name').value.trim(),
description: document.getElementById('doc-edit-description').value.trim(), description: isMeeting
category: document.getElementById('doc-edit-category').value, ? document.getElementById('doc-edit-participants').value.trim()
: document.getElementById('doc-edit-description').value.trim(),
category: cat,
customer_id: document.getElementById('doc-edit-customer').value || null, customer_id: document.getElementById('doc-edit-customer').value || null,
folder_id: document.getElementById('doc-edit-folder-id').value || null,
created_by: currentUser?.username || '' created_by: currentUser?.username || ''
}; };
@@ -4967,18 +5236,30 @@ document.getElementById('doc-edit-form')?.addEventListener('submit', async (e) =
const saved = await apiCall('document_save', 'POST', docData); const saved = await apiCall('document_save', 'POST', docData);
const docId = saved.id; const docId = saved.id;
// Jos uusi dokumentti ja tiedosto valittu → lataa ensimmäinen versio if (isMeeting) {
const fileInput = document.getElementById('doc-edit-file'); // Tallenna kokousmuistion sisältö ensimmäisenä versiona
if (!id && fileInput.files.length > 0) { const content = document.getElementById('doc-edit-content').value;
const fd = new FormData(); if (content || !id) {
fd.append('document_id', docId); await apiCall('document_content_save', 'POST', {
fd.append('file', fileInput.files[0]); document_id: docId,
fd.append('change_notes', 'Ensimmäinen versio'); content: content,
const res = await fetch(`${API}?action=document_upload`, { method: 'POST', credentials: 'include', body: fd }); change_notes: id ? 'Muistiota päivitetty' : 'Ensimmäinen versio'
const text = await res.text(); });
let data; }
try { data = JSON.parse(text); } catch (err) { throw new Error('Tiedoston lataus epäonnistui'); } } else {
if (!res.ok) throw new Error(data.error || 'Virhe'); // Jos uusi dokumentti ja tiedosto valittu → lataa ensimmäinen versio
const fileInput = document.getElementById('doc-edit-file');
if (!id && fileInput.files.length > 0) {
const fd = new FormData();
fd.append('document_id', docId);
fd.append('file', fileInput.files[0]);
fd.append('change_notes', 'Ensimmäinen versio');
const res = await fetch(`${API}?action=document_upload`, { method: 'POST', credentials: 'include', body: fd });
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch (err) { throw new Error('Tiedoston lataus epäonnistui'); }
if (!res.ok) throw new Error(data.error || 'Virhe');
}
} }
currentDocument = await apiCall(`document&id=${docId}`); currentDocument = await apiCall(`document&id=${docId}`);

View File

@@ -1599,11 +1599,57 @@ span.empty {
.doc-category.cat-ohje { background: #d1fae5; color: #065f46; } .doc-category.cat-ohje { background: #d1fae5; color: #065f46; }
.doc-category.cat-raportti { background: #ede9fe; color: #5b21b6; } .doc-category.cat-raportti { background: #ede9fe; color: #5b21b6; }
.doc-category.cat-muu { background: #f3f4f6; color: #374151; } .doc-category.cat-muu { background: #f3f4f6; color: #374151; }
.doc-category.cat-kokousmuistio { background: #fce7f3; color: #9d174d; }
#doc-versions-table tbody tr:hover { #doc-versions-table tbody tr:hover {
background: #f0f7ff !important; background: #f0f7ff !important;
} }
/* Dokumenttikansiot */
.doc-folder-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.85rem;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
color: #374151;
transition: background 0.15s, border-color 0.15s;
}
.doc-folder-item:hover { background: #e5e7eb; }
#doc-breadcrumbs a { color: var(--primary-color); text-decoration: none; font-weight: 500; }
#doc-breadcrumbs a:hover { text-decoration: underline; }
#doc-breadcrumbs .bc-sep { color: #aaa; margin: 0 0.25rem; }
#doc-inline-content {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 1rem;
font-size: 0.95rem;
resize: vertical;
width: 100%;
font-family: inherit;
line-height: 1.6;
}
/* Asiakasprofiilin dokumentit */
.customer-doc-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid #f0f0f0;
font-size: 0.85rem;
}
.customer-doc-item:last-child { border-bottom: none; }
.customer-doc-item a { color: var(--primary-color); text-decoration: none; font-weight: 500; }
.customer-doc-item a:hover { text-decoration: underline; }
/* ==================== LAITETILAT ==================== */ /* ==================== LAITETILAT ==================== */
.laitetilat-grid { .laitetilat-grid {
display: grid; display: grid;