Files
thetool/public/js/pages/AssetManagement/AssetManagement.js
2025-06-29 10:43:06 +00:00

572 lines
24 KiB
JavaScript

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: `
<tt-card>
<!-- Modals -->
<asset-management-modal
v-if="modalId"
:id="modalId"
@close="modalId = null; $refs.table.$refs.table.refreshTable()"/>
<asset-management-journal-modal
v-if="journalModalAssetId"
:asset-id="journalModalAssetId"
@close="journalModalAssetId = null"/>
<asset-reservation-modal
v-if="reservationModalAsset"
:asset="reservationModalAsset"
@close="reservationModalAsset = null; $refs.table.$refs.table.refreshTable()"/>
<button v-if="window.TT_CONFIG.ASSET_ADMIN === '1'" @click="modalId = 'create'" class="btn btn-primary">Anlage erstellen</button>
<tt-table-crud
ref="table"
emit-edit
@edit="modalId = $event.id"
@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"/>
<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 }">
<asset-borrow-return-widget
:row-data="row"
@update="$refs.table.$refs.table.refreshTable()"/>
</template>
<!-- Column 3: Journal Button -->
<template v-slot:journal="{ row }">
<tt-button
sm
icon="fas fa-history"
title="Historie"
@click="journalModalAssetId = row.id"
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') }}
</span>
</template>
</tt-table-crud>
</tt-card>
`,
data() {
return {
window: window,
modalId: null,
journalModalAssetId: null,
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 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', {
props: {
rowData: { type: Object, required: true }
},
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>
<div><strong>Seit:</strong> {{ formatDate(rowData.borrowDate, 'DD.MM.YYYY HH:mm') }}</div>
<div v-if="rowData.expectedReturnDate">
<strong>Vorauss. Rückgabe:</strong>
<span :class="{'text-danger': isDatePast(rowData.expectedReturnDate)}">
{{ formatDate(rowData.expectedReturnDate, 'DD.MM.YYYY') }}
</span>
</div>
<tt-button
v-if="window.TT_CONFIG.ASSET_ADMIN === '1'"
sm
text="Zurückgeben"
@click="showReturnModal = true"
additional-class="btn-success mt-2"/>
</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>
<div v-if="nextReservation" class="text-danger 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>
</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() {
return {
window: window,
userAutoCompleteUrl: window.TT_CONFIG.BASE_PATH + '/WarehouseShippingNote/userAutoComplete',
selectedUserId: null,
selectedUserName: '',
showBorrowModal: false,
showReturnModal: false,
borrowSite: '',
borrowReason: '',
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));
},
nextReservation() {
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);
}
},
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;
const response = await axios.get(`${this.userAutoCompleteUrl}?searchedID=${userId}`);
this.selectedUserName = response.data[0]?.text || 'Unbekannt';
this.showBorrowModal = true;
},
async borrowAsset(force = false) {
if (!this.borrowSite) {
return window.notify('error', 'Bitte Baustelle/Projekt angeben.');
}
try {
const payload = {
assetId: this.rowData.id,
userId: this.selectedUserId,
site: this.borrowSite,
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 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) {
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: `
<tt-modal
:show="true"
:title="isCreateMode ? 'Gerät anlegen' : 'Gerät bearbeiten'"
@update:show="$emit('close')"
@submit="submit"
:delete="!isCreateMode"
@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>
<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(){
return {
asset: {
name: '',
description: '',
assetNumber: '',
location: 'Liftkammer',
serviceDueDate: null,
mustReturnDate: null,
imageId: 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;
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);
}
},
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.`);
}
}
}
});
// =================================================================================
// Asset Journal/History Modal
// =================================================================================
Vue.component('asset-management-journal-modal', {
props: { assetId: { type: Number, required: true } },
template: `
<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-else>
<div v-if="!journalEntries.length" class="text-center text-muted">Keine Einträge vorhanden.</div>
<ul v-else class="list-group">
<li v-for="entry in journalEntries" class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ entry.userName }} @ {{ entry.site }}</h5>
<small>{{ formatDate(entry.borrowDate, 'DD.MM.YY HH:mm') }}</small>
</div>
<p class="mb-1"><strong>Grund:</strong> {{ entry.borrowReason || '-' }}</p>
<div v-if="entry.returnDate">
<small class="text-success">
<strong>Zurück am:</strong> {{ formatDate(entry.returnDate, 'DD.MM.YY HH:mm') }}
<br>
<strong>Bemerkung:</strong> {{ entry.returnReason || '-' }}
</small>
</div>
<div v-else>
<small class="text-warning"><strong>Aktuell ausgeliehen</strong></small><br>
<small v-if="entry.expectedReturnDate"><strong>Vorauss. Rückgabe:</strong> {{ formatDate(entry.expectedReturnDate, 'DD.MM.YYYY') }}</small>
</div>
</li>
</ul>
</div>
</tt-modal>
`,
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, format) {
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;
}
}
});