372 lines
18 KiB
JavaScript
372 lines
18 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"/>
|
||
<tt-button text="Test Prefill & Reload" icon="fas fa-magic" @click="testPrefill" additional-class="btn-info"/>
|
||
</div>
|
||
|
||
<tt-table-crud
|
||
ref="table"
|
||
emit-edit
|
||
@edit="openModal($event)">
|
||
<template v-slot:totalamount="{ row }">
|
||
{{ formatPrice(row.totalAmount) }}
|
||
</template>
|
||
<template v-slot:invoicedate="{ row }">
|
||
{{ formatDate(row.invoiceDate) }}
|
||
</template>
|
||
</tt-table-crud>
|
||
|
||
<manual-invoice-modal
|
||
v-if="isModalOpen"
|
||
:initial-data="editingInvoiceData"
|
||
@close="closeModal"
|
||
@save="handleSave"
|
||
/>
|
||
</tt-card>
|
||
`,
|
||
data() {
|
||
return {
|
||
isModalOpen: false,
|
||
editingInvoiceData: null,
|
||
}
|
||
},
|
||
mounted() {
|
||
const prefillData = localStorage.getItem('ManualInvoice_create');
|
||
if (prefillData) {
|
||
try {
|
||
this.editingInvoiceData = JSON.parse(prefillData);
|
||
this.isModalOpen = true;
|
||
} catch (e) {
|
||
console.error("Failed to parse prefill data:", e);
|
||
} finally {
|
||
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.$refs.table.$refs.table.refreshTable();
|
||
},
|
||
handleSave(invoiceData) {
|
||
console.log("--- INVOICE SAVED (DEMO) ---");
|
||
console.log(JSON.parse(JSON.stringify(invoiceData)));
|
||
window.notify('success', 'Rechnung in der Konsole geloggt!');
|
||
this.closeModal();
|
||
},
|
||
testPrefill() {
|
||
const mockInvoice = {
|
||
id: null,
|
||
invoiceNumber: `RE-${new Date().getFullYear()}-XXXX`,
|
||
invoiceDate: moment().unix(),
|
||
dueDate: moment().add(14, 'days').unix(),
|
||
status: 'draft',
|
||
billingAddressId: 1, // Example ID for autocomplete to fetch
|
||
customer: {}, // Will be populated by watcher
|
||
positions: [
|
||
{ product_name: 'Stunden Techniker', product_info: 'Arbeiten an Server-Infrastruktur', start_date: moment().format('YYYY-MM-DD'), end_date: moment().format('YYYY-MM-DD'), amount: 3.5, price: 95.00, vatrate: 20 },
|
||
{ product_name: 'Anfahrtspauschale', product_info: '', start_date: moment().format('YYYY-MM-DD'), end_date: moment().format('YYYY-MM-DD'), amount: 1, price: 45.00, vatrate: 20 }
|
||
],
|
||
closingText: 'Wir bedanken uns für die gute Zusammenarbeit.',
|
||
taxText: ''
|
||
};
|
||
localStorage.setItem('ManualInvoice_create', JSON.stringify(mockInvoice));
|
||
window.location.reload();
|
||
},
|
||
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');
|
||
}
|
||
}
|
||
});
|
||
|
||
Vue.component('manual-invoice-modal', {
|
||
props: ['initialData'],
|
||
template: `
|
||
<div class="manual-invoice-overlay" :class="overlayClasses" @keydown.ctrl.q.prevent="togglePreviewVisibility" 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="$emit('save', invoiceData)" 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.billingAddressId" 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-grid">
|
||
<tt-input label="Rechnungsnr." v-model="invoiceData.invoiceNumber" sm/>
|
||
<tt-date-picker label="Rechnungsdatum" v-model="invoiceData.invoiceDate" :date-range="false" sm/>
|
||
<tt-date-picker label="Fälligkeitsdatum" v-model="invoiceData.dueDate" :date-range="false" sm/>
|
||
</div>
|
||
</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-card>
|
||
<tt-card>
|
||
<template v-slot:header><h5><i class="fas fa-paragraph mr-2"></i>Texte</h5></template>
|
||
<tt-textarea label="Schlusstext" v-model="invoiceData.closingText" rows="4"/>
|
||
<tt-textarea label="Steuerhinweis (z.B. Reverse Charge)" v-model="invoiceData.taxText" rows="2"/>
|
||
</tt-card>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="invoice-preview-pane" v-show="isLargeScreen || showPreviewOnSmallScreen">
|
||
<div class="invoice-preview-document">
|
||
<div style="height: 50px; margin-bottom: 32px">
|
||
<img alt="Xinon Logo" src="/assets/images/xinon-full.png" style="text-align:left;height: 85px;">
|
||
</div>
|
||
<table class="preview-header-table">
|
||
<tr>
|
||
<td class="customer-details">
|
||
<div>{{ invoiceData.customer.company }}</div>
|
||
<div>{{ invoiceData.customer.name }}</div>
|
||
<div>{{ invoiceData.customer.street }}</div>
|
||
<div>{{ invoiceData.customer.zip }} {{ invoiceData.customer.city }}</div>
|
||
<div v-if="invoiceData.customer.country !== 'Österreich'">{{ invoiceData.customer.country }}</div>
|
||
</td>
|
||
<td class="invoice-details-cell">
|
||
<table class="invoice-details-box">
|
||
<tr><td>Kundennummer:</td><td>{{ selectedCustomerObject.customer_number || '-' }}</td></tr>
|
||
<tr><td>Rechnungsnummer:</td><td>{{ invoiceData.invoiceNumber }}</td></tr>
|
||
<tr><td>Belegdatum:</td><td>{{ formatDate(invoiceData.invoiceDate) }}</td></tr>
|
||
<tr v-if="invoiceData.customer.uid"><td>Ihre UID:</td><td>{{ invoiceData.customer.uid }}</td></tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
<div class="separator"></div>
|
||
<div class="preview-main">
|
||
<h2 style="text-align: center; color: #005384; font-size: 1.5rem; margin-bottom: 1.5rem;">Ihre Rechnung vom {{ formatDate(invoiceData.invoiceDate) }}</h2>
|
||
<table class="positions-table">
|
||
<thead>
|
||
<tr class="uneven">
|
||
<th style="text-align: left; padding-left: 4pt;">Leistung / Produkt</th>
|
||
<th style="text-align: center;">Zeitraum</th>
|
||
<th style="text-align: right;">Preis</th>
|
||
<th style="text-align: center;">Menge</th>
|
||
<th style="text-align: right;">Netto €</th>
|
||
<th style="text-align: right;">Ust. %</th>
|
||
<th style="text-align: right; padding-right: 4pt;">Brutto €</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<template v-for="(p, index) in invoiceData.positions">
|
||
<tr :class="{'uneven': index % 2 === 0}"> <td style="vertical-align: top; padding-left: 4pt;">
|
||
<strong>{{ p.product_name }}</strong>
|
||
<div v-if="p.product_info" class="matchcode">{{ p.product_info }}</div>
|
||
</td>
|
||
<td style="text-align: center; vertical-align: top;">{{ formatPeriod(p.start_date, p.end_date) }}</td>
|
||
<td style="text-align: right; vertical-align: top;">{{ formatPrice(p.price) }}</td>
|
||
<td style="text-align: center; vertical-align: top;">{{ p.amount }}</td>
|
||
<td style="text-align: right; vertical-align: top;">{{ formatPrice((p.amount || 0) * (p.price || 0)) }}</td>
|
||
<td style="text-align: right; vertical-align: top;">{{ p.vatrate }}%</td>
|
||
<td style="text-align: right; padding-right: 4pt; vertical-align: top;">{{ formatPrice(((p.amount || 0) * (p.price || 0)) * (1 + (p.vatrate || 0) / 100)) }}</td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
<div class="totals-section">
|
||
<table class="totals-table">
|
||
<tr class="netto">
|
||
<th>Gesamtbetrag Netto:</th>
|
||
<td>{{ formatPrice(totals.net) }} €</td>
|
||
</tr>
|
||
<tr class="ust" v-for="(vatValue, vatRate) in totals.vat" :key="vatRate">
|
||
<th>+ Umsatzsteuer {{ vatRate }}%:</th>
|
||
<td>{{ formatPrice(vatValue) }} €</td>
|
||
</tr>
|
||
<tr class="brutto">
|
||
<th>Gesamtbetrag Brutto:</th>
|
||
<td>{{ formatPrice(totals.gross) }} €</td>
|
||
</tr>
|
||
</table>
|
||
</div>
|
||
<div class="payment-info">
|
||
<p v-if="invoiceData.taxText" style="font-weight: bold;">{{invoiceData.taxText}}</p>
|
||
Bitte <b>überweisen</b> Sie den Rechnungsbetrag bis zum <b>{{ formatDate(invoiceData.dueDate) }}</b> auf folgendes Konto:<br />
|
||
<b style="padding-left: 4pt;">IBAN: {{ bankDetails.iban }}</b><br />
|
||
<b style="padding-left: 4pt;">BIC: {{ bankDetails.bic }}</b><br /><br />
|
||
Bitte geben Sie als Verwendungszweck unbedingt die Rechnungsnummer an.
|
||
</div>
|
||
</div>
|
||
<div class="preview-footer">
|
||
<div style="color:grey;text-align: center; width: 100%;">
|
||
<span>XINON GmbH | Fladnitz 150 | 8322 Studenzen</span><br>
|
||
<span>Tel.: +43 3115 40800 | E-Mail: office@xinon.at</span><br>
|
||
<span>UID: ATU68711968 | FN: 416556h | LG: Feldbach</span><br>
|
||
<span>IBAN: {{ bankDetails.iban }} | BIC: {{ bankDetails.bic }}</span><br>
|
||
</div>
|
||
<div class="page-number">Seite 1 von 1</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`,
|
||
data() {
|
||
return {
|
||
isCreateMode: !this.initialData || !this.initialData.id,
|
||
customerApiUrl: window.TT_CONFIG['BASE_PATH'] + '/Address/Api?do=findAddress&fibu_primary_account=1',
|
||
selectedCustomerObject: {},
|
||
isLargeScreen: window.innerWidth >= 1920,
|
||
showPreviewOnSmallScreen: false,
|
||
invoiceData: {
|
||
id: null,
|
||
invoiceNumber: `RE-${new Date().getFullYear()}-`,
|
||
invoiceDate: moment().unix(),
|
||
dueDate: moment().add(14, 'days').unix(),
|
||
status: 'draft',
|
||
billingAddressId: null,
|
||
customer: { company: '', name: '', street: '', zip: '', city: '', country: 'Österreich', uid: '' },
|
||
positions: [],
|
||
closingText: 'Wir danken für Ihren Auftrag und verbleiben mit freundlichen Grüßen,\nIhr Xinon Team',
|
||
taxText: '',
|
||
},
|
||
bankDetails: {
|
||
iban: 'ATXX XXXX XXXX XXXX XXXX',
|
||
bic: 'XXXXXXXX'
|
||
},
|
||
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' },
|
||
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; }
|
||
return true;
|
||
}
|
||
}
|
||
};
|
||
},
|
||
computed: {
|
||
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 => {
|
||
const lineTotal = (parseFloat(p.amount) || 0) * (parseFloat(p.price) || 0);
|
||
const vatRate = parseInt(p.vatrate) || 0;
|
||
net += lineTotal;
|
||
if (!vat[vatRate]) { vat[vatRate] = 0; }
|
||
vat[vatRate] += lineTotal * (vatRate / 100);
|
||
});
|
||
const gross = net + Object.values(vat).reduce((sum, v) => sum + v, 0);
|
||
return { net, vat, gross };
|
||
}
|
||
},
|
||
watch: {
|
||
'invoiceData.billingAddressId': {
|
||
async handler(newId) {
|
||
if (!newId) {
|
||
this.invoiceData.customer = { company: '', name: '', street: '', zip: '', city: '', country: 'Österreich', uid: '' };
|
||
this.selectedCustomerObject = {};
|
||
return;
|
||
}
|
||
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.customer = {
|
||
company: addr.company,
|
||
name: `${addr.firstname} ${addr.lastname}`,
|
||
street: addr.street,
|
||
zip: addr.zip,
|
||
city: addr.city,
|
||
country: 'Österreich',
|
||
uid: addr.uid
|
||
};
|
||
}
|
||
},
|
||
immediate: true
|
||
}
|
||
},
|
||
created() {
|
||
if (this.initialData) {
|
||
// FIX: Merge initial data with default structure to ensure all keys, especially nested ones, exist.
|
||
this.invoiceData = {
|
||
...this.invoiceData, // Start with default structure
|
||
...JSON.parse(JSON.stringify(this.initialData)) // Overwrite with passed data
|
||
};
|
||
// Explicitly ensure nested objects exist if they weren't in initialData
|
||
if (!this.invoiceData.customer) {
|
||
this.invoiceData.customer = { company: '', name: '', street: '', zip: '', city: '', country: 'Österreich', uid: '' };
|
||
}
|
||
// Ensure positions is an array
|
||
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 = [];
|
||
}
|
||
}
|
||
}
|
||
},
|
||
mounted() {
|
||
window.addEventListener('resize', this.handleResize);
|
||
this.handleResize();
|
||
this.$nextTick(() => {
|
||
if (this.$refs.overlay) {
|
||
this.$refs.overlay.focus();
|
||
}
|
||
});
|
||
},
|
||
beforeDestroy() {
|
||
window.removeEventListener('resize', this.handleResize);
|
||
},
|
||
methods: {
|
||
close() { this.$emit('close'); },
|
||
handleResize() { this.isLargeScreen = window.innerWidth >= 1920; },
|
||
togglePreviewVisibility() { if (!this.isLargeScreen) this.showPreviewOnSmallScreen = !this.showPreviewOnSmallScreen; },
|
||
formatPrice(value) { return new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value || 0); },
|
||
formatDate(timestamp) {
|
||
if (!timestamp) return '';
|
||
return moment.unix(timestamp).format('DD.MM.YYYY');
|
||
},
|
||
formatPeriod(start, end) {
|
||
if (!start) return '';
|
||
const startDate = moment(start);
|
||
const endDate = end ? moment(end) : moment(start);
|
||
if (!startDate.isValid()) return '';
|
||
if (startDate.isSame(endDate, 'day')) return startDate.format('DD.MM.YYYY');
|
||
if(startDate.isValid() && endDate.isValid()) {
|
||
return `${startDate.format('DD.MM.YYYY')} - ${endDate.format('DD.MM.YYYY')}`;
|
||
}
|
||
return startDate.format('DD.MM.YYYY');
|
||
}
|
||
}
|
||
}); |