warehouseoffer v2

This commit is contained in:
Luca Haid
2025-07-15 12:57:41 +02:00
parent 5609e73923
commit 11e4d4b270
8 changed files with 1655 additions and 660 deletions
+692 -129
View File
@@ -1,5 +1,188 @@
// noinspection JSUnresolvedReference
Vue.component('tt-file-upload-light', {
props: {
/**
* Allows multiple files to be selected.
*/
multiple: {
type: Boolean,
default: false
},
/**
* Text for the upload button.
*/
buttonText: {
type: String,
default: 'Datei hochladen'
},
/**
* Optional icon for the upload button.
*/
buttonIcon: {
type: String,
default: 'fas fa-paperclip'
}
},
template: `
<div class="tt-file-upload-light">
<!-- Hidden file input, triggered by the button -->
<input type="file" :multiple="multiple" @change="handleFileSelect" ref="fileInput" style="display: none;"/>
<!-- The button that users click to select files -->
<button type="button" class="btn btn-outline-secondary btn-sm" @click="triggerFileInput" :disabled="isUploading">
<i :class="buttonIcon"></i>
<span v-if="buttonText" class="ml-1">{{ buttonText }}</span>
</button>
<!-- List of files being uploaded or already uploaded -->
<div v-if="files.length > 0" class="file-list mt-2">
<div v-for="(file, index) in files" :key="index" class="file-item">
<div class="file-info">
<i :class="fileIcon(file)" class="file-icon"></i>
<span class="file-name">{{ file.file.name }}</span>
<span class="file-size">({{ formatSize(file.file.size) }})</span>
</div>
<div class="file-status">
<!-- Progress bar during upload -->
<div v-if="file.status === 'uploading'" class="progress" style="height: 10px; width: 100px;">
<div class="progress-bar" role="progressbar" :style="{ width: file.progress + '%' }" aria-valuenow="file.progress" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<!-- Status text -->
<span v-if="file.status === 'error'" class="text-danger small">Fehler</span>
<span v-if="file.status === 'success'" class="text-success small">Fertig</span>
<!-- Remove button -->
<button type="button" class="btn btn-link btn-sm text-danger p-0 ml-2" @click="removeFile(index)">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
</div>
`,
data() {
return {
files: [], // Array to store file wrappers { file, progress, status, response }
uploadUrl: window.TT_CONFIG['BASE_PATH'] + '/File/upload', // Standard file upload endpoint
}
},
computed: {
/**
* Checks if any file is currently being uploaded.
* @returns {boolean}
*/
isUploading() {
return this.files.some(f => f.status === 'uploading');
}
},
methods: {
/**
* Programmatically clicks the hidden file input element.
*/
triggerFileInput() {
this.$refs.fileInput.click();
},
/**
* Handles the file selection event when the user chooses files.
* @param {Event} event - The file input change event.
*/
handleFileSelect(event) {
const selectedFiles = event.target.files;
if (!selectedFiles) return;
// Create a wrapper for each file and start the upload
Array.from(selectedFiles).forEach(file => {
const fileWrapper = {
file: file,
progress: 0,
status: 'pending', // Status: pending, uploading, success, error
response: null,
};
this.files.push(fileWrapper);
this.uploadFile(fileWrapper);
});
// Reset the input value to allow selecting the same file again
event.target.value = '';
},
/**
* Uploads a single file to the server.
* @param {object} fileWrapper - The file object wrapper.
*/
async uploadFile(fileWrapper) {
const formData = new FormData();
formData.append('file', fileWrapper.file);
fileWrapper.status = 'uploading';
try {
const response = await axios.post(this.uploadUrl, formData, {
onUploadProgress: (progressEvent) => {
fileWrapper.progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
}
});
fileWrapper.status = 'success';
fileWrapper.response = response.data;
// Emit an event with the successful upload data
this.$emit('uploaded', response.data);
window.notify('success', `${fileWrapper.file.name} erfolgreich hochgeladen.`);
} catch (error) {
fileWrapper.status = 'error';
console.error('File upload failed:', error);
window.notify('error', `Upload von ${fileWrapper.file.name} fehlgeschlagen.`);
}
},
/**
* Public method to reset the component state, clearing all files.
*/
reset() {
this.files = [];
},
/**
* Removes a file from the list.
* @param {number} index - The index of the file to remove.
*/
removeFile(index) {
this.files.splice(index, 1);
},
/**
* Formats file size from bytes to a readable string.
* @param {number} bytes - The file size in bytes.
* @returns {string}
*/
formatSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
/**
* Returns a Font Awesome icon class based on the file status.
* @param {object} file - The file wrapper object.
* @returns {string}
*/
fileIcon(file) {
switch (file.status) {
case 'uploading': return 'fas fa-spinner fa-spin';
case 'success': return 'fas fa-check-circle text-success';
case 'error': return 'fas fa-exclamation-circle text-danger';
default: return 'fas fa-file-alt';
}
}
}
});
Vue.component('warehouse-offer-create-basic-offer-modal', {
props: {
show: {type: Boolean, default: false}
@@ -575,53 +758,105 @@ Vue.component('warehouse-offer-create-basic-offer-modal', {
}
});
// noinspection JSUnresolvedReference,DuplicatedCode
Vue.component('warehouse-offer-modal', {
props: {
id: {type: [String, Number], required: true},
mode: {type: String, default: 'edit'}
},
template: `
<tt-modal :show="true"
@submit="submit"
:delete="id !== 'create'"
:title="id === 'create' ? 'Angebot erstellen' : \`Angebot #\${id} bearbeiten\`"
@update:show="$emit('close')">
<div style="width: 99%"><h4 class="text-center">Angebotsdetails</h4>
<tt-select label="Sachbearbeiter"
:options="window.TT_CONFIG.CRUD_CONFIG.columns.find(column => column.key === 'createBy')?.modal.items"
sm
row
v-model="offer.editor"/>
<tt-autocomplete label="Kunde" v-model="offer.customerNumber" sm row :api-url="billAddrAutoCompleteUrl"/>
<tt-input label="Kundenreferenz" v-model="offer.reference" sm row/>
<tt-textarea label="Angebotszweck" v-model="offer.purpose" sm row/>
<hr>
<h4 class="text-center">Kundenadresse</h4>
<div style="display: grid; grid-gap: 10px; grid-template-columns: 2fr 1fr 2fr 1fr 1fr 1fr;">
<tt-input label="Name" v-model="offer.customerName" sm/>
<tt-input label="Kontakt" v-model="offer.contactPerson" sm/>
<tt-input label="Straße" v-model="offer.customerStreet" sm/>
<tt-input label="PLZ" v-model="offer.customerZip" sm/>
<tt-input label="Ort" v-model="offer.customerCity" sm/>
<tt-input label="USt-IdNr." v-model="offer.customerVAT" sm/>
:title="modalTitle"
@update:show="$emit('close')"
size="xl">
<div style="width: 99%">
<!-- Header with Version Dropdown -->
<h4 class="text-center mb-0">Angebotsdetails</h4>
<div v-if="id !== 'create' && versions.length > 0" class="d-flex align-items-center">
<label for="version-select" class="mr-2 mb-0">Version:</label>
<select id="version-select" class="form-control form-control-sm mr-2" v-model="selectedVersion" @change="loadVersion" style="max-width:300px">
<option v-for="v in versions" :value="v.version">{{ v.version }} ({{ formatDate(v.date) }} - {{ v.user }})</option>
</select>
<tt-button text="PDF für diese Version" @click="openVersionPDF" sm icon="fas fa-file-pdf" additional-class="btn-primary"/>
</div>
<!-- Customer and Contact -->
<div class="card mb-3">
<div class="card-header">Kunde & Kontakt</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<tt-autocomplete label="Kunde" v-model="offer.customerNumber" sm :api-url="billAddrAutoCompleteUrl" :disabled="isReadonly"/>
</div>
<div class="col-md-4">
<tt-input label="Kontaktperson" v-model="offer.contactPerson" sm :disabled="isReadonly"/>
</div>
<div class="col-md-4">
<tt-input label="Kontakt E-Mail" v-model="offer.contactPersonEmail" sm :disabled="isReadonly"/>
</div>
</div>
<tt-input label="Kundenreferenz" v-model="offer.reference" sm row :disabled="isReadonly"/>
<tt-textarea label="Angebotszweck" v-model="offer.purpose" sm row :disabled="isReadonly"/>
</div>
</div>
<!-- Customer Address -->
<div class="card mb-3">
<div class="card-header">Kundenadresse</div>
<div class="card-body">
<div style="display: grid; grid-gap: 10px; grid-template-columns: 2fr 1fr 2fr 1fr 1fr 1fr;">
<tt-input label="Name" v-model="offer.customerName" sm :disabled="isReadonly"/>
<tt-input label="Straße" v-model="offer.customerStreet" sm :disabled="isReadonly"/>
<tt-input label="PLZ" v-model="offer.customerZip" sm :disabled="isReadonly"/>
<tt-input label="Ort" v-model="offer.customerCity" sm :disabled="isReadonly"/>
<tt-input label="USt-IdNr." v-model="offer.customerVAT" sm :disabled="isReadonly"/>
</div>
</div>
</div>
<!-- Positions -->
<div class="card mb-3">
<div class="card-header">Positionen</div>
<div class="card-body" ref="positionsManagerContainer">
<tt-positions-manager group-mode ref="positionsManager" v-model="offer.positions" :config="positionsConfig"
@updateField-article="fetchArticleData" :readonly="isReadonly"/>
</div>
</div>
<!-- Summary and Terms -->
<div class="card mb-3">
<div class="card-header">Konditionen & Schlusstext</div>
<div class="card-body">
<div class="row">
<div class="col-md-8">
<tt-input label="Gesamtrabatt (%)" v-model.number="offer.totalDiscount" sm row type="number" :disabled="isReadonly"/>
<tt-select label="Zahlungskonditionen" :options="paymentTerms" sm row v-model="offer.paymentTerms" :disabled="isReadonly"/>
<tt-select label="Lieferkonditionen" :options="deliveryTerms" sm row v-model="offer.deliveryTerms" :disabled="isReadonly"/>
</div>
<div class="col-md-4">
<h5 class="text-right">Nettobetrag: {{ formatPrice(netTotalPrice) }} €</h5>
<h5 class="text-right">Alternativbetrag: {{ formatPrice(alternativeTotalPrice) }} €</h5>
<h4 class="text-right">Gesamtbetrag: {{ formatPrice(offerTotalPrice) }} €</h4>
</div>
</div>
<hr>
<div class="d-flex justify-content-between align-items-center">
<label>Schlusstext</label>
<tt-button v-if="!isReadonly" text="Vorlage wählen" @click="showClosingTextModal = true" sm/>
</div>
<tt-textarea sm rows="8" row v-model="offer.closingText" :disabled="isReadonly"/>
<hr>
<tt-textarea label="Interne Notizen" v-model="offer.notes" sm row :disabled="isReadonly"/>
</div>
</div>
<hr>
<h4 class="text-center">Positionen</h4>
<tt-positions-manager group-mode ref="positionsManager" v-model="offer.positions" :config="positionsConfig"
@updateField-article="fetchArticleData"/>
<hr>
<tt-input label="Gesamtrabatt (%)" v-model="offer.totalDiscount" sm row type="number"/>
<tt-input label="Gesamtsumme" v-model="offerTotalPrice" sm row type="number" disabled/>
<tt-select label="Zahlungskonditionen" :options="paymentTerms" sm row v-model="offer.paymentTerms"/>
<tt-select label="Lieferkonditionen" :options="deliveryTerms" sm row v-model="offer.deliveryTerms"/>
<tt-textarea label="Schlusstext" sm rows="11" row v-model="offer.closingText"/>
<hr>
<tt-textarea label="Notizen" v-model="offer.notes" sm row/>
</div>
<!-- Modals -->
<closing-text-modal v-if="showClosingTextModal" @close="showClosingTextModal = false" @select="applyClosingText"/>
<template v-slot:footer-prepend v-if="window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1'">
<template v-slot:footer-prepend v-if="!isReadonly">
<tt-input placeholder="Vorlagenname" no-form-group v-model="templateName"/>
<tt-button text="Als Vorlage speichern" @click="saveTemplate" icon="fas fa-save" additional-class="btn-success"/>
</template>
@@ -629,32 +864,25 @@ Vue.component('warehouse-offer-modal', {
`,
data() {
return {
billAddrAutoCompleteUrl: window.TT_CONFIG['BASE_PATH'] + '/Address/Api?do=findAddress&fibu_primary_account=1',
billAddrAutoCompleteUrl: window.TT_CONFIG['BASE_PATH'] + '/Address/api?do=findAddress&fibu_primary_account=1',
window: window,
isAddressManuallyChanged: false,
versions: [],
selectedVersion: null,
isReadonly: false,
showClosingTextModal: false,
positionsConfig: {
fields: {
article: {
type: 'input-article',
label: 'Artikel',
customFieldReference: 'WarehouseArticle',
},
amount: {type: 'input', label: 'Menge', inputType: 'number', editableInTable: true},
unit: {type: 'input', label: 'Einheit'},
articleNumber: {type: 'input', label: 'Artikelnummer'},
isAlternative: {type: 'checkbox', label: 'Alternativposition'},
unitPrice: {type: 'input', label: 'Einzelpreis', inputType: 'number'},
discount: {type: 'input', label: 'Rabatt (%)', inputType: 'number'},
},
validateForm: (formData) => {
const requiredFields = ['article', 'amount', 'unitPrice'];
for (const field of requiredFields) {
if (!formData[field]) {
window.notify('error', `Bitte füllen Sie ${this.positionsConfig.fields[field].label} aus`);
return false;
}
}
return true;
article: { type: 'input-article', label: 'Artikel', customFieldReference: 'WarehouseArticle' },
amount: { type: 'input', label: 'Menge', inputType: 'number', editableInTable: true },
unit: { type: 'input', label: 'Einheit' },
articleNumber: { type: 'input', label: 'Artikelnummer' },
unitPrice: { type: 'input', label: 'Einzelpreis', inputType: 'number', editableInTable: true },
discount: { type: 'input', label: 'Rabatt (%)', inputType: 'number', editableInTable: true },
comment: { type: 'input', label: 'Kommentar', editableInTable: true },
isAlternative: { type: 'checkbox', label: 'Alternativ' },
},
validateForm: (formData) => { /* ... validation logic ... */ return true; },
},
paymentTerms: [
{value: 'net30', text: '30 Tage netto'},
@@ -664,7 +892,6 @@ Vue.component('warehouse-offer-modal', {
deliveryTerms: [
{value: 'ex_works', text: 'Ab Werk'},
{value: 'free_delivery', text: 'Frei Haus'},
{value: 'fob', text: 'FOB'},
],
offer: {
editor: window.TT_CONFIG['USER_ID'],
@@ -676,41 +903,74 @@ Vue.component('warehouse-offer-modal', {
customerZip: '',
customerCity: '',
customerVAT: '',
contactPerson: '',
contactPersonEmail: '',
positions: [],
alternativePositions: [],
totalDiscount: 0,
paymentTerms: 'net30',
deliveryTerms: 'ex_works',
closingText: 'Sollten sich die Rohstoffpreise bzw. die Preise unserer Zulieferer um mehr als 10% innerhalb der Angebots bzw.\n' +
'\n' +
'Auftragsgültigkeit erhöhen (Stichtag Datum), sind wir gezwungen die Preise anzupassen.\n' +
'\n' +
'Diese Angebot hat eine Gültigkeit von 4 Wochen.\n' +
'\n' +
'Verrechnung erfolgt nach tatsächlichem Aufwand.\n' +
'\n' +
'Wir sind sicher, Ihnen ein konkurenzfähiges Angebot unterbreitet zu haben und sehen gern Ihrer Bestellung entgegen.\n' +
'\n' +
'Sollten Sie noch Fragen oder weitere Informationen benötigen stehen wir Ihnen jederzeit gern zu Verfügung.\n' +
' ',
closingText: 'Sollten sich die Rohstoffpreise bzw. die Preise unserer Zulieferer um mehr als 10% innerhalb der Angebots bzw.\nAuftragsgültigkeit erhöhen (Stichtag Datum), sind wir gezwungen die Preise anzupassen.\nDiese Angebot hat eine Gültigkeit von 4 Wochen.\nVerrechnung erfolgt nach tatsächlichem Aufwand.\nWir sind sicher, Ihnen ein konkurenzfähiges Angebot unterbreitet zu haben und sehen gern Ihrer Bestellung entgegen.\nSollten Sie noch Fragen oder weitere Informationen benötigen stehen wir Ihnen jederzeit gern zu Verfügung.',
notes: '',
},
templateName: '',
ignoreFirstAddressChange: false,
}
},
async mounted() {
if (this.id !== 'create') {
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/getById`, {params: {id: this.id}});
this.offer = response.data;
this.offer.positions = JSON.parse(this.offer.positions);
this.offer.alternativePositions = JSON.parse(this.offer.alternativePositions);
this.ignoreFirstAddressChange = true;
await this.loadOffer(this.id);
await this.loadVersions();
} else {
await this.$nextTick();
this.offer.editor = parseInt(window.TT_CONFIG['USER_ID']);
}
// Add focus-out listener to scroll up
this.$refs.positionsManagerContainer.addEventListener('focusout', () => {
this.$refs.positionsManagerContainer.scrollTop = 0;
});
},
methods: {
formatDate: ts => ts ? window.moment(ts * 1000).format('DD.MM.YYYY HH:mm') : '-',
formatPrice: price => new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(price || 0),
async loadOffer(id) {
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/getById`, {params: {id}});
this.offer = response.data;
this.offer.positions = JSON.parse(this.offer.positions || '[]');
this.selectedVersion = this.offer.version;
this.isReadonly = false; // Default to editable
},
async loadVersions() {
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/getVersions`, {params: {id: this.id}});
this.versions = response.data.sort((a, b) => b.version - a.version);
if(this.versions.length > 0 && !this.selectedVersion) {
this.selectedVersion = this.versions[0].version;
}
},
loadVersion() {
const versionData = this.versions.find(v => v.version == this.selectedVersion);
if (versionData && versionData.data) {
this.offer = versionData.data;
// Ensure positions is always an array
this.offer.positions = typeof versionData.data.positions === 'string' ? JSON.parse(versionData.data.positions || '[]') : (versionData.data.positions || []);
this.isReadonly = this.selectedVersion != this.versions[0].version; // Only latest version is editable
window.notify('info', `Version ${this.selectedVersion} geladen. Nur die aktuellste Version ist bearbeitbar.`);
}
},
openVersionPDF() {
if (!this.selectedVersion) {
window.notify('error', 'Keine Version ausgewählt.');
return;
}
window.open(`${window.TT_CONFIG['BASE_PATH']}/WarehouseOffer/createPDF?id=${this.id}&version=${this.selectedVersion}`);
},
applyClosingText(text) {
this.offer.closingText = text;
this.showClosingTextModal = false;
},
async submit() {
if (this.isReadonly) {
return window.notify('info', 'Alte Versionen können nicht gespeichert werden. Erstellen Sie eine neue Version durch Bearbeiten der Aktuellsten.');
}
this.offer.totalAmount = this.offerTotalPrice;
if (this.offer.positions.length === 0) return window.notify('error', 'Bitte fügen Sie mindestens eine Position hinzu.');
@@ -718,14 +978,16 @@ Vue.component('warehouse-offer-modal', {
? `${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/create`
: `${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/update`;
const response = await axios.post(url, this.offer);
if (response.data.success) {
window.notify('success', response.data.message ?? 'Angebot erfolgreich gespeichert');
this.$emit('close');
} else {
window.notify('error',
response.data.errors ? Object.values(response.data.errors).join('<br>') : response.data.message || 'Ein Fehler ist aufgetreten');
try {
const response = await axios.post(url, this.offer);
if (response.data.success) {
window.notify('success', response.data.message ?? 'Angebot erfolgreich gespeichert');
this.$emit('close');
} else {
window.notify('error', response.data.errors ? Object.values(response.data.errors).join('<br>') : response.data.message || 'Ein Fehler ist aufgetreten');
}
} catch (e) {
window.notify('error', 'Speichern fehlgeschlagen: ' + e.message);
}
},
async fetchArticleData(article) {
@@ -756,34 +1018,64 @@ Vue.component('warehouse-offer-modal', {
} else {
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten');
}
},
trackManualAddressChange() {
if (this.id === 'create' || !this.isReadonly) {
this.isAddressManuallyChanged = true;
}
}
},
watch: {
'offer.customerNumber': async function () {
if (!this.offer.customerNumber) return;
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/Address/api?do=getAddress&id=${this.offer.customerNumber}`);
if (response.data.status !== 'OK' || !response.data.result.address) {
this.window.notify('error', 'Kundenadresse konnte nicht gefunden werden');
'offer.customerNumber': async function (newVal, oldVal) {
if (!newVal || this.isAddressManuallyChanged) return;
if (this.ignoreFirstAddressChange) {
this.ignoreFirstAddressChange = false;
return;
}
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/Address/api?do=getAddress&id=${this.offer.customerNumber}`);
if (response.data.status !== 'OK' || !response.data.result.address) {
return;
}
const address = response.data.result.address;
this.offer.customerName = address.company || `${address.firstname} ${address.lastname}`;
this.offer.customerStreet = address.street;
this.offer.customerZip = address.zip;
this.offer.customerCity = address.city;
}
this.offer.customerVAT = address.vat_number || '';
this.offer.contactPersonEmail = address.email || '';
},
'offer.customerName': function() { this.trackManualAddressChange() },
'offer.customerStreet': function() { this.trackManualAddressChange() },
'offer.customerZip': function() { this.trackManualAddressChange() },
'offer.customerCity': function() { this.trackManualAddressChange() },
},
computed: {
offerTotalPrice() {
const totalPrice = this.offer.positions.reduce((total, position) => {
if (!position.amount) return total;
const discount = position.discount ? (position.unitPrice * position.amount) * position.discount / 100 : 0;
return total + (position.unitPrice * position.amount) - discount;
modalTitle() {
if (this.id === 'create') return 'Angebot erstellen';
let title = `Angebot #${this.offer.offerNumber} (v${this.selectedVersion || this.offer.version})`;
if(this.isReadonly) title += ' - Schreibgeschützt';
return title;
},
netTotalPrice() {
if (!this.offer.positions) return 0;
return this.offer.positions.reduce((total, p) => {
if (p.isAlternative || !p.amount || !p.unitPrice) return total;
const discount = p.discount ? (p.unitPrice * p.amount) * p.discount / 100 : 0;
return total + (p.unitPrice * p.amount) - discount;
}, 0);
return totalPrice - (totalPrice * this.offer.totalDiscount / 100).toFixed(2);
},
alternativeTotalPrice() {
if (!this.offer.positions) return 0;
return this.offer.positions.reduce((total, p) => {
if (!p.isAlternative || !p.amount || !p.unitPrice) return total;
const discount = p.discount ? (p.unitPrice * p.amount) * p.discount / 100 : 0;
return total + (p.unitPrice * p.amount) - discount;
}, 0);
},
offerTotalPrice() {
const total = this.netTotalPrice;
return total - (total * (this.offer.totalDiscount || 0) / 100);
}
}
});
@@ -792,20 +1084,16 @@ Vue.component('warehouse-offer', {
template: `
<tt-card>
<warehouse-offer-modal v-if="offerModalId" :id="offerModalId" ref="modal"
@close="offerModalId = null;$refs.table.$refs.table.refreshTable()"/>
<warehouse-offer-create-basic-offer-modal
v-if="showBasicModal"
:show="true"
@close="showBasicModal = false"
@created="showBasicModal = false"/>
@close="closeModal"/>
<send-mail-modal v-if="sendMailModalId" :offer-id="sendMailModalId" @close="closeModal"/>
<div style="display: flex; gap: 8px">
<button @click="offerModalId = 'create'" class="btn btn-primary">Angebot erstellen</button>
<div class="dropdown">
<div class="d-flex" style="gap: 8px; margin-bottom: 1rem;">
<tt-button text="Angebot erstellen" @click="offerModalId = 'create'" additional-class="btn-primary"/>
<div class="dropdown" id="offer-templates-dropdown">
<button class="btn btn-outline-primary dropdown-toggle" @click.stop="offerTemplatesDropdown = !offerTemplatesDropdown" >
Angebot aus Vorlage erstellen <i class="fas fa-caret-down"></i>
Vorlage <i class="fas fa-caret-down"></i>
</button>
<ul class="dropdown-menu" :class="{'show': offerTemplatesDropdown}" >
<ul class="dropdown-menu" :class="{'show': offerTemplatesDropdown}" @mouseleave="offerTemplatesDropdown = false">
<li v-for="template in offerTemplates" @click="createOfferFromTemplate(template)" style="display: flex; gap: 2px;cursor: pointer;margin-bottom: 4px;margin-right: 4px">
<a class="dropdown-item">{{ template.templateName }}</a>
<tt-button
@@ -814,19 +1102,20 @@ Vue.component('warehouse-offer', {
</li>
</ul>
</div>
<button @click="showBasicModal = true" class="btn btn-success">
<i class="fas fa-plus"></i> Einfaches Angebot erstellen
</button>
</div>
<tt-table-crud emit-edit @edit="offerModalId = $event.id" ref="table"
@openpdf="window.open(window.TT_CONFIG['BASE_PATH'] + '/WarehouseOffer/createPDF?id=' + $event.id)"
<tt-table-crud emit-edit
@edit="offerModalId = $event.id"
ref="table"
@openpdf="openPDF($event)"
@sendmail="sendMailModalId = $event.id"
>
<template v-slot:expandedRow="{ row }">
<warehouse-offer-detail :id="row.id"/>
</template>
<template v-slot:totalamount="{ row }">{{ formatPrice(row.totalAmount) }}</template>
<template v-slot:create="{ row }">{{ formatDate(row.create) }}</template>
<template v-slot:lastsentdate="{ row }">{{ formatDate(row.lastSentDate) }}</template>
</tt-table-crud>
</tt-card>
`,
@@ -834,37 +1123,311 @@ Vue.component('warehouse-offer', {
return {
window: window,
offerModalId: null,
sendMailModalId: null,
offerTemplates: [],
offerTemplatesDropdown: false,
showBasicModal: false,
}
},
async mounted() {
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/getTemplates`);
this.offerTemplates = response.data;
await this.loadTemplates();
document.addEventListener('click', this.closeDropdown);
},
beforeDestroy() {
document.removeEventListener('click', this.closeDropdown);
},
methods: {
formatDate: ts => ts ? window.moment(ts * 1000).format('DD.MM.YYYY') : '-',
formatPrice: price => new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(price || 0),
async closeModal() {
this.offerModalId = null;
this.sendMailModalId = null;
await new Promise(resolve => setTimeout(resolve, 250));
this.$refs.table.$refs.table.refreshTable();
},
async loadTemplates() {
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/getTemplates`);
this.offerTemplates = response.data;
},
openPDF(offer) {
window.open(`${window.TT_CONFIG['BASE_PATH']}/WarehouseOffer/createPDF?id=${offer.id}&version=${offer.version}`)
},
closeDropdown(event) {
if (!event.target.closest('#offer-templates-dropdown')) {
this.offerTemplatesDropdown = false;
}
},
async createOfferFromTemplate(template) {
this.offerTemplatesDropdown = false;
this.offerModalId = 'create';
await this.$nextTick();
this.$refs.modal.offer.positions = JSON.parse(template.positions);
this.$refs.modal.offer.totalDiscount = template.totalDiscount;
this.$refs.modal.offer.paymentTerms = template.paymentTerms;
this.$refs.modal.offer.deliveryTerms = template.deliveryTerms;
this.$refs.modal.offer.closingText = template.closingText;
this.$refs.modal.offer.notes = template.notes;
this.window.notify('success', 'Angebot aus Vorlage erstellt');
window.notify('success', 'Angebot aus Vorlage erstellt');
},
async deleteTemplate(id) {
if(!confirm('Vorlage wirklich löschen?')) return;
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/deleteTemplate?id=${id}`);
if (response.data.success) {
this.offerTemplates = this.offerTemplates.filter(template => template.id !== id);
this.window.notify('success', 'Vorlage erfolgreich gelöscht');
await this.loadTemplates();
window.notify('success', 'Vorlage erfolgreich gelöscht');
} else {
this.window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten');
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten');
}
}
}
});
Vue.component('warehouse-offer-detail', {
template: `
<div class="offer-detail-container">
<div v-if="loading" class="text-center p-5"><i class="fas fa-spinner fa-spin fa-2x"></i></div>
<div v-else class="offer-detail-grid">
<!-- Left Column: Offer Info -->
<div class="offer-info-pane">
<h5 class="pane-title"><i class="fas fa-info-circle mr-2"></i>Übersicht</h5>
<div class="info-grid">
<div class="info-item">
<label>Kunde</label>
<span>{{ offer.customerName }}</span>
</div>
<div class="info-item">
<label>Zweck</label>
<span>{{ offer.purpose || '-' }}</span>
</div>
<div class="info-item">
<label>Summe</label>
<span>{{ formatPrice(offer.totalAmount) }}</span>
</div>
<div class="info-item">
<label>Status</label>
<span class="badge" :class="statusInfo.badgeClass">{{ statusInfo.text }}</span>
</div>
<div class="info-item">
<label>Sachbearbeiter</label>
<span>{{ editorName }}</span>
</div>
<div class="info-item">
<label>Erstellt am</label>
<span>{{ formatDate(offer.create, 'DD.MM.YYYY') }}</span>
</div>
</div>
</div>
<!-- Right Column: Journal -->
<div class="offer-journal-pane">
<h5 class="pane-title"><i class="fas fa-history mr-2"></i>Journal</h5>
<div class="journal-box mb-3">
<div v-if="journal.length === 0" class="text-center text-muted p-4">Noch keine Einträge vorhanden.</div>
<div v-for="log in journal" class="journal-entry-styled">
<div class="journal-avatar" :style="{ backgroundColor: userColor(log.createByName) }">
<span>{{ log.createByName ? log.createByName.charAt(0) : '?' }}</span>
</div>
<div class="journal-content">
<div class="journal-header">
<strong>{{ log.createByName }}</strong>
<span class="text-muted text-xs ml-auto">{{ formatDate(log.create, 'relative') }}</span>
</div>
<p class="journal-message" v-if="log.message">{{ log.message }}</p>
<div v-if="log.fileIds && JSON.parse(log.fileIds).length > 0" class="mt-2">
<tt-file v-for="fileId in JSON.parse(log.fileIds)" :key="fileId" :id="fileId"/>
</div>
</div>
</div>
</div>
<div class="new-journal-entry">
<tt-textarea v-model="newMessage" placeholder="Neuer Journaleintrag..." no-form-group class="flex-grow-1" rows="2"/>
<div class="new-journal-actions">
<tt-file-upload-light ref="fileUpload" multiple @uploaded="onFileUploaded" button-text="" />
<tt-button text="Speichern" @click="addJournalEntry" :loading="savingJournal" sm/>
</div>
</div>
</div>
</div>
</div>
`,
props: ['id'],
data: () => ({
offer: {},
journal: [],
loading: true,
savingJournal: false,
newMessage: '',
uploadedFiles: [],
userColors: {}
}),
async mounted() {
const [offerResponse, journalResponse] = await Promise.all([
axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/getById`, {params: {id: this.id}}),
axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/getJournal`, {params: {id: this.id}})
]);
this.offer = offerResponse.data;
this.journal = journalResponse.data.sort((a,b) => b.create - a.create); // Ensure descending order
this.loading = false;
},
methods: {
formatDate(ts, format = 'DD.MM.YYYY HH:mm') {
if (!ts) return '-';
if (format === 'relative') {
return window.moment(ts * 1000).fromNow();
}
return window.moment(ts * 1000).format(format);
},
formatPrice: price => new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(price || 0),
onFileUploaded(file) {
this.uploadedFiles.push(file.id);
},
async addJournalEntry() {
if (!this.newMessage && this.uploadedFiles.length === 0) return;
this.savingJournal = true;
try {
await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/addJournalEntry`, {
id: this.id,
message: this.newMessage,
fileIds: this.uploadedFiles
});
this.newMessage = '';
this.uploadedFiles = [];
if (this.$refs.fileUpload) this.$refs.fileUpload.reset();
const journalResponse = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/getJournal`, {params: {id: this.id}});
this.journal = journalResponse.data.sort((a,b) => b.create - a.create);
window.notify('success', 'Journaleintrag gespeichert.');
} catch (e) {
window.notify('error', 'Fehler beim Speichern des Eintrags.');
} finally {
this.savingJournal = false;
}
},
userColor(userName) {
if (!userName) return '#cccccc';
if (this.userColors[userName]) return this.userColors[userName];
let hash = 0;
for (let i = 0; i < userName.length; i++) {
hash = userName.charCodeAt(i) + ((hash << 5) - hash);
}
let color = '#';
for (let i = 0; i < 3; i++) {
let value = (hash >> (i * 8)) & 0xFF;
color += ('00' + value.toString(16)).substr(-2);
}
this.userColors[userName] = color;
return color;
}
},
computed: {
editorName() {
const users = window.TT_CONFIG.CRUD_CONFIG.columns.find(c => c.key === 'editor')?.modal?.items || [];
const user = users.find(u => u.value == this.offer.editor);
return user ? user.text : 'Unbekannt';
},
statusInfo() {
const statusMap = {
new: { text: 'Neu', badgeClass: 'badge-primary' },
sent: { text: 'Ausgeschickt', badgeClass: 'badge-info' },
accepted: { text: 'Angenommen', badgeClass: 'badge-success' },
rejected: { text: 'Abgelehnt', badgeClass: 'badge-danger' },
cancelled: { text: 'Storniert', badgeClass: 'badge-secondary' },
};
return statusMap[this.offer.status] || { text: this.offer.status, badgeClass: 'badge-light' };
}
}
});
;
Vue.component('send-mail-modal', {
props: ['offerId'],
template: `
<tt-modal :show="true" title="Angebot per E-Mail senden" @close="$emit('close')" @submit="sendEmail" :save-loading="loading">
<tt-input label="Empfänger E-Mail" v-model="email" required/>
<tt-input label="Betreff" v-model="subject" required/>
<tt-textarea label="Nachricht" v-model="body" rows="5" required/>
</tt-modal>
`,
data() {
return {
loading: false,
email: '',
subject: '',
body: 'Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie das angeforderte Angebot.\n\nMit freundlichen Grüßen\nIhr Team der XINON GmbH'
}
},
async mounted() {
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/getById`, {params: {id: this.offerId}});
const offer = response.data;
this.email = offer.contactPersonEmail || '';
this.subject = `Angebot ${offer.offerNumber} von XINON GmbH`;
},
methods: {
async sendEmail() {
this.loading = true;
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/sendOfferEmail`, {
id: this.offerId,
email: this.email,
subject: this.subject,
body: this.body
});
if (response.data.success) {
window.notify('success', response.data.message);
this.$emit('close');
} else {
window.notify('error', response.data.message);
}
} catch (e) {
window.notify('error', 'E-Mail Versand fehlgeschlagen.');
} finally {
this.loading = false;
}
}
}
});
Vue.component('closing-text-modal', {
template: `
<tt-modal :show="true" title="Schlusstext-Vorlage wählen" @close="$emit('close')" :submit-button="false">
<div class="list-group">
<a href="#" v-for="template in templates" :key="template.id" class="list-group-item list-group-item-action" @click.prevent="$emit('select', template.text)">
{{ template.name }}
</a>
</div>
<hr v-if="window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1'">
<div v-if="window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1'">
<h5>Neue Vorlage erstellen</h5>
<tt-input label="Name" v-model="newTemplate.name" sm/>
<tt-textarea label="Text" v-model="newTemplate.text" sm rows="4"/>
<tt-button text="Vorlage speichern" @click="saveTemplate" sm/>
</div>
</tt-modal>
`,
data() {
return {
templates: [],
newTemplate: { name: '', text: '' },
window: window,
}
},
async mounted() {
await this.loadTemplates();
},
methods: {
async loadTemplates() {
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/getClosingTexts`);
this.templates = response.data;
},
async saveTemplate() {
if (!this.newTemplate.name || !this.newTemplate.text) {
return window.notify('error', 'Name und Text dürfen nicht leer sein.');
}
await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/createClosingText`, this.newTemplate);
this.newTemplate.name = '';
this.newTemplate.text = '';
await this.loadTemplates();
window.notify('success', 'Vorlage gespeichert.');
}
}
});