1434 lines
64 KiB
JavaScript
1434 lines
64 KiB
JavaScript
// 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}
|
|
},
|
|
data() {
|
|
return {
|
|
window: window,
|
|
loading: false,
|
|
billAddrAutoCompleteUrl: window.TT_CONFIG['BASE_PATH'] + '/Address/Api?do=findAddress',
|
|
productSearchUrl: window.TT_CONFIG['BASE_PATH'] + '/Product/api?do=findProduct',
|
|
|
|
creatorSignaturePad: null,
|
|
creatorSignatureNotes: '',
|
|
|
|
positionsConfig: {
|
|
fields: {
|
|
article: {
|
|
type: 'autocomplete',
|
|
label: 'Artikel',
|
|
apiUrl: '/WarehouseArticle/autoComplete',
|
|
customFieldReference: 'WarehouseArticle',
|
|
},
|
|
amount: {type: 'input', label: 'Menge', inputType: 'number'},
|
|
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;
|
|
},
|
|
},
|
|
|
|
offer: {
|
|
editor: window.TT_CONFIG['USER_ID'],
|
|
customerNumber: '',
|
|
reference: '',
|
|
purpose: '',
|
|
customerName: '',
|
|
customerStreet: '',
|
|
customerZip: '',
|
|
customerCity: '',
|
|
customerVAT: '',
|
|
contactPerson: '',
|
|
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.\n\nAuftragsgültigkeit erhöhen (Stichtag Datum), sind wir gezwungen die Preise anzupassen.\n\nDiese Angebot hat eine Gültigkeit von 4 Wochen.\n\nVerrechnung erfolgt nach tatsächlichem Aufwand.\n\nWir sind sicher, Ihnen ein konkurenzfähiges Angebot unterbreitet zu haben und sehen gern Ihrer Bestellung entgegen.\n\nSollten Sie noch Fragen oder weitere Informationen benötigen stehen wir Ihnen jederzeit gern zu Verfügung.',
|
|
notes: '',
|
|
},
|
|
|
|
// Simple product search and selection
|
|
productSearch: '',
|
|
searchResults: [],
|
|
selectedProducts: [],
|
|
showProductSearch: false,
|
|
|
|
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'},
|
|
{value: 'fob', text: 'FOB'},
|
|
],
|
|
}
|
|
},
|
|
template: `
|
|
<tt-modal :show="show"
|
|
@submit="submit"
|
|
:delete="false"
|
|
title="Einfaches Angebot erstellen"
|
|
@update:show="$emit('close')"
|
|
:save-loading="loading">
|
|
<div style="width: 99%">
|
|
|
|
<!-- Customer Section -->
|
|
<div class="card mb-3">
|
|
<div class="card-header">
|
|
<h5 class="mb-0"><i class="fas fa-user"></i> Kunde</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<tt-autocomplete label="Kunde suchen"
|
|
v-model="offer.customerNumber"
|
|
:api-url="billAddrAutoCompleteUrl"
|
|
sm row
|
|
placeholder="Kundenname oder -nummer eingeben"/>
|
|
<tt-input label="Kundenreferenz"
|
|
v-model="offer.reference"
|
|
sm row
|
|
placeholder="Ihre Referenz oder Bestellnummer"/>
|
|
<tt-input label="Kontaktperson"
|
|
v-model="offer.contactPerson"
|
|
sm row
|
|
placeholder="Ansprechpartner beim Kunden"/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Customer Address (Auto-filled) -->
|
|
<div class="card mb-3" v-if="offer.customerName">
|
|
<div class="card-header">
|
|
<h5 class="mb-0"><i class="fas fa-map-marker-alt"></i> Kundenadresse</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<strong>{{ offer.customerName }}</strong><br>
|
|
{{ offer.customerStreet }}<br>
|
|
{{ offer.customerZip }} {{ offer.customerCity }}
|
|
</div>
|
|
<div class="col-md-6" v-if="offer.customerVAT">
|
|
<small class="text-muted">USt-IdNr.: {{ offer.customerVAT }}</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Purpose -->
|
|
<div class="card mb-3">
|
|
<div class="card-header">
|
|
<h5 class="mb-0"><i class="fas fa-clipboard-list"></i> Angebotszweck</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<tt-textarea label="Wofür ist dieses Angebot?"
|
|
v-model="offer.purpose"
|
|
rows="2"
|
|
placeholder="Kurze Beschreibung des Projekts oder Bedarfs"/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Product Selection -->
|
|
<div class="card mb-3">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0"><i class="fas fa-box"></i> Produkte</h5>
|
|
<button type="button"
|
|
class="btn btn-primary btn-sm"
|
|
@click="showProductSearch = !showProductSearch">
|
|
<i class="fas fa-plus"></i> Produkt hinzufügen
|
|
</button>
|
|
</div>
|
|
<div class="card-body">
|
|
|
|
<!-- Product Search -->
|
|
<div v-if="showProductSearch" class="border rounded p-3 mb-3 bg-light">
|
|
<div class="input-group mb-2">
|
|
<input type="text"
|
|
class="form-control"
|
|
v-model="productSearch"
|
|
@input="searchProducts"
|
|
placeholder="Produktname oder ID eingeben..."
|
|
@keyup.enter="searchProducts">
|
|
<div class="input-group-append">
|
|
<button class="btn btn-outline-secondary"
|
|
type="button"
|
|
@click="searchProducts">
|
|
<i class="fas fa-search"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search Results -->
|
|
<div v-if="searchResults.length" class="list-group">
|
|
<button type="button"
|
|
class="list-group-item list-group-item-action"
|
|
v-for="product in searchResults"
|
|
:key="product.value"
|
|
@click="addProduct(product)">
|
|
{{ product.text }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="text-center mt-2">
|
|
<button type="button"
|
|
class="btn btn-secondary btn-sm"
|
|
@click="showProductSearch = false">
|
|
Schließen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Selected Products -->
|
|
<div v-if="selectedProducts.length">
|
|
<div v-for="(product, index) in selectedProducts"
|
|
:key="index"
|
|
class="border rounded p-3 mb-2">
|
|
<div class="row align-items-center">
|
|
<div class="col-md-4">
|
|
<strong>{{ product.name }}</strong>
|
|
<br><small class="text-muted">{{ product.description || 'Keine Beschreibung' }}</small>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<tt-input label="Menge"
|
|
v-model="product.amount"
|
|
type="number"
|
|
min="1"
|
|
sm no-form-group/>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<tt-input label="Einzelpreis (€)"
|
|
v-model="product.unitPrice"
|
|
type="number"
|
|
step="0.01"
|
|
sm no-form-group/>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<tt-input label="Rabatt (%)"
|
|
v-model="product.discount"
|
|
type="number"
|
|
min="0"
|
|
max="100"
|
|
sm no-form-group/>
|
|
</div>
|
|
<div class="col-md-1 text-center">
|
|
<strong>{{ formatPrice(calculateProductTotal(product)) }} €</strong>
|
|
</div>
|
|
<div class="col-md-1 text-center">
|
|
<button type="button"
|
|
class="btn btn-danger btn-sm"
|
|
@click="removeProduct(index)">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Total -->
|
|
<div class="row mt-3">
|
|
<div class="col-md-8">
|
|
<tt-input label="Gesamtrabatt (%)"
|
|
v-model="offer.totalDiscount"
|
|
type="number"
|
|
min="0"
|
|
max="100"
|
|
sm row/>
|
|
</div>
|
|
<div class="col-md-4 text-right">
|
|
<h4>Gesamtsumme: <strong>{{ formatPrice(totalPrice) }} €</strong></h4>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="text-center text-muted py-4">
|
|
<i class="fas fa-box-open fa-3x mb-2"></i>
|
|
<p>Noch keine Produkte ausgewählt</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Additional Positions -->
|
|
<div class="card mb-3">
|
|
<div class="card-header">
|
|
<h5 class="mb-0"><i class="fas fa-plus-square"></i> Zusätzliche Positionen</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<tt-positions-manager group-mode ref="positionsManager" v-model="offer.positions" :config="positionsConfig"
|
|
@updateField-article="fetchArticleData"/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Terms -->
|
|
<div class="card mb-3">
|
|
<div class="card-header">
|
|
<h5 class="mb-0"><i class="fas fa-handshake"></i> Konditionen</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<tt-select label="Zahlungskonditionen"
|
|
:options="paymentTerms"
|
|
v-model="offer.paymentTerms"
|
|
sm/>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<tt-select label="Lieferkonditionen"
|
|
:options="deliveryTerms"
|
|
v-model="offer.deliveryTerms"
|
|
sm/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card mb-3">
|
|
<div class="card-header">
|
|
<h5 class="mb-0"><i class="fas fa-signature"></i> Unterschrift Angebotsersteller</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row mb-3">
|
|
<div class="col-md-12">
|
|
<tt-input v-model="creatorSignatureNotes"
|
|
label="Unterschrieben von..."
|
|
placeholder="Name oder i.V."
|
|
sm/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="signature-area mb-3">
|
|
<canvas id="creator-signature-pad" width="800" height="200" class="border rounded"></canvas>
|
|
</div>
|
|
|
|
<div class="signature-actions text-right">
|
|
<button class="btn btn-outline-secondary btn-sm mr-2" @click="clearCreatorSignature()">
|
|
<i class="fas fa-eraser"></i> Zurücksetzen
|
|
</button>
|
|
<button class="btn btn-primary btn-sm" @click="saveCreatorSignature()">
|
|
<i class="fas fa-save"></i> Unterschrift speichern
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- Notes -->
|
|
<div class="card mb-3">
|
|
<div class="card-header">
|
|
<h5 class="mb-0"><i class="fas fa-sticky-note"></i> Notizen</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<tt-textarea label="Interne Notizen"
|
|
v-model="offer.notes"
|
|
rows="3"
|
|
placeholder="Zusätzliche Informationen (nur intern sichtbar)"/>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</tt-modal>
|
|
`,
|
|
methods: {
|
|
clearCreatorSignature() {
|
|
this.creatorSignaturePad.clear();
|
|
},
|
|
|
|
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 saveCreatorSignature() {
|
|
if (this.creatorSignaturePad.isEmpty()) {
|
|
this.window.notify('error', 'Bitte eine Unterschrift hinzufügen');
|
|
return;
|
|
}
|
|
|
|
this.offer.creatorSignature = this.creatorSignaturePad.toDataURL();
|
|
this.offer.creatorSignatureNotes = this.creatorSignatureNotes;
|
|
|
|
this.window.notify('success', 'Unterschrift erfolgreich gespeichert');
|
|
},
|
|
|
|
async searchProducts() {
|
|
if (!this.productSearch || this.productSearch.length < 2) {
|
|
this.searchResults = [];
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await axios.get(this.productSearchUrl, {
|
|
params: { q: this.productSearch }
|
|
});
|
|
this.searchResults = response.data || [];
|
|
} catch (error) {
|
|
console.error('Product search failed:', error);
|
|
window.notify('error', 'Produktsuche fehlgeschlagen');
|
|
this.searchResults = [];
|
|
}
|
|
},
|
|
|
|
async addProduct(searchResult) {
|
|
if (searchResult.value === 0) return; // Skip "more results" indicator
|
|
|
|
try {
|
|
// Get detailed product information
|
|
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/Product/api`, {
|
|
params: {
|
|
do: 'getProduct',
|
|
product_id: searchResult.value
|
|
}
|
|
});
|
|
|
|
if (response.data.status === 'OK' && response.data.result["product"]) {
|
|
const productData = response.data.result["product"];
|
|
|
|
const product = {
|
|
id: searchResult.value,
|
|
name: searchResult.text,
|
|
description: productData.description || '',
|
|
amount: 1,
|
|
unitPrice: parseFloat(productData.price || '0'),
|
|
discount: 0,
|
|
unit: 'Stk',
|
|
articleNumber: productData.id || '',
|
|
article: searchResult.value // For compatibility with existing system
|
|
};
|
|
|
|
this.selectedProducts.push(product);
|
|
this.productSearch = '';
|
|
this.searchResults = [];
|
|
this.showProductSearch = false;
|
|
|
|
window.notify('success', 'Produkt hinzugefügt');
|
|
} else {
|
|
window.notify('error', 'Produktdetails konnten nicht geladen werden');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load product details:', error);
|
|
window.notify('error', 'Fehler beim Laden der Produktdetails');
|
|
}
|
|
},
|
|
|
|
removeProduct(index) {
|
|
this.selectedProducts.splice(index, 1);
|
|
},
|
|
|
|
calculateProductTotal(product) {
|
|
if (!product.amount || !product.unitPrice) return 0;
|
|
|
|
const subtotal = product.amount * product.unitPrice;
|
|
const discount = product.discount ? (subtotal * product.discount / 100) : 0;
|
|
return subtotal - discount;
|
|
},
|
|
|
|
formatPrice(price) {
|
|
|
|
return parseFloat(price || '0').toFixed(2);
|
|
},
|
|
|
|
async submit() {
|
|
this.loading = true;
|
|
|
|
// Validation
|
|
if (!this.offer.customerNumber) {
|
|
this.loading = false;
|
|
return window.notify('error', 'Bitte wählen Sie einen Kunden aus');
|
|
}
|
|
|
|
if (!this.selectedProducts.length) {
|
|
this.loading = false;
|
|
return window.notify('error', 'Bitte fügen Sie mindestens ein Produkt hinzu');
|
|
}
|
|
|
|
// Convert selected products to the format expected by the existing system
|
|
this.offer.positions = this.selectedProducts.map(product => ({
|
|
article: product.article,
|
|
amount: product.amount,
|
|
unit: product.unit,
|
|
articleNumber: product.articleNumber,
|
|
unitPrice: product.unitPrice,
|
|
discount: product.discount || 0,
|
|
isAlternative: false
|
|
}));
|
|
|
|
this.offer.totalAmount = this.totalPrice;
|
|
|
|
try {
|
|
const response = await axios.post(
|
|
`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/create`,
|
|
this.offer
|
|
);
|
|
|
|
if (response.data.success) {
|
|
window.notify('success', response.data.message || 'Angebot erfolgreich erstellt');
|
|
this.$emit('close');
|
|
this.$emit('created', response.data);
|
|
} else {
|
|
window.notify('error',
|
|
response.data.errors
|
|
? Object.values(response.data.errors).join('<br>')
|
|
: response.data.message || 'Ein Fehler ist aufgetreten'
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error('Submit failed:', error);
|
|
window.notify('error', 'Fehler beim Speichern des Angebots');
|
|
}
|
|
|
|
this.loading = false;
|
|
},
|
|
|
|
resetForm() {
|
|
this.offer = {
|
|
editor: window.TT_CONFIG['USER_ID'],
|
|
customerNumber: '',
|
|
reference: '',
|
|
purpose: '',
|
|
customerName: '',
|
|
customerStreet: '',
|
|
customerZip: '',
|
|
customerCity: '',
|
|
customerVAT: '',
|
|
contactPerson: '',
|
|
positions: [],
|
|
totalDiscount: 0,
|
|
paymentTerms: 'net30',
|
|
deliveryTerms: 'ex_works',
|
|
closingText: this.offer.closingText, // Keep default text
|
|
notes: '',
|
|
};
|
|
this.selectedProducts = [];
|
|
this.productSearch = '';
|
|
this.searchResults = [];
|
|
this.showProductSearch = false;
|
|
}
|
|
},
|
|
mounted() {
|
|
this.$nextTick(() => {
|
|
console.log(document.getElementById('creator-signature-pad'));
|
|
const canvas = document.getElementById('creator-signature-pad');
|
|
this.creatorSignaturePad = new SignaturePad(canvas, {
|
|
penColor: '#000000',
|
|
penWidth: 2,
|
|
velocityFilterWeight: 0.5
|
|
});
|
|
});
|
|
},
|
|
computed: {
|
|
totalPrice() {
|
|
const subtotal = this.selectedProducts.reduce((total, product) => {
|
|
return total + this.calculateProductTotal(product);
|
|
}, 0);
|
|
|
|
const totalDiscount = this.offer.totalDiscount ? (subtotal * this.offer.totalDiscount / 100) : 0;
|
|
return subtotal - totalDiscount;
|
|
}
|
|
},
|
|
watch: {
|
|
'offer.customerNumber': async function() {
|
|
if (!this.offer.customerNumber) return;
|
|
|
|
try {
|
|
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) {
|
|
window.notify('error', 'Kundenadresse konnte nicht gefunden werden');
|
|
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 || '';
|
|
} catch (error) {
|
|
console.error('Failed to load customer address:', error);
|
|
window.notify('error', 'Fehler beim Laden der Kundenadresse');
|
|
}
|
|
},
|
|
|
|
show(newVal) {
|
|
if (newVal) {
|
|
this.resetForm();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// noinspection JSUnresolvedReference,DuplicatedCode
|
|
|
|
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.\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') {
|
|
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);
|
|
}
|
|
}
|
|
});
|
|
|
|
Vue.component('warehouse-offer', {
|
|
template: `
|
|
<tt-card>
|
|
<warehouse-offer-modal v-if="offerModalId" :id="offerModalId" ref="modal"
|
|
@close="closeModal"/>
|
|
<send-mail-modal v-if="sendMailModalId" :offer-id="sendMailModalId" @close="closeModal"/>
|
|
|
|
<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" >
|
|
Vorlage <i class="fas fa-caret-down"></i>
|
|
</button>
|
|
<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
|
|
v-if="window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1'"
|
|
icon="fas fa-trash" sm additional-class="btn-danger" @click.stop="deleteTemplate(template.id)"/>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
`,
|
|
data() {
|
|
return {
|
|
window: window,
|
|
offerModalId: null,
|
|
sendMailModalId: null,
|
|
offerTemplates: [],
|
|
offerTemplatesDropdown: false,
|
|
}
|
|
},
|
|
async mounted() {
|
|
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;
|
|
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) {
|
|
await this.loadTemplates();
|
|
window.notify('success', 'Vorlage erfolgreich gelöscht');
|
|
} else {
|
|
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.');
|
|
}
|
|
}
|
|
});
|