From 700f4030ecdf57714d2e4b4cf8e8456b5d1744f5 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Thu, 26 Jun 2025 13:46:46 +0200 Subject: [PATCH] added new asset management poc --- .../AssetManagementController.php | 149 ++++++++ .../AssetManagement/AssetManagementModel.php | 11 + .../AssetManagementJournalModel.php | 33 ++ ...6134500_create_asset_management_tables.php | 59 ++++ lib/TTCrudBaseModel/TTCrudBaseModel.php | 4 +- .../pages/AssetManagement/AssetManagement.js | 318 ++++++++++++++++++ 6 files changed, 572 insertions(+), 2 deletions(-) create mode 100644 application/AssetManagement/AssetManagementController.php create mode 100644 application/AssetManagement/AssetManagementModel.php create mode 100644 application/AssetManagementJournal/AssetManagementJournalModel.php create mode 100644 db/migrations/20250626134500_create_asset_management_tables.php create mode 100644 public/js/pages/AssetManagement/AssetManagement.js diff --git a/application/AssetManagement/AssetManagementController.php b/application/AssetManagement/AssetManagementController.php new file mode 100644 index 000000000..1b861c6c9 --- /dev/null +++ b/application/AssetManagement/AssetManagementController.php @@ -0,0 +1,149 @@ + '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]], + ['key' => 'currentSite', 'text' => 'Akt. Baustelle', 'modal' => false, 'table' => ['sortable' => false]], + ['key' => 'borrowDate', 'text' => 'Ausgeliehen seit', 'modal' => false, 'table' => ['sortable' => false]], + ['key' => 'location', 'text' => 'Lagerort', 'required' => true, 'modal' => ['type' => 'text']], + ['key' => 'serviceDueDate', 'text' => 'Service fällig', 'required' => false, 'modal' => ['type' => 'datepicker']], + ['key' => 'journal', 'text' => 'Historie', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]], + ['key' => 'actions', 'text' => 'Aktionen', 'modal' => false, 'table' => ['filter' => false, 'sortable' => false]], + ]; + + protected array $permissionCheck = ['WarehouseAdmin']; // Or a new permission + + protected function getAction() + { + $json = json_decode(file_get_contents('php://input'), true); + $assets = AssetManagementModel::getAll([], $this->request->order); + $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]]); + return; + } + + // 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; + } + } + + $users = UserModel::search(['employee' => true]); + $userMap = array_reduce($users, function ($carry, $user) { + $carry[$user->id] = $user->name; + return $carry; + }, []); + + $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; + $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, + 'pagination' => [ + 'page' => $pagination['page'], + 'per_page' => $pagination['per_page'], + 'total_rows' => $totalRows, + 'total_pages' => ceil($totalRows / $pagination['per_page']) + ] + ]); + } + + protected function suggestAssetNumberAction() + { + $lastAsset = AssetManagementModel::getOne([], ['order' => 'DESC', 'key' => 'id']); + if (!$lastAsset || !preg_match('/XI(\d+)/', $lastAsset->assetNumber, $matches)) { + $nextNumber = 1; + } else { + $nextNumber = intval($matches[1]) + 1; + } + $newAssetNumber = 'XI' . str_pad($nextNumber, 3, '0', STR_PAD_LEFT); + self::returnJson(['success' => true, 'assetNumber' => $newAssetNumber]); + } + + protected function borrowAction() + { + $post = json_decode(file_get_contents('php://input'), true); + if (empty($post['assetId']) || empty($post['userId']) || empty($post['site']) || empty($post['reason'])) { + self::sendError("Alle Felder sind erforderlich."); + } + + AssetManagementJournalModel::create([ + 'assetId' => $post['assetId'], + 'userId' => $post['userId'], + 'site' => $post['site'], + 'borrowReason' => $post['reason'], + 'borrowDate' => time(), + 'createBy' => $this->user->id, + 'create' => time(), + ]); + + self::returnJson(['success' => true, 'message' => 'Gerät erfolgreich ausgeliehen.']); + } + + protected function returnAction() + { + $post = json_decode(file_get_contents('php://input'), true); + if (empty($post['journalId'])) { + self::sendError("Journal-Eintrag nicht gefunden."); + } + + $journalEntry = AssetManagementJournalModel::get($post['journalId']); + if (!$journalEntry) { + self::sendError("Journal-Eintrag nicht gefunden."); + } + + $journalEntry->returnDate = time(); + $journalEntry->returnReason = $post['reason'] ?? 'Zurückgegeben'; + AssetManagementJournalModel::update((array)$journalEntry); + + self::returnJson(['success' => true, 'message' => 'Gerät erfolgreich zurückgegeben.']); + } + + protected function getJournalAction() + { + 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; + }, []); + + foreach ($entries as $entry) { + $entry->userName = $userMap[$entry->userId] ?? 'Unbekannt'; + } + + self::returnJson($entries); + } +} \ No newline at end of file diff --git a/application/AssetManagement/AssetManagementModel.php b/application/AssetManagement/AssetManagementModel.php new file mode 100644 index 000000000..93349738f --- /dev/null +++ b/application/AssetManagement/AssetManagementModel.php @@ -0,0 +1,11 @@ +query($sql); + + $entries = []; + while ($row = $result->fetch_assoc()) { + $entries[] = new self($row); + } + return $entries; + } + +} \ No newline at end of file diff --git a/db/migrations/20250626134500_create_asset_management_tables.php b/db/migrations/20250626134500_create_asset_management_tables.php new file mode 100644 index 000000000..e23de4e57 --- /dev/null +++ b/db/migrations/20250626134500_create_asset_management_tables.php @@ -0,0 +1,59 @@ +getEnvironment() == "thetool") { + + // Create the main asset table + $assetManagement = $this->table('AssetManagement', ['id' => false, 'primary_key' => ['id']]); + $assetManagement->addColumn('id', 'integer', ['identity' => true, 'signed' => false]) + ->addColumn('name', 'string', ['limit' => 255, 'null' => false, 'comment' => 'Name of the asset or device']) + ->addColumn('assetNumber', 'string', ['limit' => 255, 'null' => false, 'comment' => 'Unique identifier for the asset (e.g., XI001)']) + ->addColumn('location', 'string', ['limit' => 255, 'null' => false, 'comment' => 'The primary storage location of the asset']) + ->addColumn('serviceDueDate', 'integer', ['null' => true, 'comment' => 'Date when the next service is due']) + ->addColumn('create', 'integer', ['null' => false, 'comment' => 'Unix timestamp of creation']) + ->addColumn('createBy', 'integer', ['null' => false, 'comment' => 'User ID of the creator']) + ->addIndex(['assetNumber'], ['unique' => true, 'name' => 'assetNumber']) + ->create(); + + // Create the journal table for asset borrowing and returning + $assetManagementJournal = $this->table('AssetManagementJournal', ['id' => false, 'primary_key' => ['id']]); + $assetManagementJournal->addColumn('id', 'integer', ['identity' => true, 'signed' => false]) + ->addColumn('assetId', 'integer', ['null' => false, 'comment' => 'Foreign key to the AssetManagement table']) + ->addColumn('userId', 'integer', ['null' => false, 'comment' => 'User ID of the employee who borrowed the asset']) + ->addColumn('site', 'string', ['limit' => 255, 'null' => false, 'comment' => 'The construction site or project where the asset is being used']) + ->addColumn('borrowDate', 'integer', ['null' => false, 'comment' => 'Unix timestamp when the asset was borrowed']) + ->addColumn('returnDate', 'integer', ['null' => true, 'comment' => 'Unix timestamp when the asset was returned']) + ->addColumn('borrowReason', 'text', ['null' => false, 'comment' => 'Reason for borrowing the asset']) + ->addColumn('returnReason', 'text', ['null' => true, 'comment' => 'Notes or reason for returning the asset']) + ->addColumn('createBy', 'integer', ['null' => false, 'comment' => 'User ID of the person who created this journal entry']) + ->addColumn('create', 'integer', ['null' => false, 'comment' => 'Unix timestamp of journal entry creation']) + ->addIndex(['assetId'], ['name' => 'assetId']) + ->addIndex(['userId'], ['name' => 'userId']) + ->create(); + } + } + + /** + * Rollback the migration by dropping the tables. + */ + public function down(): void + { + // Environment check to ensure this migration only runs on the specified environment. + if ($this->getEnvironment() == "thetool") { + // Drop tables in reverse order of creation to avoid foreign key issues. + $this->table('AssetManagementJournal')->drop()->save(); + $this->table('AssetManagement')->drop()->save(); + } + } +} diff --git a/lib/TTCrudBaseModel/TTCrudBaseModel.php b/lib/TTCrudBaseModel/TTCrudBaseModel.php index eed546255..d02243fb3 100644 --- a/lib/TTCrudBaseModel/TTCrudBaseModel.php +++ b/lib/TTCrudBaseModel/TTCrudBaseModel.php @@ -10,14 +10,14 @@ class TTCrudBaseModel { } } - private static function getFullyQualifiedTable(): string { + protected static function getFullyQualifiedTable(): string { $table = str_replace('Model', '', get_called_class()); $tableIncludesADBSuffix = strpos($table, 'ADB') !== false; $table = str_replace('ADB', '', $table); return "`" . ($tableIncludesADBSuffix ? ADDRESSDB_DBNAME : FRONKDB_DBNAME) . "`.`" . $table . "`"; } - private static function getDB() { + protected static function getDB() { if (strpos(self::getFullyQualifiedTable(), ADDRESSDB_DBNAME) !== false) $FronkDB = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); else $FronkDB = FronkDB::singleton(); diff --git a/public/js/pages/AssetManagement/AssetManagement.js b/public/js/pages/AssetManagement/AssetManagement.js new file mode 100644 index 000000000..125c95636 --- /dev/null +++ b/public/js/pages/AssetManagement/AssetManagement.js @@ -0,0 +1,318 @@ +// ================================================================================= +// Main Asset Management Component +// ================================================================================= +Vue.component('asset-management', { + template: ` + + + + + + + + + + + + + + + + + `, + data() { + return { + window: window, + modalId: null, + journalModalAssetId: null, + crudConfig: window.TT_CONFIG.CRUD_CONFIG, // Assumes config is passed globally + } + }, +}); + +// ================================================================================= +// Asset Borrow/Return Widget Component (for the table column) +// ================================================================================= +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?

