added multiple images feature for asset mgmt

This commit is contained in:
Luca Haid
2025-07-09 12:54:01 +02:00
parent 18bc10fc46
commit a0a7d92aa5
5 changed files with 204 additions and 35 deletions

View File

@@ -25,6 +25,19 @@ class AssetManagementController extends TTCrud
}
}
/**
* This method is automatically called by the parent TTCrud->getByIdAction.
* It decodes the JSON string from the database into a PHP array.
*/
protected function getByIdParse($data) {
if (!empty($data['imageIds'])) {
$data['imageIds'] = json_decode($data['imageIds'], true);
} else {
$data['imageIds'] = [];
}
return $data;
}
protected function getAction()
{
$json = json_decode(file_get_contents('php://input'), true);
@@ -61,6 +74,13 @@ class AssetManagementController extends TTCrud
$rows = [];
foreach ($assets as $asset) {
$row = (array)$asset;
// Decode imageIds for table view if needed, though not directly displayed, useful for logic
if (!empty($row['imageIds'])) {
$row['imageIds'] = json_decode($row['imageIds'], true);
} else {
$row['imageIds'] = [];
}
$latestJournal = $journalMap[$asset->id] ?? null;
// Determine current status based on the latest journal entry.
@@ -190,6 +210,35 @@ class AssetManagementController extends TTCrud
}
}
protected function deleteAssetImageAction() {
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['assetId']) || empty($post['imageId'])) {
self::sendError("Asset ID or Image ID is missing.");
}
$asset = AssetManagementModel::get($post['assetId']);
if (!$asset) self::sendError("Asset not found.");
$imageIds = !empty($asset->imageIds) ? json_decode($asset->imageIds, true) : [];
// Remove the imageId
$imageIds = array_filter($imageIds, fn($id) => $id != $post['imageId']);
$asset->imageIds = json_encode(array_values($imageIds)); // Re-index array
// If the deleted image was the main image, set main image to the first available image or null
if ($asset->mainImageId == $post['imageId']) {
$asset->mainImageId = !empty($imageIds) ? $imageIds[0] : null;
}
AssetManagementModel::update((array)$asset);
// Optional: Delete the actual file from storage
// mfUpload::delete($post['imageId']);
self::returnJson(['success' => true, 'message' => 'Image deleted.', 'asset' => $this->getByIdParse((array)$asset)]);
}
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']);

View File

