added multiple images feature for asset mgmt
This commit is contained in:
@@ -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']);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user