+ +
+
+ `, + data() { + return { + window: window, + userAutoCompleteUrl: window.TT_CONFIG.BASE_PATH + '/WarehouseShippingNote/userAutoComplete', // Re-using existing one + selectedUserId: null, + selectedUserName: '', + showBorrowModal: false, + showReturnModal: false, + borrowSite: '', + borrowReason: '', + returnReason: '' + } + }, + methods: { + 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}`); + this.selectedUserName = response.data[0]?.text || 'Unbekannt'; + + this.showBorrowModal = true; + }, + async borrowAsset() { + if (!this.borrowSite || !this.borrowReason) { + return window.notify('error', 'Bitte Baustelle und Grund angeben.'); + } + try { + const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/borrow`, { + assetId: this.rowData.id, + userId: this.selectedUserId, + site: this.borrowSite, + reason: this.borrowReason + }); + if (response.data.success) { + window.notify('success', response.data.message); + this.showBorrowModal = false; + this.$emit('update'); + } else { + window.notify('error', response.data.message || 'Fehler beim Ausleihen.'); + } + } catch (error) { + window.notify('error', 'Ein Fehler ist aufgetreten.'); + } + }, + async returnAsset() { + try { + const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/return`, { + journalId: this.rowData.journalId, + reason: this.returnReason + }); + if (response.data.success) { + window.notify('success', response.data.message); + this.showReturnModal = false; + this.$emit('update'); + } else { + window.notify('error', response.data.message || 'Fehler beim Zurückgeben.'); + } + } catch (error) { + window.notify('error', 'Ein Fehler ist aufgetreten.'); + } + } + } +}); + + +// ================================================================================= +// Asset Create/Edit Modal +// ================================================================================= +Vue.component('asset-management-modal', { + props: ['id'], + template: ` + + + + + + + + + `, + data() { + return { + asset: { + name: '', + assetNumber: '', + location: 'Hauptlager', + serviceDueDate: null + }, + } + }, + computed: { + isCreateMode() { + return this.id === 'create'; + } + }, + async mounted() { + if (!this.isCreateMode) { + const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/getById`, { params: { id: this.id } }); + this.asset = response.data; + } else { + await this.suggestAssetNumber(); + } + }, + methods: { + async submit() { + const url = this.isCreateMode + ? `${window.TT_CONFIG.BASE_PATH}/AssetManagement/create` + : `${window.TT_CONFIG.BASE_PATH}/AssetManagement/update`; + + try { + const response = await axios.post(url, this.asset); + if (response.data.success) { + window.notify('success', response.data.message || 'Erfolgreich gespeichert.'); + this.$emit('close'); + } else { + window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.'); + } + } catch (error) { + window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.'); + } + }, + 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) { + window.notify('success', 'Gerät gelöscht.'); + this.$emit('close'); + } else { + window.notify('error', response.data.message || 'Fehler beim Löschen.'); + } + } catch (error) { + window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.'); + } + }, + async suggestAssetNumber() { + const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/suggestAssetNumber`); + if (response.data.success) { + this.$set(this.asset, 'assetNumber', response.data.assetNumber); + } + } + } +}); + +// ================================================================================= +// Asset Journal/History Modal +// ================================================================================= +Vue.component('asset-management-journal-modal', { + 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 +
    +
  • +
+
+
+ `, + data() { + return { + loading: false, + journalEntries: [] + } + }, + async mounted() { + this.loading = true; + try { + const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/getJournal`, { params: { assetId: this.assetId } }); + this.journalEntries = response.data; + } catch (error) { + window.notify('error', 'Historie konnte nicht geladen werden.'); + } finally { + this.loading = false; + } + }, + methods: { + formatDate(timestamp) { + return window.moment.unix(timestamp).format('DD.MM.YYYY HH:mm'); + } + } +}); \ No newline at end of file