Add discounts, fields, and PDF/email support to manual invoices.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user