757 lines
41 KiB
JavaScript
757 lines
41 KiB
JavaScript
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');
|
||
}
|
||
}
|
||
}); |