Add discounts, fields, and PDF/email support to manual invoices.

This commit is contained in:
2025-12-04 15:02:19 +01:00
parent 924f8c7f87
commit e310ae4bf8
13 changed files with 812 additions and 125 deletions
@@ -270,4 +270,15 @@
.preview-footer .page-number {
text-align: right;
}
/* Fix tt-select label to match tt-input styling */
.manual-invoice-overlay .tt-select-modern label.col-form-label {
font-weight: normal;
margin-bottom: 0.5rem;
}
/* Ensure consistent form-group spacing */
.manual-invoice-overlay .tt-select-modern.form-group {
margin-bottom: 1rem;
}
+218 -31
View File
@@ -4,7 +4,7 @@ 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)" @createGutschrift="openGutschriftModal($event)">
<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>
@@ -18,9 +18,10 @@ Vue.component('manual-invoice', {
</tt-table-crud>
<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"/>
<send-invoice-modal v-if="isSendModalOpen" :invoice-id="sendInvoiceId" @close="closeSendModal" @sent="handleInvoiceSent"/>
</tt-card>
`,
data: () => ({ isModalOpen: false, editingInvoiceData: null, isGutschriftModalOpen: false, gutschriftInvoiceId: null }),
data: () => ({ isModalOpen: false, editingInvoiceData: null, isGutschriftModalOpen: false, gutschriftInvoiceId: null, isSendModalOpen: false, sendInvoiceId: null }),
methods: {
openModal(invoice = null) {
this.editingInvoiceData = invoice ? JSON.parse(JSON.stringify(invoice)) : null;
@@ -36,17 +37,17 @@ Vue.component('manual-invoice', {
const positions = invoiceData.positions.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, vatrate,
price_total: amount * price,
price_gross: (amount * price) * (1 + vatrate / 100),
...p, amount, price, discount, vatrate,
unit: p.unit || 'Stk.',
price_total: priceAfterDiscount,
price_gross: priceAfterDiscount * (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,
@@ -66,7 +67,8 @@ Vue.component('manual-invoice', {
billing_delivery: 'email',
fibu_payment_due: 14,
fibu_account_number: invoiceData.fibu_account_number || 0,
vatgroup_id: 1
vatgroup_id: 1,
gesamtrabatt: parseFloat(invoiceData.gesamtrabatt) || 0
};
const url = invoiceData.id ? window.TT_CONFIG.UPDATE_URL : window.TT_CONFIG.CREATE_URL;
@@ -93,8 +95,33 @@ Vue.component('manual-invoice', {
},
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; }
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; }
}
});
@@ -117,15 +144,18 @@ Vue.component('manual-invoice-modal', {
</tt-card>
<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-input label="Rechnungsdatum" type="date" v-model="invoiceData.invoice_date" sm/>
<tt-select label="Zahlungsart" v-model="invoiceData.billing_type" :options="billingTypeOptions" sm/>
</div>
<tt-input label="Leistungszeitraum" v-model="invoiceData.leistungszeitraum" sm row placeholder="z.B. 01.01.2025 - 31.01.2025"/>
<tt-input label="Externe Referenz" v-model="invoiceData.externe_referenz" sm row placeholder="z.B. Auftragsnummer, Bestellnummer"/>
<tt-textarea label="Einleitender Text" v-model="invoiceData.einleitender_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 ref="positionsManager" v-model="invoiceData.positions" :config="positionsConfig" />
<tt-positions-manager group-mode 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-card><template v-slot:header><h5><i class="fas fa-paragraph mr-2"></i>Texte & Rabatt</h5></template>
<tt-input label="Gesamtrabatt (%)" v-model.number="invoiceData.gesamtrabatt" sm row type="number" placeholder="0"/>
<tt-textarea label="Steuerhinweis" v-model="invoiceData.tax_text" rows="2"/>
</tt-card>
</div>
@@ -150,20 +180,31 @@ Vue.component('manual-invoice-modal', {
pdfPreviewUrl: '',
previewDebounceTimer: null,
invoiceData: {
id: null, invoice_number: `MRN${new Date().getFullYear()}-X000001`, invoice_date: moment().unix(),
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: '', positions: [], total: 0, total_gross: 0
uid: '', email: '', billing_type: 'invoice', tax_text: '',
leistungszeitraum: '', einleitender_text: '', externe_referenz: '', gesamtrabatt: 0,
positions: [], total: 0, total_gross: 0
},
billingTypeOptions: [{value: 'invoice', text: 'Rechnung'}, {value: 'sepa', text: 'SEPA'}],
positionsConfig: {
fields: {
product_name: { type: 'input', label: 'Bezeichnung' },
product_info: { type: 'input', label: 'Zusatzinfo' },
start_date: { type: 'input', label: 'Start', inputType: 'date' },
end_date: { type: 'input', label: 'Ende', inputType: 'date' },
amount: { type: 'input', label: 'Menge', inputType: 'number' },
unit: {
type: 'select',
label: 'Einheit',
options: [
{ value: 'Pau.', text: 'Pau.' },
{ value: 'Stk.', text: 'Stk.' },
{ value: 'h', text: 'h' },
{ value: 'm', text: 'm' }
]
},
price: { type: 'input', label: 'Einzelpreis (€)', inputType: 'number' },
discount: { type: 'input', label: 'Rabatt (%)', inputType: 'number' },
vatrate: { type: 'input', label: 'USt. (%)', inputType: 'number' },
},
validateForm: (d) => {
@@ -178,14 +219,33 @@ Vue.component('manual-invoice-modal', {
computed: {
overlayClasses() { return { 'preview-active-small': !this.isLargeScreen && this.showPreviewOnSmallScreen, 'editor-active-small': !this.isLargeScreen && !this.showPreviewOnSmallScreen }; },
totals() {
let net = 0, vat = {};
let subtotal = 0;
(this.invoiceData.positions || []).forEach(p => {
const lineTotal = (parseFloat(p.amount) || 0) * (parseFloat(p.price) || 0);
const r = parseInt(p.vatrate) || 0;
net += lineTotal;
vat[r] = (vat[r] || 0) + lineTotal * (r / 100);
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;
});
return { net, vat, gross: net + Object.values(vat).reduce((a, b) => a + b, 0) };
// Apply gesamtrabatt
const gesamtrabatt = parseFloat(this.invoiceData.gesamtrabatt) || 0;
const net = subtotal * (1 - gesamtrabatt / 100);
// Calculate VAT
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 - gesamtrabatt / 100);
const lineVat = lineNet * (r / 100);
vat[r] = (vat[r] || 0) + lineVat;
gross += lineNet + lineVat;
});
return { subtotal, net, vat, gross };
}
},
watch: {
@@ -249,12 +309,16 @@ Vue.component('manual-invoice-modal', {
async updatePdfPreview() {
this.pdfLoading = true;
try {
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;
return { ...p, amount, price, vatrate, price_total: amount * price, price_gross: (amount * price) * (1 + vatrate / 100) };
});
const positions = this.invoiceData.positions
.filter(p => p.product_name && (parseFloat(p.amount) || 0) > 0) // Filter out empty positions
.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,
@@ -349,4 +413,127 @@ Vue.component('gutschrift-modal', {
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" @close="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" :disabled="!invoice.email">
<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">an {{ invoice.email }}</span>
<span v-else class="text-danger d-block ml-4">Keine E-Mail-Adresse vorhanden</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' && invoice.email" class="mt-3">
<tt-input label="E-Mail-Adresse" v-model="emailAddress" sm/>
</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 || '';
this.selectedAction = data.invoice.email ? 'email' : 'download';
} 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');
}
}
});