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

+ +
+
+ + + +
+
+ + + + + + + + + + + + +
OtsikkoAsiakasKategoriaVersioPäivitettyLataaja
+ +
+
+ + + + + + +
+
+ + +
+
+ +
+
+

🏢 Laitetilat

+ +
+
+ +
+ + + + + + +
+
+
@@ -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 ``; + }).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; +}