Merge branch 'ManualInvoice/add-new' into 'master'

Add Gutschrift functionality to ManualInvoice with modal and backend support

See merge request fronk/thetool!1921
This commit is contained in:
Luca Haid
2025-12-02 14:48:02 +00:00
8 changed files with 703 additions and 1253 deletions
+185 -275
View File
@@ -4,44 +4,23 @@ Vue.component('manual-invoice', {
<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)">
<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 }}
<tt-table-crud ref="table" emit-edit @edit="openModal($event)" @createGutschrift="openGutschriftModal($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>
<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"
@close="closeModal"
@save="handleSave"
/>
<manual-invoice-modal v-if="isModalOpen" :initial-data="editingInvoiceData" @close="closeModal" @save="handleSave"/>
<gutschrift-modal v-if="isGutschriftModalOpen" :invoice-id="gutschriftInvoiceId" @close="closeGutschriftModal" @created="handleGutschriftCreated"/>
</tt-card>
`,
data() {
return {
isModalOpen: false,
editingInvoiceData: null,
}
},
data: () => ({ isModalOpen: false, editingInvoiceData: null, isGutschriftModalOpen: false, gutschriftInvoiceId: null }),
methods: {
openModal(invoice = null) {
this.editingInvoiceData = invoice ? JSON.parse(JSON.stringify(invoice)) : null;
@@ -54,81 +33,68 @@ Vue.component('manual-invoice', {
},
async handleSave(invoiceData) {
try {
// Calculate totals for each position
const positions = invoiceData.positions.map(p => {
const amount = parseFloat(p.amount) || 0;
const price = parseFloat(p.price) || 0;
const vatrate = parseFloat(p.vatrate) || 0;
const price_total = amount * price;
const price_gross = price_total * (1 + vatrate / 100);
return {
...p,
amount,
price,
vatrate,
price_total,
price_gross,
product_id: 0,
contract_id: 0,
billing_id: 0,
billing_period: 0
...p, amount, price, vatrate,
price_total: amount * price,
price_gross: (amount * price) * (1 + vatrate / 100),
product_id: p.product_id || 0,
contract_id: p.contract_id || 0,
billing_id: p.billing_id || null,
billing_period: p.billing_period || 0,
start_date: p.start_date || moment().format('YYYY-MM-DD'),
end_date: p.end_date || null,
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
};
});
// Prepare invoice data
const payload = {
id: invoiceData.id || null,
invoice_number: invoiceData.invoice_number,
invoice_date: invoiceData.invoice_date,
...invoiceData,
positions,
owner_id: invoiceData.owner_id || 0,
billingaddress_id: invoiceData.billingaddress_id || 0,
customer_number: invoiceData.customer_number || 0,
company: invoiceData.company || '',
firstname: invoiceData.firstname || '',
lastname: invoiceData.lastname || '',
street: invoiceData.street || '',
zip: invoiceData.zip || '',
city: invoiceData.city || '',
country: invoiceData.country || 'Österreich',
email: invoiceData.email || '',
uid: invoiceData.uid || '',
billing_type: invoiceData.billing_type || 'invoice',
billing_delivery: 'email',
tax_text: invoiceData.tax_text || '',
fibu_payment_due: 14,
fibu_account_number: invoiceData.fibu_account_number || 0,
vatgroup_id: 1,
positions: positions
vatgroup_id: 1
};
const url = invoiceData.id
? window.TT_CONFIG.UPDATE_URL
: window.TT_CONFIG.CREATE_URL;
const url = invoiceData.id ? window.TT_CONFIG.UPDATE_URL : window.TT_CONFIG.CREATE_URL;
const { data } = await axios.post(url, payload);
const response = await axios.post(url, payload);
if (response.data.success) {
window.notify('success', response.data.message || 'Rechnung erfolgreich gespeichert!');
if (data.success) {
window.notify('success', data.message || 'Rechnung erfolgreich gespeichert!');
this.closeModal();
} else {
window.notify('error', response.data.message || 'Fehler beim Speichern der Rechnung');
window.notify('error', data.message || 'Fehler beim Speichern der Rechnung');
}
} catch (error) {
console.error('Error saving invoice:', error);
window.notify('error', 'Fehler beim Speichern der Rechnung: ' + (error.response?.data?.message || error.message));
} catch (e) {
console.error('Error saving invoice:', e);
window.notify('error', 'Fehler: ' + (e.response?.data?.message || e.message));
}
},
downloadPdf(invoiceId) {
window.location.href = `${window.TT_CONFIG.BASE_PATH}/ManualInvoice/downloadInvoicePdf?id=${invoiceId}`;
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;
},
formatPrice(value) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value || 0);
},
formatDate(timestamp) {
if (!timestamp) return '';
return moment.unix(timestamp).format('DD.MM.YYYY');
}
closeGutschriftModal() { this.isGutschriftModalOpen = false; this.gutschriftInvoiceId = null; },
handleGutschriftCreated() { this.closeGutschriftModal(); this.$refs.table.$refs.table.refreshTable(); },
getStatusClass(s) { return { 'draft': 'badge badge-secondary', 'finalized': 'badge badge-success', 'exported': 'badge badge-primary' }[s] || 'badge badge-secondary'; },
getStatusText(s) { return { 'draft': 'Entwurf', 'finalized': 'Finalisiert', 'exported': 'Exportiert' }[s] || s; }
}
});
@@ -136,10 +102,7 @@ Vue.component('manual-invoice-modal', {
props: ['initialData'],
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="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>
@@ -149,37 +112,29 @@ Vue.component('manual-invoice-modal', {
</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-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>
<tt-card><template v-slot:header><h5><i class="fas fa-file-invoice mr-2"></i>Rechnungsdetails</h5></template>
<div class="form-grid">
<tt-input label="Rechnungsnr." v-model="invoiceData.invoice_number" sm/>
<tt-date-picker label="Rechnungsdatum" v-model="invoiceData.invoice_date" :date-range="false" sm/>
<tt-select label="Zahlungsart" v-model="invoiceData.billing_type" :options="billingTypeOptions" sm/>
</div>
</tt-card>
<tt-card>
<template v-slot:header><h5><i class="fas fa-list-ol mr-2"></i>Positionen</h5></template>
<tt-card><template v-slot:header><h5><i class="fas fa-list-ol mr-2"></i>Positionen</h5></template>
<tt-positions-manager ref="positionsManager" v-model="invoiceData.positions" :config="positionsConfig" />
</tt-card>
<tt-card>
<template v-slot:header><h5><i class="fas fa-paragraph mr-2"></i>Texte</h5></template>
<tt-textarea label="Steuerhinweis (z.B. Reverse Charge)" v-model="invoiceData.tax_text" rows="2"/>
<tt-card><template v-slot:header><h5><i class="fas fa-paragraph mr-2"></i>Texte</h5></template>
<tt-textarea label="Steuerhinweis" v-model="invoiceData.tax_text" rows="2"/>
</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>
<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 zum Öffnen</a></p>
<p>PDF Vorschau kann nicht angezeigt werden. <a :href="pdfPreviewUrl" target="_blank">Hier klicken</a></p>
</object>
</div>
</div>
@@ -187,41 +142,20 @@ Vue.component('manual-invoice-modal', {
`,
data() {
return {
isCreateMode: !this.initialData || !this.initialData.id,
customerApiUrl: window.TT_CONFIG['BASE_PATH'] + '/Address/Api?do=findAddress&fibu_primary_account=1',
selectedCustomerObject: {},
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,
invoiceData: {
id: null,
invoice_number: `MRN${new Date().getFullYear()}-X000001`,
invoice_date: moment().unix(),
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: '',
positions: [],
total: 0,
total_gross: 0
id: null, invoice_number: `MRN${new Date().getFullYear()}-X000001`, invoice_date: moment().unix(),
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: '', positions: [], total: 0, total_gross: 0
},
billingTypeOptions: [
{value: 'invoice', text: 'Rechnung'},
{value: 'sepa', text: 'SEPA'}
],
billingTypeOptions: [{value: 'invoice', text: 'Rechnung'}, {value: 'sepa', text: 'SEPA'}],
positionsConfig: {
fields: {
product_name: { type: 'input', label: 'Bezeichnung' },
@@ -232,207 +166,106 @@ Vue.component('manual-invoice-modal', {
price: { type: 'input', label: 'Einzelpreis (€)', inputType: 'number' },
vatrate: { type: 'input', label: 'USt. (%)', inputType: 'number' },
},
validateForm: (formData) => {
if (!formData.product_name) { window.notify('error', 'Bezeichnung ist erforderlich.'); return false; }
if (!formData.amount) { window.notify('error', 'Menge ist erforderlich.'); return false; }
if (formData.price === null || formData.price === undefined) { window.notify('error', 'Preis ist erforderlich.'); return false; }
validateForm: (d) => {
if (!d.product_name) { window.notify('error', 'Bezeichnung ist erforderlich.'); return false; }
if (!d.amount) { window.notify('error', 'Menge ist erforderlich.'); return false; }
if (d.price == null) { window.notify('error', 'Preis ist erforderlich.'); return false; }
return true;
}
}
};
},
computed: {
overlayClasses() {
return {
'preview-active-small': !this.isLargeScreen && this.showPreviewOnSmallScreen,
'editor-active-small': !this.isLargeScreen && !this.showPreviewOnSmallScreen,
};
},
overlayClasses() { return { 'preview-active-small': !this.isLargeScreen && this.showPreviewOnSmallScreen, 'editor-active-small': !this.isLargeScreen && !this.showPreviewOnSmallScreen }; },
totals() {
let net = 0;
const vat = {};
if (!Array.isArray(this.invoiceData.positions)) return { net: 0, vat: {}, gross: 0 };
this.invoiceData.positions.forEach(p => {
let net = 0, vat = {};
(this.invoiceData.positions || []).forEach(p => {
const lineTotal = (parseFloat(p.amount) || 0) * (parseFloat(p.price) || 0);
const vatRate = parseInt(p.vatrate) || 0;
const r = parseInt(p.vatrate) || 0;
net += lineTotal;
if (!vat[vatRate]) { vat[vatRate] = 0; }
vat[vatRate] += lineTotal * (vatRate / 100);
vat[r] = (vat[r] || 0) + lineTotal * (r / 100);
});
const gross = net + Object.values(vat).reduce((sum, v) => sum + v, 0);
return { net, vat, gross };
return { net, vat, gross: net + Object.values(vat).reduce((a, b) => a + b, 0) };
}
},
watch: {
'invoiceData': {
handler() {
this.debouncedPreviewUpdate();
},
deep: true
},
'invoiceData': { handler() { this.debouncedPreviewUpdate(); }, deep: true },
'invoiceData.billingaddress_id': {
async handler(newId) {
if (!newId) {
this.invoiceData.company = '';
this.invoiceData.firstname = '';
this.invoiceData.lastname = '';
this.invoiceData.street = '';
this.invoiceData.zip = '';
this.invoiceData.city = '';
this.invoiceData.country = 'Österreich';
this.invoiceData.uid = '';
this.invoiceData.email = '';
this.invoiceData.customer_number = 0;
this.invoiceData.fibu_account_number = 0;
this.invoiceData.owner_id = 0;
this.selectedCustomerObject = {};
return;
if (!newId) return Object.assign(this.invoiceData, {
company: '', firstname: '', lastname: '', street: '', zip: '', city: '',
country: 'Österreich', uid: '', email: '', customer_number: 0, fibu_account_number: 0, owner_id: 0
});
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
});
}
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Address/api?do=getAddress&id=${newId}`);
if (response.data.status === 'OK' && response.data.result.address) {
const addr = response.data.result.address;
this.selectedCustomerObject = addr;
this.invoiceData.company = addr.company || '';
this.invoiceData.firstname = addr.firstname || '';
this.invoiceData.lastname = addr.lastname || '';
this.invoiceData.street = addr.street || '';
this.invoiceData.zip = addr.zip || '';
this.invoiceData.city = addr.city || '';
this.invoiceData.country = 'Österreich';
this.invoiceData.uid = addr.uid || '';
this.invoiceData.email = addr.email || '';
this.invoiceData.customer_number = addr.customer_number || 0;
this.invoiceData.fibu_account_number = addr.fibu_account_number || 0;
this.invoiceData.owner_id = newId;
}
},
immediate: true
}
}
},
created() {
if (this.initialData) {
this.invoiceData = {
...this.invoiceData,
...JSON.parse(JSON.stringify(this.initialData))
};
if (!Array.isArray(this.invoiceData.positions)) {
try {
const parsed = JSON.parse(this.invoiceData.positions);
this.invoiceData.positions = Array.isArray(parsed) ? parsed : [];
} catch (e) {
this.invoiceData.positions = [];
}
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 = [];
}
},
mounted() {
window.addEventListener('resize', this.handleResize);
window.addEventListener('keydown', this.handleGlobalKeydown);
this.handleResize();
this.$nextTick(() => {
if (this.$refs.overlay) {
this.$refs.overlay.focus();
}
this.updatePdfPreview();
});
this.$nextTick(() => { this.$refs.overlay?.focus(); this.updatePdfPreview(); });
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize);
window.removeEventListener('keydown', this.handleGlobalKeydown);
if (this.previewDebounceTimer) {
clearTimeout(this.previewDebounceTimer);
}
clearTimeout(this.previewDebounceTimer);
},
methods: {
close() { this.$emit('close'); },
saveInvoice() {
if (!this.invoiceData.billingaddress_id) {
window.notify('error', 'Bitte wählen Sie einen Kunden aus.');
return;
}
if (!this.invoiceData.positions || this.invoiceData.positions.length === 0) {
window.notify('error', 'Bitte fügen Sie mindestens eine Position hinzu.');
return;
}
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);
},
handleResize() { this.isLargeScreen = window.innerWidth >= 1920; },
handleGlobalKeydown(event) {
// Handle CTRL+Q to toggle preview on small screens
if (event.ctrlKey && event.key === 'q') {
event.preventDefault();
this.togglePreviewVisibility();
}
handleGlobalKeydown(e) {
if (e.ctrlKey && e.key === 'q') { e.preventDefault(); this.togglePreviewVisibility(); }
},
togglePreviewVisibility() { if (!this.isLargeScreen) this.showPreviewOnSmallScreen = !this.showPreviewOnSmallScreen; },
debouncedPreviewUpdate() {
if (this.previewDebounceTimer) {
clearTimeout(this.previewDebounceTimer);
}
this.previewDebounceTimer = setTimeout(() => {
this.updatePdfPreview();
}, 2000);
clearTimeout(this.previewDebounceTimer);
this.previewDebounceTimer = setTimeout(() => this.updatePdfPreview(), 2000);
},
async updatePdfPreview() {
this.pdfLoading = true;
try {
// Calculate position totals
const positions = this.invoiceData.positions.map(p => {
const amount = parseFloat(p.amount) || 0;
const price = parseFloat(p.price) || 0;
const vatrate = parseFloat(p.vatrate) || 0;
const price_total = amount * price;
const price_gross = price_total * (1 + vatrate / 100);
return {
...p,
amount,
price,
vatrate,
price_total,
price_gross
};
return { ...p, amount, price, vatrate, price_total: amount * price, price_gross: (amount * price) * (1 + vatrate / 100) };
});
const payload = {
preview: true,
invoice_number: this.invoiceData.invoice_number,
invoice_date: this.invoiceData.invoice_date,
customer_number: this.invoiceData.customer_number,
fibu_account_number: this.invoiceData.fibu_account_number,
company: this.invoiceData.company,
firstname: this.invoiceData.firstname,
lastname: this.invoiceData.lastname,
street: this.invoiceData.street,
zip: this.invoiceData.zip,
city: this.invoiceData.city,
country: this.invoiceData.country,
email: this.invoiceData.email,
uid: this.invoiceData.uid,
tax_text: this.invoiceData.tax_text,
billing_type: this.invoiceData.billing_type,
total: this.totals.net,
total_gross: this.totals.gross,
positions: positions
preview: true, ...this.invoiceData,
total: this.totals.net, total_gross: this.totals.gross, positions
};
const response = await axios.post(
`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/createPDF`,
payload,
{ responseType: 'blob' }
);
// Create a blob URL for the PDF
const blob = new Blob([response.data], { type: 'application/pdf' });
if (this.pdfPreviewUrl) {
URL.revokeObjectURL(this.pdfPreviewUrl);
}
this.pdfPreviewUrl = URL.createObjectURL(blob) + '#view=FitH';
} catch (error) {
console.error('Error generating PDF preview:', error);
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;
@@ -440,3 +273,80 @@ Vue.component('manual-invoice-modal', {
}
}
});
Vue.component('gutschrift-modal', {
props: ['invoiceId'],
template: `
<tt-modal :show="true" @close="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.product_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.product_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); }
}
});