Files
thetool/public/js/pages/ManualInvoice/ManualInvoice.js

372 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
}
}
});