diff --git a/application/AssetManagement/AssetManagementController.php b/application/AssetManagement/AssetManagementController.php index 201b333fa..56d7c8a13 100644 --- a/application/AssetManagement/AssetManagementController.php +++ b/application/AssetManagement/AssetManagementController.php @@ -3,104 +3,104 @@ class AssetManagementController extends TTCrud { protected string $headerTitle = 'Anlagenverwaltung'; - protected string $singleText = 'Anlage'; protected bool $createText = false; + // Simplified columns for better layout, details are in the 'assetDetails' slot protected array $columns = [ - ['key' => 'id', 'text' => 'ID', 'modal' => false, 'table' => false], - ['key' => 'name', 'text' => 'Gerät', 'required' => true, 'modal' => ['type' => 'text']], - ['key' => 'assetNumber', 'text' => 'Kennzeichen / Nr.', 'required' => true, 'modal' => ['type' => 'text']], - ['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' => 'assetDetails', 'text' => 'Gerät', 'modal' => false, 'table' => ['sortable' => false, 'filter' => ['type' => 'search', 'key' => 'name|assetNumber|description']]], + ['key' => 'currentUser', 'text' => 'Status', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]], + ['key' => 'location', 'text' => 'Lagerort', 'required' => true, 'modal' => ['type' => 'text'], 'table' => ['filter' => 'search']], ['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' => '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() { - if ($this->user->can('AssetAdmin')) return; - $this->columns = array_filter($this->columns, function ($column) { - return $column['key'] != 'actions'; - }); - - $this->additionalJSVariables['ASSET_ADMIN'] = false; + // Restrict actions if the user does not have the 'AssetAdmin' permission. + if (!$this->user->can('AssetAdmin')) { + $this->additionalJSVariables['ASSET_ADMIN'] = '0'; + $this->columns = array_filter($this->columns, fn($col) => !in_array($col['key'], ['actions', 'journal'])); + } } - 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); - $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); 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; } + $latestJournalEntries = AssetManagementJournalModel::getLatestOpenEntries($assetIds); - // Get the latest open journal entry for each asset - $journalEntries = AssetManagementJournalModel::getLatestOpenEntries($assetIds); - $journalMap = []; - foreach ($journalEntries as $entry) { - // Only map it if it's not returned - if ($entry->returnDate === null) { - $journalMap[$entry->assetId] = $entry; - } - } - + // Fetch other related data + $reservations = AssetManagementReservationModel::getAll(['assetId' => $assetIds]); $users = UserModel::search(['employee' => true]); - $userMap = array_reduce($users, function ($carry, $user) { - $carry[$user->id] = $user->name; - return $carry; - }, []); + + // Create maps for efficient lookup + $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 = []; foreach ($assets as $asset) { $row = (array)$asset; $latestJournal = $journalMap[$asset->id] ?? null; - $row['journalId'] = $latestJournal->id ?? null; - $row['currentUser'] = $latestJournal ? ($userMap[$latestJournal->userId] ?? 'Unbekannt') : null; - $row['currentUserId'] = $latestJournal->userId ?? null; - $row['currentSite'] = $latestJournal->site ?? null; - $row['borrowDate'] = $latestJournal->borrowDate ?? null; + // Determine current status based on the latest journal entry. + // If the latest entry has a returnDate, the asset is considered available. + if ($latestJournal && $latestJournal->returnDate === null) { + $row['journalId'] = $latestJournal->id; + $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; } - // 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([ - 'rows' => $paginatedRows, + 'rows' => $rows, 'pagination' => [ 'page' => $pagination['page'], 'per_page' => $pagination['per_page'], - 'total_rows' => $totalRows, - 'total_pages' => ceil($totalRows / $pagination['per_page']) + 'total_rows' => $totalCount, + 'total_pages' => ceil($totalCount / $pagination['per_page']), + 'filtered_available' => $totalCount ] ]); } 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)) { $nextNumber = 1; } else { @@ -114,14 +114,35 @@ class AssetManagementController extends TTCrud { $post = json_decode(file_get_contents('php://input'), true); 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([ 'assetId' => $post['assetId'], 'userId' => $post['userId'], 'site' => $post['site'], - 'borrowReason' => $post['reason'], + 'borrowReason' => $post['reason'] ?? null, + 'expectedReturnDate' => $post['expectedReturnDate'] ?? null, 'borrowDate' => time(), 'createBy' => $this->user->id, 'create' => time(), @@ -133,14 +154,10 @@ class AssetManagementController extends TTCrud protected function returnAction() { $post = json_decode(file_get_contents('php://input'), true); - if (empty($post['journalId'])) { - self::sendError("Journal-Eintrag nicht gefunden."); - } + if (empty($post['journalId'])) self::sendError("Journal-Eintrag nicht gefunden."); $journalEntry = AssetManagementJournalModel::get($post['journalId']); - if (!$journalEntry) { - self::sendError("Journal-Eintrag nicht gefunden."); - } + if (!$journalEntry) self::sendError("Journal-Eintrag nicht gefunden."); $journalEntry->returnDate = time(); $journalEntry->returnReason = $post['reason'] ?? 'Zurückgegeben'; @@ -153,18 +170,64 @@ class AssetManagementController extends TTCrud { if (empty($this->request->assetId)) self::sendError("Asset ID fehlt."); $entries = AssetManagementJournalModel::getAll(['assetId' => $this->request->assetId], null, 0, ['key' => 'borrowDate', 'order' => 'DESC']); - - // Enhance with user names $users = UserModel::search(['employee' => true]); - $userMap = array_reduce($users, function ($carry, $user) { - $carry[$user->id] = $user->name; - return $carry; - }, []); + $userMap = array_reduce($users, fn($carry, $user) => $carry + [$user->id => $user->name], []); foreach ($entries as $entry) { $entry->userName = $userMap[$entry->userId] ?? 'Unbekannt'; } - self::returnJson($entries); } -} \ No newline at end of file + + 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.']); + } +} diff --git a/application/AssetManagement/AssetManagementModel.php b/application/AssetManagement/AssetManagementModel.php index 2da2b6fe4..6cb2ffd57 100644 --- a/application/AssetManagement/AssetManagementModel.php +++ b/application/AssetManagement/AssetManagementModel.php @@ -4,9 +4,11 @@ class AssetManagementModel extends TTCrudBaseModel { public int $id; public string $name; public ?string $description; + public ?int $imageId; public string $assetNumber; public string $location; - public ?string $serviceDueDate; + public ?int $serviceDueDate; + public ?int $mustReturnDate; public int $create; public int $createBy; -} \ No newline at end of file +} diff --git a/application/AssetManagementJournal/AssetManagementJournalModel.php b/application/AssetManagementJournal/AssetManagementJournalModel.php index 8ed98237d..5fffca7f5 100644 --- a/application/AssetManagementJournal/AssetManagementJournalModel.php +++ b/application/AssetManagementJournal/AssetManagementJournalModel.php @@ -6,6 +6,7 @@ class AssetManagementJournalModel extends TTCrudBaseModel { public int $userId; public string $site; public int $borrowDate; + public ?int $expectedReturnDate; public ?int $returnDate; public ?string $borrowReason; public ?string $returnReason; @@ -16,11 +17,15 @@ class AssetManagementJournalModel extends TTCrudBaseModel { public static function getLatestOpenEntries($assetIds): array { $db = self::getDB(); $table = self::getFullyQualifiedTable(); - $sql = "SELECT j1.* - FROM AssetManagementJournal j1 - LEFT JOIN AssetManagementJournal j2 - ON j1.assetId = j2.assetId AND j1.borrowDate < j2.borrowDate - WHERE j2.id IS NULL AND j1.assetId IN (" . implode(',', $assetIds) . ")"; + $assetIdString = implode(',', array_map('intval', (array)$assetIds)); + $sql = " + SELECT j.* + FROM ( + 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); $entries = []; diff --git a/application/AssetManagementReservation/AssetManagementReservationModel.php b/application/AssetManagementReservation/AssetManagementReservationModel.php new file mode 100644 index 000000000..3303d9083 --- /dev/null +++ b/application/AssetManagementReservation/AssetManagementReservationModel.php @@ -0,0 +1,12 @@ +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); + } + } + } \ No newline at end of file diff --git a/db/migrations/20250627103000_asset_management_schema_v2.php b/db/migrations/20250627103000_asset_management_schema_v2.php new file mode 100644 index 000000000..887dbfffd --- /dev/null +++ b/db/migrations/20250627103000_asset_management_schema_v2.php @@ -0,0 +1,115 @@ +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(); + } +} diff --git a/public/js/pages/AssetManagement/AssetManagement.css b/public/js/pages/AssetManagement/AssetManagement.css new file mode 100644 index 000000000..a93cf1b79 --- /dev/null +++ b/public/js/pages/AssetManagement/AssetManagement.css @@ -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; +} \ No newline at end of file diff --git a/public/js/pages/AssetManagement/AssetManagement.js b/public/js/pages/AssetManagement/AssetManagement.js index 42e5d9826..5bd6768d5 100644 --- a/public/js/pages/AssetManagement/AssetManagement.js +++ b/public/js/pages/AssetManagement/AssetManagement.js @@ -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 // ================================================================================= Vue.component('asset-management', { template: ` - + - + - + - - + + + + + - + + + - - @@ -59,95 +79,171 @@ Vue.component('asset-management', { window: window, modalId: 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: ` +
+ +
+ +
+
+ `, + 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', { props: { rowData: { type: Object, required: true } }, template: ` -
-
- {{ rowData.currentUser }} - -
- -
- -
- - -

Gerät: {{ rowData.name }}

-

Mitarbeiter: {{ selectedUserName }}

- - -
- - -

Gerät: {{ rowData.name }}

-

Soll dieses Gerät wirklich als zurückgegeben markiert werden?

- -
+
+ +
+
Ausgeliehen von: {{ rowData.currentUser }}
+
Baustelle: {{ rowData.currentSite }}
+
Seit: {{ formatDate(rowData.borrowDate, 'DD.MM.YYYY HH:mm') }}
+
+ Vorauss. Rückgabe: + + {{ formatDate(rowData.expectedReturnDate, 'DD.MM.YYYY') }} + +
+
+ + +
+
Verfügbar
+
+ Reserviert für {{ activeReservation.userName }} +
+ +
+ + + +
{{ reservationWarning }}
+

Gerät: {{ rowData.name }}

+

Mitarbeiter: {{ selectedUserName }}

+ + + +
+ + +

Gerät: {{ rowData.name }}

+

Soll dieses Gerät wirklich als zurückgegeben markiert werden?

+ +
+
`, data() { return { window: window, - userAutoCompleteUrl: window.TT_CONFIG.BASE_PATH + '/WarehouseShippingNote/userAutoComplete', // Re-using existing one + userAutoCompleteUrl: window.TT_CONFIG.BASE_PATH + '/WarehouseShippingNote/userAutoComplete', selectedUserId: null, selectedUserName: '', showBorrowModal: false, showReturnModal: false, borrowSite: '', 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: { + 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) { if (!userId) return; this.selectedUserId = userId; - - // Fetch user name to display in modal - const response = await axios.get(`${this.userAutoCompleteUrl}&searchedID=${userId}`); + const response = await axios.get(`${this.userAutoCompleteUrl}?searchedID=${userId}`); this.selectedUserName = response.data[0]?.text || 'Unbekannt'; - this.showBorrowModal = true; }, - async borrowAsset() { + async borrowAsset(force = false) { if (!this.borrowSite) { return window.notify('error', 'Bitte Baustelle/Projekt angeben.'); } try { - const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/borrow`, { + const payload = { assetId: this.rowData.id, userId: this.selectedUserId, 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) { window.notify('success', response.data.message); this.showBorrowModal = false; 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.'); } } catch (error) { @@ -181,28 +277,41 @@ Vue.component('asset-borrow-return-widget', { Vue.component('asset-management-modal', { props: ['id'], template: ` - - - - - - - + +
+
+ + +
+
+ + + +
+
+
+ + + +
`, data(){ return { asset: { name: '', + description: '', assetNumber: '', location: 'Liftkammer', - serviceDueDate: null + serviceDueDate: null, + mustReturnDate: null, + imageId: null, }, } }, @@ -212,7 +321,6 @@ Vue.component('asset-management-modal', { } }, async mounted() { - console.log('AssetManagementModal mounted with id:', this.id, this.isCreateMode); if (!this.isCreateMode) { const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/getById`, { params: { id: this.id } }); this.asset = response.data; @@ -240,7 +348,6 @@ Vue.component('asset-management-modal', { }, async deleteAsset() { if (!confirm('Soll dieses Gerät wirklich gelöscht werden?')) return; - // Also check for journal entries before deleting try { const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/delete`, { id: this.id }); if (response.data.success) { @@ -258,6 +365,25 @@ Vue.component('asset-management-modal', { if (response.data.success) { 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 // ================================================================================= Vue.component('asset-management-journal-modal', { - props: { - assetId: { type: Number, required: true } - }, + props: { assetId: { type: Number, required: true } }, template: ` - -
Lade...
-
-
Keine Einträge vorhanden.
-
    -
  • -
    -
    {{ entry.userName }} @ {{ entry.site }}
    - {{ formatDate(entry.borrowDate) }} -
    -

    Grund: {{ entry.borrowReason }}

    -
    - - Zurück am: {{ formatDate(entry.returnDate) }} -
    - Bemerkung: {{ entry.returnReason }} -
    -
    -
    - Aktuell ausgeliehen -
    -
  • -
-
-
+ +
Lade...
+
+
Keine Einträge vorhanden.
+
    +
  • +
    +
    {{ entry.userName }} @ {{ entry.site }}
    + {{ formatDate(entry.borrowDate, 'DD.MM.YY HH:mm') }} +
    +

    Grund: {{ entry.borrowReason || '-' }}

    +
    + + Zurück am: {{ formatDate(entry.returnDate, 'DD.MM.YY HH:mm') }} +
    + Bemerkung: {{ entry.returnReason || '-' }} +
    +
    +
    + Aktuell ausgeliehen
    + Vorauss. Rückgabe: {{ formatDate(entry.expectedReturnDate, 'DD.MM.YYYY') }} +
    +
  • +
+
+
`, - data() { - return { - loading: false, - journalEntries: [] - } - }, + data() { return { loading: false, journalEntries: [] } }, async mounted() { this.loading = true; try { @@ -314,8 +434,127 @@ Vue.component('asset-management-journal-modal', { } }, methods: { - formatDate(timestamp) { - return window.moment.unix(timestamp).format('DD.MM.YYYY HH:mm'); + formatDate(timestamp, format) { + return window.moment.unix(timestamp).format(format); } } -}); \ No newline at end of file +}); + +// ================================================================================= +// Asset Reservation Modal +// ================================================================================= +Vue.component('asset-reservation-modal', { + props: { asset: { type: Object, required: true } }, + template: ` + + +
+
+
Neue Reservierung
+ + + + + + +
+
+ + +
Lade...
+
+
Bestehende Reservierungen
+
Keine Reservierungen vorhanden.
+
    +
  • +
    + {{ res.userName }}
    + + {{ formatDate(res.startDate, 'DD.MM.YYYY') }} - {{ res.endDate ? formatDate(res.endDate, 'DD.MM.YYYY') : 'Dauerhaft' }} + +
    Notiz: {{ res.notes }}
    +
    + +
  • +
+
+
+ `, + 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; + } + } +});