@@ -4,7 +4,8 @@ class AssetManagementModel extends TTCrudBaseModel {
public int $id;
public string $name;
public ?string $description;
public ?int $imageId;
public ?int $mainImageId; // Renamed from imageId
public ?string $imageIds; // Changed to JSON (will be a string in PHP)
public string $assetNumber;
public string $location;
public ?int $serviceDueDate;

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AssetManagementAddImageIds extends AbstractMigration
{
public function up(): void
{
if ($this->getEnvironment() == "thetool") {
$table = $this->table("AssetManagement");
$table->renameColumn('imageId', 'mainImageId');
$table->changeColumn('mainImageId', 'integer', [
'null' => true,
'default' => null,
'comment' => 'Foreign key to the File table for the asset\'s main image',
]);
$table->addColumn('imageIds', 'json', [
'null' => true,
'default' => null,
'after' => 'mainImageId',
'comment' => 'An array of file IDs for all asset images',
]);
$table->update();
$this->execute("
UPDATE `AssetManagement`
SET `imageIds` = JSON_ARRAY(`mainImageId`)
WHERE `mainImageId` IS NOT NULL;
");
}
}
public function down(): void
{
if ($this->getEnvironment() == "thetool") {
$table = $this->table("AssetManagement");
$this->execute("
UPDATE `AssetManagement`
SET `mainImageId` = JSON_UNQUOTE(JSON_EXTRACT(`imageIds`, '$[0]'))
WHERE JSON_TYPE(`imageIds`) = 'ARRAY' AND JSON_LENGTH(`imageIds`) > 0;
");
$table->removeColumn('imageIds');
$table->renameColumn('mainImageId', 'imageId');
$table->changeColumn('imageId', 'integer', [
'null' => true,
'default' => null,
'comment' => 'Foreign key to the File table for the asset\'s image',
]);
$table->update();
}
}
}

View File

@@ -12,7 +12,6 @@ window.TT_CONFIG["CRUD_CONFIG"]["additionalActions"] = [
Vue.component('asset-management', {
template: `
<tt-card>
<!-- Modals -->
<asset-management-modal
v-if="modalId"
:id="modalId"
@@ -35,10 +34,9 @@ Vue.component('asset-management', {
@reserve="reservationModalAsset = $event"
: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"/>
<tt-asset-image :image-id="row.mainImageId" class="mr-3"/>
<div>
<strong>{{ row.name }}</strong><br>
<small class="text-muted">{{ row.assetNumber }}</small><br>
@@ -47,14 +45,12 @@ Vue.component('asset-management', {
</div>
</template>
<!-- Column 2: Status (Borrow/Return widget) -->
<template v-slot:currentuser="{ row }">
<asset-borrow-return-widget
:row-data="row"
@update="updateTableWithoutModalsOpening"/>
</template>
<!-- Column 3: Journal Button -->
<template v-slot:journal="{ row }">
<tt-button
sm
@@ -64,7 +60,6 @@ Vue.component('asset-management', {
additional-class="btn-outline-info"/>
</template>
<!-- Formatted Dates -->
<template v-slot:serviceduedate="{ row }">
<span v-if="row.serviceDueDate" :class="{'text-danger font-weight-bold': isDatePast(row.serviceDueDate)}">
{{ formatDate(row.serviceDueDate, 'DD.MM.YYYY') }}
@@ -130,8 +125,8 @@ Vue.component('tt-asset-image', {
<img :src="'/File/show?id=' + imageId"
class="tt-fullscreen-image"
@click.stop /> <button class="tt-fullscreen-close-btn" @click="closeFullScreen">
<i class="fas fa-times"></i>
</button>
<i class="fas fa-times"></i>
</button>
</div>
</div>
`,
@@ -162,7 +157,7 @@ Vue.component('tt-asset-image', {
}
}
}
});;
});
// =================================================================================
@@ -174,7 +169,6 @@ Vue.component('asset-borrow-return-widget', {
},
template: `
<div>
<!-- Case 1: Asset is Borrowed -->
<div v-if="rowData.currentUserId">
<div><strong>Ausgeliehen von:</strong> {{ rowData.currentUser }}</div>
<div><strong>Baustelle:</strong> {{ rowData.currentSite }}</div>
@@ -203,17 +197,16 @@ Vue.component('asset-borrow-return-widget', {
</div>
</div>
<!-- Case 2: Asset is Available -->
<div v-else>
<div class="text-success">Verfügbar</div>
<div v-if="activeReservation" class="text-danger small">
<i class="fas fa-clock"></i> Dauerhaft Reserviert für {{ activeReservation.userName }}
</div>
<div v-if="nextReservation" class="text-warning small">
<i class="fas fa-calendar-alt"></i> Nächste Reservierung: {{ formatDate(nextReservation.startDate, 'DD.MM.YYYY') }}
<span v-if="nextReservation.endDate"> bis {{ formatDate(nextReservation.endDate, 'DD.MM.YYYY') }}</span>
<span v-if="nextReservation.notes"><br> ({{ nextReservation.notes }})</span>
<span v-if="nextReservation.userName"><br> für {{ nextReservation.userName }}</span>
<i class="fas fa-calendar-alt"></i> Nächste Reservierung: {{ formatDate(nextReservation.startDate, 'DD.MM.YYYY') }}
<span v-if="nextReservation.endDate"> bis {{ formatDate(nextReservation.endDate, 'DD.MM.YYYY') }}</span>
<span v-if="nextReservation.notes"><br> ({{ nextReservation.notes }})</span>
<span v-if="nextReservation.userName"><br> für {{ nextReservation.userName }}</span>
</div>
<tt-autocomplete
v-if="window.TT_CONFIG.ASSET_ADMIN === '1'"
@@ -227,7 +220,6 @@ Vue.component('asset-borrow-return-widget', {
/>
</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>
@@ -354,9 +346,12 @@ Vue.component('asset-management-modal', {
@delete="deleteAsset"
>
<div class="row">
<div class="col-md-4 text-center">
<tt-asset-image :image-id="asset.imageId" class="mb-2"/>
<input type="file" @change="handleFileUpload" class="form-control-file" accept="image/*"/>
<div class="col-md-4">
<label>Hauptbild</label>
<tt-asset-image :image-id="asset.mainImageId" class="mb-2 asset-main-image"/>
<label for="asset-images-upload">Bilder hochladen (mehrere möglich)</label>
<input type="file" @change="handleFileUpload" multiple class="form-control-file" id="asset-images-upload" accept="image/*"/>
</div>
<div class="col-md-8">
<tt-input label="Gerätename" v-model="asset.name" sm required/>
@@ -364,6 +359,30 @@ Vue.component('asset-management-modal', {
<tt-input label="Lagerort" v-model="asset.location" sm required/>
</div>
</div>
<hr/>
<h5>Bildergalerie</h5>
<div v-if="!asset.imageIds || asset.imageIds.length === 0" class="text-muted text-center p-3">
Noch keine Bilder hochgeladen.
</div>
<div v-else class="asset-gallery">
<div v-for="imgId in asset.imageIds" :key="imgId"
class="gallery-item"
:class="{ 'is-main': imgId === asset.mainImageId }">
<tt-asset-image :image-id="imgId" />
<div class="gallery-item-actions">
<button @click="setMainImage(imgId)" title="Als Hauptbild festlegen" class="btn btn-sm btn-outline-success">
<i class="fas fa-star"></i>
</button>
<button @click="deleteImage(imgId)" title="Bild löschen" class="btn btn-sm btn-outline-danger">
<i class="fas fa-trash"></i>
</button>
</div>
</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/>
@@ -379,7 +398,8 @@ Vue.component('asset-management-modal', {
location: 'Liftkammer',
serviceDueDate: null,
mustReturnDate: null,
imageId: null,
mainImageId: null,
imageIds: [],
},
}
},
@@ -391,6 +411,9 @@ Vue.component('asset-management-modal', {
async mounted() {
if (!this.isCreateMode) {
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/getById`, { params: { id: this.id } });
if (!response.data.imageIds) {
response.data.imageIds = [];
}
this.asset = response.data;
} else {
await this.suggestAssetNumber();
@@ -434,24 +457,67 @@ Vue.component('asset-management-modal', {
this.$set(this.asset, 'assetNumber', response.data.assetNumber);
}
},
async handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
setMainImage(imageId) {
this.$set(this.asset, 'mainImageId', imageId);
},
async deleteImage(imageId) {
if (!confirm('Soll dieses Bild wirklich gelöscht werden?')) return;
const formData = new FormData();
formData.append('file', file);
// If it's a new asset (not yet saved), just remove from local array
if (this.isCreateMode) {
this.asset.imageIds = this.asset.imageIds.filter(id => id !== imageId);
if (this.asset.mainImageId === imageId) {
this.asset.mainImageId = this.asset.imageIds.length > 0 ? this.asset.imageIds[0] : null;
}
return;
}
// If it's an existing asset, call the backend
try {
const response = await axios.post(`${window.TT_CONFIG["BASE_PATH"]}/AssetManagement/uploadFile`, formData);
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/AssetManagement/deleteAssetImage`, {
assetId: this.id,
imageId: imageId
});
if (response.data.success) {
this.$set(this.asset, 'imageId', response.data.fileId);
window.notify('success', `Bild erfolgreich hochgeladen.`);
window.notify('success', 'Bild gelöscht.');
// Update local asset data with the response from the server
this.asset.imageIds = response.data.asset.imageIds;
this.asset.mainImageId = response.data.asset.mainImageId;
} else {
window.notify('error', `Bild-Upload fehlgeschlagen: ${response.data.error || 'Unbekannter Fehler'}`);
window.notify('error', response.data.message || 'Fehler beim Löschen des Bildes.');
}
} catch (error) {
window.notify('error', `Fehler beim Hochladen des Bildes.`);
window.notify('error', 'Netzwerkfehler beim Löschen des Bildes.');
}
},
async handleFileUpload(event) {
const files = event.target.files;
if (!files || files.length === 0) return;
const currentImageIds = Array.isArray(this.asset.imageIds) ? [...this.asset.imageIds] : [];
for (const file of files) {
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) {
currentImageIds.push(response.data.fileId);
if (!this.asset.mainImageId) {
this.$set(this.asset, 'mainImageId', response.data.fileId);
}
window.notify('success', `Bild ${file.name} erfolgreich hochgeladen.`);
} else {
window.notify('error', `Upload für ${file.name} fehlgeschlagen: ${response.data.error || 'Unbekannter Fehler'}`);
}
} catch (error) {
window.notify('error', `Fehler beim Hochladen von ${file.name}.`);
}
}
this.$set(this.asset, 'imageIds', currentImageIds);
// Clear file input to allow re-uploading the same file
event.target.value = '';
}
}
});
@@ -515,7 +581,6 @@ 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>
@@ -528,7 +593,6 @@ Vue.component('asset-reservation-modal', {
</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>

View File

@@ -113,6 +113,9 @@ Vue.component('tt-date-picker', {
const input = this.$refs.input;
if (datepicker && !datepicker.contains(event.target) && event.target !== input) {
if (input.parentElement.tagName.toLowerCase() === 'div' && input.parentElement.classList.contains('form-group')) {
return;
}
$(this.$refs.input).data('daterangepicker').hide();
}
},