diff --git a/api.php b/api.php
index 2f09f3d..32fc7ab 100644
--- a/api.php
+++ b/api.php
@@ -3994,6 +3994,453 @@ switch ($action) {
echo json_encode(['success' => true]);
break;
+ // ==================== DOKUMENTIT ====================
+
+ case 'documents':
+ requireAuth();
+ $companyId = requireCompany();
+ try {
+ $customerId = $_GET['customer_id'] ?? null;
+ $docs = dbLoadDocuments($companyId, $customerId ?: null);
+ echo json_encode($docs);
+ } catch (Exception $e) {
+ http_response_code(500);
+ echo json_encode(['error' => 'Dokumenttien haku epäonnistui: ' . $e->getMessage()]);
+ }
+ break;
+
+ case 'document':
+ requireAuth();
+ $companyId = requireCompany();
+ try {
+ $docId = $_GET['id'] ?? '';
+ if (empty($docId)) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Dokumentin ID puuttuu']);
+ break;
+ }
+ $doc = dbLoadDocument($docId);
+ if (!$doc || $doc['company_id'] !== $companyId) {
+ http_response_code(404);
+ echo json_encode(['error' => 'Dokumenttia ei löytynyt']);
+ break;
+ }
+ echo json_encode($doc);
+ } catch (Exception $e) {
+ http_response_code(500);
+ echo json_encode(['error' => 'Dokumentin haku epäonnistui: ' . $e->getMessage()]);
+ }
+ break;
+
+ case 'document_save':
+ requireAuth();
+ $companyId = requireCompany();
+ if ($method !== 'POST') break;
+ try {
+ $input = json_decode(file_get_contents('php://input'), true);
+ $input['created_by'] = $input['created_by'] ?? currentUser();
+ $input['muokkaaja'] = currentUser();
+ $id = dbSaveDocument($companyId, $input);
+ $doc = dbLoadDocument($id);
+ echo json_encode($doc);
+ } catch (Exception $e) {
+ http_response_code(500);
+ echo json_encode(['error' => 'Dokumentin tallennus epäonnistui: ' . $e->getMessage()]);
+ }
+ break;
+
+ case 'document_upload':
+ requireAuth();
+ $companyId = requireCompany();
+ if ($method !== 'POST') break;
+ try {
+ $docId = $_POST['document_id'] ?? '';
+ if (empty($docId)) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Dokumentin ID puuttuu']);
+ break;
+ }
+ // Tarkista dokumentin olemassaolo
+ $doc = dbLoadDocument($docId);
+ if (!$doc || $doc['company_id'] !== $companyId) {
+ http_response_code(404);
+ echo json_encode(['error' => 'Dokumenttia ei löytynyt']);
+ break;
+ }
+ if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Tiedosto puuttuu tai lähetys epäonnistui']);
+ break;
+ }
+ $file = $_FILES['file'];
+ // Max 50MB
+ if ($file['size'] > 50 * 1024 * 1024) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Tiedosto on liian suuri (max 50MB)']);
+ break;
+ }
+ // Luo hakemisto
+ $docDir = DATA_DIR . '/companies/' . $companyId . '/documents/' . $docId;
+ if (!file_exists($docDir)) mkdir($docDir, 0755, true);
+
+ $nextVersion = (int)$doc['current_version'] + 1;
+ $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
+ $safeFilename = $nextVersion . '_' . preg_replace('/[^a-zA-Z0-9._-]/', '_', $file['name']);
+
+ move_uploaded_file($file['tmp_name'], $docDir . '/' . $safeFilename);
+
+ $mimeType = '';
+ if (function_exists('finfo_open')) {
+ $finfo = finfo_open(FILEINFO_MIME_TYPE);
+ $mimeType = finfo_file($finfo, $docDir . '/' . $safeFilename);
+ finfo_close($finfo);
+ }
+
+ dbAddDocumentVersion($docId, [
+ 'filename' => $safeFilename,
+ 'original_name' => $file['name'],
+ 'file_size' => $file['size'],
+ 'mime_type' => $mimeType ?: ($file['type'] ?? ''),
+ 'change_notes' => $_POST['change_notes'] ?? '',
+ 'created_by' => currentUser()
+ ]);
+
+ $updatedDoc = dbLoadDocument($docId);
+ echo json_encode($updatedDoc);
+ } catch (Exception $e) {
+ http_response_code(500);
+ echo json_encode(['error' => 'Tiedoston lataus epäonnistui: ' . $e->getMessage()]);
+ }
+ break;
+
+ case 'document_download':
+ requireAuth();
+ $companyId = requireCompany();
+ try {
+ $docId = $_GET['id'] ?? '';
+ $versionNum = (int)($_GET['version'] ?? 0);
+ if (empty($docId)) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Dokumentin ID puuttuu']);
+ break;
+ }
+ $doc = dbLoadDocument($docId);
+ if (!$doc || $doc['company_id'] !== $companyId) {
+ http_response_code(404);
+ echo json_encode(['error' => 'Dokumenttia ei löytynyt']);
+ break;
+ }
+ // Jos versiota ei annettu, käytä nykyistä
+ if ($versionNum <= 0) $versionNum = (int)$doc['current_version'];
+ $version = dbGetDocumentVersion($docId, $versionNum);
+ if (!$version) {
+ http_response_code(404);
+ echo json_encode(['error' => 'Versiota ei löytynyt']);
+ break;
+ }
+ $filePath = DATA_DIR . '/companies/' . $companyId . '/documents/' . $docId . '/' . $version['filename'];
+ if (!file_exists($filePath)) {
+ http_response_code(404);
+ echo json_encode(['error' => 'Tiedostoa ei löytynyt palvelimelta']);
+ break;
+ }
+ header('Content-Type: ' . ($version['mime_type'] ?: 'application/octet-stream'));
+ header('Content-Disposition: attachment; filename="' . $version['original_name'] . '"');
+ header('Content-Length: ' . filesize($filePath));
+ readfile($filePath);
+ exit;
+ } catch (Exception $e) {
+ http_response_code(500);
+ echo json_encode(['error' => 'Lataus epäonnistui: ' . $e->getMessage()]);
+ }
+ break;
+
+ case 'document_restore':
+ requireAdmin();
+ $companyId = requireCompany();
+ if ($method !== 'POST') break;
+ try {
+ $input = json_decode(file_get_contents('php://input'), true);
+ $docId = $input['document_id'] ?? '';
+ $versionId = $input['version_id'] ?? '';
+ if (empty($docId) || empty($versionId)) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Parametrit puuttuvat']);
+ break;
+ }
+ $doc = dbLoadDocument($docId);
+ if (!$doc || $doc['company_id'] !== $companyId) {
+ http_response_code(404);
+ echo json_encode(['error' => 'Dokumenttia ei löytynyt']);
+ break;
+ }
+ // Hae vanha versio ja kopioi tiedosto
+ $oldVersion = null;
+ foreach ($doc['versions'] as $v) {
+ if ($v['id'] === $versionId) { $oldVersion = $v; break; }
+ }
+ if (!$oldVersion) {
+ http_response_code(404);
+ echo json_encode(['error' => 'Versiota ei löytynyt']);
+ break;
+ }
+ // Kopioi tiedosto uudella nimellä
+ $docDir = DATA_DIR . '/companies/' . $companyId . '/documents/' . $docId;
+ $oldFilePath = $docDir . '/' . $oldVersion['filename'];
+ if (file_exists($oldFilePath)) {
+ $nextVersion = (int)$doc['current_version'] + 1;
+ $newFilename = $nextVersion . '_' . $oldVersion['original_name'];
+ $newFilename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $newFilename);
+ copy($oldFilePath, $docDir . '/' . $newFilename);
+ }
+ $result = dbRestoreDocumentVersion($docId, $versionId, currentUser());
+ if ($result === null) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Palautus epäonnistui']);
+ break;
+ }
+ $updatedDoc = dbLoadDocument($docId);
+ echo json_encode($updatedDoc);
+ } catch (Exception $e) {
+ http_response_code(500);
+ echo json_encode(['error' => 'Palautus epäonnistui: ' . $e->getMessage()]);
+ }
+ break;
+
+ case 'document_delete':
+ requireAdmin();
+ $companyId = requireCompany();
+ if ($method !== 'POST') break;
+ try {
+ $input = json_decode(file_get_contents('php://input'), true);
+ $docId = $input['id'] ?? '';
+ $doc = dbLoadDocument($docId);
+ if (!$doc || $doc['company_id'] !== $companyId) {
+ http_response_code(404);
+ echo json_encode(['error' => 'Dokumenttia ei löytynyt']);
+ break;
+ }
+ // Poista tiedostot levyltä
+ $docDir = DATA_DIR . '/companies/' . $companyId . '/documents/' . $docId;
+ if (is_dir($docDir)) {
+ $files = glob($docDir . '/*');
+ foreach ($files as $f) { if (is_file($f)) unlink($f); }
+ rmdir($docDir);
+ }
+ dbDeleteDocument($docId);
+ echo json_encode(['success' => true]);
+ } catch (Exception $e) {
+ http_response_code(500);
+ echo json_encode(['error' => 'Poisto epäonnistui: ' . $e->getMessage()]);
+ }
+ break;
+
+ // ==================== LAITETILAT ====================
+
+ case 'laitetilat':
+ requireAuth();
+ $companyId = requireCompany();
+ try {
+ $tilat = dbLoadLaitetilat($companyId);
+ echo json_encode($tilat);
+ } catch (Exception $e) {
+ http_response_code(500);
+ echo json_encode(['error' => 'Laitetilojen haku epäonnistui: ' . $e->getMessage()]);
+ }
+ break;
+
+ case 'laitetila':
+ requireAuth();
+ $companyId = requireCompany();
+ try {
+ $tilaId = $_GET['id'] ?? '';
+ if (empty($tilaId)) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Laitetilan ID puuttuu']);
+ break;
+ }
+ $tila = dbLoadLaitetila($tilaId);
+ if (!$tila || $tila['company_id'] !== $companyId) {
+ http_response_code(404);
+ echo json_encode(['error' => 'Laitetilaa ei löytynyt']);
+ break;
+ }
+ echo json_encode($tila);
+ } catch (Exception $e) {
+ http_response_code(500);
+ echo json_encode(['error' => 'Laitetilan haku epäonnistui: ' . $e->getMessage()]);
+ }
+ break;
+
+ case 'laitetila_save':
+ requireAuth();
+ $companyId = requireCompany();
+ if ($method !== 'POST') break;
+ try {
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (empty($input['nimi'])) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Laitetilan nimi puuttuu']);
+ break;
+ }
+ $input['muokkaaja'] = currentUser();
+ $id = dbSaveLaitetila($companyId, $input);
+ $tila = dbLoadLaitetila($id);
+ echo json_encode($tila);
+ } catch (Exception $e) {
+ http_response_code(500);
+ echo json_encode(['error' => 'Laitetilan tallennus epäonnistui: ' . $e->getMessage()]);
+ }
+ break;
+
+ case 'laitetila_delete':
+ requireAdmin();
+ $companyId = requireCompany();
+ if ($method !== 'POST') break;
+ try {
+ $input = json_decode(file_get_contents('php://input'), true);
+ $tilaId = $input['id'] ?? '';
+ $tila = dbLoadLaitetila($tilaId);
+ if (!$tila || $tila['company_id'] !== $companyId) {
+ http_response_code(404);
+ echo json_encode(['error' => 'Laitetilaa ei löytynyt']);
+ break;
+ }
+ // Poista tiedostot levyltä
+ $tilaDir = DATA_DIR . '/companies/' . $companyId . '/laitetilat/' . $tilaId;
+ if (is_dir($tilaDir)) {
+ $files = glob($tilaDir . '/*');
+ foreach ($files as $f) { if (is_file($f)) unlink($f); }
+ rmdir($tilaDir);
+ }
+ dbDeleteLaitetila($tilaId);
+ echo json_encode(['success' => true]);
+ } catch (Exception $e) {
+ http_response_code(500);
+ echo json_encode(['error' => 'Poisto epäonnistui: ' . $e->getMessage()]);
+ }
+ break;
+
+ case 'laitetila_file_upload':
+ requireAuth();
+ $companyId = requireCompany();
+ if ($method !== 'POST') break;
+ try {
+ $tilaId = $_POST['laitetila_id'] ?? '';
+ if (empty($tilaId)) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Laitetilan ID puuttuu']);
+ break;
+ }
+ $tila = dbLoadLaitetila($tilaId);
+ if (!$tila || $tila['company_id'] !== $companyId) {
+ http_response_code(404);
+ echo json_encode(['error' => 'Laitetilaa ei löytynyt']);
+ break;
+ }
+ if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Tiedosto puuttuu tai lähetys epäonnistui']);
+ break;
+ }
+ $file = $_FILES['file'];
+ if ($file['size'] > 50 * 1024 * 1024) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Tiedosto on liian suuri (max 50MB)']);
+ break;
+ }
+ $tilaDir = DATA_DIR . '/companies/' . $companyId . '/laitetilat/' . $tilaId;
+ if (!file_exists($tilaDir)) mkdir($tilaDir, 0755, true);
+
+ $safeFilename = time() . '_' . preg_replace('/[^a-zA-Z0-9._-]/', '_', $file['name']);
+ move_uploaded_file($file['tmp_name'], $tilaDir . '/' . $safeFilename);
+
+ $mimeType = '';
+ if (function_exists('finfo_open')) {
+ $finfo = finfo_open(FILEINFO_MIME_TYPE);
+ $mimeType = finfo_file($finfo, $tilaDir . '/' . $safeFilename);
+ finfo_close($finfo);
+ }
+
+ dbAddLaitetilaFile($tilaId, [
+ 'filename' => $safeFilename,
+ 'original_name' => $file['name'],
+ 'file_size' => $file['size'],
+ 'mime_type' => $mimeType ?: ($file['type'] ?? ''),
+ 'description' => $_POST['description'] ?? '',
+ 'created_by' => currentUser()
+ ]);
+
+ $updatedTila = dbLoadLaitetila($tilaId);
+ echo json_encode($updatedTila);
+ } catch (Exception $e) {
+ http_response_code(500);
+ echo json_encode(['error' => 'Tiedoston lataus epäonnistui: ' . $e->getMessage()]);
+ }
+ break;
+
+ case 'laitetila_file_delete':
+ requireAdmin();
+ $companyId = requireCompany();
+ if ($method !== 'POST') break;
+ try {
+ $input = json_decode(file_get_contents('php://input'), true);
+ $fileId = $input['id'] ?? '';
+ $file = dbDeleteLaitetilaFile($fileId);
+ if ($file) {
+ $filePath = DATA_DIR . '/companies/' . $file['company_id'] . '/laitetilat/' . $file['laitetila_id'] . '/' . $file['filename'];
+ if (file_exists($filePath)) unlink($filePath);
+ }
+ echo json_encode(['success' => true]);
+ } catch (Exception $e) {
+ http_response_code(500);
+ echo json_encode(['error' => 'Tiedoston poisto epäonnistui: ' . $e->getMessage()]);
+ }
+ break;
+
+ case 'laitetila_file_download':
+ requireAuth();
+ $companyId = requireCompany();
+ try {
+ $tilaId = $_GET['laitetila_id'] ?? '';
+ $fileId = $_GET['file_id'] ?? '';
+ if (empty($tilaId) || empty($fileId)) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Parametrit puuttuvat']);
+ break;
+ }
+ $tila = dbLoadLaitetila($tilaId);
+ if (!$tila || $tila['company_id'] !== $companyId) {
+ http_response_code(404);
+ echo json_encode(['error' => 'Laitetilaa ei löytynyt']);
+ break;
+ }
+ $targetFile = null;
+ foreach ($tila['files'] as $f) {
+ if ($f['id'] === $fileId) { $targetFile = $f; break; }
+ }
+ if (!$targetFile) {
+ http_response_code(404);
+ echo json_encode(['error' => 'Tiedostoa ei löytynyt']);
+ break;
+ }
+ $filePath = DATA_DIR . '/companies/' . $companyId . '/laitetilat/' . $tilaId . '/' . $targetFile['filename'];
+ if (!file_exists($filePath)) {
+ http_response_code(404);
+ echo json_encode(['error' => 'Tiedostoa ei löytynyt palvelimelta']);
+ break;
+ }
+ header('Content-Type: ' . ($targetFile['mime_type'] ?: 'application/octet-stream'));
+ header('Content-Disposition: attachment; filename="' . $targetFile['original_name'] . '"');
+ header('Content-Length: ' . filesize($filePath));
+ readfile($filePath);
+ exit;
+ } catch (Exception $e) {
+ http_response_code(500);
+ echo json_encode(['error' => 'Lataus epäonnistui: ' . $e->getMessage()]);
+ }
+ break;
+
default:
http_response_code(404);
echo json_encode(['error' => 'Tuntematon toiminto']);
diff --git a/db.php b/db.php
index e48d0a9..206433d 100644
--- a/db.php
+++ b/db.php
@@ -502,6 +502,65 @@ function initDatabase(): void {
FOREIGN KEY (todo_id) REFERENCES todos(id) ON DELETE CASCADE,
INDEX idx_todo (todo_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+
+ "CREATE TABLE IF NOT EXISTS documents (
+ id VARCHAR(20) PRIMARY KEY,
+ company_id VARCHAR(50) NOT NULL,
+ customer_id VARCHAR(20) DEFAULT NULL,
+ title VARCHAR(255) NOT NULL,
+ description TEXT DEFAULT '',
+ category VARCHAR(50) DEFAULT 'muu',
+ current_version INT DEFAULT 0,
+ created_by VARCHAR(100) DEFAULT '',
+ luotu DATETIME,
+ muokattu DATETIME,
+ muokkaaja VARCHAR(100) DEFAULT '',
+ FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
+ INDEX idx_company (company_id),
+ INDEX idx_customer (customer_id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+
+ "CREATE TABLE IF NOT EXISTS document_versions (
+ id VARCHAR(20) PRIMARY KEY,
+ document_id VARCHAR(20) NOT NULL,
+ version_number INT NOT NULL,
+ filename VARCHAR(255) NOT NULL,
+ original_name VARCHAR(255) NOT NULL,
+ file_size INT DEFAULT 0,
+ mime_type VARCHAR(100) DEFAULT '',
+ change_notes TEXT DEFAULT '',
+ created_by VARCHAR(100) DEFAULT '',
+ luotu DATETIME,
+ FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE,
+ INDEX idx_document (document_id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+
+ "CREATE TABLE IF NOT EXISTS laitetilat (
+ id VARCHAR(20) PRIMARY KEY,
+ company_id VARCHAR(50) NOT NULL,
+ nimi VARCHAR(255) NOT NULL,
+ kuvaus TEXT DEFAULT '',
+ osoite VARCHAR(255) DEFAULT '',
+ luotu DATETIME,
+ muokattu DATETIME,
+ 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 laitetila_files (
+ id VARCHAR(20) PRIMARY KEY,
+ laitetila_id VARCHAR(20) NOT NULL,
+ filename VARCHAR(255) NOT NULL,
+ original_name VARCHAR(255) NOT NULL,
+ file_size INT DEFAULT 0,
+ mime_type VARCHAR(100) DEFAULT '',
+ description VARCHAR(500) DEFAULT '',
+ created_by VARCHAR(100) DEFAULT '',
+ luotu DATETIME,
+ FOREIGN KEY (laitetila_id) REFERENCES laitetilat(id) ON DELETE CASCADE,
+ INDEX idx_laitetila (laitetila_id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
];
foreach ($tables as $i => $sql) {
@@ -1625,3 +1684,197 @@ function dbToggleTodoSubtask(string $subtaskId): bool {
function dbDeleteTodoSubtask(string $subtaskId): void {
_dbExecute("DELETE FROM todo_subtasks WHERE id = ?", [$subtaskId]);
}
+
+// ==================== DOKUMENTIT ====================
+
+function dbLoadDocuments(string $companyId, ?string $customerId = null): array {
+ $sql = "SELECT d.*,
+ dv.original_name AS current_file, dv.file_size AS current_size, dv.created_by AS version_author, dv.luotu AS version_date
+ FROM documents d
+ LEFT JOIN document_versions dv ON dv.document_id = d.id AND dv.version_number = d.current_version
+ WHERE d.company_id = :companyId";
+ $params = ['companyId' => $companyId];
+ if ($customerId !== null) {
+ $sql .= " AND d.customer_id = :customerId";
+ $params['customerId'] = $customerId;
+ }
+ $sql .= " ORDER BY d.muokattu DESC, d.luotu DESC";
+ return _dbFetchAll($sql, $params);
+}
+
+function dbLoadDocument(string $documentId): ?array {
+ $doc = _dbFetchOne("SELECT * FROM documents WHERE id = ?", [$documentId]);
+ if (!$doc) return null;
+ $doc['versions'] = _dbFetchAll(
+ "SELECT * FROM document_versions WHERE document_id = ? ORDER BY version_number DESC",
+ [$documentId]
+ );
+ return $doc;
+}
+
+function dbSaveDocument(string $companyId, array $doc): string {
+ $id = $doc['id'] ?? generateId();
+ $now = date('Y-m-d H:i:s');
+ _dbExecute("
+ INSERT INTO documents (id, company_id, customer_id, title, description, category, current_version, created_by, luotu, muokattu, muokkaaja)
+ VALUES (:id, :companyId, :customerId, :title, :description, :category, :currentVersion, :createdBy, :luotu, :muokattu, :muokkaaja)
+ ON DUPLICATE KEY UPDATE
+ title = VALUES(title),
+ description = VALUES(description),
+ category = VALUES(category),
+ customer_id = VALUES(customer_id),
+ muokattu = VALUES(muokattu),
+ muokkaaja = VALUES(muokkaaja)
+ ", [
+ 'id' => $id,
+ 'companyId' => $companyId,
+ 'customerId' => !empty($doc['customer_id']) ? $doc['customer_id'] : null,
+ 'title' => $doc['title'] ?? '',
+ 'description' => $doc['description'] ?? '',
+ 'category' => $doc['category'] ?? 'muu',
+ 'currentVersion' => (int)($doc['current_version'] ?? 0),
+ 'createdBy' => $doc['created_by'] ?? '',
+ 'luotu' => $doc['luotu'] ?? $now,
+ 'muokattu' => $now,
+ 'muokkaaja' => $doc['muokkaaja'] ?? ''
+ ]);
+ return $id;
+}
+
+function dbDeleteDocument(string $documentId): ?array {
+ // Palauta dokumentin tiedot tiedostojen poistoa varten
+ $doc = _dbFetchOne("SELECT id, company_id FROM documents WHERE id = ?", [$documentId]);
+ if ($doc) {
+ _dbExecute("DELETE FROM documents WHERE id = ?", [$documentId]); // CASCADE poistaa versiot
+ }
+ return $doc;
+}
+
+function dbAddDocumentVersion(string $documentId, array $version): void {
+ $now = date('Y-m-d H:i:s');
+ // Hae seuraava versionumero
+ $maxVersion = _dbFetchScalar("SELECT COALESCE(MAX(version_number), 0) FROM document_versions WHERE document_id = ?", [$documentId]);
+ $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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [
+ $version['id'] ?? generateId(),
+ $documentId,
+ $nextVersion,
+ $version['filename'] ?? '',
+ $version['original_name'] ?? '',
+ $version['file_size'] ?? 0,
+ $version['mime_type'] ?? '',
+ $version['change_notes'] ?? '',
+ $version['created_by'] ?? '',
+ $now
+ ]);
+ // Päivitä dokumentin current_version
+ _dbExecute("UPDATE documents SET current_version = ?, muokattu = ?, muokkaaja = ? WHERE id = ?", [
+ $nextVersion, $now, $version['created_by'] ?? '', $documentId
+ ]);
+}
+
+function dbRestoreDocumentVersion(string $documentId, string $versionId, string $user): ?int {
+ // Hae palautettava versio
+ $oldVersion = _dbFetchOne("SELECT * FROM document_versions WHERE id = ? AND document_id = ?", [$versionId, $documentId]);
+ if (!$oldVersion) return null;
+
+ // Lisää uutena versiona (kopioi tiedostotiedot)
+ $now = date('Y-m-d H:i:s');
+ $maxVersion = _dbFetchScalar("SELECT COALESCE(MAX(version_number), 0) FROM document_versions WHERE document_id = ?", [$documentId]);
+ $nextVersion = (int)$maxVersion + 1;
+ $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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [
+ $newId,
+ $documentId,
+ $nextVersion,
+ $oldVersion['filename'],
+ $oldVersion['original_name'],
+ $oldVersion['file_size'],
+ $oldVersion['mime_type'],
+ 'Palautettu versiosta ' . $oldVersion['version_number'],
+ $user,
+ $now
+ ]);
+ _dbExecute("UPDATE documents SET current_version = ?, muokattu = ?, muokkaaja = ? WHERE id = ?", [
+ $nextVersion, $now, $user, $documentId
+ ]);
+ return $nextVersion;
+}
+
+function dbGetDocumentVersion(string $documentId, int $versionNumber): ?array {
+ return _dbFetchOne("SELECT * FROM document_versions WHERE document_id = ? AND version_number = ?", [$documentId, $versionNumber]);
+}
+
+// ==================== LAITETILAT ====================
+
+function dbLoadLaitetilat(string $companyId): array {
+ $tilat = _dbFetchAll("SELECT * FROM laitetilat WHERE company_id = ? ORDER BY nimi", [$companyId]);
+ foreach ($tilat as &$t) {
+ $t['file_count'] = (int)_dbFetchScalar("SELECT COUNT(*) FROM laitetila_files WHERE laitetila_id = ?", [$t['id']]);
+ }
+ return $tilat;
+}
+
+function dbLoadLaitetila(string $laitetilaId): ?array {
+ $tila = _dbFetchOne("SELECT * FROM laitetilat WHERE id = ?", [$laitetilaId]);
+ if (!$tila) return null;
+ $tila['files'] = _dbFetchAll("SELECT * FROM laitetila_files WHERE laitetila_id = ? ORDER BY luotu DESC", [$laitetilaId]);
+ return $tila;
+}
+
+function dbSaveLaitetila(string $companyId, array $tila): string {
+ $id = $tila['id'] ?? generateId();
+ $now = date('Y-m-d H:i:s');
+ _dbExecute("
+ INSERT INTO laitetilat (id, company_id, nimi, kuvaus, osoite, luotu, muokattu, muokkaaja)
+ VALUES (:id, :companyId, :nimi, :kuvaus, :osoite, :luotu, :muokattu, :muokkaaja)
+ ON DUPLICATE KEY UPDATE
+ nimi = VALUES(nimi),
+ kuvaus = VALUES(kuvaus),
+ osoite = VALUES(osoite),
+ muokattu = VALUES(muokattu),
+ muokkaaja = VALUES(muokkaaja)
+ ", [
+ 'id' => $id,
+ 'companyId' => $companyId,
+ 'nimi' => $tila['nimi'] ?? '',
+ 'kuvaus' => $tila['kuvaus'] ?? '',
+ 'osoite' => $tila['osoite'] ?? '',
+ 'luotu' => $tila['luotu'] ?? $now,
+ 'muokattu' => $now,
+ 'muokkaaja' => $tila['muokkaaja'] ?? ''
+ ]);
+ return $id;
+}
+
+function dbDeleteLaitetila(string $laitetilaId): ?array {
+ $tila = _dbFetchOne("SELECT id, company_id FROM laitetilat WHERE id = ?", [$laitetilaId]);
+ if ($tila) {
+ _dbExecute("DELETE FROM laitetilat WHERE id = ?", [$laitetilaId]); // CASCADE poistaa tiedostot
+ }
+ return $tila;
+}
+
+function dbAddLaitetilaFile(string $laitetilaId, array $file): void {
+ _dbExecute("INSERT INTO laitetila_files (id, laitetila_id, filename, original_name, file_size, mime_type, description, created_by, luotu) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [
+ $file['id'] ?? generateId(),
+ $laitetilaId,
+ $file['filename'] ?? '',
+ $file['original_name'] ?? '',
+ $file['file_size'] ?? 0,
+ $file['mime_type'] ?? '',
+ $file['description'] ?? '',
+ $file['created_by'] ?? '',
+ date('Y-m-d H:i:s')
+ ]);
+}
+
+function dbDeleteLaitetilaFile(string $fileId): ?array {
+ $file = _dbFetchOne("SELECT lf.*, lt.company_id FROM laitetila_files lf JOIN laitetilat lt ON lt.id = lf.laitetila_id WHERE lf.id = ?", [$fileId]);
+ if ($file) {
+ _dbExecute("DELETE FROM laitetila_files WHERE id = ?", [$fileId]);
+ }
+ return $file;
+}
diff --git a/index.html b/index.html
index dda2849..eed0fcf 100644
--- a/index.html
+++ b/index.html
@@ -82,6 +82,8 @@
+
+
@@ -725,6 +727,242 @@
+
+
+
+
+
+
+
📄 Dokumentit
+
+
+
+
+
+
+
+
+
+
+
+ | Otsikko |
+ Asiakas |
+ Kategoria |
+ Versio |
+ Päivitetty |
+ Lataaja |
+
+
+
+
+
+
📄
+
Ei dokumentteja vielä.
+
Klikkaa "+ Uusi dokumentti" aloittaaksesi.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Versiohistoria
+
+
+
+
+ | Versio |
+ Päivämäärä |
+ Lataaja |
+ Muistiinpanot |
+ Koko |
+ Toiminnot |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🏢 Laitetilat
+
+
+
+
+
🏢
+
Ei laitetiloja vielä.
+
Klikkaa "+ Uusi laitetila" aloittaaksesi.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Uusi laitetila
+
+
+
+
+
+
@@ -1161,6 +1399,12 @@
+
+
diff --git a/script.js b/script.js
index 2390955..8fb5cf5 100644
--- a/script.js
+++ b/script.js
@@ -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 = '
';
+ if (typeof customers !== 'undefined' && customers.length > 0) {
+ customers.forEach(c => {
+ sel.innerHTML += `
`;
+ });
+ } else {
+ Object.keys(customerMap).forEach(id => {
+ sel.innerHTML += `
`;
+ });
+ }
+ 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) : '
Yleinen';
+ 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 `
+ | ${esc(d.title)} |
+ ${customerName} |
+ ${catLabel} |
+ v${version} |
+ ${date} |
+ ${esc(author)} |
+
`;
+ }).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 = `
${docCategoryLabels[d.category] || d.category || 'Muu'}`;
+ 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 = '
| Ei versioita vielä. |
';
+ } 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 `
+ | v${v.version_number}${isCurrent ? ' ✓' : ''} |
+ ${date} |
+ ${esc(v.created_by || '-')} |
+ ${esc(v.change_notes || '-')} |
+ ${formatFileSize(v.file_size || 0)} |
+
+ ⬇️
+ ${isAdmin && !isCurrent ? `` : ''}
+ |
+
`;
+ }).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 = '
';
+ if (typeof customers !== 'undefined') {
+ customers.forEach(c => {
+ custSel.innerHTML += `
`;
+ });
+ }
+ 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 => `
+
+
${esc(t.nimi)}
+
${esc(t.osoite || '')}
+
📁 ${t.file_count || 0} tiedostoa
+
+ `).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 `
+

+
+ ${esc(f.original_name)}
+ ${isAdmin ? `` : ''}
+
+
`;
+ }).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 `
+
+
${esc(f.original_name)}
+
${formatFileSize(f.file_size || 0)} · ${f.luotu ? new Date(f.luotu).toLocaleDateString('fi-FI') : ''}
+ ${f.description ? `
${esc(f.description)}` : ''}
+
+ ${isAdmin ? `
` : ''}
+
`;
+ }).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) {
diff --git a/style.css b/style.css
index 6da7f4f..c17586b 100644
--- a/style.css
+++ b/style.css
@@ -1558,3 +1558,153 @@ span.empty {
background: #e8f0fe;
color: #1a56db;
}
+
+/* ==================== DOKUMENTIT ==================== */
+.doc-category {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-weight: 600;
+}
+.doc-category.cat-sopimus { background: #dbeafe; color: #1e40af; }
+.doc-category.cat-lasku { background: #fef3c7; color: #92400e; }
+.doc-category.cat-ohje { background: #d1fae5; color: #065f46; }
+.doc-category.cat-raportti { background: #ede9fe; color: #5b21b6; }
+.doc-category.cat-muu { background: #f3f4f6; color: #374151; }
+
+#doc-versions-table tbody tr:hover {
+ background: #f0f7ff !important;
+}
+
+/* ==================== LAITETILAT ==================== */
+.laitetilat-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 1rem;
+}
+
+.laitetila-card {
+ background: #fff;
+ border-radius: 12px;
+ padding: 1.25rem;
+ box-shadow: 0 1px 4px rgba(0,0,0,0.06);
+ cursor: pointer;
+ transition: box-shadow 0.2s, transform 0.15s;
+ border: 2px solid transparent;
+}
+
+.laitetila-card:hover {
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+ transform: translateY(-2px);
+ border-color: var(--primary-color);
+}
+
+.laitetila-card h4 {
+ margin: 0 0 0.25rem;
+ color: var(--primary-dark);
+ font-size: 1rem;
+}
+
+.laitetila-osoite {
+ color: #888;
+ font-size: 0.85rem;
+ margin: 0 0 0.5rem;
+}
+
+.laitetila-meta {
+ color: #aaa;
+ font-size: 0.8rem;
+ margin: 0;
+}
+
+/* Kuvagalleria */
+.gallery-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 0.75rem;
+}
+
+.gallery-item {
+ border-radius: 8px;
+ overflow: hidden;
+ background: #f8f9fb;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.08);
+}
+
+.gallery-item img {
+ width: 100%;
+ height: 140px;
+ object-fit: cover;
+ cursor: pointer;
+ transition: opacity 0.2s;
+}
+
+.gallery-item img:hover {
+ opacity: 0.85;
+}
+
+.gallery-caption {
+ padding: 0.4rem 0.6rem;
+ font-size: 0.75rem;
+ color: #666;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.gallery-caption span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Laitetilan tiedostolistaus */
+.laitetila-file-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.6rem 0;
+ border-bottom: 1px solid #f0f0f0;
+}
+
+.laitetila-file-item:last-child {
+ border-bottom: none;
+}
+
+.laitetila-file-item .file-name {
+ font-weight: 600;
+ color: var(--primary-color);
+ text-decoration: none;
+}
+
+.laitetila-file-item .file-name:hover {
+ text-decoration: underline;
+}
+
+.laitetila-file-item .file-meta {
+ display: block;
+ font-size: 0.8rem;
+ color: #999;
+}
+
+.laitetila-file-item .file-desc {
+ display: block;
+ font-size: 0.8rem;
+ color: #666;
+ font-style: italic;
+}
+
+.btn-icon {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 1rem;
+ padding: 2px 4px;
+ border-radius: 4px;
+ transition: background 0.15s;
+}
+
+.btn-icon:hover {
+ background: #fee2e2;
+}