- Introduced CSS custom properties for spacing, typography, colors, and shadows. - Developed styles for form sections, grids, invoice items, and summary components. - Implemented responsive design adjustments for various screen sizes. - Added utility classes for text, spacing, and flex layouts. - Included dark mode and high contrast mode support. - Established loading states and validation/error styles. - Enhanced accessibility features with focus styles and screen reader utilities.
646 lines
21 KiB
Vue
646 lines
21 KiB
Vue
<template>
|
|
<div class="invoice-form">
|
|
<div class="flex flex-col gap-6">
|
|
<!-- Basic Information Panel -->
|
|
<Panel header="Grunddaten" class="invoice-panel">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class="flex flex-col gap-2">
|
|
<label for="invoiceNumber">Rechnungsnummer *</label>
|
|
<InputText
|
|
id="invoiceNumber"
|
|
v-model="form.invoiceNumber"
|
|
:class="{ 'p-invalid': submitted && !form.invoiceNumber }"
|
|
:disabled="saving"
|
|
/>
|
|
<small v-if="submitted && !form.invoiceNumber" class="p-error">Rechnungsnummer ist erforderlich</small>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<label for="status">Status</label>
|
|
<Dropdown
|
|
id="status"
|
|
v-model="form.status"
|
|
:options="statusOptions"
|
|
option-label="label"
|
|
option-value="value"
|
|
:disabled="saving"
|
|
>
|
|
<template #value="slotProps">
|
|
<div v-if="slotProps.value" class="flex align-items-center">
|
|
<Tag :value="getStatusLabel(slotProps.value)" :severity="getStatusSeverity(slotProps.value)" />
|
|
</div>
|
|
</template>
|
|
<template #option="slotProps">
|
|
<div class="flex align-items-center">
|
|
<Tag :value="slotProps.option.label" :severity="getStatusSeverity(slotProps.option.value)" />
|
|
</div>
|
|
</template>
|
|
</Dropdown>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<label for="contact">Kunde *</label>
|
|
<Dropdown
|
|
id="contact"
|
|
v-model="form.contactId"
|
|
:options="contacts"
|
|
option-label="companyName"
|
|
option-value="id"
|
|
filter
|
|
:filter-fields="['companyName', 'email']"
|
|
placeholder="Kunde auswählen"
|
|
:class="{ 'p-invalid': submitted && !form.contactId }"
|
|
:loading="loadingContacts"
|
|
:disabled="saving"
|
|
show-clear
|
|
>
|
|
<template #value="slotProps">
|
|
<div v-if="slotProps.value" class="flex align-items-center">
|
|
<i class="pi pi-building mr-2"></i>
|
|
<span>{{ getContactName(slotProps.value) }}</span>
|
|
</div>
|
|
</template>
|
|
<template #option="slotProps">
|
|
<div class="flex align-items-center gap-2">
|
|
<i class="pi pi-building"></i>
|
|
<div>
|
|
<div class="font-semibold">{{ slotProps.option.companyName }}</div>
|
|
<div class="text-sm text-500">{{ slotProps.option.email }}</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</Dropdown>
|
|
<small v-if="submitted && !form.contactId" class="p-error">Bitte wählen Sie einen Kunden aus</small>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2 col-span-full">
|
|
<label for="notes">Notizen</label>
|
|
<Textarea
|
|
id="notes"
|
|
v-model="form.notes"
|
|
rows="3"
|
|
:disabled="saving"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
|
|
<!-- Dates Panel -->
|
|
<Panel header="Daten" class="invoice-panel">
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div class="flex flex-col gap-2">
|
|
<label for="invoiceDate">Rechnungsdatum *</label>
|
|
<Calendar
|
|
id="invoiceDate"
|
|
v-model="form.invoiceDate"
|
|
date-format="dd.mm.yy"
|
|
:disabled="saving"
|
|
show-icon
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<label for="dueDate">Fälligkeitsdatum *</label>
|
|
<Calendar
|
|
id="dueDate"
|
|
v-model="form.dueDate"
|
|
date-format="dd.mm.yy"
|
|
:disabled="saving"
|
|
show-icon
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<label class="text-500">Zahlungsziel</label>
|
|
<div class="flex align-items-center h-full pt-2">
|
|
<Tag :value="`${paymentTermDays} Tage`" severity="info" class="text-base px-3 py-2" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
|
|
<!-- Invoice Items Panel -->
|
|
<Panel header="Rechnungspositionen" class="invoice-panel">
|
|
|
|
<div v-if="form.items.length === 0" class="p-4 border-round border-1 surface-border text-center text-500 mb-3">
|
|
<i class="pi pi-inbox text-3xl mb-2"></i>
|
|
<p>Noch keine Positionen hinzugefügt</p>
|
|
</div>
|
|
|
|
<DataTable v-else :value="form.items" responsiveLayout="scroll" striped-rows>
|
|
<Column field="description" header="Beschreibung" style="min-width: 200px">
|
|
<template #body="slotProps">
|
|
<Textarea
|
|
v-model="slotProps.data.description"
|
|
rows="2"
|
|
class="w-full"
|
|
placeholder="Beschreibung der Position"
|
|
@input="calculateTotals"
|
|
/>
|
|
</template>
|
|
</Column>
|
|
<Column field="quantity" header="Menge" style="width: 120px">
|
|
<template #body="slotProps">
|
|
<InputNumber
|
|
v-model="slotProps.data.quantity"
|
|
:min-fraction-digits="2"
|
|
:max-fraction-digits="2"
|
|
:min="0"
|
|
class="w-full"
|
|
@input="calculateTotals"
|
|
/>
|
|
</template>
|
|
</Column>
|
|
<Column field="unitPrice" header="Einzelpreis" style="width: 150px">
|
|
<template #body="slotProps">
|
|
<InputNumber
|
|
v-model="slotProps.data.unitPrice"
|
|
mode="currency"
|
|
currency="EUR"
|
|
locale="de-DE"
|
|
class="w-full"
|
|
@input="calculateTotals"
|
|
/>
|
|
</template>
|
|
</Column>
|
|
<Column field="taxRate" header="MwSt %" style="width: 120px">
|
|
<template #body="slotProps">
|
|
<Dropdown
|
|
v-model="slotProps.data.taxRate"
|
|
:options="taxRates"
|
|
class="w-full"
|
|
@change="calculateTotals"
|
|
>
|
|
<template #value="{ value }">
|
|
{{ value }}%
|
|
</template>
|
|
<template #option="{ option }">
|
|
{{ option }}%
|
|
</template>
|
|
</Dropdown>
|
|
</template>
|
|
</Column>
|
|
<Column header="Gesamt" style="width: 150px">
|
|
<template #body="slotProps">
|
|
<div class="font-semibold">
|
|
{{ formatCurrency(calculateItemTotal(slotProps.data)) }}
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
<Column style="width: 10%">
|
|
<template #body="slotProps">
|
|
<div class="flex gap-2 justify-end">
|
|
<Button
|
|
icon="pi pi-trash"
|
|
size="small"
|
|
text
|
|
severity="danger"
|
|
@click="removeItem(slotProps.index)"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
</DataTable>
|
|
|
|
<!-- Add Item Button -->
|
|
<div class="mt-3">
|
|
<Button
|
|
label="Position hinzufügen"
|
|
icon="pi pi-plus"
|
|
size="small"
|
|
outlined
|
|
@click="addItem"
|
|
/>
|
|
</div>
|
|
</Panel>
|
|
|
|
<!-- Totals Panel -->
|
|
<Panel v-if="form.items.length > 0" header="Summen" class="invoice-panel totals-panel">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div></div>
|
|
<div class="flex flex-col gap-3">
|
|
<div class="flex justify-between p-3 border-round surface-50">
|
|
<span class="text-600 font-medium">Nettobetrag:</span>
|
|
<span class="font-semibold">{{ formatCurrency(calculatedTotals.netAmount) }}</span>
|
|
</div>
|
|
<div v-for="(tax, rate) in calculatedTotals.taxBreakdown" :key="rate" class="flex justify-between p-3 border-round surface-50">
|
|
<span class="text-600 font-medium">MwSt {{ rate }}%:</span>
|
|
<span class="font-semibold">{{ formatCurrency(tax) }}</span>
|
|
</div>
|
|
<Divider class="my-2" />
|
|
<div class="flex justify-between p-4 border-round bg-primary-50 border-2 border-primary-200">
|
|
<span class="font-bold text-xl text-primary-700">Gesamtbetrag:</span>
|
|
<span class="font-bold text-2xl text-primary-700">{{ formatCurrency(calculatedTotals.total) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
</div>
|
|
|
|
<!-- Footer Actions -->
|
|
<div class="flex justify-end gap-3 pt-4 border-top-1 surface-border mt-6">
|
|
<Button label="Abbrechen" @click="cancel" text severity="secondary" :disabled="saving" />
|
|
<Button label="Speichern" @click="save" :loading="saving" icon="pi pi-check" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useToast } from 'primevue/usetoast'
|
|
import InputText from 'primevue/inputtext'
|
|
import Dropdown from 'primevue/dropdown'
|
|
import Calendar from 'primevue/calendar'
|
|
import Textarea from 'primevue/textarea'
|
|
import Button from 'primevue/button'
|
|
import DataTable from 'primevue/datatable'
|
|
import Column from 'primevue/column'
|
|
import InputNumber from 'primevue/inputnumber'
|
|
import Tag from 'primevue/tag'
|
|
import Panel from 'primevue/panel'
|
|
import Divider from 'primevue/divider'
|
|
|
|
const props = defineProps({
|
|
invoice: Object
|
|
})
|
|
|
|
const emit = defineEmits(['save', 'cancel'])
|
|
|
|
const toast = useToast()
|
|
const submitted = ref(false)
|
|
const saving = ref(false)
|
|
const loadingContacts = ref(false)
|
|
|
|
const form = ref({
|
|
invoiceNumber: '',
|
|
status: 'draft',
|
|
contactId: null,
|
|
invoiceDate: new Date(),
|
|
dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // +14 Tage
|
|
notes: '',
|
|
items: []
|
|
})
|
|
|
|
const contacts = ref([])
|
|
const calculatedTotals = ref({
|
|
netAmount: 0,
|
|
taxBreakdown: {},
|
|
total: 0
|
|
})
|
|
|
|
const statusOptions = [
|
|
{ label: 'Entwurf', value: 'draft' },
|
|
{ label: 'Offen', value: 'open' },
|
|
{ label: 'Bezahlt', value: 'paid' },
|
|
{ label: 'Teilweise bezahlt', value: 'partial' },
|
|
{ label: 'Überfällig', value: 'overdue' },
|
|
{ label: 'Storniert', value: 'cancelled' }
|
|
]
|
|
|
|
const taxRates = ['0.00', '7.00', '19.00']
|
|
|
|
// Computed Properties
|
|
const paymentTermDays = computed(() => {
|
|
if (!form.value.invoiceDate || !form.value.dueDate) return 0
|
|
const diff = form.value.dueDate - form.value.invoiceDate
|
|
return Math.round(diff / (1000 * 60 * 60 * 24))
|
|
})
|
|
|
|
// Helper Functions
|
|
const getStatusLabel = (status) => {
|
|
const option = statusOptions.find(opt => opt.value === status)
|
|
return option ? option.label : status
|
|
}
|
|
|
|
const getStatusSeverity = (status) => {
|
|
const severities = {
|
|
draft: 'secondary',
|
|
open: 'info',
|
|
paid: 'success',
|
|
partial: 'warning',
|
|
overdue: 'danger',
|
|
cancelled: 'secondary'
|
|
}
|
|
return severities[status] || 'info'
|
|
}
|
|
|
|
const getContactName = (contactId) => {
|
|
const contact = contacts.value.find(c => c.id === contactId)
|
|
return contact ? contact.companyName : ''
|
|
}
|
|
|
|
const formatCurrency = (amount) => {
|
|
if (!amount && amount !== 0) return '0,00 €'
|
|
return new Intl.NumberFormat('de-DE', {
|
|
style: 'currency',
|
|
currency: 'EUR'
|
|
}).format(parseFloat(amount))
|
|
}
|
|
|
|
const calculateItemTotal = (item) => {
|
|
const quantity = parseFloat(item.quantity) || 0
|
|
const unitPrice = parseFloat(item.unitPrice) || 0
|
|
const taxRate = parseFloat(item.taxRate) || 0
|
|
const net = quantity * unitPrice
|
|
const tax = net * (taxRate / 100)
|
|
return net + tax
|
|
}
|
|
|
|
const calculateTotals = () => {
|
|
let netAmount = 0
|
|
const taxBreakdown = {}
|
|
|
|
form.value.items.forEach(item => {
|
|
const quantity = parseFloat(item.quantity) || 0
|
|
const unitPrice = parseFloat(item.unitPrice) || 0
|
|
const taxRate = parseFloat(item.taxRate) || 0
|
|
|
|
const itemNet = quantity * unitPrice
|
|
const itemTax = itemNet * (taxRate / 100)
|
|
|
|
netAmount += itemNet
|
|
|
|
if (taxBreakdown[taxRate]) {
|
|
taxBreakdown[taxRate] += itemTax
|
|
} else {
|
|
taxBreakdown[taxRate] = itemTax
|
|
}
|
|
})
|
|
|
|
const totalTax = Object.values(taxBreakdown).reduce((sum, tax) => sum + tax, 0)
|
|
|
|
calculatedTotals.value = {
|
|
netAmount,
|
|
taxBreakdown,
|
|
total: netAmount + totalTax
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
// Load contacts
|
|
loadingContacts.value = true
|
|
try {
|
|
const response = await fetch('/api/contacts?itemsPerPage=1000', {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Accept': 'application/ld+json'
|
|
}
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Fehler beim Laden der Kontakte')
|
|
}
|
|
|
|
const data = await response.json()
|
|
// API Platform kann 'hydra:member' oder 'member' verwenden
|
|
contacts.value = data['hydra:member'] || data.member || []
|
|
|
|
console.log('Loaded contacts:', contacts.value.length)
|
|
} catch (error) {
|
|
console.error('Error loading contacts:', error)
|
|
toast.add({
|
|
severity: 'error',
|
|
summary: 'Fehler',
|
|
detail: 'Kontakte konnten nicht geladen werden',
|
|
life: 3000
|
|
})
|
|
} finally {
|
|
loadingContacts.value = false
|
|
}
|
|
|
|
// Load existing invoice data
|
|
if (props.invoice) {
|
|
form.value = { ...props.invoice }
|
|
|
|
// Extract contact ID from IRI if needed
|
|
if (props.invoice.contact) {
|
|
if (typeof props.invoice.contact === 'string') {
|
|
// Extract ID from IRI like "/api/contacts/123"
|
|
const matches = props.invoice.contact.match(/\/api\/contacts\/(\d+)/)
|
|
form.value.contactId = matches ? parseInt(matches[1]) : null
|
|
} else if (props.invoice.contact.id) {
|
|
form.value.contactId = props.invoice.contact.id
|
|
}
|
|
}
|
|
|
|
if (props.invoice.invoiceDate) {
|
|
form.value.invoiceDate = new Date(props.invoice.invoiceDate)
|
|
}
|
|
if (props.invoice.dueDate) {
|
|
form.value.dueDate = new Date(props.invoice.dueDate)
|
|
}
|
|
|
|
// Calculate initial totals if items exist
|
|
if (form.value.items && form.value.items.length > 0) {
|
|
calculateTotals()
|
|
}
|
|
}
|
|
})
|
|
|
|
const addItem = () => {
|
|
form.value.items.push({
|
|
description: '',
|
|
quantity: 1.00,
|
|
unitPrice: 0.00,
|
|
taxRate: '19.00'
|
|
})
|
|
calculateTotals()
|
|
}
|
|
|
|
const removeItem = (index) => {
|
|
form.value.items.splice(index, 1)
|
|
calculateTotals()
|
|
}
|
|
|
|
const save = async () => {
|
|
submitted.value = true
|
|
|
|
// Validation
|
|
if (!form.value.invoiceNumber || !form.value.contactId) {
|
|
toast.add({
|
|
severity: 'warn',
|
|
summary: 'Validierung fehlgeschlagen',
|
|
detail: 'Bitte füllen Sie alle Pflichtfelder aus',
|
|
life: 3000
|
|
})
|
|
return
|
|
}
|
|
|
|
if (form.value.items.length === 0) {
|
|
toast.add({
|
|
severity: 'warn',
|
|
summary: 'Validierung fehlgeschlagen',
|
|
detail: 'Bitte fügen Sie mindestens eine Position hinzu',
|
|
life: 3000
|
|
})
|
|
return
|
|
}
|
|
|
|
saving.value = true
|
|
|
|
try {
|
|
const method = props.invoice ? 'PUT' : 'POST'
|
|
const url = props.invoice ? `/api/invoices/${props.invoice.id}` : '/api/invoices'
|
|
|
|
// Konvertiere alle numerischen Werte in Items zu Strings (für DECIMAL Felder)
|
|
const items = form.value.items.map(item => ({
|
|
description: item.description,
|
|
quantity: String(item.quantity),
|
|
unitPrice: String(item.unitPrice),
|
|
taxRate: String(item.taxRate)
|
|
}))
|
|
|
|
const payload = {
|
|
invoiceNumber: form.value.invoiceNumber,
|
|
contact: `/api/contacts/${form.value.contactId}`,
|
|
status: form.value.status,
|
|
invoiceDate: form.value.invoiceDate.toISOString().split('T')[0],
|
|
dueDate: form.value.dueDate.toISOString().split('T')[0],
|
|
notes: form.value.notes,
|
|
items: items
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/ld+json',
|
|
'Accept': 'application/ld+json'
|
|
},
|
|
credentials: 'include',
|
|
body: JSON.stringify(payload)
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json()
|
|
throw new Error(error['hydra:description'] || error.message || 'Fehler beim Speichern')
|
|
}
|
|
|
|
toast.add({
|
|
severity: 'success',
|
|
summary: 'Erfolg',
|
|
detail: `Rechnung wurde ${props.invoice ? 'aktualisiert' : 'erstellt'}`,
|
|
life: 3000
|
|
})
|
|
|
|
emit('save', form.value)
|
|
} catch (error) {
|
|
console.error('Error saving invoice:', error)
|
|
toast.add({
|
|
severity: 'error',
|
|
summary: 'Fehler',
|
|
detail: error.message || 'Rechnung konnte nicht gespeichert werden',
|
|
life: 5000
|
|
})
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
const cancel = () => {
|
|
emit('cancel')
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.invoice-form {
|
|
padding: 1.5rem;
|
|
background: var(--surface-ground);
|
|
}
|
|
|
|
/* Panel Styling */
|
|
.invoice-panel :deep(.p-panel-header) {
|
|
background: var(--surface-card);
|
|
border-bottom: 2px solid var(--primary-color);
|
|
font-weight: 600;
|
|
font-size: 1.1rem;
|
|
color: var(--primary-color);
|
|
}
|
|
|
|
.invoice-panel :deep(.p-panel-content) {
|
|
background: var(--surface-card);
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.totals-panel :deep(.p-panel-header) {
|
|
background: var(--primary-50);
|
|
border-bottom: 2px solid var(--primary-color);
|
|
}
|
|
|
|
.totals-panel :deep(.p-panel-content) {
|
|
background: var(--surface-0);
|
|
}
|
|
|
|
/* Empty State */
|
|
.p-4.border-round.border-1.surface-border.text-center {
|
|
background: var(--surface-50);
|
|
border-style: dashed !important;
|
|
}
|
|
|
|
/* DataTable Styling */
|
|
:deep(.p-datatable) {
|
|
border-radius: var(--border-radius);
|
|
overflow: hidden;
|
|
}
|
|
|
|
:deep(.p-datatable-thead > tr > th) {
|
|
background: var(--surface-100);
|
|
color: var(--text-color);
|
|
font-weight: 600;
|
|
padding: 1rem;
|
|
}
|
|
|
|
:deep(.p-datatable-tbody > tr > td) {
|
|
padding: 0.75rem 1rem;
|
|
}
|
|
|
|
:deep(.p-datatable-tbody > tr:hover) {
|
|
background: var(--surface-50);
|
|
}
|
|
|
|
:deep(.p-inputnumber),
|
|
:deep(.p-dropdown) {
|
|
width: 100%;
|
|
}
|
|
|
|
:deep(.p-inputtext),
|
|
:deep(.p-dropdown),
|
|
:deep(.p-calendar) {
|
|
width: 100%;
|
|
}
|
|
|
|
/* Footer Actions */
|
|
.border-top-1 {
|
|
border-top-width: 1px !important;
|
|
border-top-style: solid !important;
|
|
}
|
|
|
|
/* Responsive Adjustments */
|
|
@media (max-width: 768px) {
|
|
.invoice-form {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.invoice-panel :deep(.p-panel-content) {
|
|
padding: 1rem;
|
|
}
|
|
|
|
:deep(.p-panel-header) {
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.grid.grid-cols-1.md\\:grid-cols-2,
|
|
.grid.grid-cols-1.md\\:grid-cols-3 {
|
|
grid-template-columns: 1fr !important;
|
|
}
|
|
}
|
|
|
|
/* Animation */
|
|
.invoice-panel {
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.invoice-panel:hover {
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
}
|
|
</style>
|