diff --git a/Layout/default/WarehouseOffer/PDF_HEADER.html b/Layout/default/WarehouseOffer/PDF_HEADER.html index 87352ed31..cf9a38549 100644 --- a/Layout/default/WarehouseOffer/PDF_HEADER.html +++ b/Layout/default/WarehouseOffer/PDF_HEADER.html @@ -59,7 +59,6 @@
-

{{ addressLine_header }}

{{ addressLine_1 }}
{{ addressLine_2 }}
{{ addressLine_3 }}
diff --git a/Layout/default/WarehouseOffer/PDF_MAIN.php b/Layout/default/WarehouseOffer/PDF_MAIN.php index da8f13027..7bb8a81f4 100644 --- a/Layout/default/WarehouseOffer/PDF_MAIN.php +++ b/Layout/default/WarehouseOffer/PDF_MAIN.php @@ -29,6 +29,9 @@ $texts = [ 'title' => 'Angebot', 'offerNumberLabel' => 'Angebotsnr.:', 'offerDateLabel' => 'Datum:', + 'editorLabel' => 'Sachbearbeiter:', + 'usageLabel' => 'Zweck:', + 'customerReferenceLabel' => 'Kundenreferenz:', 'validUntilLabel' => 'Gültig bis:', 'pageLabel' => 'Seite', // For page numbering in content if needed 'table' => [ @@ -40,16 +43,19 @@ $texts = [ 'unitPrice' => 'Einzelpreis', 'totalPrice' => 'Gesamtpreis' ], + 'paymentTerms' => [ + 'net30' => 'Zahlungsbedingungen: 30 Tage netto', + 'net60' => 'Zahlungsbedingungen: 60 Tage netto', + 'immediate' => 'Zahlungsbedingungen: Sofort fällig' + ], 'summary' => [ - 'subTotal' => 'Zwischensumme', + 'subTotal' => 'Nettobetrag', 'vatFormatted' => 'zzgl. {VAT_RATE}% MwSt.', // Placeholder for rate 'total' => 'Gesamtbetrag', 'currency' => '€' // Currency symbol ], 'notes' => 'Anmerkungen:', 'defaultOfferText' => 'Vielen Dank für Ihre Anfrage. Es gelten unsere Allgemeinen Geschäftsbedingungen.', - 'taxInfoNet' => '(Alle Preise exkl. MwSt.)', // Info if tax is added at the end - 'taxInfoGross' => '(Alle Preise inkl. MwSt.)', // Info if prices already include tax (less common in B2B offers) ] // Add 'EN' => [...] section if needed ]; @@ -220,15 +226,28 @@ $formattedValidUntil = $validUntilDate ? date("d.m.Y", $validUntilDate) : date("
- + - + + + + + + + + + + + + + +
purpose ?? 'Keine Angabe' ?>
reference ?? 'Keine Angabe' ?>
@@ -260,7 +279,12 @@ $formattedValidUntil = $validUntilDate ? date("d.m.Y", $validUntilDate) : date("
-
+
+ + | + + +
@@ -307,14 +331,6 @@ $formattedValidUntil = $validUntilDate ? date("d.m.Y", $validUntilDate) : date("
-' . $text['taxInfoNet'] . ''; -} -// Add taxInfoGross if your prices *include* tax, which is less common for B2B offers. -?> - -

@@ -322,8 +338,9 @@ if ($includeTax) {

-

Zahlungsbedingungen: 14 Tage netto.

+

paymentTerms] ?? $text['paymentTerms']['immediate'] ?>

Lieferzeit: nach Vereinbarung.

+

closingText ?? '')) ?>

diff --git a/application/WarehouseArticle/WarehouseArticleController.php b/application/WarehouseArticle/WarehouseArticleController.php index c440545ee..b035e1956 100644 --- a/application/WarehouseArticle/WarehouseArticleController.php +++ b/application/WarehouseArticle/WarehouseArticleController.php @@ -24,6 +24,7 @@ class WarehouseArticleController extends TTCrud { ]; protected array $autocompleteColumns = ['articleNumber', 'title', 'description']; + protected array $permissionCheck = ['WarehouseUser']; protected array $additionalActions = [['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary']]; // @formatter:on diff --git a/application/WarehouseOffer/WarehouseOfferController.php b/application/WarehouseOffer/WarehouseOfferController.php index b581a6f20..271b88460 100644 --- a/application/WarehouseOffer/WarehouseOfferController.php +++ b/application/WarehouseOffer/WarehouseOfferController.php @@ -148,8 +148,8 @@ class WarehouseOfferController extends TTCrud { $article = WarehouseArticleModel::get($position['article']); if (!$article) continue; // Skip if article not found - $position['articleText'] = $article->title ?? 'N/A'; - // Avoid showing description if same as title + $position['articleNumber'] = $article->articleNumber ?? null; + $position['articleText'] = $article->title; $position['articleDescription'] = ($article->description !== $article->title) ? ($article->description ?? '') : ''; $position['articleUnit'] = $position['unit'] ?? $article->unit ?? 'Stk.'; // Default to 'Stk.' $position['price'] = (float)($position['unitPrice'] ?? 0); // Ensure price is float @@ -170,6 +170,9 @@ class WarehouseOfferController extends TTCrud { trigger_error("Invalid or empty positions JSON for offer ID: " . $id, E_USER_WARNING); } + $editor = UserModel::getOne($offer->editor); + $editorName = $editor ? $editor->name : 'Unbekannt'; // Fallback if editor not found + // --- Prepare PDF Variables --- $pdf_vars = [ @@ -179,6 +182,7 @@ class WarehouseOfferController extends TTCrud { // Add other offer details needed in PDF_MAIN "offerNumber" => $offer->offerNumber ?? $offer->id, // Use offerNumber if available "offerDate" => $offer->createDate ?? time(), // Assuming createDate is a timestamp + "offerEditorName" => $editorName, // Editor name for the offer "validUntilDate" => $offer->validUntilDate ?? null, // Assuming validUntilDate is a timestamp "includeTax" => $offer->includeTax ?? true, // Default to including tax (e.g., 20% VAT) "vatRate" => 0.20, // Example VAT rate (20%) - make this configurable if needed diff --git a/application/WarehouseOffer/WarehouseOfferModel.php b/application/WarehouseOffer/WarehouseOfferModel.php index d49805e00..12c7647df 100644 --- a/application/WarehouseOffer/WarehouseOfferModel.php +++ b/application/WarehouseOffer/WarehouseOfferModel.php @@ -1,33 +1,5 @@ getEnvironment() == "thetool") { + $WarehouseOffer = $this->table("WarehouseOffer"); + + if ($WarehouseOffer->hasColumn("customerVAT")) { + $WarehouseOffer + ->changeColumn("customerVAT", "string", ["limit" => 255, "null" => true]) + ->update(); + } + } + } + + public function down(): void { + if ($this->getEnvironment() == "thetool") { + $WarehouseOffer = $this->table("WarehouseOffer"); + + if ($WarehouseOffer->hasColumn("customerVAT")) { + $WarehouseOffer + ->changeColumn("customerVAT", "string", ["limit" => 255, "null" => false]) + ->update(); + } + } + } +} diff --git a/public/js/pages/WarehouseOffer/WarehouseOffer.js b/public/js/pages/WarehouseOffer/WarehouseOffer.js index b73997446..8c009cd4e 100644 --- a/public/js/pages/WarehouseOffer/WarehouseOffer.js +++ b/public/js/pages/WarehouseOffer/WarehouseOffer.js @@ -1,3 +1,580 @@ +// noinspection JSUnresolvedReference + +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: ` + +
+ + +
+
+
Kunde
+
+
+ + + +
+
+ + +
+
+
Kundenadresse
+
+
+
+
+ {{ offer.customerName }}
+ {{ offer.customerStreet }}
+ {{ offer.customerZip }} {{ offer.customerCity }} +
+
+ USt-IdNr.: {{ offer.customerVAT }} +
+
+
+
+ + +
+
+
Angebotszweck
+
+
+ +
+
+ + +
+
+
Produkte
+ +
+
+ + +
+
+ +
+ +
+
+ + +
+ +
+ +
+ +
+
+ + +
+
+
+
+ {{ product.name }} +
{{ product.description || 'Keine Beschreibung' }} +
+
+ +
+
+ +
+
+ +
+
+ {{ formatPrice(calculateProductTotal(product)) }} € +
+
+ +
+
+
+ + +
+
+ +
+
+

Gesamtsumme: {{ formatPrice(totalPrice) }} €

+
+
+
+ +
+ +

Noch keine Produkte ausgewählt

+
+
+
+ + +
+
+
Zusätzliche Positionen
+
+
+ +
+
+ + +
+
+
Konditionen
+
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+
Unterschrift Angebotsersteller
+
+
+
+
+ +
+
+ +
+ +
+ +
+ + +
+
+
+ + + +
+
+
Notizen
+
+
+ +
+
+ +
+
+ `, + 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(); + } + } + } +}); + Vue.component('warehouse-offer-modal', { props: { id: {type: [String, Number], required: true}, @@ -9,7 +586,7 @@ Vue.component('warehouse-offer-modal', { :delete="id !== 'create'" :title="id === 'create' ? 'Angebot erstellen' : \`Angebot #\${id} bearbeiten\`" @update:show="$emit('close')"> -

Angebotdetails

+

Angebotsdetails

+ +
+ + + + +
@@ -246,6 +836,7 @@ Vue.component('warehouse-offer', { offerModalId: null, offerTemplates: [], offerTemplatesDropdown: false, + showBasicModal: false, } }, async mounted() {