Files
thetool/public/js/pages/ManualInvoice/ManualInvoice.js
2026-01-26 15:44:35 +01:00

757 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Vue.component('manual-invoice', {
template: `
<tt-card>
<div class="d-flex justify-content-between align-items-center mb-3">
<tt-button text="Neue Rechnung" icon="fas fa-plus" @click="openModal()" additional-class="btn-primary"/>
</div>
<tt-table-crud ref="table" emit-edit @edit="openModal($event)" @createGutschrift="openGutschriftModal($event)" @pdfPreview="handlePdfPreview($event)" @sendInvoice="handleSendInvoice($event)">
<template v-slot:total="{ row }">{{ formatPrice(row.total) }}</template>
<template v-slot:total_gross="{ row }">{{ formatPrice(row.total_gross) }}</template>
<template v-slot:invoice_date="{ row }">{{ formatDate(row.invoice_date) }}</template>
<template v-slot:customerName="{ row }">{{ row.customerName }}</template>
<template v-slot:status="{ row }">
<span :class="getStatusClass(row.status)">{{ getStatusText(row.status) }}</span>
</template>
<template v-slot:actions="{ row }">
<button class="btn btn-sm btn-primary" @click="downloadPdf(row.id)" title="PDF herunterladen"><i class="fas fa-file-pdf"></i></button>
</template>
</tt-table-crud>
<manual-invoice-modal v-if="isModalOpen" :initial-data="editingInvoiceData" :shipping-note-import="shippingNoteImportData" @close="closeModal" @save="handleSave"/>
<gutschrift-modal v-if="isGutschriftModalOpen" :invoice-id="gutschriftInvoiceId" @close="closeGutschriftModal" @created="handleGutschriftCreated"/>
<send-invoice-modal v-if="isSendModalOpen" :invoice-id="sendInvoiceId" @close="closeSendModal" @sent="handleInvoiceSent"/>
</tt-card>
`,
data: () => ({ isModalOpen: false, editingInvoiceData: null, shippingNoteImportData: null, isGutschriftModalOpen: false, gutschriftInvoiceId: null, isSendModalOpen: false, sendInvoiceId: null }),
mounted() {
// Check for shipping note import data
const shippingNoteData = localStorage.getItem('ManualInvoice_create');
if (shippingNoteData) {
try {
// Parse and store the data
this.shippingNoteImportData = JSON.parse(shippingNoteData);
// Delete from localStorage immediately so it doesn't auto-open again on reload
localStorage.removeItem('ManualInvoice_create');
// Auto-open modal for import
this.openModal();
} catch (e) {
console.error('Error parsing shipping note data:', e);
localStorage.removeItem('ManualInvoice_create');
}
}
},
methods: {
openModal(invoice = null) {
this.editingInvoiceData = invoice ? JSON.parse(JSON.stringify(invoice)) : null;
this.isModalOpen = true;
},
closeModal() {
this.isModalOpen = false;
this.editingInvoiceData = null;
this.shippingNoteImportData = null;
this.$refs.table.$refs.table.refreshTable();
},
async handleSave(invoiceData) {
try {
const positions = invoiceData.positions.map(p => ({
...p,
amount: parseFloat(p.amount) || 0,
price: parseFloat(p.price) || 0,
discount: parseFloat(p.discount) || 0,
vatrate: parseFloat(p.vatrate) || 0,
unit: p.unit || 'Stk.',
warehousearticle_id: p.warehousearticle_id || p.product_id || 0,
warehousearticle_name: p.warehousearticle_name || p.product_name || '',
matchcode: p.matchcode || null,
fibu_cost_account: p.fibu_cost_account || null,
fibu_cost_account_legacy: p.fibu_cost_account_legacy || null,
fibu_taxcode: p.fibu_taxcode || null,
options: p.options || null
}));
const payload = {
...invoiceData,
positions,
owner_id: invoiceData.owner_id || 0,
billingaddress_id: invoiceData.billingaddress_id || 0,
customer_number: invoiceData.customer_number || 0,
country: invoiceData.country || 'Österreich',
billing_type: invoiceData.billing_type || 'invoice',
fibu_payment_due: 14,
fibu_account_number: invoiceData.fibu_account_number || 0,
vatgroup_id: invoiceData.vatgroup_id || 1,
performance_period: invoiceData.performance_period || invoiceData.leistungszeitraum || '',
introductory_text: invoiceData.introductory_text || invoiceData.einleitender_text || '',
external_reference: invoiceData.external_reference || invoiceData.externe_referenz || '',
total_discount: parseFloat(invoiceData.total_discount || invoiceData.gesamtrabatt) || 0
};
const url = invoiceData.id ? window.TT_CONFIG.UPDATE_URL : window.TT_CONFIG.CREATE_URL;
const { data } = await axios.post(url, payload);
if (data.success) {
window.notify('success', data.message || 'Rechnung erfolgreich gespeichert!');
this.closeModal();
} else {
window.notify('error', data.message || 'Fehler beim Speichern der Rechnung');
}
} catch (e) {
console.error('Error saving invoice:', e);
window.notify('error', 'Fehler: ' + (e.response?.data?.message || e.message));
}
},
downloadPdf(id) { window.location.href = `${window.TT_CONFIG.BASE_PATH}/ManualInvoice/downloadInvoicePdf?id=${id}`; },
formatPrice(v) { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v || 0); },
formatDate(ts) { return ts ? moment.unix(ts).format('DD.MM.YYYY') : ''; },
openGutschriftModal(invoice) {
if (invoice.total < 0) return window.notify('error', 'Kann keine Gutschrift für eine Gutschrift erstellen');
this.gutschriftInvoiceId = invoice.id;
this.isGutschriftModalOpen = true;
},
closeGutschriftModal() { this.isGutschriftModalOpen = false; this.gutschriftInvoiceId = null; },
handleGutschriftCreated() { this.closeGutschriftModal(); this.$refs.table.$refs.table.refreshTable(); },
async handlePdfPreview(invoice) {
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/pdfPreview`, { id: invoice.id });
if (data.success && data.url) {
window.open(data.url, '_blank');
} else {
window.notify('error', data.message || 'Fehler beim Öffnen der PDF Vorschau');
}
} catch (e) {
console.error('Error opening PDF preview:', e);
window.notify('error', 'Fehler: ' + (e.response?.data?.message || e.message));
}
},
handleSendInvoice(invoice) {
this.sendInvoiceId = invoice.id;
this.isSendModalOpen = true;
},
closeSendModal() {
this.isSendModalOpen = false;
this.sendInvoiceId = null;
},
handleInvoiceSent() {
this.closeSendModal();
this.$refs.table.$refs.table.refreshTable();
},
getStatusClass(s) { return { 'erstellt': 'badge badge-secondary', 'gesendet': 'badge badge-success', 'exportiert': 'badge badge-primary' }[s] || 'badge badge-secondary'; },
getStatusText(s) { return { 'erstellt': 'Erstellt', 'gesendet': 'Gesendet', 'exportiert': 'Exportiert' }[s] || s; }
}
});
Vue.component('manual-invoice-modal', {
props: ['initialData', 'shippingNoteImport'],
template: `
<div class="manual-invoice-overlay" :class="overlayClasses" tabindex="-1" ref="overlay">
<div class="info-bar" v-if="!isLargeScreen"><i class="fas fa-info-circle mr-2"></i> Drücke <strong>STRG + Q</strong> um die Vorschau umzuschalten.</div>
<div class="invoice-editor-pane" v-show="isLargeScreen || !showPreviewOnSmallScreen">
<div class="editor-header">
<h3>{{ isCreateMode ? 'Neue Rechnung' : 'Rechnung bearbeiten' }}</h3>
<div class="editor-actions">
<tt-button text="Speichern" icon="fas fa-save" @click="saveInvoice" additional-class="btn-success"/>
<tt-button text="Schließen" icon="fas fa-times" @click="close" additional-class="btn-secondary"/>
</div>
</div>
<div class="editor-content">
<tt-card><template v-slot:header><h5><i class="fas fa-user-tie mr-2"></i>Kunde</h5></template>
<tt-autocomplete label="Kunde suchen" :api-url="customerApiUrl" v-model="invoiceData.billingaddress_id" sm row />
</tt-card>
<tt-card><template v-slot:header><h5><i class="fas fa-file-invoice mr-2"></i>Rechnungsdetails</h5></template>
<div class="form-row mb-2">
<div class="col-md-6">
<label class="small text-muted">Zahlungsart</label>
<div class="d-flex align-items-center">
<span :class="['badge', effectiveBillingType === 'sepa' ? 'badge-info' : 'badge-secondary']">
{{ effectiveBillingType === 'sepa' ? 'SEPA' : 'Rechnung' }}
</span>
<small v-if="customerBillingInfo.billing_type === 'sepa' && effectiveBillingType === 'invoice'" class="text-warning ml-2">
<i class="fas fa-exclamation-triangle"></i> Brutto überschreitet SEPA-Limit ({{ formatPrice(customerBillingInfo.manual_invoice_sepa_limit) }})
</small>
</div>
</div>
</div>
<tt-input label="Leistungszeitraum" v-model="invoiceData.performance_period" sm row placeholder="z.B. 01.01.2025 - 31.01.2025"/>
<tt-input label="Externe Referenz" v-model="invoiceData.external_reference" sm row placeholder="z.B. Auftragsnummer, Bestellnummer"/>
<tt-textarea label="Einleitender Text" v-model="invoiceData.introductory_text" rows="3" sm row/>
</tt-card>
<tt-card><template v-slot:header><h5><i class="fas fa-list-ol mr-2"></i>Positionen</h5></template>
<tt-positions-manager group-mode ref="positionsManager" v-model="invoiceData.positions" :config="positionsConfig" @updateField-article_id="onArticleSelected" @updateField-price_type="onPriceTypeChanged" />
</tt-card>
<tt-card><template v-slot:header><h5><i class="fas fa-paragraph mr-2"></i>Rabatt</h5></template>
<tt-input label="Gesamtrabatt (%)" v-model.number="invoiceData.total_discount" sm row type="number" placeholder="0"/>
</tt-card>
</div>
</div>
<div class="invoice-preview-pane" v-show="isLargeScreen || showPreviewOnSmallScreen">
<div class="pdf-preview-container">
<div v-if="pdfLoading" class="pdf-loading"><i class="fas fa-spinner fa-spin fa-3x"></i><p>PDF wird generiert...</p></div>
<object v-else :data="pdfPreviewUrl" type="application/pdf" width="100%" height="100%">
<p>PDF Vorschau kann nicht angezeigt werden. <a :href="pdfPreviewUrl" target="_blank">Hier klicken</a></p>
</object>
</div>
</div>
</div>
`,
data() {
return {
isCreateMode: !this.initialData?.id,
customerApiUrl: window.TT_CONFIG.BASE_PATH + '/Address/Api?do=findAddress&fibu_primary_account=1',
isLargeScreen: window.innerWidth >= 1920,
showPreviewOnSmallScreen: false,
pdfLoading: false,
pdfPreviewUrl: '',
previewDebounceTimer: null,
customerBillingInfo: {
billing_type: 'invoice',
manual_invoice_sepa_limit: null,
vatarea: 'domestic',
tax_text: ''
},
invoiceData: {
id: null, invoice_number: null, invoice_date: moment().format('YYYY-MM-DD'),
billingaddress_id: null, owner_id: null, customer_number: 0, fibu_account_number: 0,
company: '', firstname: '', lastname: '', street: '', zip: '', city: '', country: 'Österreich',
uid: '', email: '', billing_type: 'invoice', tax_text: '', vatgroup_id: 1,
performance_period: '', introductory_text: '', external_reference: '', total_discount: 0,
positions: [], total: 0, total_gross: 0
},
positionsConfig: {
fields: {
article_id: {
type: 'input-article',
label: 'Artikel',
apiUrl: '/WarehouseArticle/autocomplete',
customFieldReference: 'WarehouseArticle',
emitDisplayValue: true
},
warehousearticle_name: { type: 'input', label: 'Bezeichnung' },
product_info: { type: 'input', label: 'Zusatzinfo' },
amount: { type: 'input', label: 'Menge', inputType: 'number' },
price_type: {
type: 'select',
label: 'Preistyp',
options: []
},
price: { type: 'input', label: 'Einzelpreis (€)', inputType: 'number' },
discount: { type: 'input', label: 'Rabatt (%)', inputType: 'number' },
},
validateForm: (d) => {
if (!d.warehousearticle_name) { window.notify('error', 'Bezeichnung ist erforderlich.'); return false; }
if (d.amount == null || d.amount === '') { window.notify('error', 'Menge ist erforderlich.'); return false; }
if (d.price == null) { window.notify('error', 'Preis ist erforderlich.'); return false; }
return true;
}
},
articlePrices: []
};
},
computed: {
overlayClasses() { return { 'preview-active-small': !this.isLargeScreen && this.showPreviewOnSmallScreen, 'editor-active-small': !this.isLargeScreen && !this.showPreviewOnSmallScreen }; },
totals() {
let subtotal = 0;
(this.invoiceData.positions || []).forEach(p => {
const amount = parseFloat(p.amount) || 0;
const price = parseFloat(p.price) || 0;
const discount = parseFloat(p.discount) || 0;
const lineTotal = amount * price * (1 - discount / 100);
subtotal += lineTotal;
});
const totalDiscount = parseFloat(this.invoiceData.total_discount) || 0;
const net = subtotal * (1 - totalDiscount / 100);
let vat = {}, gross = 0;
(this.invoiceData.positions || []).forEach(p => {
const amount = parseFloat(p.amount) || 0;
const price = parseFloat(p.price) || 0;
const discount = parseFloat(p.discount) || 0;
const r = parseInt(p.vatrate) || 0;
const lineNet = amount * price * (1 - discount / 100) * (1 - totalDiscount / 100);
const lineVat = lineNet * (r / 100);
vat[r] = (vat[r] || 0) + lineVat;
gross += lineNet + lineVat;
});
return { subtotal, net, vat, gross };
},
effectiveBillingType() {
if (this.customerBillingInfo.billing_type !== 'sepa') return 'invoice';
if (this.customerBillingInfo.manual_invoice_sepa_limit === null) return 'sepa';
return this.totals.gross <= this.customerBillingInfo.manual_invoice_sepa_limit ? 'sepa' : 'invoice';
}
},
watch: {
'invoiceData': { handler() { this.debouncedPreviewUpdate(); }, deep: true },
effectiveBillingType: {
handler(newType) {
this.invoiceData.billing_type = newType;
},
immediate: true
},
'invoiceData.billingaddress_id': {
async handler(newId) {
if (!newId) {
Object.assign(this.invoiceData, {
company: '', firstname: '', lastname: '', street: '', zip: '', city: '',
country: 'Österreich', uid: '', email: '', customer_number: 0, fibu_account_number: 0, owner_id: 0
});
this.customerBillingInfo = { billing_type: 'invoice', manual_invoice_sepa_limit: null, vatarea: 'domestic', tax_text: '' };
return;
}
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Address/api?do=getAddress&id=${newId}`);
if (data.status === 'OK' && data.result.address) {
const a = data.result.address;
Object.assign(this.invoiceData, {
company: a.company || '', firstname: a.firstname || '', lastname: a.lastname || '',
street: a.street || '', zip: a.zip || '', city: a.city || '', country: 'Österreich',
uid: a.uid || '', email: a.email || '', customer_number: a.customer_number || 0,
fibu_account_number: a.fibu_account_number || 0, owner_id: newId
});
}
await this.fetchCustomerBillingInfo(newId);
}
}
},
created() {
if (this.initialData) {
this.invoiceData = { ...this.invoiceData, ...JSON.parse(JSON.stringify(this.initialData)) };
if (typeof this.invoiceData.positions === 'string') {
try { this.invoiceData.positions = JSON.parse(this.invoiceData.positions); } catch { this.invoiceData.positions = []; }
}
if (!Array.isArray(this.invoiceData.positions)) this.invoiceData.positions = [];
}
// Check for shipping note import data from prop
if (this.shippingNoteImport && Array.isArray(this.shippingNoteImport) && this.shippingNoteImport.length > 0) {
try {
this.processShippingNoteImport(this.shippingNoteImport);
} catch (e) {
console.error('Error processing shipping note import:', e);
window.notify('error', 'Fehler beim Importieren des Lieferscheins');
}
}
},
mounted() {
window.addEventListener('resize', this.handleResize);
window.addEventListener('keydown', this.handleGlobalKeydown);
this.handleResize();
this.$nextTick(() => { this.$refs.overlay?.focus(); this.updatePdfPreview(); });
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize);
window.removeEventListener('keydown', this.handleGlobalKeydown);
clearTimeout(this.previewDebounceTimer);
},
methods: {
close() { this.$emit('close'); },
saveInvoice() {
this.invoiceData.invoice_date = moment().format('YYYY-MM-DD');
this.invoiceData.billing_type = this.effectiveBillingType;
this.invoiceData.tax_text = this.customerBillingInfo.tax_text;
if (!this.invoiceData.billingaddress_id) return window.notify('error', 'Bitte wählen Sie einen Kunden aus.');
if (!this.invoiceData.positions?.length) return window.notify('error', 'Bitte fügen Sie mindestens eine Position hinzu.');
this.$emit('save', this.invoiceData);
},
formatPrice(value) {
if (value === null || value === undefined) return '-';
return new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(value);
},
async fetchCustomerBillingInfo(addressId) {
if (!addressId) return;
try {
const vatgroupId = this.invoiceData.vatgroup_id || 2;
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getCustomerBillingInfo?address_id=${addressId}&vatgroup_id=${vatgroupId}`);
if (data.success) {
this.customerBillingInfo = {
billing_type: data.billing_type || 'invoice',
manual_invoice_sepa_limit: data.manual_invoice_sepa_limit,
vatarea: data.vatarea || 'domestic',
tax_text: data.tax_text || ''
};
this.invoiceData.tax_text = data.tax_text || '';
}
} catch (e) {
console.error('Error fetching customer billing info:', e);
}
},
handleResize() { this.isLargeScreen = window.innerWidth >= 1920; },
handleGlobalKeydown(e) {
if (e.ctrlKey && e.key === 'q') { e.preventDefault(); this.togglePreviewVisibility(); }
},
togglePreviewVisibility() { if (!this.isLargeScreen) this.showPreviewOnSmallScreen = !this.showPreviewOnSmallScreen; },
async onArticleSelected(articleId) {
if (!articleId) {
// Reset price type options when no article selected
this.articlePrices = [];
this.positionsConfig.fields.price_type.options = [];
return;
}
try {
const vatarea = this.customerBillingInfo.vatarea || 'domestic';
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getArticleVatInfo?article_id=${articleId}&vatarea=${vatarea}`);
if (data.success && this.$refs.positionsManager) {
const pm = this.$refs.positionsManager;
if (data.article) {
pm.$set(pm.formData, 'warehousearticle_name', data.article.articleNumber + ' | ' + data.article.title);
pm.$set(pm.formData, 'product_info', data.article.description || '');
pm.$set(pm.formData, 'unit', data.article.unit || 'Stk.');
}
pm.$set(pm.formData, 'vatrate', parseFloat(data.vatrate) || 20);
pm.$set(pm.formData, 'fibu_cost_account', data.fibu_cost_account);
pm.$set(pm.formData, 'fibu_cost_account_legacy', data.fibu_cost_account_legacy);
pm.$set(pm.formData, 'fibu_taxcode', data.fibu_taxcode);
this.invoiceData.vatgroup_id = data.vatgroup_id;
await this.updateTaxText(data.vatgroup_id);
// Handle prices and price type selection
this.articlePrices = data.prices || [];
if (this.articlePrices.length > 0) {
const priceOptions = this.articlePrices.map(p => ({ value: p.title, text: `${p.title} (${this.formatPrice(p.price)})` }));
this.positionsConfig.fields.price_type.options = priceOptions;
// Set first price as default
pm.$set(pm.formData, 'price_type', this.articlePrices[0].title);
pm.$set(pm.formData, 'price', this.articlePrices[0].price);
} else {
this.positionsConfig.fields.price_type.options = [];
}
}
} catch (e) {
console.error('Error fetching article VAT info:', e);
}
},
onPriceTypeChanged(priceType) {
if (!priceType || !this.articlePrices.length) return;
const selectedPrice = this.articlePrices.find(p => p.title === priceType);
if (selectedPrice && this.$refs.positionsManager) {
this.$refs.positionsManager.$set(this.$refs.positionsManager.formData, 'price', selectedPrice.price);
}
},
async updateTaxText(vatgroupId) {
if (!vatgroupId) return;
try {
const vatarea = this.customerBillingInfo.vatarea || 'domestic';
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getTaxText?vatgroup_id=${vatgroupId}&vatarea=${vatarea}`);
if (data.success) {
this.customerBillingInfo.tax_text = data.tax_text || '';
this.invoiceData.tax_text = data.tax_text || '';
}
} catch (e) {
console.error('Error fetching tax text:', e);
}
},
debouncedPreviewUpdate() {
clearTimeout(this.previewDebounceTimer);
this.previewDebounceTimer = setTimeout(() => this.updatePdfPreview(), 2000);
},
async updatePdfPreview() {
this.pdfLoading = true;
try {
const positions = this.invoiceData.positions
.filter(p => p.warehousearticle_name && (parseFloat(p.amount) || 0) !== 0)
.map(p => {
const amount = parseFloat(p.amount) || 0;
const price = parseFloat(p.price) || 0;
const discount = parseFloat(p.discount) || 0;
const vatrate = parseFloat(p.vatrate) || 0;
const priceAfterDiscount = amount * price * (1 - discount / 100);
return { ...p, amount, price, discount, vatrate, unit: p.unit || 'Stk.', price_total: priceAfterDiscount, price_gross: priceAfterDiscount * (1 + vatrate / 100) };
});
const payload = {
preview: true, ...this.invoiceData,
total: this.totals.net, total_gross: this.totals.gross, positions
};
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/createPDF`, payload, { responseType: 'blob' });
if (this.pdfPreviewUrl) URL.revokeObjectURL(this.pdfPreviewUrl);
this.pdfPreviewUrl = URL.createObjectURL(new Blob([data], { type: 'application/pdf' })) + '#view=FitH';
} catch (e) {
console.error('Error preview:', e);
window.notify('error', 'Fehler beim Generieren der PDF-Vorschau');
} finally {
this.pdfLoading = false;
}
},
processShippingNoteImport(shippingNoteDataArray) {
// Temporarily disable the preview update during import to prevent memory leak
clearTimeout(this.previewDebounceTimer);
const originalWatcher = this.$options.watch['invoiceData'];
delete this.$options.watch['invoiceData'];
try {
for (const shippingNoteData of shippingNoteDataArray) {
// Pre-fill billing address fields
if (shippingNoteData.billingAddress) {
const addr = shippingNoteData.billingAddress;
Object.assign(this.invoiceData, {
billingaddress_id: addr.id,
customer_number: addr.customer_number || 0,
company: addr.company || '',
firstname: addr.firstname || '',
lastname: addr.lastname || '',
street: addr.street || '',
zip: addr.zip || '',
city: addr.city || '',
email: addr.email || '',
uid: addr.uid || '',
fibu_account_number: addr.fibu_account_number || 0,
fibu_payment_due: addr.fibu_payment_due || 14,
fibu_payment_skonto: addr.fibu_payment_skonto || 0,
fibu_payment_skonto_rate: addr.fibu_payment_skonto_rate || 0,
billing_type: addr.billing_type || 'invoice',
owner_id: addr.id
});
// Banking info (if SEPA)
if (addr.billing_type === 'sepa') {
Object.assign(this.invoiceData, {
bank_account_bank: addr.bank_account_bank || '',
bank_account_owner: addr.bank_account_owner || '',
bank_account_iban: addr.bank_account_iban || '',
bank_account_bic: addr.bank_account_bic || '',
sepa_date: addr.sepa_date || ''
});
}
}
// Pre-fill external reference with shipping note reference
this.invoiceData.external_reference = `Lieferschein #${shippingNoteData.shippingNoteId}`;
// Add introductory text if shipping note has notes
if (shippingNoteData.note) {
this.invoiceData.introductory_text = shippingNoteData.note;
}
// Add all positions (batch operation to avoid triggering watcher for each item)
if (shippingNoteData.positions && Array.isArray(shippingNoteData.positions)) {
const newPositions = shippingNoteData.positions.map(position => ({
warehousearticle_name: position.warehousearticle_name || position.product_name || '',
product_info: position.product_info || '',
amount: parseFloat(position.amount) || 0,
unit: position.unit || 'Stk.',
price: parseFloat(position.price) || 0,
discount: parseFloat(position.discount) || 0,
vatrate: parseFloat(position.vatrate) || 20
}));
// Add all positions at once instead of one by one
this.invoiceData.positions.push(...newPositions);
}
}
// Notify user
const positionCount = shippingNoteDataArray.reduce((sum, sn) => sum + (sn.positions?.length || 0), 0);
window.notify('success', `Lieferschein erfolgreich importiert (${positionCount} Position(en))`);
} finally {
// Re-enable the watcher
this.$options.watch['invoiceData'] = originalWatcher;
// Trigger one preview update after import is complete
this.$nextTick(() => {
this.debouncedPreviewUpdate();
});
}
}
}
});
Vue.component('gutschrift-modal', {
props: ['invoiceId'],
template: `
<tt-modal :show="true" @update:show="close" size="lg" title="Gutschrift erstellen">
<div v-if="loading" class="text-center py-5"><i class="fas fa-spinner fa-spin fa-3x"></i><p class="mt-3">Lade Rechnungsdaten...</p></div>
<div v-else-if="invoice">
<div class="alert alert-info"><strong>Originalrechnung:</strong> {{ invoice.invoice_number }} - {{ invoice.customer_name }}</div>
<div v-if="!invoice.positions.length" class="alert alert-warning"><i class="fas fa-exclamation-triangle"></i> Alle Positionen gutgeschrieben.</div>
<div v-else>
<p><strong>Positionen wählen:</strong></p>
<table class="table table-sm table-bordered">
<thead><tr>
<th style="width: 50px;"><input type="checkbox" @change="toggleAll" v-model="allSelected"></th>
<th>Bezeichnung</th><th style="width: 100px;">Orig.</th><th style="width: 100px;">Gutschr.</th><th style="width: 100px;">Verfügbar</th>
<th style="width: 120px;">Neu Gutschrift</th><th style="width: 100px;">Einzel</th><th style="width: 100px;">Gesamt</th>
</tr></thead>
<tbody>
<tr v-for="(pos, index) in invoice.positions" :key="index">
<td class="text-center"><input type="checkbox" v-model="selectedPositions[index]"></td>
<td><strong>{{ pos.warehousearticle_name }}</strong><div v-if="pos.product_info" class="text-muted small">{{ pos.product_info }}</div></td>
<td class="text-right">{{ pos.original_amount }}</td><td class="text-right">{{ pos.credited_amount }}</td>
<td class="text-right">{{ pos.available_amount }}</td>
<td><input type="number" class="form-control form-control-sm" v-model.number="creditAmounts[index]" :max="pos.available_amount" :disabled="!selectedPositions[index]" step="0.001" min="0.001"></td>
<td class="text-right">{{ formatPrice(pos.price) }}</td><td class="text-right">{{ formatPrice(calcTotal(index)) }}</td>
</tr>
</tbody>
<tfoot><tr><td colspan="7" class="text-right"><strong>Gesamt:</strong></td><td class="text-right"><strong>{{ formatPrice(totalCredit) }}</strong></td></tr></tfoot>
</table>
</div>
</div>
<template v-slot:footer>
<tt-button text="Erstellen" icon="fas fa-check" @click="create" additional-class="btn-success" :disabled="!validSelection || creating"/>
<tt-button text="Abbrechen" icon="fas fa-times" @click="close" additional-class="btn-secondary"/>
</template>
</tt-modal>
`,
data: () => ({ loading: true, creating: false, invoice: null, selectedPositions: {}, creditAmounts: {}, allSelected: false }),
computed: {
validSelection() { return this.invoice && Object.keys(this.selectedPositions).some(i => this.selectedPositions[i] && this.creditAmounts[i] > 0 && this.creditAmounts[i] <= this.invoice.positions[i].available_amount); },
totalCredit() { return Object.keys(this.selectedPositions).reduce((sum, i) => this.selectedPositions[i] ? sum + ((this.creditAmounts[i] || 0) * this.invoice.positions[i].price) : sum, 0); }
},
async mounted() {
try {
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getInvoiceForGutschrift?id=${this.invoiceId}`);
if (data.success) {
this.invoice = data.invoice;
this.invoice.positions.forEach((p, i) => { this.$set(this.selectedPositions, i, false); this.$set(this.creditAmounts, i, p.available_amount); });
} else {
window.notify('error', data.message || 'Fehler'); this.close();
}
} catch (e) { window.notify('error', 'Fehler'); this.close(); } finally { this.loading = false; }
},
methods: {
toggleAll() { this.invoice.positions.forEach((p, i) => this.$set(this.selectedPositions, i, this.allSelected)); },
calcTotal(i) { return this.selectedPositions[i] ? (this.creditAmounts[i] || 0) * this.invoice.positions[i].price : 0; },
async create() {
const positions = this.invoice.positions
.map((p, i) => ({ p, i })).filter(({ i }) => this.selectedPositions[i])
.map(({ p, i }) => {
const amt = this.creditAmounts[i];
if (amt > p.available_amount) throw new Error(`Menge zu hoch: ${p.warehousearticle_name}`);
return amt > 0 ? { ...p, amount: amt } : null;
}).filter(Boolean);
if (!positions.length) return window.notify('error', 'Keine Positionen gewählt');
this.creating = true;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/createGutschrift`, { original_invoice_id: this.invoiceId, positions });
if (data.success) { window.notify('success', 'Gutschrift erstellt'); this.$emit('created', data.credit_invoice_id); }
else window.notify('error', data.message || 'Fehler');
} catch (e) { window.notify('error', e.message || 'Fehler'); } finally { this.creating = false; }
},
close() { this.$emit('close'); },
formatPrice(v) { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v || 0); }
}
});
Vue.component('send-invoice-modal', {
props: ['invoiceId'],
template: `
<tt-modal :show="true" @update:show="close" @submit="handleAction" :submit-text="actionButtonText" :is-loading="loading" size="md">
<template v-slot:header>
<h5><i class="fas fa-paper-plane mr-2"></i>Rechnung aussenden</h5>
</template>
<div v-if="!invoice" class="text-center py-4">
<tt-loader />
</div>
<div v-else>
<div class="mb-3">
<strong>Rechnung:</strong> {{ invoice.invoice_number }}<br/>
<strong>Kunde:</strong> {{ invoice.customerName }}
</div>
<hr/>
<div class="form-group">
<label>Aktion auswählen:</label>
<div class="form-check">
<input class="form-check-input" type="radio" id="action-email" value="email" v-model="selectedAction">
<label class="form-check-label" for="action-email">
<i class="fas fa-envelope mr-2"></i>Per E-Mail versenden
<span v-if="invoice.email" class="text-muted d-block ml-4">Kunden-E-Mail: {{ invoice.email }}</span>
<span v-else class="text-warning d-block ml-4"><i class="fas fa-exclamation-triangle"></i> Keine Kunden-E-Mail - bitte manuell eingeben</span>
</label>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="radio" id="action-download" value="download" v-model="selectedAction">
<label class="form-check-label" for="action-download">
<i class="fas fa-download mr-2"></i>PDF herunterladen
</label>
</div>
</div>
<div v-if="selectedAction === 'email'" class="mt-3">
<tt-input label="E-Mail-Adresse" v-model="emailAddress" sm :placeholder="invoice.email ? 'Standard: ' + invoice.email : 'E-Mail-Adresse eingeben'"/>
</div>
</div>
</tt-modal>
`,
data() {
return {
invoice: null,
loading: false,
selectedAction: 'email',
emailAddress: ''
};
},
computed: {
actionButtonText() {
return this.selectedAction === 'email' ? 'E-Mail versenden' : 'Herunterladen';
}
},
async mounted() {
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getInvoiceEmail`, { id: this.invoiceId });
if (data.success) {
this.invoice = data.invoice;
this.emailAddress = data.invoice.email || '';
} else {
window.notify('error', data.message || 'Fehler beim Laden der Rechnung');
this.close();
}
} catch (e) {
window.notify('error', 'Fehler beim Laden der Rechnung');
this.close();
}
},
methods: {
async handleAction() {
if (this.selectedAction === 'email') {
await this.sendEmail();
} else {
await this.downloadPdf();
}
},
async sendEmail() {
if (!this.emailAddress) {
window.notify('error', 'Bitte E-Mail-Adresse eingeben');
return;
}
this.loading = true;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/sendInvoiceEmail`, {
id: this.invoiceId,
email: this.emailAddress
});
if (data.success) {
window.notify('success', data.message);
this.$emit('sent');
} else {
window.notify('error', data.message || 'Fehler beim Versenden');
}
} catch (e) {
window.notify('error', 'Fehler: ' + (e.response?.data?.message || e.message));
} finally {
this.loading = false;
}
},
async downloadPdf() {
this.loading = true;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/downloadInvoice`, {
id: this.invoiceId
});
if (data.success && data.url) {
window.location.href = data.url;
this.$emit('sent');
} else {
window.notify('error', data.message || 'Fehler beim Download');
}
} catch (e) {
window.notify('error', 'Fehler: ' + (e.response?.data?.message || e.message));
} finally {
this.loading = false;
}
},
close() {
this.$emit('close');
}
}
});