Versioiva dokumentinhallinta + Laitetilat-moduuli
Dokumentit: versioiva tiedostonhallinta asiakkaille (sopimukset, laskut, ohjeet). Sisältää versiohistorian, tiedostojen latauksen/palautuksen ja asiakas-suodatuksen. Laitetilat: laitetilojen hallinta kuvagallerialla ja tiedostolistauksella. Sisältää korttipohjaisen listanäkymän, kuvien esikatselun ja tiedostojen hallinnan. Molemmat moduulit: 4 DB-taulua, 14 API-endpointtia, täysi CRUD, tiedostoupload. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
447
api.php
447
api.php
@@ -3994,6 +3994,453 @@ switch ($action) {
|
|||||||
echo json_encode(['success' => true]);
|
echo json_encode(['success' => true]);
|
||||||
break;
|
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:
|
default:
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['error' => 'Tuntematon toiminto']);
|
echo json_encode(['error' => 'Tuntematon toiminto']);
|
||||||
|
|||||||
253
db.php
253
db.php
@@ -502,6 +502,65 @@ function initDatabase(): void {
|
|||||||
FOREIGN KEY (todo_id) REFERENCES todos(id) ON DELETE CASCADE,
|
FOREIGN KEY (todo_id) REFERENCES todos(id) ON DELETE CASCADE,
|
||||||
INDEX idx_todo (todo_id)
|
INDEX idx_todo (todo_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
) 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) {
|
foreach ($tables as $i => $sql) {
|
||||||
@@ -1625,3 +1684,197 @@ function dbToggleTodoSubtask(string $subtaskId): bool {
|
|||||||
function dbDeleteTodoSubtask(string $subtaskId): void {
|
function dbDeleteTodoSubtask(string $subtaskId): void {
|
||||||
_dbExecute("DELETE FROM todo_subtasks WHERE id = ?", [$subtaskId]);
|
_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;
|
||||||
|
}
|
||||||
|
|||||||
244
index.html
244
index.html
@@ -82,6 +82,8 @@
|
|||||||
<button class="tab active" data-tab="customers">Asiakkaat</button>
|
<button class="tab active" data-tab="customers">Asiakkaat</button>
|
||||||
<button class="tab" data-tab="leads">Liidit</button>
|
<button class="tab" data-tab="leads">Liidit</button>
|
||||||
<button class="tab" data-tab="tekniikka">Tekniikka</button>
|
<button class="tab" data-tab="tekniikka">Tekniikka</button>
|
||||||
|
<button class="tab" data-tab="documents">Dokumentit</button>
|
||||||
|
<button class="tab" data-tab="laitetilat">Laitetilat</button>
|
||||||
<button class="tab" data-tab="ohjeet">Ohjeet</button>
|
<button class="tab" data-tab="ohjeet">Ohjeet</button>
|
||||||
<button class="tab" data-tab="archive">Arkisto</button>
|
<button class="tab" data-tab="archive">Arkisto</button>
|
||||||
<button class="tab" data-tab="changelog">Muutosloki</button>
|
<button class="tab" data-tab="changelog">Muutosloki</button>
|
||||||
@@ -725,6 +727,242 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Dokumentit -->
|
||||||
|
<div class="tab-content" id="tab-content-documents">
|
||||||
|
<div class="main-container">
|
||||||
|
<!-- Listanäkymä -->
|
||||||
|
<div id="docs-list-view">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem;">
|
||||||
|
<h3 style="color:var(--primary-dark);margin:0;">📄 Dokumentit</h3>
|
||||||
|
<button class="btn-primary" id="btn-new-document">+ Uusi dokumentti</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap;">
|
||||||
|
<input type="text" id="doc-search" placeholder="Hae dokumentteja..." style="flex:1;min-width:150px;">
|
||||||
|
<select id="doc-filter-customer" style="min-width:140px;">
|
||||||
|
<option value="">Kaikki asiakkaat</option>
|
||||||
|
</select>
|
||||||
|
<select id="doc-filter-category" style="min-width:120px;">
|
||||||
|
<option value="">Kaikki kategoriat</option>
|
||||||
|
<option value="sopimus">Sopimus</option>
|
||||||
|
<option value="lasku">Lasku</option>
|
||||||
|
<option value="ohje">Ohje</option>
|
||||||
|
<option value="raportti">Raportti</option>
|
||||||
|
<option value="muu">Muu</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="table-card">
|
||||||
|
<table id="docs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Otsikko</th>
|
||||||
|
<th>Asiakas</th>
|
||||||
|
<th>Kategoria</th>
|
||||||
|
<th>Versio</th>
|
||||||
|
<th>Päivitetty</th>
|
||||||
|
<th>Lataaja</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="docs-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="no-docs" class="empty-state" style="display:none;">
|
||||||
|
<div class="empty-icon">📄</div>
|
||||||
|
<p>Ei dokumentteja vielä.</p>
|
||||||
|
<p class="empty-hint">Klikkaa "+ Uusi dokumentti" aloittaaksesi.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lukunäkymä -->
|
||||||
|
<div id="doc-read-view" style="display:none;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem;">
|
||||||
|
<button class="btn-secondary" id="btn-doc-back">← Takaisin</button>
|
||||||
|
<div style="display:flex;gap:0.5rem;">
|
||||||
|
<button class="btn-primary" id="btn-doc-edit">✏️ Muokkaa</button>
|
||||||
|
<button class="btn-danger" id="btn-doc-delete" style="display:none;">🗑 Poista</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-card" style="padding:1.5rem;">
|
||||||
|
<h2 id="doc-read-title" style="margin:0 0 0.5rem;color:var(--primary-dark);"></h2>
|
||||||
|
<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem;font-size:0.85rem;color:#666;">
|
||||||
|
<span id="doc-read-customer"></span>
|
||||||
|
<span id="doc-read-category"></span>
|
||||||
|
<span id="doc-read-version"></span>
|
||||||
|
<span id="doc-read-date"></span>
|
||||||
|
</div>
|
||||||
|
<p id="doc-read-description" style="color:#555;margin-bottom:1.5rem;"></p>
|
||||||
|
|
||||||
|
<!-- Nykyisen version lataus -->
|
||||||
|
<div style="margin-bottom:1.5rem;">
|
||||||
|
<button class="btn-primary" id="btn-doc-download" style="font-size:1rem;padding:0.7rem 1.5rem;">⬇️ Lataa tiedosto</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Uusi versio -->
|
||||||
|
<div style="background:#f8f9fb;padding:1rem;border-radius:10px;margin-bottom:1.5rem;">
|
||||||
|
<h4 style="margin:0 0 0.75rem;color:var(--primary-dark);">Lataa uusi versio</h4>
|
||||||
|
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:end;">
|
||||||
|
<input type="file" id="doc-version-file" style="flex:1;min-width:200px;">
|
||||||
|
<input type="text" id="doc-version-notes" placeholder="Muistiinpanot (valinnainen)" style="flex:1;min-width:200px;">
|
||||||
|
<button class="btn-primary" id="btn-doc-upload-version">Lataa</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Versiohistoria -->
|
||||||
|
<h4 style="color:var(--primary-dark);margin-bottom:0.5rem;">Versiohistoria</h4>
|
||||||
|
<div class="table-card" style="margin:0;">
|
||||||
|
<table id="doc-versions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Versio</th>
|
||||||
|
<th>Päivämäärä</th>
|
||||||
|
<th>Lataaja</th>
|
||||||
|
<th>Muistiinpanot</th>
|
||||||
|
<th>Koko</th>
|
||||||
|
<th>Toiminnot</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="doc-versions-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Muokkausnäkymä -->
|
||||||
|
<div id="doc-edit-view" style="display:none;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||||
|
<button class="btn-secondary" id="btn-doc-edit-back">← Takaisin</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-card" style="padding:1.5rem;">
|
||||||
|
<h3 style="color:var(--primary-dark);margin:0 0 1rem;" id="doc-edit-title">Uusi dokumentti</h3>
|
||||||
|
<form id="doc-edit-form">
|
||||||
|
<input type="hidden" id="doc-edit-id">
|
||||||
|
<div class="form-grid" style="grid-template-columns:1fr 1fr;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Otsikko *</label>
|
||||||
|
<input type="text" id="doc-edit-name" required placeholder="Esim. Palvelusopimus">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Kategoria</label>
|
||||||
|
<select id="doc-edit-category">
|
||||||
|
<option value="sopimus">Sopimus</option>
|
||||||
|
<option value="lasku">Lasku</option>
|
||||||
|
<option value="ohje">Ohje</option>
|
||||||
|
<option value="raportti">Raportti</option>
|
||||||
|
<option value="muu">Muu</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Asiakas (valinnainen)</label>
|
||||||
|
<select id="doc-edit-customer">
|
||||||
|
<option value="">Ei asiakasta (yleinen)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Tiedosto</label>
|
||||||
|
<input type="file" id="doc-edit-file">
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label>Kuvaus</label>
|
||||||
|
<textarea id="doc-edit-description" rows="3" placeholder="Dokumentin kuvaus..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:1rem;display:flex;gap:0.5rem;">
|
||||||
|
<button type="submit" class="btn-primary">Tallenna</button>
|
||||||
|
<button type="button" class="btn-secondary" id="btn-doc-edit-cancel">Peruuta</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Laitetilat -->
|
||||||
|
<div class="tab-content" id="tab-content-laitetilat">
|
||||||
|
<div class="main-container">
|
||||||
|
<!-- Listanäkymä -->
|
||||||
|
<div id="laitetilat-list-view">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem;">
|
||||||
|
<h3 style="color:var(--primary-dark);margin:0;">🏢 Laitetilat</h3>
|
||||||
|
<button class="btn-primary" id="btn-new-laitetila">+ Uusi laitetila</button>
|
||||||
|
</div>
|
||||||
|
<div id="laitetilat-grid" class="laitetilat-grid"></div>
|
||||||
|
<div id="no-laitetilat" class="empty-state" style="display:none;">
|
||||||
|
<div class="empty-icon">🏢</div>
|
||||||
|
<p>Ei laitetiloja vielä.</p>
|
||||||
|
<p class="empty-hint">Klikkaa "+ Uusi laitetila" aloittaaksesi.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lukunäkymä -->
|
||||||
|
<div id="laitetila-read-view" style="display:none;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem;">
|
||||||
|
<button class="btn-secondary" id="btn-laitetila-back">← Takaisin</button>
|
||||||
|
<div style="display:flex;gap:0.5rem;">
|
||||||
|
<button class="btn-primary" id="btn-laitetila-edit">✏️ Muokkaa</button>
|
||||||
|
<button class="btn-danger" id="btn-laitetila-delete" style="display:none;">🗑 Poista</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-card" style="padding:1.5rem;">
|
||||||
|
<h2 id="laitetila-read-nimi" style="margin:0 0 0.25rem;color:var(--primary-dark);"></h2>
|
||||||
|
<p id="laitetila-read-osoite" style="color:#888;font-size:0.9rem;margin-bottom:1rem;"></p>
|
||||||
|
<p id="laitetila-read-kuvaus" style="color:#555;margin-bottom:1.5rem;white-space:pre-wrap;"></p>
|
||||||
|
|
||||||
|
<!-- Tiedostojen lataus -->
|
||||||
|
<div style="background:#f8f9fb;padding:1rem;border-radius:10px;margin-bottom:1.5rem;">
|
||||||
|
<h4 style="margin:0 0 0.75rem;color:var(--primary-dark);">Lisää tiedosto</h4>
|
||||||
|
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:end;">
|
||||||
|
<input type="file" id="laitetila-file-input" multiple style="flex:1;min-width:200px;">
|
||||||
|
<input type="text" id="laitetila-file-desc" placeholder="Kuvaus (valinnainen)" style="flex:1;min-width:200px;">
|
||||||
|
<button class="btn-primary" id="btn-laitetila-upload">Lataa</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kuvagalleria -->
|
||||||
|
<div id="laitetila-gallery" style="display:none;margin-bottom:1.5rem;">
|
||||||
|
<h4 style="color:var(--primary-dark);margin-bottom:0.5rem;">Kuvat</h4>
|
||||||
|
<div id="laitetila-gallery-grid" class="gallery-grid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Muut tiedostot -->
|
||||||
|
<div id="laitetila-files-section" style="display:none;">
|
||||||
|
<h4 style="color:var(--primary-dark);margin-bottom:0.5rem;">Tiedostot</h4>
|
||||||
|
<div id="laitetila-files-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Muokkausnäkymä -->
|
||||||
|
<div id="laitetila-edit-view" style="display:none;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||||
|
<button class="btn-secondary" id="btn-laitetila-edit-back">← Takaisin</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-card" style="padding:1.5rem;">
|
||||||
|
<h3 style="color:var(--primary-dark);margin:0 0 1rem;" id="laitetila-edit-title">Uusi laitetila</h3>
|
||||||
|
<form id="laitetila-edit-form">
|
||||||
|
<input type="hidden" id="laitetila-edit-id">
|
||||||
|
<div class="form-grid" style="grid-template-columns:1fr 1fr;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Nimi *</label>
|
||||||
|
<input type="text" id="laitetila-edit-nimi" required placeholder="Esim. Keskuskatu 5 - Kellari">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Osoite</label>
|
||||||
|
<input type="text" id="laitetila-edit-osoite" placeholder="Katuosoite">
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label>Kuvaus</label>
|
||||||
|
<textarea id="laitetila-edit-kuvaus" rows="4" placeholder="Laitetilan kuvaus..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:1rem;display:flex;gap:0.5rem;">
|
||||||
|
<button type="submit" class="btn-primary">Tallenna</button>
|
||||||
|
<button type="button" class="btn-secondary" id="btn-laitetila-edit-cancel">Peruuta</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tab: Muutosloki -->
|
<!-- Tab: Muutosloki -->
|
||||||
<div class="tab-content" id="tab-content-changelog">
|
<div class="tab-content" id="tab-content-changelog">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
@@ -1161,6 +1399,12 @@
|
|||||||
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
|
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
|
||||||
<input type="checkbox" data-module="todo"> Tehtävät
|
<input type="checkbox" data-module="todo"> Tehtävät
|
||||||
</label>
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
|
||||||
|
<input type="checkbox" data-module="documents"> Dokumentit
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
|
||||||
|
<input type="checkbox" data-module="laitetilat"> Laitetilat
|
||||||
|
</label>
|
||||||
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
|
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;cursor:pointer;">
|
||||||
<input type="checkbox" data-module="settings" checked> Asetukset / API
|
<input type="checkbox" data-module="settings" checked> Asetukset / API
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
520
script.js
520
script.js
@@ -200,7 +200,7 @@ async function showDashboard() {
|
|||||||
// Avaa oikea tabi URL-hashin perusteella (tai customers oletuks)
|
// Avaa oikea tabi URL-hashin perusteella (tai customers oletuks)
|
||||||
const hash = window.location.hash.replace('#', '');
|
const hash = window.location.hash.replace('#', '');
|
||||||
const [mainHash, subHash] = hash.split('/');
|
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';
|
const startTab = validTabs.includes(mainHash) ? mainHash : 'customers';
|
||||||
switchToTab(startTab, subHash);
|
switchToTab(startTab, subHash);
|
||||||
}
|
}
|
||||||
@@ -263,6 +263,8 @@ function switchToTab(target, subTab) {
|
|||||||
if (target === 'ohjeet') loadGuides();
|
if (target === 'ohjeet') loadGuides();
|
||||||
if (target === 'todo') { loadTodos(); if (subTab) switchTodoSubTab(subTab); }
|
if (target === 'todo') { loadTodos(); if (subTab) switchTodoSubTab(subTab); }
|
||||||
if (target === 'support') { loadTickets(); showTicketListView(); if (document.getElementById('ticket-auto-refresh').checked) startTicketAutoRefresh(); }
|
if (target === 'support') { loadTickets(); showTicketListView(); if (document.getElementById('ticket-auto-refresh').checked) startTicketAutoRefresh(); }
|
||||||
|
if (target === 'documents') { loadDocuments(); showDocsListView(); }
|
||||||
|
if (target === 'laitetilat') { loadLaitetilat(); showLaitetilatListView(); }
|
||||||
if (target === 'users') loadUsers();
|
if (target === 'users') loadUsers();
|
||||||
if (target === 'settings') loadSettings();
|
if (target === 'settings') loadSettings();
|
||||||
if (target === 'companies') loadCompaniesTab();
|
if (target === 'companies') loadCompaniesTab();
|
||||||
@@ -4392,9 +4394,523 @@ document.getElementById('btn-time-cancel')?.addEventListener('click', () => {
|
|||||||
});
|
});
|
||||||
document.getElementById('btn-time-save')?.addEventListener('click', () => addTimeEntry());
|
document.getElementById('btn-time-save')?.addEventListener('click', () => addTimeEntry());
|
||||||
|
|
||||||
|
// ==================== DOKUMENTIT ====================
|
||||||
|
|
||||||
|
let allDocuments = [];
|
||||||
|
let currentDocument = null;
|
||||||
|
|
||||||
|
const docCategoryLabels = {
|
||||||
|
sopimus: 'Sopimus',
|
||||||
|
lasku: 'Lasku',
|
||||||
|
ohje: 'Ohje',
|
||||||
|
raportti: 'Raportti',
|
||||||
|
muu: 'Muu'
|
||||||
|
};
|
||||||
|
|
||||||
|
function showDocsListView() {
|
||||||
|
document.getElementById('docs-list-view').style.display = '';
|
||||||
|
document.getElementById('doc-read-view').style.display = 'none';
|
||||||
|
document.getElementById('doc-edit-view').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDocReadView() {
|
||||||
|
document.getElementById('docs-list-view').style.display = 'none';
|
||||||
|
document.getElementById('doc-read-view').style.display = '';
|
||||||
|
document.getElementById('doc-edit-view').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDocEditView() {
|
||||||
|
document.getElementById('docs-list-view').style.display = 'none';
|
||||||
|
document.getElementById('doc-read-view').style.display = 'none';
|
||||||
|
document.getElementById('doc-edit-view').style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDocuments() {
|
||||||
|
try {
|
||||||
|
allDocuments = await apiCall('documents');
|
||||||
|
populateDocCustomerFilter();
|
||||||
|
renderDocumentsList();
|
||||||
|
} catch (e) { console.error('Dokumenttien lataus epäonnistui:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateDocCustomerFilter() {
|
||||||
|
const sel = document.getElementById('doc-filter-customer');
|
||||||
|
const existing = sel.value;
|
||||||
|
// Kerää uniikki lista asiakkaista
|
||||||
|
const customerMap = {};
|
||||||
|
allDocuments.forEach(d => {
|
||||||
|
if (d.customer_id) {
|
||||||
|
customerMap[d.customer_id] = d.customer_id; // käytetään myöhemmin nimeä jos saatavilla
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Käytä customers-listaa nimien näyttämiseen
|
||||||
|
sel.innerHTML = '<option value="">Kaikki asiakkaat</option>';
|
||||||
|
if (typeof customers !== 'undefined' && customers.length > 0) {
|
||||||
|
customers.forEach(c => {
|
||||||
|
sel.innerHTML += `<option value="${c.id}">${esc(c.yritys)}</option>`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Object.keys(customerMap).forEach(id => {
|
||||||
|
sel.innerHTML += `<option value="${id}">${id}</option>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sel.value = existing || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDocumentsList() {
|
||||||
|
const query = (document.getElementById('doc-search')?.value || '').toLowerCase().trim();
|
||||||
|
const filterCustomer = document.getElementById('doc-filter-customer')?.value || '';
|
||||||
|
const filterCategory = document.getElementById('doc-filter-category')?.value || '';
|
||||||
|
|
||||||
|
let filtered = allDocuments;
|
||||||
|
if (query) {
|
||||||
|
filtered = filtered.filter(d =>
|
||||||
|
(d.title || '').toLowerCase().includes(query) ||
|
||||||
|
(d.description || '').toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filterCustomer) {
|
||||||
|
filtered = filtered.filter(d => d.customer_id === filterCustomer);
|
||||||
|
}
|
||||||
|
if (filterCategory) {
|
||||||
|
filtered = filtered.filter(d => d.category === filterCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tbody = document.getElementById('docs-tbody');
|
||||||
|
const noDocsEl = document.getElementById('no-docs');
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
noDocsEl.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
noDocsEl.style.display = 'none';
|
||||||
|
|
||||||
|
// Hae asiakasnimien map
|
||||||
|
const customerNameMap = {};
|
||||||
|
if (typeof customers !== 'undefined') {
|
||||||
|
customers.forEach(c => { customerNameMap[c.id] = c.yritys; });
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = filtered.map(d => {
|
||||||
|
const customerName = d.customer_id ? (customerNameMap[d.customer_id] || d.customer_id) : '<span style="color:#aaa;">Yleinen</span>';
|
||||||
|
const catLabel = docCategoryLabels[d.category] || d.category || '-';
|
||||||
|
const version = d.current_version || 0;
|
||||||
|
const date = d.muokattu ? new Date(d.muokattu).toLocaleDateString('fi-FI') : '-';
|
||||||
|
const author = d.version_author || d.created_by || '-';
|
||||||
|
return `<tr onclick="openDocRead('${d.id}')" style="cursor:pointer;">
|
||||||
|
<td><strong>${esc(d.title)}</strong></td>
|
||||||
|
<td>${customerName}</td>
|
||||||
|
<td><span class="doc-category cat-${d.category || 'muu'}">${catLabel}</span></td>
|
||||||
|
<td style="text-align:center;">v${version}</td>
|
||||||
|
<td>${date}</td>
|
||||||
|
<td>${esc(author)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('doc-search')?.addEventListener('input', renderDocumentsList);
|
||||||
|
document.getElementById('doc-filter-customer')?.addEventListener('change', renderDocumentsList);
|
||||||
|
document.getElementById('doc-filter-category')?.addEventListener('change', renderDocumentsList);
|
||||||
|
|
||||||
|
async function openDocRead(docId) {
|
||||||
|
try {
|
||||||
|
currentDocument = await apiCall(`document&id=${docId}`);
|
||||||
|
renderDocReadView();
|
||||||
|
showDocReadView();
|
||||||
|
} catch (e) { alert('Dokumentin avaus epäonnistui: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDocReadView() {
|
||||||
|
const d = currentDocument;
|
||||||
|
if (!d) return;
|
||||||
|
|
||||||
|
// Asiakasnimen haku
|
||||||
|
let customerName = 'Ei asiakasta (yleinen)';
|
||||||
|
if (d.customer_id && typeof customers !== 'undefined') {
|
||||||
|
const c = customers.find(c => c.id === d.customer_id);
|
||||||
|
if (c) customerName = c.yritys;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('doc-read-title').textContent = d.title || '';
|
||||||
|
document.getElementById('doc-read-customer').textContent = '👤 ' + customerName;
|
||||||
|
document.getElementById('doc-read-category').innerHTML = `<span class="doc-category cat-${d.category || 'muu'}">${docCategoryLabels[d.category] || d.category || 'Muu'}</span>`;
|
||||||
|
document.getElementById('doc-read-version').textContent = `📌 Versio ${d.current_version || 0}`;
|
||||||
|
document.getElementById('doc-read-date').textContent = d.muokattu ? '📅 ' + new Date(d.muokattu).toLocaleDateString('fi-FI') : '';
|
||||||
|
document.getElementById('doc-read-description').textContent = d.description || '';
|
||||||
|
|
||||||
|
// Admin-napit
|
||||||
|
const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'superadmin';
|
||||||
|
document.getElementById('btn-doc-delete').style.display = isAdmin ? '' : 'none';
|
||||||
|
|
||||||
|
// Latausnappi - piilota jos ei versioita
|
||||||
|
document.getElementById('btn-doc-download').style.display = (d.current_version && d.current_version > 0) ? '' : 'none';
|
||||||
|
|
||||||
|
// Versiohistoria
|
||||||
|
const vtbody = document.getElementById('doc-versions-tbody');
|
||||||
|
if (!d.versions || d.versions.length === 0) {
|
||||||
|
vtbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:#aaa;padding:1rem;">Ei versioita vielä.</td></tr>';
|
||||||
|
} else {
|
||||||
|
vtbody.innerHTML = d.versions.map(v => {
|
||||||
|
const date = v.luotu ? new Date(v.luotu).toLocaleDateString('fi-FI', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-';
|
||||||
|
const isCurrent = v.version_number === d.current_version;
|
||||||
|
return `<tr${isCurrent ? ' style="background:#f0f7ff;"' : ''}>
|
||||||
|
<td style="font-weight:600;">v${v.version_number}${isCurrent ? ' ✓' : ''}</td>
|
||||||
|
<td>${date}</td>
|
||||||
|
<td>${esc(v.created_by || '-')}</td>
|
||||||
|
<td>${esc(v.change_notes || '-')}</td>
|
||||||
|
<td>${formatFileSize(v.file_size || 0)}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<a href="${API}?action=document_download&id=${d.id}&version=${v.version_number}" target="_blank" title="Lataa">⬇️</a>
|
||||||
|
${isAdmin && !isCurrent ? `<button onclick="restoreDocVersion('${d.id}', '${v.id}', ${v.version_number})" title="Palauta tämä versio" style="background:none;border:none;cursor:pointer;font-size:1rem;">🔄</button>` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latausnappi
|
||||||
|
document.getElementById('btn-doc-download')?.addEventListener('click', () => {
|
||||||
|
if (!currentDocument || !currentDocument.current_version) return;
|
||||||
|
window.open(`${API}?action=document_download&id=${currentDocument.id}&version=${currentDocument.current_version}`, '_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uusi versio
|
||||||
|
document.getElementById('btn-doc-upload-version')?.addEventListener('click', async () => {
|
||||||
|
const fileInput = document.getElementById('doc-version-file');
|
||||||
|
const notesInput = document.getElementById('doc-version-notes');
|
||||||
|
if (!fileInput.files.length) { alert('Valitse tiedosto'); return; }
|
||||||
|
if (!currentDocument) return;
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('document_id', currentDocument.id);
|
||||||
|
fd.append('file', fileInput.files[0]);
|
||||||
|
fd.append('change_notes', notesInput.value || '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}?action=document_upload`, { method: 'POST', credentials: 'include', body: fd });
|
||||||
|
const text = await res.text();
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(text); } catch (e) { throw new Error('Palvelin palautti virheellisen vastauksen'); }
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Virhe');
|
||||||
|
currentDocument = data;
|
||||||
|
renderDocReadView();
|
||||||
|
fileInput.value = '';
|
||||||
|
notesInput.value = '';
|
||||||
|
} catch (e) { alert('Tiedoston lataus epäonnistui: ' + e.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function restoreDocVersion(docId, versionId, versionNum) {
|
||||||
|
if (!confirm(`Palautetaanko versio ${versionNum}? Siitä tulee uusi nykyinen versio.`)) return;
|
||||||
|
try {
|
||||||
|
currentDocument = await apiCall('document_restore', 'POST', { document_id: docId, version_id: versionId });
|
||||||
|
renderDocReadView();
|
||||||
|
} catch (e) { alert('Palautus epäonnistui: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poista dokumentti
|
||||||
|
document.getElementById('btn-doc-delete')?.addEventListener('click', async () => {
|
||||||
|
if (!currentDocument) return;
|
||||||
|
if (!confirm(`Poistetaanko dokumentti "${currentDocument.title}" ja kaikki sen versiot?`)) return;
|
||||||
|
try {
|
||||||
|
await apiCall('document_delete', 'POST', { id: currentDocument.id });
|
||||||
|
currentDocument = null;
|
||||||
|
showDocsListView();
|
||||||
|
loadDocuments();
|
||||||
|
} catch (e) { alert('Poisto epäonnistui: ' + e.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigaatio
|
||||||
|
document.getElementById('btn-doc-back')?.addEventListener('click', () => { showDocsListView(); });
|
||||||
|
document.getElementById('btn-doc-edit')?.addEventListener('click', () => { openDocEdit(currentDocument); });
|
||||||
|
document.getElementById('btn-doc-edit-back')?.addEventListener('click', () => {
|
||||||
|
if (currentDocument) showDocReadView();
|
||||||
|
else showDocsListView();
|
||||||
|
});
|
||||||
|
document.getElementById('btn-doc-edit-cancel')?.addEventListener('click', () => {
|
||||||
|
if (currentDocument) showDocReadView();
|
||||||
|
else showDocsListView();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uusi dokumentti
|
||||||
|
document.getElementById('btn-new-document')?.addEventListener('click', () => { openDocEdit(null); });
|
||||||
|
|
||||||
|
function openDocEdit(doc) {
|
||||||
|
document.getElementById('doc-edit-id').value = doc?.id || '';
|
||||||
|
document.getElementById('doc-edit-name').value = doc?.title || '';
|
||||||
|
document.getElementById('doc-edit-description').value = doc?.description || '';
|
||||||
|
document.getElementById('doc-edit-category').value = doc?.category || 'muu';
|
||||||
|
document.getElementById('doc-edit-title').textContent = doc ? 'Muokkaa dokumenttia' : 'Uusi dokumentti';
|
||||||
|
|
||||||
|
// Täytä asiakas-dropdown
|
||||||
|
const custSel = document.getElementById('doc-edit-customer');
|
||||||
|
custSel.innerHTML = '<option value="">Ei asiakasta (yleinen)</option>';
|
||||||
|
if (typeof customers !== 'undefined') {
|
||||||
|
customers.forEach(c => {
|
||||||
|
custSel.innerHTML += `<option value="${c.id}" ${doc?.customer_id === c.id ? 'selected' : ''}>${esc(c.yritys)}</option>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (doc?.customer_id) custSel.value = doc.customer_id;
|
||||||
|
|
||||||
|
// Piilota tiedostokenttä muokkaustilassa (versiot hoidetaan read-viewissä)
|
||||||
|
document.getElementById('doc-edit-file').parentElement.style.display = doc ? 'none' : '';
|
||||||
|
|
||||||
|
showDocEditView();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lomakkeen lähetys
|
||||||
|
document.getElementById('doc-edit-form')?.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = document.getElementById('doc-edit-id').value;
|
||||||
|
const docData = {
|
||||||
|
id: id || undefined,
|
||||||
|
title: document.getElementById('doc-edit-name').value.trim(),
|
||||||
|
description: document.getElementById('doc-edit-description').value.trim(),
|
||||||
|
category: document.getElementById('doc-edit-category').value,
|
||||||
|
customer_id: document.getElementById('doc-edit-customer').value || null,
|
||||||
|
created_by: currentUser?.username || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!docData.title) { alert('Otsikko on pakollinen'); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = await apiCall('document_save', 'POST', docData);
|
||||||
|
const docId = saved.id;
|
||||||
|
|
||||||
|
// Jos uusi dokumentti ja tiedosto valittu → lataa ensimmäinen versio
|
||||||
|
const fileInput = document.getElementById('doc-edit-file');
|
||||||
|
if (!id && fileInput.files.length > 0) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('document_id', docId);
|
||||||
|
fd.append('file', fileInput.files[0]);
|
||||||
|
fd.append('change_notes', 'Ensimmäinen versio');
|
||||||
|
const res = await fetch(`${API}?action=document_upload`, { method: 'POST', credentials: 'include', body: fd });
|
||||||
|
const text = await res.text();
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(text); } catch (err) { throw new Error('Tiedoston lataus epäonnistui'); }
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Virhe');
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDocument = await apiCall(`document&id=${docId}`);
|
||||||
|
renderDocReadView();
|
||||||
|
showDocReadView();
|
||||||
|
loadDocuments();
|
||||||
|
} catch (e) { alert('Tallennus epäonnistui: ' + e.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== LAITETILAT ====================
|
||||||
|
|
||||||
|
let allLaitetilat = [];
|
||||||
|
let currentLaitetila = null;
|
||||||
|
|
||||||
|
function showLaitetilatListView() {
|
||||||
|
document.getElementById('laitetilat-list-view').style.display = '';
|
||||||
|
document.getElementById('laitetila-read-view').style.display = 'none';
|
||||||
|
document.getElementById('laitetila-edit-view').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLaitetilaReadView() {
|
||||||
|
document.getElementById('laitetilat-list-view').style.display = 'none';
|
||||||
|
document.getElementById('laitetila-read-view').style.display = '';
|
||||||
|
document.getElementById('laitetila-edit-view').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLaitetilaEditView() {
|
||||||
|
document.getElementById('laitetilat-list-view').style.display = 'none';
|
||||||
|
document.getElementById('laitetila-read-view').style.display = 'none';
|
||||||
|
document.getElementById('laitetila-edit-view').style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLaitetilat() {
|
||||||
|
try {
|
||||||
|
allLaitetilat = await apiCall('laitetilat');
|
||||||
|
renderLaitetilatList();
|
||||||
|
} catch (e) { console.error('Laitetilojen lataus epäonnistui:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLaitetilatList() {
|
||||||
|
const grid = document.getElementById('laitetilat-grid');
|
||||||
|
const noEl = document.getElementById('no-laitetilat');
|
||||||
|
|
||||||
|
if (allLaitetilat.length === 0) {
|
||||||
|
grid.innerHTML = '';
|
||||||
|
noEl.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
noEl.style.display = 'none';
|
||||||
|
|
||||||
|
grid.innerHTML = allLaitetilat.map(t => `
|
||||||
|
<div class="laitetila-card" onclick="openLaitetilaRead('${t.id}')">
|
||||||
|
<h4>${esc(t.nimi)}</h4>
|
||||||
|
<p class="laitetila-osoite">${esc(t.osoite || '')}</p>
|
||||||
|
<p class="laitetila-meta">📁 ${t.file_count || 0} tiedostoa</p>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLaitetilaRead(tilaId) {
|
||||||
|
try {
|
||||||
|
currentLaitetila = await apiCall(`laitetila&id=${tilaId}`);
|
||||||
|
renderLaitetilaReadView();
|
||||||
|
showLaitetilaReadView();
|
||||||
|
} catch (e) { alert('Laitetilan avaus epäonnistui: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLaitetilaReadView() {
|
||||||
|
const t = currentLaitetila;
|
||||||
|
if (!t) return;
|
||||||
|
|
||||||
|
document.getElementById('laitetila-read-nimi').textContent = t.nimi || '';
|
||||||
|
document.getElementById('laitetila-read-osoite').textContent = t.osoite ? '📍 ' + t.osoite : '';
|
||||||
|
document.getElementById('laitetila-read-kuvaus').textContent = t.kuvaus || '';
|
||||||
|
|
||||||
|
const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'superadmin';
|
||||||
|
document.getElementById('btn-laitetila-delete').style.display = isAdmin ? '' : 'none';
|
||||||
|
|
||||||
|
// Erota kuvat ja muut tiedostot
|
||||||
|
const files = t.files || [];
|
||||||
|
const images = files.filter(f => (f.mime_type || '').startsWith('image/'));
|
||||||
|
const otherFiles = files.filter(f => !(f.mime_type || '').startsWith('image/'));
|
||||||
|
|
||||||
|
// Kuvagalleria
|
||||||
|
const gallerySection = document.getElementById('laitetila-gallery');
|
||||||
|
const galleryGrid = document.getElementById('laitetila-gallery-grid');
|
||||||
|
if (images.length > 0) {
|
||||||
|
gallerySection.style.display = '';
|
||||||
|
galleryGrid.innerHTML = images.map(f => {
|
||||||
|
const imgUrl = `${API}?action=laitetila_file_download&laitetila_id=${t.id}&file_id=${f.id}`;
|
||||||
|
return `<div class="gallery-item">
|
||||||
|
<img src="${imgUrl}" alt="${esc(f.original_name)}" onclick="window.open('${imgUrl}', '_blank')" title="Klikkaa avataksesi">
|
||||||
|
<div class="gallery-caption">
|
||||||
|
<span>${esc(f.original_name)}</span>
|
||||||
|
${isAdmin ? `<button onclick="deleteLaitetilaFile('${f.id}')" class="btn-icon" title="Poista">🗑</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} else {
|
||||||
|
gallerySection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Muut tiedostot
|
||||||
|
const filesSection = document.getElementById('laitetila-files-section');
|
||||||
|
const filesList = document.getElementById('laitetila-files-list');
|
||||||
|
if (otherFiles.length > 0) {
|
||||||
|
filesSection.style.display = '';
|
||||||
|
filesList.innerHTML = otherFiles.map(f => {
|
||||||
|
const dlUrl = `${API}?action=laitetila_file_download&laitetila_id=${t.id}&file_id=${f.id}`;
|
||||||
|
return `<div class="laitetila-file-item">
|
||||||
|
<div>
|
||||||
|
<a href="${dlUrl}" target="_blank" class="file-name">${esc(f.original_name)}</a>
|
||||||
|
<span class="file-meta">${formatFileSize(f.file_size || 0)} · ${f.luotu ? new Date(f.luotu).toLocaleDateString('fi-FI') : ''}</span>
|
||||||
|
${f.description ? `<span class="file-desc">${esc(f.description)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${isAdmin ? `<button onclick="deleteLaitetilaFile('${f.id}')" class="btn-icon" title="Poista">🗑</button>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} else {
|
||||||
|
filesSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiedoston lataus
|
||||||
|
document.getElementById('btn-laitetila-upload')?.addEventListener('click', async () => {
|
||||||
|
const fileInput = document.getElementById('laitetila-file-input');
|
||||||
|
const descInput = document.getElementById('laitetila-file-desc');
|
||||||
|
if (!fileInput.files.length) { alert('Valitse tiedosto'); return; }
|
||||||
|
if (!currentLaitetila) return;
|
||||||
|
|
||||||
|
for (const file of fileInput.files) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('laitetila_id', currentLaitetila.id);
|
||||||
|
fd.append('file', file);
|
||||||
|
fd.append('description', descInput.value || '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}?action=laitetila_file_upload`, { method: 'POST', credentials: 'include', body: fd });
|
||||||
|
const text = await res.text();
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(text); } catch (e) { throw new Error('Palvelin palautti virheellisen vastauksen'); }
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Virhe');
|
||||||
|
currentLaitetila = data;
|
||||||
|
} catch (e) { alert('Tiedoston lataus epäonnistui: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLaitetilaReadView();
|
||||||
|
fileInput.value = '';
|
||||||
|
descInput.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deleteLaitetilaFile(fileId) {
|
||||||
|
if (!confirm('Poistetaanko tiedosto?')) return;
|
||||||
|
try {
|
||||||
|
await apiCall('laitetila_file_delete', 'POST', { id: fileId });
|
||||||
|
// Päivitä näkymä
|
||||||
|
currentLaitetila = await apiCall(`laitetila&id=${currentLaitetila.id}`);
|
||||||
|
renderLaitetilaReadView();
|
||||||
|
} catch (e) { alert('Poisto epäonnistui: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigaatio
|
||||||
|
document.getElementById('btn-laitetila-back')?.addEventListener('click', () => { showLaitetilatListView(); });
|
||||||
|
document.getElementById('btn-laitetila-edit')?.addEventListener('click', () => { openLaitetilaEdit(currentLaitetila); });
|
||||||
|
document.getElementById('btn-laitetila-edit-back')?.addEventListener('click', () => {
|
||||||
|
if (currentLaitetila) showLaitetilaReadView();
|
||||||
|
else showLaitetilatListView();
|
||||||
|
});
|
||||||
|
document.getElementById('btn-laitetila-edit-cancel')?.addEventListener('click', () => {
|
||||||
|
if (currentLaitetila) showLaitetilaReadView();
|
||||||
|
else showLaitetilatListView();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poista laitetila
|
||||||
|
document.getElementById('btn-laitetila-delete')?.addEventListener('click', async () => {
|
||||||
|
if (!currentLaitetila) return;
|
||||||
|
if (!confirm(`Poistetaanko laitetila "${currentLaitetila.nimi}" ja kaikki sen tiedostot?`)) return;
|
||||||
|
try {
|
||||||
|
await apiCall('laitetila_delete', 'POST', { id: currentLaitetila.id });
|
||||||
|
currentLaitetila = null;
|
||||||
|
showLaitetilatListView();
|
||||||
|
loadLaitetilat();
|
||||||
|
} catch (e) { alert('Poisto epäonnistui: ' + e.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uusi laitetila
|
||||||
|
document.getElementById('btn-new-laitetila')?.addEventListener('click', () => { openLaitetilaEdit(null); });
|
||||||
|
|
||||||
|
function openLaitetilaEdit(tila) {
|
||||||
|
document.getElementById('laitetila-edit-id').value = tila?.id || '';
|
||||||
|
document.getElementById('laitetila-edit-nimi').value = tila?.nimi || '';
|
||||||
|
document.getElementById('laitetila-edit-osoite').value = tila?.osoite || '';
|
||||||
|
document.getElementById('laitetila-edit-kuvaus').value = tila?.kuvaus || '';
|
||||||
|
document.getElementById('laitetila-edit-title').textContent = tila ? 'Muokkaa laitetilaa' : 'Uusi laitetila';
|
||||||
|
showLaitetilaEditView();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lomakkeen lähetys
|
||||||
|
document.getElementById('laitetila-edit-form')?.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = document.getElementById('laitetila-edit-id').value;
|
||||||
|
const tilaData = {
|
||||||
|
id: id || undefined,
|
||||||
|
nimi: document.getElementById('laitetila-edit-nimi').value.trim(),
|
||||||
|
osoite: document.getElementById('laitetila-edit-osoite').value.trim(),
|
||||||
|
kuvaus: document.getElementById('laitetila-edit-kuvaus').value.trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!tilaData.nimi) { alert('Nimi on pakollinen'); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = await apiCall('laitetila_save', 'POST', tilaData);
|
||||||
|
currentLaitetila = saved;
|
||||||
|
renderLaitetilaReadView();
|
||||||
|
showLaitetilaReadView();
|
||||||
|
loadLaitetilat();
|
||||||
|
} catch (e) { alert('Tallennus epäonnistui: ' + e.message); }
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== MODUULIT ====================
|
// ==================== 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'];
|
const DEFAULT_MODULES = ['customers', 'support', 'archive', 'changelog', 'settings'];
|
||||||
|
|
||||||
function applyModules(modules) {
|
function applyModules(modules) {
|
||||||
|
|||||||
150
style.css
150
style.css
@@ -1558,3 +1558,153 @@ span.empty {
|
|||||||
background: #e8f0fe;
|
background: #e8f0fe;
|
||||||
color: #1a56db;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user