added new stuff for asset management
This commit is contained in:
@@ -3,104 +3,104 @@
|
|||||||
class AssetManagementController extends TTCrud
|
class AssetManagementController extends TTCrud
|
||||||
{
|
{
|
||||||
protected string $headerTitle = 'Anlagenverwaltung';
|
protected string $headerTitle = 'Anlagenverwaltung';
|
||||||
protected string $singleText = 'Anlage';
|
|
||||||
protected bool $createText = false;
|
protected bool $createText = false;
|
||||||
|
|
||||||
|
// Simplified columns for better layout, details are in the 'assetDetails' slot
|
||||||
protected array $columns = [
|
protected array $columns = [
|
||||||
['key' => 'id', 'text' => 'ID', 'modal' => false, 'table' => false],
|
['key' => 'assetDetails', 'text' => 'Gerät', 'modal' => false, 'table' => ['sortable' => false, 'filter' => ['type' => 'search', 'key' => 'name|assetNumber|description']]],
|
||||||
['key' => 'name', 'text' => 'Gerät', 'required' => true, 'modal' => ['type' => 'text']],
|
['key' => 'currentUser', 'text' => 'Status', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]],
|
||||||
['key' => 'assetNumber', 'text' => 'Kennzeichen / Nr.', 'required' => true, 'modal' => ['type' => 'text']],
|
['key' => 'location', 'text' => 'Lagerort', 'required' => true, 'modal' => ['type' => 'text'], 'table' => ['filter' => 'search']],
|
||||||
['key' => 'currentUser', 'text' => 'Akt. Mitarbeiter', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]],
|
|
||||||
['key' => 'currentSite', 'text' => 'Akt. Baustelle', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]],
|
|
||||||
['key' => 'borrowDate', 'text' => 'Ausgeliehen seit', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]],
|
|
||||||
['key' => 'location', 'text' => 'Lagerort', 'required' => true, 'modal' => ['type' => 'text']],
|
|
||||||
['key' => 'description', 'text' => 'Beschreibung', 'modal' => ['type' => 'text'], 'table' => false],
|
|
||||||
['key' => 'serviceDueDate', 'text' => 'Service fällig', 'required' => false, 'modal' => ['type' => 'date'], 'table' => ['filter' => 'date']],
|
['key' => 'serviceDueDate', 'text' => 'Service fällig', 'required' => false, 'modal' => ['type' => 'date'], 'table' => ['filter' => 'date']],
|
||||||
['key' => 'journal', 'text' => 'Historie', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]],
|
['key' => 'journal', 'text' => 'Historie', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]],
|
||||||
['key' => 'actions', 'text' => 'Aktionen', 'modal' => false, 'table' => ['filter' => false, 'sortable' => false]],
|
['key' => 'actions', 'text' => 'Aktionen', 'modal' => false, 'table' => ['filter' => false, 'sortable' => false]],
|
||||||
];
|
];
|
||||||
|
|
||||||
protected array $additionalJSVariables = ['ASSET_ADMIN' => true];
|
protected array $additionalJSVariables = ['ASSET_ADMIN' => '1']; // Default to true
|
||||||
|
|
||||||
protected function prepareCrudConfig() {
|
protected function prepareCrudConfig() {
|
||||||
if ($this->user->can('AssetAdmin')) return;
|
// Restrict actions if the user does not have the 'AssetAdmin' permission.
|
||||||
$this->columns = array_filter($this->columns, function ($column) {
|
if (!$this->user->can('AssetAdmin')) {
|
||||||
return $column['key'] != 'actions';
|
$this->additionalJSVariables['ASSET_ADMIN'] = '0';
|
||||||
});
|
$this->columns = array_filter($this->columns, fn($col) => !in_array($col['key'], ['actions', 'journal']));
|
||||||
|
}
|
||||||
$this->additionalJSVariables['ASSET_ADMIN'] = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
protected function getAction()
|
protected function getAction()
|
||||||
{
|
{
|
||||||
$filter = $this->postData['filters'] ?? [];
|
|
||||||
$order = $this->postData['order'] ?? ['key' => null, 'order' => 'ASC'];
|
|
||||||
$page = $this->postData['pagination']['page'] ?? 1;
|
|
||||||
$perPage = $this->postData['pagination']['per_page'] ?? 10;
|
|
||||||
|
|
||||||
if ($order['key'] === null && isset($this->defaultOrder)) {
|
|
||||||
$order = $this->defaultOrder;
|
|
||||||
}
|
|
||||||
|
|
||||||
$json = json_decode(file_get_contents('php://input'), true);
|
$json = json_decode(file_get_contents('php://input'), true);
|
||||||
$assets = AssetManagementModel::getAll($filter, $perPage, ($page - 1) * $perPage, $order);
|
$pagination = $json['pagination'] ?? ['page' => 1, 'per_page' => 10];
|
||||||
|
$filters = $json['filters'] ?? [];
|
||||||
|
$order = $json['order'] ?? ['key' => 'id', 'order' => 'DESC'];
|
||||||
|
|
||||||
|
// Fetch paginated assets
|
||||||
|
$assets = AssetManagementModel::getAll($filters, $pagination['per_page'], ($pagination['page'] - 1) * $pagination['per_page'], $order);
|
||||||
|
$totalCount = AssetManagementModel::count($filters);
|
||||||
$assetIds = array_map(fn($asset) => $asset->id, $assets);
|
$assetIds = array_map(fn($asset) => $asset->id, $assets);
|
||||||
|
|
||||||
if (empty($assetIds)) {
|
if (empty($assetIds)) {
|
||||||
self::returnJson(['rows' => [], 'pagination' => ['total_rows' => 0, 'total_pages' => 1, 'page' => 1, 'per_page' => 10, 'filtered_available' => 0]]);
|
self::returnJson(['rows' => [], 'pagination' => ['total_rows' => 0, 'total_pages' => 1, 'page' => 1, 'per_page' => $pagination['per_page'], 'filtered_available' => 0]]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$latestJournalEntries = AssetManagementJournalModel::getLatestOpenEntries($assetIds);
|
||||||
|
|
||||||
// Get the latest open journal entry for each asset
|
// Fetch other related data
|
||||||
$journalEntries = AssetManagementJournalModel::getLatestOpenEntries($assetIds);
|
$reservations = AssetManagementReservationModel::getAll(['assetId' => $assetIds]);
|
||||||
$journalMap = [];
|
|
||||||
foreach ($journalEntries as $entry) {
|
|
||||||
// Only map it if it's not returned
|
|
||||||
if ($entry->returnDate === null) {
|
|
||||||
$journalMap[$entry->assetId] = $entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$users = UserModel::search(['employee' => true]);
|
$users = UserModel::search(['employee' => true]);
|
||||||
$userMap = array_reduce($users, function ($carry, $user) {
|
|
||||||
$carry[$user->id] = $user->name;
|
// Create maps for efficient lookup
|
||||||
return $carry;
|
$journalMap = array_reduce($latestJournalEntries, fn($carry, $item) => $carry + [$item->assetId => $item], []);
|
||||||
}, []);
|
$userMap = array_reduce($users, fn($carry, $user) => $carry + [$user->id => $user->name], []);
|
||||||
|
|
||||||
|
$reservationMap = [];
|
||||||
|
foreach ($reservations as $res) {
|
||||||
|
if (!isset($reservationMap[$res->assetId])) $reservationMap[$res->assetId] = [];
|
||||||
|
$res->userName = $userMap[$res->userId] ?? 'Unbekannt';
|
||||||
|
$reservationMap[$res->assetId][] = $res;
|
||||||
|
}
|
||||||
|
|
||||||
$rows = [];
|
$rows = [];
|
||||||
foreach ($assets as $asset) {
|
foreach ($assets as $asset) {
|
||||||
$row = (array)$asset;
|
$row = (array)$asset;
|
||||||
$latestJournal = $journalMap[$asset->id] ?? null;
|
$latestJournal = $journalMap[$asset->id] ?? null;
|
||||||
|
|
||||||
$row['journalId'] = $latestJournal->id ?? null;
|
// Determine current status based on the latest journal entry.
|
||||||
$row['currentUser'] = $latestJournal ? ($userMap[$latestJournal->userId] ?? 'Unbekannt') : null;
|
// If the latest entry has a returnDate, the asset is considered available.
|
||||||
$row['currentUserId'] = $latestJournal->userId ?? null;
|
if ($latestJournal && $latestJournal->returnDate === null) {
|
||||||
$row['currentSite'] = $latestJournal->site ?? null;
|
$row['journalId'] = $latestJournal->id;
|
||||||
$row['borrowDate'] = $latestJournal->borrowDate ?? null;
|
$row['currentUser'] = $userMap[$latestJournal->userId] ?? 'Unbekannt';
|
||||||
|
$row['currentUserId'] = $latestJournal->userId;
|
||||||
|
$row['currentSite'] = $latestJournal->site;
|
||||||
|
$row['borrowDate'] = $latestJournal->borrowDate;
|
||||||
|
$row['expectedReturnDate'] = $latestJournal->expectedReturnDate;
|
||||||
|
} else {
|
||||||
|
// Asset is available
|
||||||
|
$row['journalId'] = null;
|
||||||
|
$row['currentUser'] = null;
|
||||||
|
$row['currentUserId'] = null;
|
||||||
|
$row['currentSite'] = null;
|
||||||
|
$row['borrowDate'] = null;
|
||||||
|
$row['expectedReturnDate'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row['reservations'] = $reservationMap[$asset->id] ?? [];
|
||||||
$rows[] = $row;
|
$rows[] = $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple pagination/filtering after getting all data
|
|
||||||
// For larger datasets, this should be done in the SQL query
|
|
||||||
$totalRows = count($rows);
|
|
||||||
$pagination = $json['pagination'] ?? ['page' => 1, 'per_page' => 10];
|
|
||||||
$paginatedRows = array_slice($rows, ($pagination['page'] - 1) * $pagination['per_page'], $pagination['per_page']);
|
|
||||||
|
|
||||||
self::returnJson([
|
self::returnJson([
|
||||||
'rows' => $paginatedRows,
|
'rows' => $rows,
|
||||||
'pagination' => [
|
'pagination' => [
|
||||||
'page' => $pagination['page'],
|
'page' => $pagination['page'],
|
||||||
'per_page' => $pagination['per_page'],
|
'per_page' => $pagination['per_page'],
|
||||||
'total_rows' => $totalRows,
|
'total_rows' => $totalCount,
|
||||||
'total_pages' => ceil($totalRows / $pagination['per_page'])
|
'total_pages' => ceil($totalCount / $pagination['per_page']),
|
||||||
|
'filtered_available' => $totalCount
|
||||||
]
|
]
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function suggestAssetNumberAction()
|
protected function suggestAssetNumberAction()
|
||||||
{
|
{
|
||||||
$lastAsset = AssetManagementModel::getAll(['assetNumber' => 'XI%'],1,0, ['order' => 'DESC', 'key' => 'id'])[0];
|
$lastAsset = AssetManagementModel::getAll(['assetNumber' => 'XI%'], 1, 0, ['order' => 'DESC', 'key' => 'id'])[0] ?? null;
|
||||||
if (!$lastAsset || !preg_match('/XI(\d+)/', $lastAsset->assetNumber, $matches)) {
|
if (!$lastAsset || !preg_match('/XI(\d+)/', $lastAsset->assetNumber, $matches)) {
|
||||||
$nextNumber = 1;
|
$nextNumber = 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -114,14 +114,35 @@ class AssetManagementController extends TTCrud
|
|||||||
{
|
{
|
||||||
$post = json_decode(file_get_contents('php://input'), true);
|
$post = json_decode(file_get_contents('php://input'), true);
|
||||||
if (empty($post['assetId']) || empty($post['userId']) || empty($post['site'])) {
|
if (empty($post['assetId']) || empty($post['userId']) || empty($post['site'])) {
|
||||||
self::sendError("Alle Felder sind erforderlich.");
|
self::sendError("Alle erforderlichen Felder wurden nicht ausgefüllt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for conflicting reservations if not forced
|
||||||
|
if (empty($post['force'])) {
|
||||||
|
$now = time();
|
||||||
|
$conflictingReservations = AssetManagementReservationModel::dbSelect("
|
||||||
|
SELECT * FROM AssetManagementReservation
|
||||||
|
WHERE assetId = {$post['assetId']} AND startDate <= $now AND (endDate IS NULL OR endDate >= $now)
|
||||||
|
");
|
||||||
|
|
||||||
|
if (!empty($conflictingReservations)) {
|
||||||
|
$res = $conflictingReservations[0];
|
||||||
|
$user = UserModel::getOne($res->userId);
|
||||||
|
self::returnJson([
|
||||||
|
'success' => false,
|
||||||
|
'warning' => 'conflict',
|
||||||
|
'message' => "Dieses Gerät ist für {$user->name} reserviert. Trotzdem ausleihen?"
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AssetManagementJournalModel::create([
|
AssetManagementJournalModel::create([
|
||||||
'assetId' => $post['assetId'],
|
'assetId' => $post['assetId'],
|
||||||
'userId' => $post['userId'],
|
'userId' => $post['userId'],
|
||||||
'site' => $post['site'],
|
'site' => $post['site'],
|
||||||
'borrowReason' => $post['reason'],
|
'borrowReason' => $post['reason'] ?? null,
|
||||||
|
'expectedReturnDate' => $post['expectedReturnDate'] ?? null,
|
||||||
'borrowDate' => time(),
|
'borrowDate' => time(),
|
||||||
'createBy' => $this->user->id,
|
'createBy' => $this->user->id,
|
||||||
'create' => time(),
|
'create' => time(),
|
||||||
@@ -133,14 +154,10 @@ class AssetManagementController extends TTCrud
|
|||||||
protected function returnAction()
|
protected function returnAction()
|
||||||
{
|
{
|
||||||
$post = json_decode(file_get_contents('php://input'), true);
|
$post = json_decode(file_get_contents('php://input'), true);
|
||||||
if (empty($post['journalId'])) {
|
if (empty($post['journalId'])) self::sendError("Journal-Eintrag nicht gefunden.");
|
||||||
self::sendError("Journal-Eintrag nicht gefunden.");
|
|
||||||
}
|
|
||||||
|
|
||||||
$journalEntry = AssetManagementJournalModel::get($post['journalId']);
|
$journalEntry = AssetManagementJournalModel::get($post['journalId']);
|
||||||
if (!$journalEntry) {
|
if (!$journalEntry) self::sendError("Journal-Eintrag nicht gefunden.");
|
||||||
self::sendError("Journal-Eintrag nicht gefunden.");
|
|
||||||
}
|
|
||||||
|
|
||||||
$journalEntry->returnDate = time();
|
$journalEntry->returnDate = time();
|
||||||
$journalEntry->returnReason = $post['reason'] ?? 'Zurückgegeben';
|
$journalEntry->returnReason = $post['reason'] ?? 'Zurückgegeben';
|
||||||
@@ -153,18 +170,64 @@ class AssetManagementController extends TTCrud
|
|||||||
{
|
{
|
||||||
if (empty($this->request->assetId)) self::sendError("Asset ID fehlt.");
|
if (empty($this->request->assetId)) self::sendError("Asset ID fehlt.");
|
||||||
$entries = AssetManagementJournalModel::getAll(['assetId' => $this->request->assetId], null, 0, ['key' => 'borrowDate', 'order' => 'DESC']);
|
$entries = AssetManagementJournalModel::getAll(['assetId' => $this->request->assetId], null, 0, ['key' => 'borrowDate', 'order' => 'DESC']);
|
||||||
|
|
||||||
// Enhance with user names
|
|
||||||
$users = UserModel::search(['employee' => true]);
|
$users = UserModel::search(['employee' => true]);
|
||||||
$userMap = array_reduce($users, function ($carry, $user) {
|
$userMap = array_reduce($users, fn($carry, $user) => $carry + [$user->id => $user->name], []);
|
||||||
$carry[$user->id] = $user->name;
|
|
||||||
return $carry;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
foreach ($entries as $entry) {
|
foreach ($entries as $entry) {
|
||||||
$entry->userName = $userMap[$entry->userId] ?? 'Unbekannt';
|
$entry->userName = $userMap[$entry->userId] ?? 'Unbekannt';
|
||||||
}
|
}
|
||||||
|
|
||||||
self::returnJson($entries);
|
self::returnJson($entries);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
protected function uploadFileAction()
|
||||||
|
{
|
||||||
|
$file = $_FILES['file'] ?? null;
|
||||||
|
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
self::returnJson(['error' => 'File upload failed']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$uploaded = mfUpload::handleFormUpload("file", false, "/AssetManagement");
|
||||||
|
self::returnJson(['success' => true, 'fileId' => $uploaded->id]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
self::returnJson(['error' => 'Upload error: ' . $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getReservationsAction() {
|
||||||
|
if (empty($this->request->assetId)) self::sendError("Asset ID fehlt.");
|
||||||
|
$reservations = AssetManagementReservationModel::getAll(['assetId' => $this->request->assetId], null, 0, ['key' => 'startDate', 'order' => 'ASC']);
|
||||||
|
$users = UserModel::search(['employee' => true]);
|
||||||
|
$userMap = array_reduce($users, fn($carry, $user) => $carry + [$user->id => $user->name], []);
|
||||||
|
foreach ($reservations as $res) {
|
||||||
|
$res->userName = $userMap[$res->userId] ?? 'Unbekannt';
|
||||||
|
}
|
||||||
|
self::returnJson($reservations);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function createReservationAction() {
|
||||||
|
$post = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (empty($post['assetId']) || empty($post['userId']) || empty($post['startDate'])) {
|
||||||
|
self::sendError("Alle erforderlichen Felder sind nicht ausgefüllt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetManagementReservationModel::create([
|
||||||
|
'assetId' => $post['assetId'],
|
||||||
|
'userId' => $post['userId'],
|
||||||
|
'startDate' => $post['startDate'],
|
||||||
|
'endDate' => $post['endDate'] ?? null,
|
||||||
|
'notes' => $post['notes'] ?? null,
|
||||||
|
'createBy' => $this->user->id,
|
||||||
|
'create' => time()
|
||||||
|
]);
|
||||||
|
self::returnJson(['success' => true, 'message' => 'Reservierung erfolgreich erstellt.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function deleteReservationAction() {
|
||||||
|
$post = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (empty($post['id'])) self::sendError("Reservierungs-ID fehlt.");
|
||||||
|
AssetManagementReservationModel::delete($post['id']);
|
||||||
|
self::returnJson(['success' => true, 'message' => 'Reservierung gelöscht.']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ class AssetManagementModel extends TTCrudBaseModel {
|
|||||||
public int $id;
|
public int $id;
|
||||||
public string $name;
|
public string $name;
|
||||||
public ?string $description;
|
public ?string $description;
|
||||||
|
public ?int $imageId;
|
||||||
public string $assetNumber;
|
public string $assetNumber;
|
||||||
public string $location;
|
public string $location;
|
||||||
public ?string $serviceDueDate;
|
public ?int $serviceDueDate;
|
||||||
|
public ?int $mustReturnDate;
|
||||||
public int $create;
|
public int $create;
|
||||||
public int $createBy;
|
public int $createBy;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class AssetManagementJournalModel extends TTCrudBaseModel {
|
|||||||
public int $userId;
|
public int $userId;
|
||||||
public string $site;
|
public string $site;
|
||||||
public int $borrowDate;
|
public int $borrowDate;
|
||||||
|
public ?int $expectedReturnDate;
|
||||||
public ?int $returnDate;
|
public ?int $returnDate;
|
||||||
public ?string $borrowReason;
|
public ?string $borrowReason;
|
||||||
public ?string $returnReason;
|
public ?string $returnReason;
|
||||||
@@ -16,11 +17,15 @@ class AssetManagementJournalModel extends TTCrudBaseModel {
|
|||||||
public static function getLatestOpenEntries($assetIds): array {
|
public static function getLatestOpenEntries($assetIds): array {
|
||||||
$db = self::getDB();
|
$db = self::getDB();
|
||||||
$table = self::getFullyQualifiedTable();
|
$table = self::getFullyQualifiedTable();
|
||||||
$sql = "SELECT j1.*
|
$assetIdString = implode(',', array_map('intval', (array)$assetIds));
|
||||||
FROM AssetManagementJournal j1
|
$sql = "
|
||||||
LEFT JOIN AssetManagementJournal j2
|
SELECT j.*
|
||||||
ON j1.assetId = j2.assetId AND j1.borrowDate < j2.borrowDate
|
FROM (
|
||||||
WHERE j2.id IS NULL AND j1.assetId IN (" . implode(',', $assetIds) . ")";
|
SELECT *, ROW_NUMBER() OVER (PARTITION BY assetId ORDER BY borrowDate DESC, id DESC) as rn
|
||||||
|
FROM AssetManagementJournal
|
||||||
|
WHERE assetId IN ($assetIdString)
|
||||||
|
) j
|
||||||
|
WHERE j.rn = 1";
|
||||||
$result = $db->query($sql);
|
$result = $db->query($sql);
|
||||||
|
|
||||||
$entries = [];
|
$entries = [];
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class AssetManagementReservationModel extends TTCrudBaseModel {
|
||||||
|
public int $id;
|
||||||
|
public int $assetId;
|
||||||
|
public int $userId;
|
||||||
|
public int $startDate;
|
||||||
|
public ?int $endDate;
|
||||||
|
public ?string $notes;
|
||||||
|
public int $createBy;
|
||||||
|
public int $create;
|
||||||
|
}
|
||||||
@@ -88,4 +88,25 @@ class FileController extends mfBaseController {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function showAction() {
|
||||||
|
$id = $this->request->id;
|
||||||
|
if (!is_numeric($id) || $id < 1) return true;
|
||||||
|
|
||||||
|
$file = new File($id);
|
||||||
|
if (!$file || !$file->id) throw new Exception("File record not found", 404);
|
||||||
|
|
||||||
|
$path = MFUPLOAD_FILE_SAVE_PATH . ($file->subfolder ? "/{$file->subfolder}" : "") . "/{$file->store_filename}";
|
||||||
|
|
||||||
|
if (!is_readable($path)) throw new Exception("Physical file not found", 4041);
|
||||||
|
|
||||||
|
if (($imageInfo = @getimagesize($path)) !== false) {
|
||||||
|
header('Content-Type: ' . $imageInfo['mime']);
|
||||||
|
header('Content-Disposition: inline; filename="' . ($file->orig_filename ?: $file->store_filename) . '"');
|
||||||
|
readfile($path);
|
||||||
|
exit;
|
||||||
|
} else {
|
||||||
|
throw new Exception("File is not a displayable image.", 415);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Migration\AbstractMigration;
|
||||||
|
|
||||||
|
final class AssetManagementSchemaV2 extends AbstractMigration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Implements schema changes for Asset Management v2
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// 1. Add columns to AssetManagement table
|
||||||
|
$assetManagement = $this->table('AssetManagement');
|
||||||
|
$assetManagement
|
||||||
|
->addColumn('imageId', 'integer', [
|
||||||
|
'null' => true,
|
||||||
|
'default' => null,
|
||||||
|
'signed' => false,
|
||||||
|
'comment' => 'Foreign key to the File table for the asset image',
|
||||||
|
'after' => 'description'
|
||||||
|
])
|
||||||
|
->addColumn('mustReturnDate', 'integer', [
|
||||||
|
'null' => true,
|
||||||
|
'default' => null,
|
||||||
|
'signed' => false,
|
||||||
|
'comment' => 'Mandatory return date for rented items (Unix timestamp)',
|
||||||
|
'after' => 'serviceDueDate'
|
||||||
|
])
|
||||||
|
->update();
|
||||||
|
|
||||||
|
// 2. Add column to AssetManagementJournal table
|
||||||
|
$journalTable = $this->table('AssetManagementJournal');
|
||||||
|
$journalTable
|
||||||
|
->addColumn('expectedReturnDate', 'integer', [
|
||||||
|
'null' => true,
|
||||||
|
'default' => null,
|
||||||
|
'signed' => false,
|
||||||
|
'comment' => 'Expected return date set upon borrowing (Unix timestamp)',
|
||||||
|
'after' => 'borrowDate'
|
||||||
|
])
|
||||||
|
->update();
|
||||||
|
|
||||||
|
// 3. Create AssetManagementReservation table
|
||||||
|
$reservationTable = $this->table('AssetManagementReservation', [
|
||||||
|
'id' => false,
|
||||||
|
'primary_key' => ['id'],
|
||||||
|
'engine' => 'InnoDB',
|
||||||
|
'comment' => 'Asset reservation system'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$reservationTable
|
||||||
|
->addColumn('id', 'integer', [
|
||||||
|
'identity' => true,
|
||||||
|
'signed' => false,
|
||||||
|
'null' => false
|
||||||
|
])
|
||||||
|
->addColumn('assetId', 'integer', [
|
||||||
|
'signed' => false,
|
||||||
|
'null' => false,
|
||||||
|
'comment' => 'Foreign key to the AssetManagement table'
|
||||||
|
])
|
||||||
|
->addColumn('userId', 'integer', [
|
||||||
|
'signed' => false,
|
||||||
|
'null' => false,
|
||||||
|
'comment' => 'User ID of the employee who made the reservation'
|
||||||
|
])
|
||||||
|
->addColumn('startDate', 'integer', [
|
||||||
|
'signed' => false,
|
||||||
|
'null' => false,
|
||||||
|
'comment' => 'Start date of the reservation (Unix timestamp)'
|
||||||
|
])
|
||||||
|
->addColumn('endDate', 'integer', [
|
||||||
|
'signed' => false,
|
||||||
|
'null' => true,
|
||||||
|
'default' => null,
|
||||||
|
'comment' => 'End date of the reservation (Unix timestamp). NULL means permanent.'
|
||||||
|
])
|
||||||
|
->addColumn('notes', 'text', [
|
||||||
|
'null' => true,
|
||||||
|
'default' => null,
|
||||||
|
'comment' => 'Notes for the reservation'
|
||||||
|
])
|
||||||
|
->addColumn('createBy', 'integer', [
|
||||||
|
'signed' => false,
|
||||||
|
'null' => false
|
||||||
|
])
|
||||||
|
->addColumn('create', 'integer', [
|
||||||
|
'signed' => false,
|
||||||
|
'null' => false
|
||||||
|
])
|
||||||
|
->addIndex(['assetId'])
|
||||||
|
->create();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverts the schema changes
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Remove columns from AssetManagement
|
||||||
|
$this->table('AssetManagement')
|
||||||
|
->removeColumn('imageId')
|
||||||
|
->removeColumn('mustReturnDate')
|
||||||
|
->update();
|
||||||
|
|
||||||
|
// Remove column from AssetManagementJournal
|
||||||
|
$this->table('AssetManagementJournal')
|
||||||
|
->removeColumn('expectedReturnDate')
|
||||||
|
->update();
|
||||||
|
|
||||||
|
// Drop reservation table
|
||||||
|
$this->table('AssetManagementReservation')->drop()->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
.asset-image-container {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.asset-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
.asset-image-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
@@ -1,53 +1,73 @@
|
|||||||
|
window.TT_CONFIG["CRUD_CONFIG"]["additionalActions"] = [
|
||||||
|
{
|
||||||
|
"key": "reserve",
|
||||||
|
"title": "Reservieren",
|
||||||
|
"class": "fas fa-calendar-alt btn-outline-warning",
|
||||||
|
"condition": (row) => window.TT_CONFIG.ASSET_ADMIN === '1',
|
||||||
|
},
|
||||||
|
];
|
||||||
// =================================================================================
|
// =================================================================================
|
||||||
// Main Asset Management Component
|
// Main Asset Management Component
|
||||||
// =================================================================================
|
// =================================================================================
|
||||||
Vue.component('asset-management', {
|
Vue.component('asset-management', {
|
||||||
template: `
|
template: `
|
||||||
<tt-card>
|
<tt-card>
|
||||||
<asset-management-modal
|
<!-- Modals -->
|
||||||
v-if="modalId"
|
<asset-management-modal
|
||||||
:id="modalId"
|
v-if="modalId"
|
||||||
|
:id="modalId"
|
||||||
@close="modalId = null; $refs.table.$refs.table.refreshTable()"/>
|
@close="modalId = null; $refs.table.$refs.table.refreshTable()"/>
|
||||||
<asset-management-journal-modal
|
<asset-management-journal-modal
|
||||||
v-if="journalModalAssetId"
|
v-if="journalModalAssetId"
|
||||||
:asset-id="journalModalAssetId"
|
:asset-id="journalModalAssetId"
|
||||||
@close="journalModalAssetId = null"/>
|
@close="journalModalAssetId = null"/>
|
||||||
|
<asset-reservation-modal
|
||||||
|
v-if="reservationModalAsset"
|
||||||
|
:asset="reservationModalAsset"
|
||||||
|
@close="reservationModalAsset = null; $refs.table.$refs.table.refreshTable()"/>
|
||||||
|
|
||||||
<button @click="modalId = 'create'" class="btn btn-primary">Anlage erstellen</button>
|
<button v-if="window.TT_CONFIG.ASSET_ADMIN === '1'" @click="modalId = 'create'" class="btn btn-primary">Anlage erstellen</button>
|
||||||
|
|
||||||
<tt-table-crud
|
<tt-table-crud
|
||||||
ref="table"
|
ref="table"
|
||||||
@edit="window.console.log($event.id);modalId = $event.id"
|
|
||||||
emit-edit
|
emit-edit
|
||||||
|
@edit="modalId = $event.id"
|
||||||
|
@reserve="reservationModalAsset = $event"
|
||||||
:crud-config="crudConfig">
|
:crud-config="crudConfig">
|
||||||
|
|
||||||
|
<!-- Column 1: Asset Details (Image, Name, Number, etc.) -->
|
||||||
|
<template v-slot:assetdetails="{ row }">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<tt-asset-image :image-id="row.imageId" class="mr-3"/>
|
||||||
|
<div>
|
||||||
|
<strong>{{ row.name }}</strong><br>
|
||||||
|
<small class="text-muted">{{ row.assetNumber }}</small><br>
|
||||||
|
<small v-if="row.description">{{ row.description }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Column 2: Status (Borrow/Return widget) -->
|
||||||
<template v-slot:currentuser="{ row }">
|
<template v-slot:currentuser="{ row }">
|
||||||
<asset-borrow-return-widget
|
<asset-borrow-return-widget
|
||||||
v-if="window.TT_CONFIG.ASSET_ADMIN === '1'"
|
:row-data="row"
|
||||||
:row-data="row"
|
|
||||||
@update="$refs.table.$refs.table.refreshTable()"/>
|
@update="$refs.table.$refs.table.refreshTable()"/>
|
||||||
<span v-else>
|
|
||||||
{{ row.currentUser || 'Nicht ausgeliehen' }}
|
|
||||||
</span>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Column 3: Journal Button -->
|
||||||
<template v-slot:journal="{ row }">
|
<template v-slot:journal="{ row }">
|
||||||
<tt-button
|
<tt-button
|
||||||
sm
|
sm
|
||||||
icon="fas fa-history"
|
icon="fas fa-history"
|
||||||
@click="journalModalAssetId = row.id"
|
title="Historie"
|
||||||
|
@click="journalModalAssetId = row.id"
|
||||||
additional-class="btn-outline-info"/>
|
additional-class="btn-outline-info"/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Formatted Dates -->
|
||||||
<template v-slot:serviceduedate="{ row }">
|
<template v-slot:serviceduedate="{ row }">
|
||||||
<span v-if="row.serviceDueDate">
|
<span v-if="row.serviceDueDate" :class="{'text-danger font-weight-bold': isDatePast(row.serviceDueDate)}">
|
||||||
{{ window.moment.unix(row.serviceDueDate).format('DD.MM.YYYY') }}
|
{{ formatDate(row.serviceDueDate, 'DD.MM.YYYY') }}
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:borrowdate="{ row }">
|
|
||||||
<span v-if="row.borrowDate">
|
|
||||||
{{ window.moment.unix(row.borrowDate).format('DD.MM.YYYY HH:mm') }}
|
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -59,95 +79,171 @@ Vue.component('asset-management', {
|
|||||||
window: window,
|
window: window,
|
||||||
modalId: null,
|
modalId: null,
|
||||||
journalModalAssetId: null,
|
journalModalAssetId: null,
|
||||||
crudConfig: window.TT_CONFIG.CRUD_CONFIG, // Assumes config is passed globally
|
reservationModalAsset: null,
|
||||||
|
crudConfig: window.TT_CONFIG.CRUD_CONFIG,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
methods: {
|
||||||
|
formatDate(timestamp, format) {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
return window.moment.unix(timestamp).format(format);
|
||||||
|
},
|
||||||
|
isDatePast(timestamp) {
|
||||||
|
if (!timestamp) return false;
|
||||||
|
return window.moment.unix(timestamp).isBefore(window.moment(), 'day');
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// =================================================================================
|
// =================================================================================
|
||||||
// Asset Borrow/Return Widget Component (for the table column)
|
// Asset Image Component
|
||||||
|
// =================================================================================
|
||||||
|
Vue.component('tt-asset-image', {
|
||||||
|
props: {
|
||||||
|
imageId: Number,
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="asset-image-container">
|
||||||
|
<img v-if="imageId" :src="'/File/show?id=' + imageId" @error="onImageError" class="asset-image"/>
|
||||||
|
<div v-else class="asset-image-placeholder">
|
||||||
|
<i class="fas fa-camera"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
methods: {
|
||||||
|
onImageError(event) {
|
||||||
|
event.target.src = 'https://placehold.co/60x60/eee/ccc?text=Error'; // Fallback placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// =================================================================================
|
||||||
|
// Asset Borrow/Return Widget Component
|
||||||
// =================================================================================
|
// =================================================================================
|
||||||
Vue.component('asset-borrow-return-widget', {
|
Vue.component('asset-borrow-return-widget', {
|
||||||
props: {
|
props: {
|
||||||
rowData: { type: Object, required: true }
|
rowData: { type: Object, required: true }
|
||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
<div>
|
<div>
|
||||||
<div v-if="rowData.currentUserId" class="d-flex align-items-center">
|
<!-- Case 1: Asset is Borrowed -->
|
||||||
<span>{{ rowData.currentUser }}</span>
|
<div v-if="rowData.currentUserId">
|
||||||
<tt-button
|
<div><strong>Ausgeliehen von:</strong> {{ rowData.currentUser }}</div>
|
||||||
sm
|
<div><strong>Baustelle:</strong> {{ rowData.currentSite }}</div>
|
||||||
text="Zurück"
|
<div><strong>Seit:</strong> {{ formatDate(rowData.borrowDate, 'DD.MM.YYYY HH:mm') }}</div>
|
||||||
@click="showReturnModal = true"
|
<div v-if="rowData.expectedReturnDate">
|
||||||
additional-class="btn-success ml-2"/>
|
<strong>Vorauss. Rückgabe:</strong>
|
||||||
</div>
|
<span :class="{'text-danger': isDatePast(rowData.expectedReturnDate)}">
|
||||||
|
{{ formatDate(rowData.expectedReturnDate, 'DD.MM.YYYY') }}
|
||||||
<div v-else>
|
</span>
|
||||||
<tt-autocomplete
|
</div>
|
||||||
:label="null"
|
<tt-button
|
||||||
:api-url="userAutoCompleteUrl"
|
v-if="window.TT_CONFIG.ASSET_ADMIN === '1'"
|
||||||
placeholder="Mitarbeiter..."
|
sm
|
||||||
sm
|
text="Zurückgeben"
|
||||||
no-form-group
|
@click="showReturnModal = true"
|
||||||
v-model="selectedUserId"
|
additional-class="btn-success mt-2"/>
|
||||||
@input="onUserSelect"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<tt-modal v-if="showBorrowModal" :show="true" title="Gerät ausleihen" @update:show="showBorrowModal = false" @submit="borrowAsset">
|
|
||||||
<p><strong>Gerät:</strong> {{ rowData.name }}</p>
|
|
||||||
<p><strong>Mitarbeiter:</strong> {{ selectedUserName }}</p>
|
|
||||||
<tt-input label="Baustelle / Projekt" v-model="borrowSite" sm row required/>
|
|
||||||
<tt-textarea label="Grund (optional)" v-model="borrowReason" sm row required/>
|
|
||||||
</tt-modal>
|
|
||||||
|
|
||||||
<tt-modal v-if="showReturnModal" :show="true" title="Gerät zurückgeben" @update:show="showReturnModal = false" @submit="returnAsset">
|
|
||||||
<p><strong>Gerät:</strong> {{ rowData.name }}</p>
|
|
||||||
<p>Soll dieses Gerät wirklich als zurückgegeben markiert werden?</p>
|
|
||||||
<tt-textarea label="Bemerkung (optional)" v-model="returnReason" sm row/>
|
|
||||||
</tt-modal>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Case 2: Asset is Available -->
|
||||||
|
<div v-else>
|
||||||
|
<div class="text-success">Verfügbar</div>
|
||||||
|
<div v-if="activeReservation" class="text-warning small">
|
||||||
|
<i class="fas fa-clock"></i> Reserviert für {{ activeReservation.userName }}
|
||||||
|
</div>
|
||||||
|
<tt-autocomplete
|
||||||
|
v-if="window.TT_CONFIG.ASSET_ADMIN === '1'"
|
||||||
|
:label="null"
|
||||||
|
:api-url="userAutoCompleteUrl"
|
||||||
|
placeholder="Mitarbeiter für Ausleihe..."
|
||||||
|
sm
|
||||||
|
no-form-group
|
||||||
|
v-model="selectedUserId"
|
||||||
|
@input="onUserSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modals -->
|
||||||
|
<tt-modal v-if="showBorrowModal" :show.sync="showBorrowModal" title="Gerät ausleihen" @submit="borrowAsset()">
|
||||||
|
<div v-if="reservationWarning" class="alert alert-warning">{{ reservationWarning }}</div>
|
||||||
|
<p><strong>Gerät:</strong> {{ rowData.name }}</p>
|
||||||
|
<p><strong>Mitarbeiter:</strong> {{ selectedUserName }}</p>
|
||||||
|
<tt-input label="Baustelle / Projekt" v-model="borrowSite" sm row required/>
|
||||||
|
<tt-date-picker label="Vorauss. Rückgabedatum" v-model="expectedReturnDate" :date-range="false" sm row/>
|
||||||
|
<tt-textarea label="Grund (optional)" v-model="borrowReason" sm row/>
|
||||||
|
</tt-modal>
|
||||||
|
|
||||||
|
<tt-modal v-if="showReturnModal" :show.sync="showReturnModal" title="Gerät zurückgeben" @submit="returnAsset">
|
||||||
|
<p><strong>Gerät:</strong> {{ rowData.name }}</p>
|
||||||
|
<p>Soll dieses Gerät wirklich als zurückgegeben markiert werden?</p>
|
||||||
|
<tt-textarea label="Bemerkung (optional)" v-model="returnReason" sm row/>
|
||||||
|
</tt-modal>
|
||||||
|
</div>
|
||||||
`,
|
`,
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
window: window,
|
window: window,
|
||||||
userAutoCompleteUrl: window.TT_CONFIG.BASE_PATH + '/WarehouseShippingNote/userAutoComplete', // Re-using existing one
|
userAutoCompleteUrl: window.TT_CONFIG.BASE_PATH + '/WarehouseShippingNote/userAutoComplete',
|
||||||
selectedUserId: null,
|
selectedUserId: null,
|
||||||
selectedUserName: '',
|
selectedUserName: '',
|
||||||
showBorrowModal: false,
|
showBorrowModal: false,
|
||||||
showReturnModal: false,
|
showReturnModal: false,
|
||||||
borrowSite: '',
|
borrowSite: '',
|
||||||
borrowReason: '',
|
borrowReason: '',
|
||||||
returnReason: ''
|
returnReason: '',
|
||||||
|
expectedReturnDate: null,
|
||||||
|
reservationWarning: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
activeReservation() {
|
||||||
|
if (!this.rowData.reservations || this.rowData.reservations.length === 0) return null;
|
||||||
|
const now = window.moment().unix();
|
||||||
|
return this.rowData.reservations.find(r => r.startDate <= now && (r.endDate === null || r.endDate >= now));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
formatDate(timestamp, format) {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
return window.moment.unix(timestamp).format(format);
|
||||||
|
},
|
||||||
|
isDatePast(timestamp) {
|
||||||
|
if (!timestamp) return false;
|
||||||
|
return window.moment.unix(timestamp).isBefore(window.moment(), 'day');
|
||||||
|
},
|
||||||
async onUserSelect(userId) {
|
async onUserSelect(userId) {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
this.selectedUserId = userId;
|
this.selectedUserId = userId;
|
||||||
|
const response = await axios.get(`${this.userAutoCompleteUrl}?searchedID=${userId}`);
|
||||||
// Fetch user name to display in modal
|
|
||||||
const response = await axios.get(`${this.userAutoCompleteUrl}&searchedID=${userId}`);
|
|
||||||
this.selectedUserName = response.data[0]?.text || 'Unbekannt';
|
this.selectedUserName = response.data[0]?.text || 'Unbekannt';
|
||||||
|
|
||||||
this.showBorrowModal = true;
|
this.showBorrowModal = true;
|
||||||
},
|
},
|
||||||
async borrowAsset() {
|
async borrowAsset(force = false) {
|
||||||
if (!this.borrowSite) {
|
if (!this.borrowSite) {
|
||||||
return window.notify('error', 'Bitte Baustelle/Projekt angeben.');
|
return window.notify('error', 'Bitte Baustelle/Projekt angeben.');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/borrow`, {
|
const payload = {
|
||||||
assetId: this.rowData.id,
|
assetId: this.rowData.id,
|
||||||
userId: this.selectedUserId,
|
userId: this.selectedUserId,
|
||||||
site: this.borrowSite,
|
site: this.borrowSite,
|
||||||
reason: this.borrowReason
|
reason: this.borrowReason,
|
||||||
});
|
expectedReturnDate: this.expectedReturnDate,
|
||||||
|
force: force
|
||||||
|
};
|
||||||
|
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/borrow`, payload);
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
window.notify('success', response.data.message);
|
window.notify('success', response.data.message);
|
||||||
this.showBorrowModal = false;
|
this.showBorrowModal = false;
|
||||||
this.$emit('update');
|
this.$emit('update');
|
||||||
} else {
|
} else if (response.data.warning === 'conflict') {
|
||||||
|
if (confirm(response.data.message)) {
|
||||||
|
this.borrowAsset(true); // Retry with force flag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
window.notify('error', response.data.message || 'Fehler beim Ausleihen.');
|
window.notify('error', response.data.message || 'Fehler beim Ausleihen.');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -181,28 +277,41 @@ Vue.component('asset-borrow-return-widget', {
|
|||||||
Vue.component('asset-management-modal', {
|
Vue.component('asset-management-modal', {
|
||||||
props: ['id'],
|
props: ['id'],
|
||||||
template: `
|
template: `
|
||||||
<tt-modal
|
<tt-modal
|
||||||
:show="true"
|
:show="true"
|
||||||
:title="isCreateMode ? 'Gerät anlegen' : 'Gerät bearbeiten'"
|
:title="isCreateMode ? 'Gerät anlegen' : 'Gerät bearbeiten'"
|
||||||
@update:show="$emit('close')"
|
@update:show="$emit('close')"
|
||||||
@submit="submit"
|
@submit="submit"
|
||||||
:delete="!isCreateMode"
|
:delete="!isCreateMode"
|
||||||
@delete="deleteAsset"
|
@delete="deleteAsset"
|
||||||
>
|
>
|
||||||
<tt-input label="Gerätename" v-model="asset.name" sm row required/>
|
<div class="row">
|
||||||
<tt-input label="Kennzeichen / Nr." v-model="asset.assetNumber" sm row required/>
|
<div class="col-md-4 text-center">
|
||||||
<tt-input label="Lagerort" v-model="asset.location" sm row required/>
|
<tt-asset-image :image-id="asset.imageId" class="mb-2"/>
|
||||||
<tt-date-picker label="Nächstes Service" v-model="asset.serviceDueDate" sm row :date-range="false"/>
|
<input type="file" @change="handleFileUpload" class="form-control-file" accept="image/*"/>
|
||||||
<tt-textarea label="Beschreibung" v-model="asset.description" sm row/>
|
</div>
|
||||||
</tt-modal>
|
<div class="col-md-8">
|
||||||
|
<tt-input label="Gerätename" v-model="asset.name" sm required/>
|
||||||
|
<tt-input label="Kennzeichen / Nr." v-model="asset.assetNumber" sm required/>
|
||||||
|
<tt-input label="Lagerort" v-model="asset.location" sm required/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr/>
|
||||||
|
<tt-date-picker label="Nächstes Service" v-model="asset.serviceDueDate" :date-range="false" sm row/>
|
||||||
|
<tt-date-picker label="Muss zurück am (für Mietgeräte)" v-model="asset.mustReturnDate" :date-range="false" sm row/>
|
||||||
|
<tt-textarea label="Beschreibung" v-model="asset.description" sm row/>
|
||||||
|
</tt-modal>
|
||||||
`,
|
`,
|
||||||
data(){
|
data(){
|
||||||
return {
|
return {
|
||||||
asset: {
|
asset: {
|
||||||
name: '',
|
name: '',
|
||||||
|
description: '',
|
||||||
assetNumber: '',
|
assetNumber: '',
|
||||||
location: 'Liftkammer',
|
location: 'Liftkammer',
|
||||||
serviceDueDate: null
|
serviceDueDate: null,
|
||||||
|
mustReturnDate: null,
|
||||||
|
imageId: null,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -212,7 +321,6 @@ Vue.component('asset-management-modal', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
console.log('AssetManagementModal mounted with id:', this.id, this.isCreateMode);
|
|
||||||
if (!this.isCreateMode) {
|
if (!this.isCreateMode) {
|
||||||
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/getById`, { params: { id: this.id } });
|
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/getById`, { params: { id: this.id } });
|
||||||
this.asset = response.data;
|
this.asset = response.data;
|
||||||
@@ -240,7 +348,6 @@ Vue.component('asset-management-modal', {
|
|||||||
},
|
},
|
||||||
async deleteAsset() {
|
async deleteAsset() {
|
||||||
if (!confirm('Soll dieses Gerät wirklich gelöscht werden?')) return;
|
if (!confirm('Soll dieses Gerät wirklich gelöscht werden?')) return;
|
||||||
// Also check for journal entries before deleting
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/delete`, { id: this.id });
|
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/delete`, { id: this.id });
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
@@ -258,6 +365,25 @@ Vue.component('asset-management-modal', {
|
|||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
this.$set(this.asset, 'assetNumber', response.data.assetNumber);
|
this.$set(this.asset, 'assetNumber', response.data.assetNumber);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
async handleFileUpload(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${window.TT_CONFIG["BASE_PATH"]}/AssetManagement/uploadFile`, formData);
|
||||||
|
if (response.data.success) {
|
||||||
|
this.$set(this.asset, 'imageId', response.data.fileId);
|
||||||
|
window.notify('success', `Bild erfolgreich hochgeladen.`);
|
||||||
|
} else {
|
||||||
|
window.notify('error', `Bild-Upload fehlgeschlagen: ${response.data.error || 'Unbekannter Fehler'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
window.notify('error', `Fehler beim Hochladen des Bildes.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -266,42 +392,36 @@ Vue.component('asset-management-modal', {
|
|||||||
// Asset Journal/History Modal
|
// Asset Journal/History Modal
|
||||||
// =================================================================================
|
// =================================================================================
|
||||||
Vue.component('asset-management-journal-modal', {
|
Vue.component('asset-management-journal-modal', {
|
||||||
props: {
|
props: { assetId: { type: Number, required: true } },
|
||||||
assetId: { type: Number, required: true }
|
|
||||||
},
|
|
||||||
template: `
|
template: `
|
||||||
<tt-modal :show="true" title="Gerätehistorie" :save="false" :delete="false" @update:show="$emit('close');">
|
<tt-modal :show="true" title="Gerätehistorie" :save="false" :delete="false" @update:show="$emit('close')">
|
||||||
<div v-if="loading" class="text-center"><i class="fas fa-spinner fa-spin"></i> Lade...</div>
|
<div v-if="loading" class="text-center"><i class="fas fa-spinner fa-spin"></i> Lade...</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div v-if="!journalEntries.length" class="text-center text-muted">Keine Einträge vorhanden.</div>
|
<div v-if="!journalEntries.length" class="text-center text-muted">Keine Einträge vorhanden.</div>
|
||||||
<ul v-else class="list-group">
|
<ul v-else class="list-group">
|
||||||
<li v-for="entry in journalEntries" class="list-group-item">
|
<li v-for="entry in journalEntries" class="list-group-item">
|
||||||
<div class="d-flex w-100 justify-content-between">
|
<div class="d-flex w-100 justify-content-between">
|
||||||
<h5 class="mb-1">{{ entry.userName }} @ {{ entry.site }}</h5>
|
<h5 class="mb-1">{{ entry.userName }} @ {{ entry.site }}</h5>
|
||||||
<small>{{ formatDate(entry.borrowDate) }}</small>
|
<small>{{ formatDate(entry.borrowDate, 'DD.MM.YY HH:mm') }}</small>
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-1"><strong>Grund:</strong> {{ entry.borrowReason }}</p>
|
<p class="mb-1"><strong>Grund:</strong> {{ entry.borrowReason || '-' }}</p>
|
||||||
<div v-if="entry.returnDate">
|
<div v-if="entry.returnDate">
|
||||||
<small class="text-success">
|
<small class="text-success">
|
||||||
<strong>Zurück am:</strong> {{ formatDate(entry.returnDate) }}
|
<strong>Zurück am:</strong> {{ formatDate(entry.returnDate, 'DD.MM.YY HH:mm') }}
|
||||||
<br>
|
<br>
|
||||||
<strong>Bemerkung:</strong> {{ entry.returnReason }}
|
<strong>Bemerkung:</strong> {{ entry.returnReason || '-' }}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<small class="text-warning"><strong>Aktuell ausgeliehen</strong></small>
|
<small class="text-warning"><strong>Aktuell ausgeliehen</strong></small><br>
|
||||||
</div>
|
<small v-if="entry.expectedReturnDate"><strong>Vorauss. Rückgabe:</strong> {{ formatDate(entry.expectedReturnDate, 'DD.MM.YYYY') }}</small>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
</li>
|
||||||
</div>
|
</ul>
|
||||||
</tt-modal>
|
</div>
|
||||||
|
</tt-modal>
|
||||||
`,
|
`,
|
||||||
data() {
|
data() { return { loading: false, journalEntries: [] } },
|
||||||
return {
|
|
||||||
loading: false,
|
|
||||||
journalEntries: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async mounted() {
|
async mounted() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
@@ -314,8 +434,127 @@ Vue.component('asset-management-journal-modal', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
formatDate(timestamp) {
|
formatDate(timestamp, format) {
|
||||||
return window.moment.unix(timestamp).format('DD.MM.YYYY HH:mm');
|
return window.moment.unix(timestamp).format(format);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =================================================================================
|
||||||
|
// Asset Reservation Modal
|
||||||
|
// =================================================================================
|
||||||
|
Vue.component('asset-reservation-modal', {
|
||||||
|
props: { asset: { type: Object, required: true } },
|
||||||
|
template: `
|
||||||
|
<tt-modal :show="true" :title="'Reservierungen für ' + asset.name" :save="false" :delete="false" @update:show="$emit('close')">
|
||||||
|
<!-- New Reservation Form -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Neue Reservierung</h5>
|
||||||
|
<tt-autocomplete label="Mitarbeiter" :api-url="userAutoCompleteUrl" v-model="newReservation.userId" sm row/>
|
||||||
|
<tt-date-picker label="Startdatum" v-model="newReservation.startDate" :date-range="false" sm row/>
|
||||||
|
<tt-date-picker label="Enddatum" v-model="newReservation.endDate" :date-range="false" :disabled="isPermanent" sm row/>
|
||||||
|
<tt-checkbox label="Dauerhaft" v-model="isPermanent" sm row/>
|
||||||
|
<tt-textarea label="Notizen" v-model="newReservation.notes" sm row/>
|
||||||
|
<tt-button text="Reservierung speichern" @click="saveReservation" additional-class="btn-primary float-right"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Reservations List -->
|
||||||
|
<div v-if="loading" class="text-center"><i class="fas fa-spinner fa-spin"></i> Lade...</div>
|
||||||
|
<div v-else>
|
||||||
|
<h5>Bestehende Reservierungen</h5>
|
||||||
|
<div v-if="!reservations.length" class="text-center text-muted">Keine Reservierungen vorhanden.</div>
|
||||||
|
<ul v-else class="list-group">
|
||||||
|
<li v-for="res in reservations" class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>{{ res.userName }}</strong><br>
|
||||||
|
<small>
|
||||||
|
{{ formatDate(res.startDate, 'DD.MM.YYYY') }} - {{ res.endDate ? formatDate(res.endDate, 'DD.MM.YYYY') : 'Dauerhaft' }}
|
||||||
|
</small>
|
||||||
|
<div v-if="res.notes" class="small text-muted">Notiz: {{ res.notes }}</div>
|
||||||
|
</div>
|
||||||
|
<tt-button icon="fas fa-trash" @click="deleteReservation(res.id)" additional-class="btn-danger" sm/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</tt-modal>
|
||||||
|
`,
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
reservations: [],
|
||||||
|
isPermanent: false,
|
||||||
|
newReservation: {
|
||||||
|
userId: null,
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
notes: ''
|
||||||
|
},
|
||||||
|
userAutoCompleteUrl: window.TT_CONFIG.BASE_PATH + '/WarehouseShippingNote/userAutoComplete',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
isPermanent(val) {
|
||||||
|
if (val) {
|
||||||
|
this.newReservation.endDate = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
this.fetchReservations();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatDate(timestamp, format) {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
return window.moment.unix(timestamp).format(format);
|
||||||
|
},
|
||||||
|
async fetchReservations() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/getReservations`, { params: { assetId: this.asset.id } });
|
||||||
|
this.reservations = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
window.notify('error', 'Reservierungen konnten nicht geladen werden.');
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async saveReservation() {
|
||||||
|
if (!this.newReservation.userId || !this.newReservation.startDate) {
|
||||||
|
return window.notify('error', 'Mitarbeiter und Startdatum sind erforderlich.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = { ...this.newReservation, assetId: this.asset.id };
|
||||||
|
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/createReservation`, payload);
|
||||||
|
if (response.data.success) {
|
||||||
|
window.notify('success', 'Reservierung gespeichert.');
|
||||||
|
this.resetForm();
|
||||||
|
await this.fetchReservations();
|
||||||
|
} else {
|
||||||
|
window.notify('error', response.data.message || 'Fehler beim Speichern.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
window.notify('error', 'Ein Fehler ist aufgetreten.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteReservation(id) {
|
||||||
|
if (!confirm('Soll diese Reservierung wirklich gelöscht werden?')) return;
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/deleteReservation`, { id });
|
||||||
|
if (response.data.success) {
|
||||||
|
window.notify('success', 'Reservierung gelöscht.');
|
||||||
|
await this.fetchReservations();
|
||||||
|
} else {
|
||||||
|
window.notify('error', response.data.message || 'Fehler beim Löschen.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
window.notify('error', 'Ein Fehler ist aufgetreten.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetForm() {
|
||||||
|
this.newReservation = { userId: null, startDate: null, endDate: null, notes: '' };
|
||||||
|
this.isPermanent = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user