Versioiva dokumentinhallinta + Laitetilat-moduuli

Dokumentit: versioiva tiedostonhallinta asiakkaille (sopimukset, laskut, ohjeet).
Sisältää versiohistorian, tiedostojen latauksen/palautuksen ja asiakas-suodatuksen.

Laitetilat: laitetilojen hallinta kuvagallerialla ja tiedostolistauksella.
Sisältää korttipohjaisen listanäkymän, kuvien esikatselun ja tiedostojen hallinnan.

Molemmat moduulit: 4 DB-taulua, 14 API-endpointtia, täysi CRUD, tiedostoupload.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 15:18:32 +02:00
parent 093f40ac09
commit e6fa65165e
5 changed files with 1612 additions and 2 deletions

520
script.js
View File

@@ -200,7 +200,7 @@ async function showDashboard() {
// Avaa oikea tabi URL-hashin perusteella (tai customers oletuks)
const hash = window.location.hash.replace('#', '');
const [mainHash, subHash] = hash.split('/');
const validTabs = ['customers', 'leads', 'tekniikka', 'ohjeet', 'todo', 'archive', 'changelog', 'support', 'users', 'settings', 'companies'];
const validTabs = ['customers', 'leads', 'tekniikka', 'ohjeet', 'todo', 'documents', 'laitetilat', 'archive', 'changelog', 'support', 'users', 'settings', 'companies'];
const startTab = validTabs.includes(mainHash) ? mainHash : 'customers';
switchToTab(startTab, subHash);
}
@@ -263,6 +263,8 @@ 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 === 'laitetilat') { loadLaitetilat(); showLaitetilatListView(); }
if (target === 'users') loadUsers();
if (target === 'settings') loadSettings();
if (target === 'companies') loadCompaniesTab();
@@ -4392,9 +4394,523 @@ document.getElementById('btn-time-cancel')?.addEventListener('click', () => {
});
document.getElementById('btn-time-save')?.addEventListener('click', () => addTimeEntry());
// ==================== DOKUMENTIT ====================
let allDocuments = [];
let currentDocument = null;
const docCategoryLabels = {
sopimus: 'Sopimus',
lasku: 'Lasku',
ohje: 'Ohje',
raportti: 'Raportti',
muu: 'Muu'
};
function showDocsListView() {
document.getElementById('docs-list-view').style.display = '';
document.getElementById('doc-read-view').style.display = 'none';
document.getElementById('doc-edit-view').style.display = 'none';
}
function showDocReadView() {
document.getElementById('docs-list-view').style.display = 'none';
document.getElementById('doc-read-view').style.display = '';
document.getElementById('doc-edit-view').style.display = 'none';
}
function showDocEditView() {
document.getElementById('docs-list-view').style.display = 'none';
document.getElementById('doc-read-view').style.display = 'none';
document.getElementById('doc-edit-view').style.display = '';
}
async function loadDocuments() {
try {
allDocuments = await apiCall('documents');
populateDocCustomerFilter();
renderDocumentsList();
} catch (e) { console.error('Dokumenttien lataus epäonnistui:', e); }
}
function populateDocCustomerFilter() {
const sel = document.getElementById('doc-filter-customer');
const existing = sel.value;
// Kerää uniikki lista asiakkaista
const customerMap = {};
allDocuments.forEach(d => {
if (d.customer_id) {
customerMap[d.customer_id] = d.customer_id; // käytetään myöhemmin nimeä jos saatavilla
}
});
// Käytä customers-listaa nimien näyttämiseen
sel.innerHTML = '<option value="">Kaikki asiakkaat</option>';
if (typeof customers !== 'undefined' && customers.length > 0) {
customers.forEach(c => {
sel.innerHTML += `<option value="${c.id}">${esc(c.yritys)}</option>`;
});
} else {
Object.keys(customerMap).forEach(id => {
sel.innerHTML += `<option value="${id}">${id}</option>`;
});
}
sel.value = existing || '';
}
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;
if (query) {
filtered = filtered.filter(d =>
(d.title || '').toLowerCase().includes(query) ||
(d.description || '').toLowerCase().includes(query)
);
}
if (filterCustomer) {
filtered = filtered.filter(d => d.customer_id === filterCustomer);
}
if (filterCategory) {
filtered = filtered.filter(d => d.category === filterCategory);
}
const tbody = document.getElementById('docs-tbody');
const noDocsEl = document.getElementById('no-docs');
if (filtered.length === 0) {
tbody.innerHTML = '';
noDocsEl.style.display = '';
return;
}
noDocsEl.style.display = 'none';
// Hae asiakasnimien map
const customerNameMap = {};
if (typeof customers !== 'undefined') {
customers.forEach(c => { customerNameMap[c.id] = c.yritys; });
}
tbody.innerHTML = filtered.map(d => {
const customerName = d.customer_id ? (customerNameMap[d.customer_id] || d.customer_id) : '<span style="color:#aaa;">Yleinen</span>';
const catLabel = docCategoryLabels[d.category] || d.category || '-';
const version = d.current_version || 0;
const date = d.muokattu ? new Date(d.muokattu).toLocaleDateString('fi-FI') : '-';
const author = d.version_author || d.created_by || '-';
return `<tr onclick="openDocRead('${d.id}')" style="cursor:pointer;">
<td><strong>${esc(d.title)}</strong></td>
<td>${customerName}</td>
<td><span class="doc-category cat-${d.category || 'muu'}">${catLabel}</span></td>
<td style="text-align:center;">v${version}</td>
<td>${date}</td>
<td>${esc(author)}</td>
</tr>`;
}).join('');
}
document.getElementById('doc-search')?.addEventListener('input', renderDocumentsList);
document.getElementById('doc-filter-customer')?.addEventListener('change', renderDocumentsList);
document.getElementById('doc-filter-category')?.addEventListener('change', renderDocumentsList);
async function openDocRead(docId) {
try {
currentDocument = await apiCall(`document&id=${docId}`);
renderDocReadView();
showDocReadView();
} catch (e) { alert('Dokumentin avaus epäonnistui: ' + e.message); }
}
function renderDocReadView() {
const d = currentDocument;
if (!d) return;
// Asiakasnimen haku
let customerName = 'Ei asiakasta (yleinen)';
if (d.customer_id && typeof customers !== 'undefined') {
const c = customers.find(c => c.id === d.customer_id);
if (c) customerName = c.yritys;
}
document.getElementById('doc-read-title').textContent = d.title || '';
document.getElementById('doc-read-customer').textContent = '👤 ' + customerName;
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 || '';
// Admin-napit
const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'superadmin';
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';
// Versiohistoria
const vtbody = document.getElementById('doc-versions-tbody');
if (!d.versions || d.versions.length === 0) {
vtbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:#aaa;padding:1rem;">Ei versioita vielä.</td></tr>';
} else {
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;
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 class="actions-cell">
<a href="${API}?action=document_download&id=${d.id}&version=${v.version_number}" target="_blank" title="Lataa">⬇️</a>
${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>`;
}).join('');
}
}
// Latausnappi
document.getElementById('btn-doc-download')?.addEventListener('click', () => {
if (!currentDocument || !currentDocument.current_version) return;
window.open(`${API}?action=document_download&id=${currentDocument.id}&version=${currentDocument.current_version}`, '_blank');
});
// Uusi versio
document.getElementById('btn-doc-upload-version')?.addEventListener('click', async () => {
const fileInput = document.getElementById('doc-version-file');
const notesInput = document.getElementById('doc-version-notes');
if (!fileInput.files.length) { alert('Valitse tiedosto'); return; }
if (!currentDocument) return;
const fd = new FormData();
fd.append('document_id', currentDocument.id);
fd.append('file', fileInput.files[0]);
fd.append('change_notes', notesInput.value || '');
try {
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 (e) { throw new Error('Palvelin palautti virheellisen vastauksen'); }
if (!res.ok) throw new Error(data.error || 'Virhe');
currentDocument = data;
renderDocReadView();
fileInput.value = '';
notesInput.value = '';
} catch (e) { alert('Tiedoston lataus epäonnistui: ' + e.message); }
});
async function restoreDocVersion(docId, versionId, versionNum) {
if (!confirm(`Palautetaanko versio ${versionNum}? Siitä tulee uusi nykyinen versio.`)) return;
try {
currentDocument = await apiCall('document_restore', 'POST', { document_id: docId, version_id: versionId });
renderDocReadView();
} catch (e) { alert('Palautus epäonnistui: ' + e.message); }
}
// Poista dokumentti
document.getElementById('btn-doc-delete')?.addEventListener('click', async () => {
if (!currentDocument) return;
if (!confirm(`Poistetaanko dokumentti "${currentDocument.title}" ja kaikki sen versiot?`)) return;
try {
await apiCall('document_delete', 'POST', { id: currentDocument.id });
currentDocument = null;
showDocsListView();
loadDocuments();
} catch (e) { alert('Poisto epäonnistui: ' + e.message); }
});
// Navigaatio
document.getElementById('btn-doc-back')?.addEventListener('click', () => { showDocsListView(); });
document.getElementById('btn-doc-edit')?.addEventListener('click', () => { openDocEdit(currentDocument); });
document.getElementById('btn-doc-edit-back')?.addEventListener('click', () => {
if (currentDocument) showDocReadView();
else showDocsListView();
});
document.getElementById('btn-doc-edit-cancel')?.addEventListener('click', () => {
if (currentDocument) showDocReadView();
else showDocsListView();
});
// Uusi dokumentti
document.getElementById('btn-new-document')?.addEventListener('click', () => { openDocEdit(null); });
function openDocEdit(doc) {
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';
// 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>`;
});
}
if (doc?.customer_id) custSel.value = doc.customer_id;
// Piilota tiedostokenttä muokkaustilassa (versiot hoidetaan read-viewissä)
document.getElementById('doc-edit-file').parentElement.style.display = doc ? 'none' : '';
showDocEditView();
}
// Lomakkeen lähetys
document.getElementById('doc-edit-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('doc-edit-id').value;
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,
customer_id: document.getElementById('doc-edit-customer').value || null,
created_by: currentUser?.username || ''
};
if (!docData.title) { alert('Otsikko on pakollinen'); return; }
try {
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');
}
currentDocument = await apiCall(`document&id=${docId}`);
renderDocReadView();
showDocReadView();
loadDocuments();
} catch (e) { alert('Tallennus epäonnistui: ' + e.message); }
});
// ==================== LAITETILAT ====================
let allLaitetilat = [];
let currentLaitetila = null;
function showLaitetilatListView() {
document.getElementById('laitetilat-list-view').style.display = '';
document.getElementById('laitetila-read-view').style.display = 'none';
document.getElementById('laitetila-edit-view').style.display = 'none';
}
function showLaitetilaReadView() {
document.getElementById('laitetilat-list-view').style.display = 'none';
document.getElementById('laitetila-read-view').style.display = '';
document.getElementById('laitetila-edit-view').style.display = 'none';
}
function showLaitetilaEditView() {
document.getElementById('laitetilat-list-view').style.display = 'none';
document.getElementById('laitetila-read-view').style.display = 'none';
document.getElementById('laitetila-edit-view').style.display = '';
}
async function loadLaitetilat() {
try {
allLaitetilat = await apiCall('laitetilat');
renderLaitetilatList();
} catch (e) { console.error('Laitetilojen lataus epäonnistui:', e); }
}
function renderLaitetilatList() {
const grid = document.getElementById('laitetilat-grid');
const noEl = document.getElementById('no-laitetilat');
if (allLaitetilat.length === 0) {
grid.innerHTML = '';
noEl.style.display = '';
return;
}
noEl.style.display = 'none';
grid.innerHTML = allLaitetilat.map(t => `
<div class="laitetila-card" onclick="openLaitetilaRead('${t.id}')">
<h4>${esc(t.nimi)}</h4>
<p class="laitetila-osoite">${esc(t.osoite || '')}</p>
<p class="laitetila-meta">📁 ${t.file_count || 0} tiedostoa</p>
</div>
`).join('');
}
async function openLaitetilaRead(tilaId) {
try {
currentLaitetila = await apiCall(`laitetila&id=${tilaId}`);
renderLaitetilaReadView();
showLaitetilaReadView();
} catch (e) { alert('Laitetilan avaus epäonnistui: ' + e.message); }
}
function renderLaitetilaReadView() {
const t = currentLaitetila;
if (!t) return;
document.getElementById('laitetila-read-nimi').textContent = t.nimi || '';
document.getElementById('laitetila-read-osoite').textContent = t.osoite ? '📍 ' + t.osoite : '';
document.getElementById('laitetila-read-kuvaus').textContent = t.kuvaus || '';
const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'superadmin';
document.getElementById('btn-laitetila-delete').style.display = isAdmin ? '' : 'none';
// Erota kuvat ja muut tiedostot
const files = t.files || [];
const images = files.filter(f => (f.mime_type || '').startsWith('image/'));
const otherFiles = files.filter(f => !(f.mime_type || '').startsWith('image/'));
// Kuvagalleria
const gallerySection = document.getElementById('laitetila-gallery');
const galleryGrid = document.getElementById('laitetila-gallery-grid');
if (images.length > 0) {
gallerySection.style.display = '';
galleryGrid.innerHTML = images.map(f => {
const imgUrl = `${API}?action=laitetila_file_download&laitetila_id=${t.id}&file_id=${f.id}`;
return `<div class="gallery-item">
<img src="${imgUrl}" alt="${esc(f.original_name)}" onclick="window.open('${imgUrl}', '_blank')" title="Klikkaa avataksesi">
<div class="gallery-caption">
<span>${esc(f.original_name)}</span>
${isAdmin ? `<button onclick="deleteLaitetilaFile('${f.id}')" class="btn-icon" title="Poista">🗑</button>` : ''}
</div>
</div>`;
}).join('');
} else {
gallerySection.style.display = 'none';
}
// Muut tiedostot
const filesSection = document.getElementById('laitetila-files-section');
const filesList = document.getElementById('laitetila-files-list');
if (otherFiles.length > 0) {
filesSection.style.display = '';
filesList.innerHTML = otherFiles.map(f => {
const dlUrl = `${API}?action=laitetila_file_download&laitetila_id=${t.id}&file_id=${f.id}`;
return `<div class="laitetila-file-item">
<div>
<a href="${dlUrl}" target="_blank" class="file-name">${esc(f.original_name)}</a>
<span class="file-meta">${formatFileSize(f.file_size || 0)} · ${f.luotu ? new Date(f.luotu).toLocaleDateString('fi-FI') : ''}</span>
${f.description ? `<span class="file-desc">${esc(f.description)}</span>` : ''}
</div>
${isAdmin ? `<button onclick="deleteLaitetilaFile('${f.id}')" class="btn-icon" title="Poista">🗑</button>` : ''}
</div>`;
}).join('');
} else {
filesSection.style.display = 'none';
}
}
// Tiedoston lataus
document.getElementById('btn-laitetila-upload')?.addEventListener('click', async () => {
const fileInput = document.getElementById('laitetila-file-input');
const descInput = document.getElementById('laitetila-file-desc');
if (!fileInput.files.length) { alert('Valitse tiedosto'); return; }
if (!currentLaitetila) return;
for (const file of fileInput.files) {
const fd = new FormData();
fd.append('laitetila_id', currentLaitetila.id);
fd.append('file', file);
fd.append('description', descInput.value || '');
try {
const res = await fetch(`${API}?action=laitetila_file_upload`, { method: 'POST', credentials: 'include', body: fd });
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch (e) { throw new Error('Palvelin palautti virheellisen vastauksen'); }
if (!res.ok) throw new Error(data.error || 'Virhe');
currentLaitetila = data;
} catch (e) { alert('Tiedoston lataus epäonnistui: ' + e.message); }
}
renderLaitetilaReadView();
fileInput.value = '';
descInput.value = '';
});
async function deleteLaitetilaFile(fileId) {
if (!confirm('Poistetaanko tiedosto?')) return;
try {
await apiCall('laitetila_file_delete', 'POST', { id: fileId });
// Päivitä näkymä
currentLaitetila = await apiCall(`laitetila&id=${currentLaitetila.id}`);
renderLaitetilaReadView();
} catch (e) { alert('Poisto epäonnistui: ' + e.message); }
}
// Navigaatio
document.getElementById('btn-laitetila-back')?.addEventListener('click', () => { showLaitetilatListView(); });
document.getElementById('btn-laitetila-edit')?.addEventListener('click', () => { openLaitetilaEdit(currentLaitetila); });
document.getElementById('btn-laitetila-edit-back')?.addEventListener('click', () => {
if (currentLaitetila) showLaitetilaReadView();
else showLaitetilatListView();
});
document.getElementById('btn-laitetila-edit-cancel')?.addEventListener('click', () => {
if (currentLaitetila) showLaitetilaReadView();
else showLaitetilatListView();
});
// Poista laitetila
document.getElementById('btn-laitetila-delete')?.addEventListener('click', async () => {
if (!currentLaitetila) return;
if (!confirm(`Poistetaanko laitetila "${currentLaitetila.nimi}" ja kaikki sen tiedostot?`)) return;
try {
await apiCall('laitetila_delete', 'POST', { id: currentLaitetila.id });
currentLaitetila = null;
showLaitetilatListView();
loadLaitetilat();
} catch (e) { alert('Poisto epäonnistui: ' + e.message); }
});
// Uusi laitetila
document.getElementById('btn-new-laitetila')?.addEventListener('click', () => { openLaitetilaEdit(null); });
function openLaitetilaEdit(tila) {
document.getElementById('laitetila-edit-id').value = tila?.id || '';
document.getElementById('laitetila-edit-nimi').value = tila?.nimi || '';
document.getElementById('laitetila-edit-osoite').value = tila?.osoite || '';
document.getElementById('laitetila-edit-kuvaus').value = tila?.kuvaus || '';
document.getElementById('laitetila-edit-title').textContent = tila ? 'Muokkaa laitetilaa' : 'Uusi laitetila';
showLaitetilaEditView();
}
// Lomakkeen lähetys
document.getElementById('laitetila-edit-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('laitetila-edit-id').value;
const tilaData = {
id: id || undefined,
nimi: document.getElementById('laitetila-edit-nimi').value.trim(),
osoite: document.getElementById('laitetila-edit-osoite').value.trim(),
kuvaus: document.getElementById('laitetila-edit-kuvaus').value.trim()
};
if (!tilaData.nimi) { alert('Nimi on pakollinen'); return; }
try {
const saved = await apiCall('laitetila_save', 'POST', tilaData);
currentLaitetila = saved;
renderLaitetilaReadView();
showLaitetilaReadView();
loadLaitetilat();
} catch (e) { alert('Tallennus epäonnistui: ' + e.message); }
});
// ==================== MODUULIT ====================
const ALL_MODULES = ['customers', 'support', 'leads', 'tekniikka', 'ohjeet', 'todo', 'archive', 'changelog', 'settings'];
const ALL_MODULES = ['customers', 'support', 'leads', 'tekniikka', 'ohjeet', 'todo', 'documents', 'laitetilat', 'archive', 'changelog', 'settings'];
const DEFAULT_MODULES = ['customers', 'support', 'archive', 'changelog', 'settings'];
function applyModules(modules) {