myCRM/assets/js/views/InvoiceForm.vue
olli 5ffd7bd0d1 feat: add api-platform and symfony/uid dependencies; introduce billing module service configuration
- Added api-platform/symfony version 4.1 with necessary configuration files.
- Included symfony/uid version 7.1 with its recipe details.
- Created billing_module.yaml to define the BillingModulePlugin service with autowiring and autoconfiguration.
- Added SoftwareBuddy agent for web development support in PHP (Symfony) and JavaScript (Vue.js).
2025-12-05 15:07:37 +01:00

314 lines
10 KiB
Vue

<template>
<div class="invoice-form">
<Message severity="info" v-if="!invoice">
Rechnungsformular (Phase 1 MVP - wird erweitert)
</Message>
<div class="grid p-fluid">
<!-- Rechnungsnummer -->
<div class="col-12 md:col-6">
<label for="invoiceNumber">Rechnungsnummer *</label>
<InputText
id="invoiceNumber"
v-model="form.invoiceNumber"
:class="{ 'p-invalid': submitted && !form.invoiceNumber }"
/>
<small v-if="submitted && !form.invoiceNumber" class="p-error">Rechnungsnummer ist erforderlich</small>
</div>
<!-- Status -->
<div class="col-12 md:col-6">
<label for="status">Status</label>
<Dropdown
id="status"
v-model="form.status"
:options="statusOptions"
option-label="label"
option-value="value"
/>
</div>
<!-- Kunde -->
<div class="col-12 md:col-6">
<label for="contact">Kunde *</label>
<Dropdown
id="contact"
v-model="form.contactId"
:options="contacts"
option-label="companyName"
option-value="id"
filter
placeholder="Kunde auswählen"
:class="{ 'p-invalid': submitted && !form.contactId }"
/>
<small v-if="submitted && !form.contactId" class="p-error">Bitte wählen Sie einen Kunden aus</small>
</div>
<!-- Rechnungsdatum -->
<div class="col-12 md:col-6">
<label for="invoiceDate">Rechnungsdatum *</label>
<Calendar id="invoiceDate" v-model="form.invoiceDate" date-format="dd.mm.yy" />
</div>
<!-- Fälligkeitsdatum -->
<div class="col-12 md:col-6">
<label for="dueDate">Fälligkeitsdatum *</label>
<Calendar id="dueDate" v-model="form.dueDate" date-format="dd.mm.yy" />
</div>
<!-- Notizen -->
<div class="col-12">
<label for="notes">Notizen</label>
<Textarea id="notes" v-model="form.notes" rows="3" />
</div>
<!-- Positionen -->
<div class="col-12">
<h3>Rechnungspositionen</h3>
<DataTable :value="form.items" responsiveLayout="scroll">
<Column field="description" header="Beschreibung">
<template #body="slotProps">
<Textarea v-model="slotProps.data.description" rows="2" class="w-full" />
</template>
</Column>
<Column field="quantity" header="Menge">
<template #body="slotProps">
<InputNumber v-model="slotProps.data.quantity" :min-fraction-digits="2" class="w-full" />
</template>
</Column>
<Column field="unitPrice" header="Einzelpreis">
<template #body="slotProps">
<InputNumber v-model="slotProps.data.unitPrice" mode="currency" currency="EUR" locale="de-DE" class="w-full" />
</template>
</Column>
<Column field="taxRate" header="MwSt %">
<template #body="slotProps">
<Dropdown v-model="slotProps.data.taxRate" :options="taxRates" class="w-full" />
</template>
</Column>
<Column header="Aktionen">
<template #body="slotProps">
<Button icon="pi pi-trash" text severity="danger" @click="removeItem(slotProps.index)" />
</template>
</Column>
</DataTable>
<Button label="Position hinzufügen" icon="pi pi-plus" text @click="addItem" class="mt-2" />
</div>
</div>
<!-- Actions -->
<div class="flex gap-2 mt-4">
<Button label="Speichern" icon="pi pi-check" @click="save" :loading="saving" />
<Button label="Abbrechen" icon="pi pi-times" severity="secondary" text @click="cancel" :disabled="saving" />
</div>
</div>
</template>
<script setup>
import { ref, 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 Message from 'primevue/message'
const props = defineProps({
invoice: Object
})
const emit = defineEmits(['save', 'cancel'])
const toast = useToast()
const submitted = ref(false)
const saving = 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 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']
onMounted(async () => {
// Load contacts
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
})
}
// 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)
}
}
})
const addItem = () => {
form.value.items.push({
description: '',
quantity: 1.00,
unitPrice: 0.00,
taxRate: '19.00'
})
}
const removeItem = (index) => {
form.value.items.splice(index, 1)
}
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: 1rem;
}
</style>