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

335
script.js
View File

@@ -284,7 +284,7 @@ function switchToTab(target, subTab) {
if (target === 'ohjeet') loadGuides();
if (target === 'todo') { loadTodos(); if (subTab) switchTodoSubTab(subTab); }
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 === 'netadmin') loadNetadmin();
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>
</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>`;
// Synkronoi prices-hidden tila detail-modaliin
@@ -604,6 +609,7 @@ function showDetail(id) {
detailModal.querySelector('.modal-content')?.classList.toggle('prices-hidden', !!pricesHidden);
detailModal.style.display = 'flex';
loadFiles(id);
loadCustomerDocuments(id);
document.getElementById('file-upload-input').addEventListener('change', async function () {
for (const file of this.files) {
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) {
const fileList = document.getElementById('file-list');
if (!fileList) return;
@@ -4688,12 +4732,16 @@ function openFeatureSuggestion() {
let allDocuments = [];
let currentDocument = null;
let allDocFolders = [];
let currentDocFolderId = null; // null = root (kaikki)
let docSubTabMode = 'docs-all'; // 'docs-all' | 'docs-kokoukset'
const docCategoryLabels = {
sopimus: 'Sopimus',
lasku: 'Lasku',
ohje: 'Ohje',
raportti: 'Raportti',
kokousmuistio: 'Kokousmuistio',
muu: 'Muu'
};
@@ -4718,7 +4766,9 @@ function showDocEditView() {
async function loadDocuments() {
try {
allDocuments = await apiCall('documents');
try { allDocFolders = await apiCall('document_folders'); } catch (e2) { allDocFolders = []; }
populateDocCustomerFilter();
renderDocFolderBar();
renderDocumentsList();
} catch (e) { console.error('Dokumenttien lataus epäonnistui:', e); }
}
@@ -4747,12 +4797,107 @@ function populateDocCustomerFilter() {
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() {
const query = (document.getElementById('doc-search')?.value || '').toLowerCase().trim();
const filterCustomer = document.getElementById('doc-filter-customer')?.value || '';
const filterCategory = document.getElementById('doc-filter-category')?.value || '';
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) {
filtered = filtered.filter(d =>
(d.title || '').toLowerCase().includes(query) ||
@@ -4762,7 +4907,7 @@ function renderDocumentsList() {
if (filterCustomer) {
filtered = filtered.filter(d => d.customer_id === filterCustomer);
}
if (filterCategory) {
if (filterCategory && docSubTabMode !== 'docs-kokoukset') {
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-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-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
const isAdmin = isCurrentUserAdmin();
document.getElementById('btn-doc-delete').style.display = isAdmin ? '' : 'none';
// Latausnappi - piilota jos ei versioita
document.getElementById('btn-doc-download').style.display = (d.current_version && d.current_version > 0) ? '' : 'none';
// Kokousmuistio vs tiedostopohjainen
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
const vtbody = document.getElementById('doc-versions-tbody');
@@ -4844,14 +5015,18 @@ function renderDocReadView() {
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 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;"' : ''}>
<td style="font-weight:600;">v${v.version_number}${isCurrent ? ' ✓' : ''}</td>
<td>${date}</td>
<td>${esc(v.created_by || '-')}</td>
<td>${esc(v.change_notes || '-')}</td>
<td>${formatFileSize(v.file_size || 0)}</td>
<td>${sizeDisplay}</td>
<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>` : ''}
</td>
</tr>`;
@@ -4924,40 +5099,134 @@ document.getElementById('btn-doc-edit-cancel')?.addEventListener('click', () =>
// Uusi dokumentti
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-name').value = doc?.title || '';
document.getElementById('doc-edit-description').value = doc?.description || '';
document.getElementById('doc-edit-category').value = doc?.category || 'muu';
document.getElementById('doc-edit-title').textContent = doc ? 'Muokkaa dokumenttia' : 'Uusi dokumentti';
const cat = forceCategory || doc?.category || 'muu';
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
const custSel = document.getElementById('doc-edit-customer');
custSel.innerHTML = '<option value="">Ei asiakasta (yleinen)</option>';
if (typeof customers !== 'undefined') {
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ä)
document.getElementById('doc-edit-file').parentElement.style.display = doc ? 'none' : '';
if (!isMeeting) {
document.getElementById('doc-edit-file').parentElement.style.display = doc ? 'none' : '';
}
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
document.getElementById('doc-edit-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('doc-edit-id').value;
const cat = document.getElementById('doc-edit-category').value;
const isMeeting = cat === 'kokousmuistio';
const docData = {
id: id || undefined,
title: document.getElementById('doc-edit-name').value.trim(),
description: document.getElementById('doc-edit-description').value.trim(),
category: document.getElementById('doc-edit-category').value,
description: isMeeting
? 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,
folder_id: document.getElementById('doc-edit-folder-id').value || null,
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 docId = saved.id;
// 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');
if (isMeeting) {
// Tallenna kokousmuistion sisältö ensimmäisenä versiona
const content = document.getElementById('doc-edit-content').value;
if (content || !id) {
await apiCall('document_content_save', 'POST', {
document_id: docId,
content: content,
change_notes: id ? 'Muistiota päivitetty' : 'Ensimmäinen versio'
});
}
} else {
// 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}`);