myCRM/docs/design/invoice-form-improved.vue
olli 82b022ba3b Add comprehensive styles for invoice form components and utilities
- 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.
2025-12-13 10:02:30 +01:00

975 lines
25 KiB
Vue

<!--
REFERENZ-IMPLEMENTIERUNG: Verbessertes Invoice-Formular
Dieses File ist eine Design-Referenz und nicht für den direkten Einsatz gedacht.
Es zeigt die empfohlenen UX/UI-Verbesserungen.
-->
<template>
<div class="invoice-form-improved">
<!-- Info Banner (nur bei Neuerstellung) -->
<Message v-if="!invoice" severity="info" :closable="false" class="mb-4">
<div class="flex align-items-center gap-2">
<i class="pi pi-info-circle"></i>
<span>Rechnungsformular Phase 1 MVP - weitere Funktionen folgen</span>
</div>
</Message>
<!-- SEKTION 1: Stammdaten -->
<div class="form-section mb-4">
<div class="form-section-header">
<h3 class="form-section-title">Rechnungsinformationen</h3>
<Tag :value="getStatusLabel(form.status)"
:severity="getStatusSeverity(form.status)"
:icon="getStatusIcon(form.status)" />
</div>
<!-- Rechnungsnummer + Status -->
<div class="form-grid mb-3">
<FloatLabel>
<InputText
id="invoiceNumber"
v-model="form.invoiceNumber"
:invalid="validation.hasError('invoiceNumber')"
@blur="validation.touchField('invoiceNumber')"
@input="validation.validateField('invoiceNumber', form.invoiceNumber, {
required: true,
minLength: 3
})" />
<label for="invoiceNumber">
Rechnungsnummer
<abbr title="Pflichtfeld" aria-label="Pflichtfeld">*</abbr>
</label>
</FloatLabel>
<small v-if="validation.hasError('invoiceNumber')" class="p-error" role="alert">
{{ validation.getError('invoiceNumber') }}
</small>
<FloatLabel>
<Select
id="status"
v-model="form.status"
:options="statusOptions"
option-label="label"
option-value="value">
<template #value="slotProps">
<div class="flex align-items-center gap-2">
<i :class="getStatusIcon(slotProps.value)"></i>
<span>{{ getStatusLabel(slotProps.value) }}</span>
</div>
</template>
</Select>
<label for="status">Status</label>
</FloatLabel>
</div>
<!-- Kunde (volle Breite) -->
<div class="form-field-full mb-3">
<FloatLabel>
<Select
id="contact"
v-model="form.contactId"
:options="contacts"
option-label="companyName"
option-value="id"
filter
:filter-fields="['companyName', 'email']"
placeholder="Kunde suchen..."
:invalid="validation.hasError('contactId')"
:loading="loadingContacts"
@blur="validation.touchField('contactId')">
<template #value="slotProps">
<div v-if="slotProps.value" class="flex align-items-center gap-2">
<i class="pi pi-building"></i>
<span>{{ getContactName(slotProps.value) }}</span>
</div>
<span v-else class="text-muted">Kunde auswählen...</span>
</template>
<template #option="slotProps">
<div class="flex flex-column">
<span class="font-semibold">{{ slotProps.option.companyName }}</span>
<span class="text-sm text-secondary">{{ slotProps.option.email }}</span>
</div>
</template>
</Select>
<label for="contact">
Kunde
<abbr title="Pflichtfeld" aria-label="Pflichtfeld">*</abbr>
</label>
</FloatLabel>
<small v-if="validation.hasError('contactId')" class="p-error" role="alert">
{{ validation.getError('contactId') }}
</small>
</div>
<!-- Datum-Felder -->
<div class="form-grid">
<FloatLabel>
<DatePicker
id="invoiceDate"
v-model="form.invoiceDate"
date-format="dd.mm.yy"
:show-icon="true"
icon="pi pi-calendar"
@date-select="updateDueDate" />
<label for="invoiceDate">
Rechnungsdatum
<abbr title="Pflichtfeld" aria-label="Pflichtfeld">*</abbr>
</label>
</FloatLabel>
<FloatLabel>
<DatePicker
id="dueDate"
v-model="form.dueDate"
date-format="dd.mm.yy"
:show-icon="true"
icon="pi pi-calendar"
:min-date="form.invoiceDate" />
<label for="dueDate">
Fälligkeitsdatum
<abbr title="Pflichtfeld" aria-label="Pflichtfeld">*</abbr>
</label>
</FloatLabel>
</div>
</div>
<!-- SEKTION 2: Rechnungspositionen -->
<div class="form-section mb-4">
<div class="form-section-header">
<h3 class="form-section-title">
Rechnungspositionen
<span v-if="form.items.length > 0" class="text-secondary text-sm ml-2">
({{ form.items.length }})
</span>
</h3>
<Button
label="Position hinzufügen"
icon="pi pi-plus"
size="small"
@click="addItem"
:disabled="!canAddItems" />
</div>
<!-- Empty State -->
<div v-if="form.items.length === 0" class="empty-state">
<i class="pi pi-inbox empty-state-icon"></i>
<p class="empty-state-text">Noch keine Positionen hinzugefügt</p>
<p class="text-sm text-muted mb-3">
Fügen Sie mindestens eine Position hinzu, um die Rechnung zu erstellen
</p>
<Button
label="Erste Position hinzufügen"
icon="pi pi-plus"
outlined
@click="addItem" />
</div>
<!-- Invoice Items List -->
<TransitionGroup v-else name="list" tag="div" class="invoice-items-list">
<div
v-for="(item, index) in form.items"
:key="item.id || `item-${index}`"
class="invoice-item-card">
<!-- Item Header -->
<div class="invoice-item-header">
<span class="invoice-item-number">
<i class="pi pi-list mr-2"></i>
Position {{ index + 1 }}
</span>
<Button
icon="pi pi-trash"
text
rounded
severity="danger"
size="small"
@click="removeItem(index)"
:aria-label="`Position ${index + 1} entfernen`"
v-tooltip.top="'Position entfernen'" />
</div>
<!-- Description -->
<div class="form-field-full mb-3">
<FloatLabel>
<Textarea
:id="`description-${index}`"
v-model="item.description"
rows="2"
:invalid="validation.hasError(`items.${index}.description`)"
@blur="validation.touchField(`items.${index}.description`)"
@input="calculateItemTotal(index)" />
<label :for="`description-${index}`">
Beschreibung
<abbr title="Pflichtfeld" aria-label="Pflichtfeld">*</abbr>
</label>
</FloatLabel>
</div>
<!-- Quantity, Price, Tax -->
<div class="invoice-item-fields">
<div class="invoice-item-field">
<FloatLabel>
<InputNumber
:id="`quantity-${index}`"
v-model="item.quantity"
:min-fraction-digits="2"
:max-fraction-digits="2"
:min="0.01"
suffix=" Stk."
@input="calculateItemTotal(index)" />
<label :for="`quantity-${index}`">Menge</label>
</FloatLabel>
</div>
<div class="invoice-item-field">
<FloatLabel>
<InputNumber
:id="`unitPrice-${index}`"
v-model="item.unitPrice"
mode="currency"
currency="EUR"
locale="de-DE"
@input="calculateItemTotal(index)" />
<label :for="`unitPrice-${index}`">Einzelpreis</label>
</FloatLabel>
</div>
<div class="invoice-item-field invoice-item-field--small">
<FloatLabel>
<Select
:id="`taxRate-${index}`"
v-model="item.taxRate"
:options="taxRates"
@change="calculateItemTotal(index)">
<template #value="slotProps">
{{ slotProps.value }}%
</template>
<template #option="slotProps">
{{ slotProps.option }}% MwSt.
</template>
</Select>
<label :for="`taxRate-${index}`">MwSt.</label>
</FloatLabel>
</div>
</div>
<!-- Item Subtotal -->
<div class="invoice-item-subtotal">
<span class="text-secondary">Zwischensumme:</span>
<span class="font-semibold text-lg">
{{ formatCurrency(item.subtotal || 0) }}
</span>
</div>
</div>
</TransitionGroup>
<!-- Add Another Button -->
<Button
v-if="form.items.length > 0"
label="Weitere Position hinzufügen"
icon="pi pi-plus"
outlined
class="w-full mt-3"
@click="addItem" />
<!-- Summary Box -->
<div v-if="form.items.length > 0" class="invoice-summary">
<div class="invoice-summary-header">
<h4>Zusammenfassung</h4>
</div>
<div class="invoice-summary-body">
<div class="invoice-summary-row">
<span>Nettobetrag:</span>
<span>{{ formatCurrency(totals.net) }}</span>
</div>
<div
v-for="(amount, rate) in totals.taxBreakdown"
:key="rate"
class="invoice-summary-row">
<span>MwSt. ({{ rate }}%):</span>
<span>{{ formatCurrency(amount) }}</span>
</div>
<Divider class="my-3" />
<div class="invoice-summary-total">
<span>Gesamtbetrag:</span>
<span>{{ formatCurrency(totals.gross) }}</span>
</div>
</div>
</div>
</div>
<!-- SEKTION 3: Zusätzliche Informationen -->
<div class="form-section mb-4">
<Panel
header="Zusätzliche Informationen"
toggleable
:collapsed="!form.notes">
<template #icons>
<i class="pi pi-info-circle text-muted"></i>
</template>
<FloatLabel>
<Textarea
id="notes"
v-model="form.notes"
rows="4"
placeholder="z.B. Zahlungsbedingungen, Lieferbedingungen, Rabatte..." />
<label for="notes">Notizen</label>
</FloatLabel>
<small class="text-muted">
Diese Informationen werden nur intern gespeichert und nicht auf der Rechnung angezeigt
</small>
</Panel>
</div>
<!-- Action Buttons -->
<div class="form-actions">
<div class="form-actions-secondary">
<Button
label="Abbrechen"
icon="pi pi-times"
severity="secondary"
text
@click="cancel"
:disabled="saving" />
<Button
v-if="!invoice"
label="Als Entwurf speichern"
icon="pi pi-save"
outlined
severity="secondary"
@click="saveAsDraft"
:loading="savingDraft"
:disabled="saving" />
</div>
<Button
:label="invoice ? 'Aktualisieren' : 'Rechnung erstellen'"
icon="pi pi-check"
@click="save"
:loading="saving"
:disabled="!isFormValid || saving" />
</div>
<!-- Keyboard Shortcuts Hint -->
<div class="keyboard-hints" v-if="showKeyboardHints">
<small class="text-muted">
<kbd>Ctrl+S</kbd> Speichern
<kbd>Ctrl+Shift+P</kbd> Position hinzufügen
<kbd>Esc</kbd> Abbrechen
</small>
</div>
<!-- Live Region for Screen Readers -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
<span v-if="saving">Rechnung wird gespeichert...</span>
<span v-if="saved">Rechnung wurde erfolgreich gespeichert</span>
<span v-if="form.items.length === 0">Keine Rechnungspositionen vorhanden</span>
<span v-else>{{ form.items.length }} Rechnungsposition(en)</span>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useFormValidation } from '@/composables/useFormValidation'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
// PrimeVue Components
import InputText from 'primevue/inputtext'
import Select from 'primevue/select'
import DatePicker from 'primevue/datepicker'
import Textarea from 'primevue/textarea'
import Button from 'primevue/button'
import InputNumber from 'primevue/inputnumber'
import FloatLabel from 'primevue/floatlabel'
import Tag from 'primevue/tag'
import Message from 'primevue/message'
import Panel from 'primevue/panel'
import Divider from 'primevue/divider'
// Props & Emits
const props = defineProps({
invoice: Object
})
const emit = defineEmits(['save', 'cancel'])
// Composables
const toast = useToast()
const validation = useFormValidation()
// State
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 loadingContacts = ref(false)
const saving = ref(false)
const savingDraft = ref(false)
const saved = ref(false)
const showKeyboardHints = ref(true)
// Constants
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
const totals = computed(() => {
const result = {
net: 0,
taxBreakdown: {},
gross: 0
}
form.value.items.forEach(item => {
const net = (item.quantity || 0) * (item.unitPrice || 0)
const tax = net * ((item.taxRate || 0) / 100)
result.net += net
if (!result.taxBreakdown[item.taxRate]) {
result.taxBreakdown[item.taxRate] = 0
}
result.taxBreakdown[item.taxRate] += tax
result.gross += net + tax
})
return result
})
const isFormValid = computed(() => {
return form.value.invoiceNumber &&
form.value.contactId &&
form.value.items.length > 0 &&
form.value.items.every(item =>
item.description &&
item.quantity > 0 &&
item.unitPrice >= 0
)
})
const canAddItems = computed(() => {
return form.value.contactId !== null
})
// Methods
const loadContacts = async () => {
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()
contacts.value = data['hydra:member'] || data.member || []
} 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
}
}
const addItem = () => {
form.value.items.push({
id: `new-${Date.now()}`,
description: '',
quantity: 1.00,
unitPrice: 0.00,
taxRate: '19.00',
subtotal: 0
})
// Focus first input of new item
setTimeout(() => {
const index = form.value.items.length - 1
document.getElementById(`description-${index}`)?.focus()
}, 100)
}
const removeItem = (index) => {
form.value.items.splice(index, 1)
toast.add({
severity: 'info',
summary: 'Position entfernt',
life: 2000
})
}
const calculateItemTotal = (index) => {
const item = form.value.items[index]
const net = (item.quantity || 0) * (item.unitPrice || 0)
const tax = net * ((item.taxRate || 0) / 100)
item.subtotal = net + tax
}
const updateDueDate = () => {
// Auto-set due date to 14 days after invoice date
if (form.value.invoiceDate) {
form.value.dueDate = new Date(
form.value.invoiceDate.getTime() + 14 * 24 * 60 * 60 * 1000
)
}
}
const getContactName = (contactId) => {
const contact = contacts.value.find(c => c.id === contactId)
return contact?.companyName || ''
}
const getStatusLabel = (status) => {
const option = statusOptions.find(o => o.value === status)
return 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 getStatusIcon = (status) => {
const icons = {
draft: 'pi pi-file-edit',
open: 'pi pi-clock',
paid: 'pi pi-check-circle',
partial: 'pi pi-exclamation-triangle',
overdue: 'pi pi-times-circle',
cancelled: 'pi pi-ban'
}
return icons[status] || 'pi pi-circle'
}
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return '0,00 €'
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(parseFloat(amount))
}
const save = async () => {
if (!isFormValid.value) {
toast.add({
severity: 'warn',
summary: 'Validierung fehlgeschlagen',
detail: 'Bitte füllen Sie alle Pflichtfelder aus',
life: 3000
})
return
}
saving.value = true
try {
const method = props.invoice ? 'PUT' : 'POST'
const url = props.invoice ? `/api/invoices/${props.invoice.id}` : '/api/invoices'
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')
}
saved.value = true
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 saveAsDraft = async () => {
form.value.status = 'draft'
savingDraft.value = true
await save()
savingDraft.value = false
}
const cancel = () => {
emit('cancel')
}
// Keyboard Shortcuts
useKeyboardShortcuts({
'ctrl+s': save,
'ctrl+shift+p': addItem,
'escape': cancel
})
// Lifecycle
onMounted(async () => {
await loadContacts()
// Load existing invoice data
if (props.invoice) {
form.value = { ...props.invoice }
// Extract contact ID from IRI
if (props.invoice.contact) {
if (typeof props.invoice.contact === 'string') {
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)
}
}
})
</script>
<style scoped>
/* Layout */
.invoice-form-improved {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
/* Form Sections */
.form-section {
background: var(--surface-0);
border: 1px solid var(--surface-200);
border-radius: 0.75rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.form-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--surface-200);
}
.form-section-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
/* Form Grid */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
align-items: start;
}
.form-field-full {
grid-column: 1 / -1;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
text-align: center;
}
.empty-state-icon {
font-size: 3rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.empty-state-text {
font-size: 1rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
/* Invoice Items */
.invoice-items-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.invoice-item-card {
background: var(--surface-50);
border: 1px solid var(--surface-200);
border-radius: 0.5rem;
padding: 1rem;
transition: all 0.2s ease;
}
.invoice-item-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border-color: var(--primary-color);
}
.invoice-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.invoice-item-number {
font-weight: 600;
color: var(--text-secondary);
display: flex;
align-items: center;
}
.invoice-item-fields {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 1rem;
align-items: start;
}
.invoice-item-field--small {
max-width: 120px;
}
.invoice-item-subtotal {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--surface-200);
}
/* Invoice Summary */
.invoice-summary {
background: linear-gradient(135deg, var(--primary-50) 0%, var(--primary-100) 100%);
border: 2px solid var(--primary-color);
border-radius: 0.75rem;
padding: 1.5rem;
margin-top: 1.5rem;
}
.invoice-summary-header h4 {
margin: 0 0 1rem 0;
color: var(--text-primary);
}
.invoice-summary-row {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
font-size: 0.875rem;
color: var(--text-secondary);
}
.invoice-summary-total {
display: flex;
justify-content: space-between;
padding: 1rem 0 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
}
/* Form Actions */
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1.5rem 0;
border-top: 1px solid var(--surface-200);
}
.form-actions-secondary {
display: flex;
gap: 0.5rem;
}
/* Keyboard Hints */
.keyboard-hints {
text-align: center;
padding: 1rem;
border-top: 1px solid var(--surface-200);
margin-top: 1rem;
}
kbd {
background: var(--surface-100);
border: 1px solid var(--surface-300);
border-radius: 0.25rem;
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
font-family: monospace;
}
/* Transitions */
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from {
opacity: 0;
transform: translateY(-20px);
}
.list-leave-to {
opacity: 0;
transform: translateX(20px);
}
.list-move {
transition: transform 0.3s ease;
}
/* Accessibility */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Responsive */
@media (max-width: 992px) {
.form-grid {
grid-template-columns: 1fr;
}
.invoice-item-fields {
grid-template-columns: 1fr;
}
.invoice-item-field--small {
max-width: 100%;
}
}
@media (max-width: 768px) {
.invoice-form-improved {
padding: 1rem;
}
.form-section {
padding: 1rem;
}
.form-section-title {
font-size: 1rem;
}
.form-actions {
flex-direction: column-reverse;
align-items: stretch;
}
.form-actions-secondary {
flex-direction: column;
width: 100%;
}
}
@media (max-width: 576px) {
.invoice-summary {
padding: 1rem;
}
.invoice-summary-total {
font-size: 1rem;
}
.keyboard-hints {
display: none;
}
}
</style>