diff --git a/application/Cpeprovisioning/CpeprovisioningController.php b/application/Cpeprovisioning/CpeprovisioningController.php
index 51f425fb2..f1f010ce1 100644
--- a/application/Cpeprovisioning/CpeprovisioningController.php
+++ b/application/Cpeprovisioning/CpeprovisioningController.php
@@ -1,12 +1,7 @@
me->loadMe();
$this->layout()->set("me", $this->me);
- if (!$this->me->is(["Admin"])) {
- $this->redirect("Dashboard");
- }
+ if (!$this->me->is(["Admin"])) $this->redirect("Dashboard");
}
- protected function indexAction()
- {
+ protected function indexAction() {
$cpecounter = 0;
$r = $this->request;
$page = $r->s;
@@ -120,8 +112,7 @@ class CpeprovisioningController extends mfBaseController
$this->layout()->set("products", $cpeproducts);
}
- private function getPreparedFilter($filter)
- {
+ private function getPreparedFilter($filter) {
$new_filter = [];
if (!is_array($filter)) $filter = [];
@@ -161,8 +152,7 @@ class CpeprovisioningController extends mfBaseController
return $new_filter;
}
- protected function saveAction()
- {
+ protected function saveAction() {
$r = $this->request;
$id = $r->id;
//var_dump($r);exit;
@@ -397,8 +387,7 @@ class CpeprovisioningController extends mfBaseController
self::returnJson(['success' => false, 'message' => $e->getMessage()]);
}
}
- protected function apiGetAction()
- {
+ protected function apiGetAction(){
$p = json_decode(file_get_contents('php://input'), true) ?? [];
// --- Pagination and Sorting setup ---
@@ -528,8 +517,7 @@ class CpeprovisioningController extends mfBaseController
]);
}
- protected function newIndexAction()
- {
+ protected function newIndexAction() {
$this->layout()->set('additionalJS', ['js/pages/Cpeprovisioning/Cpeprovisioning.js']);
$this->layout()->set('additionalHead', ['']);
@@ -650,6 +638,4 @@ class CpeprovisioningController extends mfBaseController
self::returnJson(['open_count' => $openCount]);
}
-
-
}
diff --git a/public/js/pages/WarehouseOffer/WarehouseBasicOfferModal.js b/public/js/pages/WarehouseOffer/WarehouseBasicOfferModal.js
new file mode 100644
index 000000000..0559f8157
--- /dev/null
+++ b/public/js/pages/WarehouseOffer/WarehouseBasicOfferModal.js
@@ -0,0 +1,574 @@
+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: `
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ offer.customerName }}
+ {{ offer.customerStreet }}
+ {{ offer.customerZip }} {{ offer.customerCity }}
+
+
+ USt-IdNr.: {{ offer.customerVAT }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ product.name }}
+
{{ product.description || 'Keine Beschreibung' }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatPrice(calculateProductTotal(product)) }} €
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Gesamtsumme: {{ formatPrice(totalPrice) }} €
+
+
+
+
+
+
+
Noch keine Produkte ausgewählt
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ 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('
')
+ : 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();
+ }
+ }
+ }
+})
\ No newline at end of file
diff --git a/public/js/pages/WarehouseOffer/WarehouseLib.js b/public/js/pages/WarehouseOffer/WarehouseLib.js
new file mode 100644
index 000000000..aad637f48
--- /dev/null
+++ b/public/js/pages/WarehouseOffer/WarehouseLib.js
@@ -0,0 +1,181 @@
+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: `
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ file.file.name }}
+ ({{ formatSize(file.file.size) }})
+
+
+
+
+
+
Fehler
+
Fertig
+
+
+
+
+
+
+
+ `,
+ 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';
+ }
+ }
+ }
+})
\ No newline at end of file
diff --git a/public/js/pages/WarehouseOffer/WarehouseOffer.js b/public/js/pages/WarehouseOffer/WarehouseOffer.js
index 86a22853e..35198001d 100644
--- a/public/js/pages/WarehouseOffer/WarehouseOffer.js
+++ b/public/js/pages/WarehouseOffer/WarehouseOffer.js
@@ -1,1085 +1,3 @@
-// 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: `
-
-
-
-
-
-
-
-
-
-
-
-
- {{ file.file.name }}
- ({{ formatSize(file.file.size) }})
-
-
-
-
-
-
Fehler
-
Fertig
-
-
-
-
-
-
-
- `,
- 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: `
-
-
-
-
-
-
-
-
-
-
-
-
- {{ offer.customerName }}
- {{ offer.customerStreet }}
- {{ offer.customerZip }} {{ offer.customerCity }}
-
-
- USt-IdNr.: {{ offer.customerVAT }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ product.name }}
-
{{ product.description || 'Keine Beschreibung' }}
-
-
-
-
-
-
-
-
-
-
-
- {{ formatPrice(calculateProductTotal(product)) }} €
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Gesamtsumme: {{ formatPrice(totalPrice) }} €
-
-
-
-
-
-
-
Noch keine Produkte ausgewählt
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `,
- 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('
')
- : 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: `
-
-
-
-
Angebotsdetails
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Nettobetrag: {{ formatPrice(netTotalPrice) }} €
- Alternativbetrag: {{ formatPrice(alternativeTotalPrice) }} €
- Gesamtbetrag: {{ formatPrice(offerTotalPrice) }} €
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `,
- 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('
') : 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: `
diff --git a/public/js/pages/WarehouseOffer/WarehouseOfferModal.js b/public/js/pages/WarehouseOffer/WarehouseOfferModal.js
new file mode 100644
index 000000000..3a41899f5
--- /dev/null
+++ b/public/js/pages/WarehouseOffer/WarehouseOfferModal.js
@@ -0,0 +1,319 @@
+Vue.component('warehouse-offer-modal', {
+ props: {
+ id: {type: [String, Number], required: true},
+ },
+ template: `
+
+
+
+
Angebotsdetails
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Nettobetrag: {{ formatPrice(netTotalPrice) }} €
+ Alternativbetrag: {{ formatPrice(alternativeTotalPrice) }} €
+ Gesamtbetrag: {{ formatPrice(offerTotalPrice) }} €
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ 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('
') : 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);
+ }
+ }
+})
\ No newline at end of file
diff --git a/public/plugins/vue/tt-components/css/tt-position-manager.css b/public/plugins/vue/tt-components/css/tt-position-manager.css
index 8b7bd4090..19e06318a 100644
--- a/public/plugins/vue/tt-components/css/tt-position-manager.css
+++ b/public/plugins/vue/tt-components/css/tt-position-manager.css
@@ -23,16 +23,16 @@
}
.positions-manager .form-container .button-wrapper {
- align-self: flex-end; /* Align button container at the bottom */
+ align-self: flex-end;
}
.positions-manager .form-container [class*="tt-input"],
.positions-manager .form-container [class*="tt-checkbox"] {
- flex: 1 1; /* Flexible width with a minimum of 200px */
+ flex: 1 1;
}
.positions-manager .form-container .btn {
- align-self: flex-end; /* Align button at the bottom of the form area */
+ align-self: flex-end;
}
.positions-manager table {
@@ -51,11 +51,11 @@
.positions-manager table th,
.positions-manager table td {
padding: 8px;
- vertical-align: middle; /* Vertically center text */
+ vertical-align: middle;
}
.positions-manager table tbody tr:nth-child(even) {
- background-color: #f9f9f9; /* Alternate row color */
+ background-color: #f9f9f9;
}
.positions-manager table .btn {
@@ -64,24 +64,26 @@
.positions-manager .form-container .btn-sm,
.positions-manager table .btn.btn-sm {
- padding: 0.3rem 0.6rem; /* Small button padding */
+ padding: 0.3rem 0.6rem;
font-size: 0.875rem;
}
-.positions-manager.ctrl-pressed .position-row {
+.positions-manager.ctrl-pressed .position-row,
+.positions-manager.ctrl-pressed .group-header-row {
cursor: grab;
}
-.positions-manager.ctrl-pressed .position-row:active {
+.positions-manager.ctrl-pressed .position-row:active,
+.positions-manager.ctrl-pressed .group-header-row:active {
cursor: grabbing;
}
-.position-row.dragging {
+.position-row.dragging,
+.group-header-row.dragging {
opacity: 0.5;
background: #f0f0f0;
}
-/* Style for the drop indicator */
.drop-indicator {
border-top: 2px solid #007bff !important;
-}
+}
\ No newline at end of file
diff --git a/public/plugins/vue/tt-components/tt-position-manager.js b/public/plugins/vue/tt-components/tt-position-manager.js
index 4910c2c05..4fe0d4636 100644
--- a/public/plugins/vue/tt-components/tt-position-manager.js
+++ b/public/plugins/vue/tt-components/tt-position-manager.js
@@ -56,10 +56,10 @@ Vue.component('tt-positions-manager', {
index: null,
key: null
},
- // --- New properties for Drag and Drop ---
- ctrlPressed: false, // Is the Ctrl key currently pressed?
- draggingItem: null, // The actual position object being dragged
- dragOverItem: null // The position object the mouse is currently over
+ ctrlPressed: false,
+ draggingItem: null,
+ dragOverItem: null,
+ draggingGroup: null
}
},
template: `
@@ -127,7 +127,6 @@ Vue.component('tt-positions-manager', {
/>
-
@@ -138,11 +137,8 @@ Vue.component('tt-positions-manager', {
:additional-class="selectedIndex === null ? 'btn-primary' : 'btn-success'"
:text="selectedIndex === null ? 'Hinzufügen' : 'Aktualisieren'"/>
-
-
-
@@ -158,10 +154,16 @@ Vue.component('tt-positions-manager', {