Files
thetool/public/js/pages/WarehouseOffer/WarehouseOfferModal.js
2025-12-02 14:46:22 +00:00

318 lines
16 KiB
JavaScript

Vue.component('warehouse-offer-modal', {
props: {
id: {type: [String, Number], required: true},
},
template: `
<tt-modal :show="true"
@submit="submit"
:delete="id !== 'create'"
: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>
</div>
<!-- Modals -->
<closing-text-modal v-if="showClosingTextModal" @close="showClosingTextModal = false" @select="applyClosingText"/>
<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>
</tt-modal>
`,
data() {
return {
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' },
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'},
{value: 'net60', text: '60 Tage netto'},
{value: 'immediate', text: 'Sofort fällig'},
],
deliveryTerms: [
{value: 'ex_works', text: 'Ab Werk'},
{value: 'free_delivery', text: 'Frei Haus'},
],
offer: {
editor: window.TT_CONFIG['USER_ID'],
customerNumber: '',
reference: '',
purpose: '',
customerName: '',
customerStreet: '',
customerZip: '',
customerCity: '',
customerVAT: '',
contactPerson: '',
contactPersonEmail: '',
positions: [],
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.\nAuftragsgültigkeit erhöhen (Stichtag Datum), sind wir gezwungen die Preise anzupassen.\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') {
this.ignoreFirstAddressChange = true;
await this.loadOffer(this.id);
await this.loadVersions();
} else {
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.');
const url = this.id === 'create'
? `${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/create`
: `${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/update`;
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) {
if (typeof article === 'number') {
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseArticle/getById`, {params: {id: article}});
this.$refs.positionsManager.updateField('articleNumber', response.data.articleNumber);
this.$refs.positionsManager.updateField('unitPrice',
Object.values(JSON.parse(response.data.cheapestSellPrice)).find(price => price.title === 'Verkauf').price);
this.$refs.positionsManager.updateField('unit', response.data.unit);
}
},
async saveTemplate() {
if (!this.templateName) return window.notify('error', 'Bitte geben Sie einen Namen für die Vorlage ein.');
if (this.offer.positions.length === 0) return window.notify('error', 'Bitte fügen Sie mindestens eine Position hinzu.');
const response = await axios.post(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/createTemplate`, {
name: this.templateName,
positions: this.offer.positions,
totalDiscount: this.offer.totalDiscount,
paymentTerms: this.offer.paymentTerms,
deliveryTerms: this.offer.deliveryTerms,
closingText: this.offer.closingText,
notes: this.offer.notes
});
if (response.data.success) {
window.notify('success', response.data.message ?? 'Vorlage erfolgreich gespeichert');
} 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 (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: {
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);
},
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);
}
}
})