myCRM/docs/design/INVOICE_FORM_UX_DESIGN.md
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

1806 lines
50 KiB
Markdown
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.

# Invoice Form UX/UI Design Konzept
**myCRM Billing Module - Invoice Management**
**Erstellt:** 2025-12-12
**Projekt:** myCRM - Symfony 7.1 LTS + Vue.js 3 + PrimeVue
**Fokus:** Rechnungsformular (InvoiceForm.vue) und zugehörige Komponenten
---
## Executive Summary
Dieses Dokument analysiert das aktuelle Invoice-Formular und präsentiert ein umfassendes UX/UI-Design-Konzept für eine optimierte, professionelle Rechnungsverwaltung. Der Fokus liegt auf verbesserter visueller Hierarchie, optimierter Formularstruktur und konsistenter Nutzererfahrung.
---
## 1. Analyse des Ist-Zustands
### 1.1 Bestehende Komponenten
**Hauptkomponenten:**
- `InvoiceManagement.vue` - Rechnungsübersicht mit DataTable
- `InvoiceForm.vue` - Rechnungserstellung/-bearbeitung (Hauptformular)
- `PaymentForm.vue` - Zahlungserfassung
- `PDFUploadForm.vue` - PDF-Upload (MVP-Platzhalter)
### 1.2 Identifizierte UX-Probleme
#### Schwerwiegende Probleme (P0)
1. **Fehlende visuelle Hierarchie im Hauptformular**
- Alle Felder auf gleicher Ebene ohne logische Gruppierung
- Rechnungspositionen-Tabelle verliert sich im Formular
- Keine klare Trennung zwischen Stammdaten und Positionen
2. **Unklare Validierungs-Feedback**
- Validierung nur beim Submit sichtbar
- Keine Echtzeit-Validierung während Eingabe
- Fehler-Messages zu klein und leicht zu übersehen
3. **Schlechte Rechnungspositions-Verwaltung**
- DataTable in Formular wirkt überladen
- Inline-Editing schwer zu bedienen
- Fehlende Summenberechnung in Echtzeit
- Keine visuelle Bestätigung beim Hinzufügen/Entfernen
4. **Inkonsistente Spacing und Layouts**
- Inkonsistente Abstände zwischen Formulargruppen
- Dialog-Größe (70vw) nicht optimal für verschiedene Bildschirme
- Fehlende responsive Breakpoints für Tablet-Ansicht
#### Mittelschwere Probleme (P1)
5. **Fehlende Benutzerführung**
- Keine Hilfe-Tooltips für komplexe Felder
- Fehlende Feldkontexte (z.B. Steuerberechnung)
- Keine Keyboard-Shortcuts
6. **Unvollständige Status-Kommunikation**
- Loading-States nur beim Speichern
- Keine Progress-Indication beim Laden von Kontakten
- Fehlende Empty-States
7. **Schwache Datenvisualisierung**
- Keine Gesamtsummen-Anzeige
- Fehlende Berechnungsübersicht (Netto, Steuer, Brutto)
- Kein Currency-Formatting in Tabelle
#### Niedrige Priorität (P2)
8. **Fehlende Micro-Interactions**
- Keine Animationen beim Hinzufügen/Entfernen von Positionen
- Fehlende Hover-States auf interaktiven Elementen
- Keine visuellen Bestätigungen
9. **Accessibility-Mängel**
- Fehlende ARIA-Labels für Screen Reader
- Keine Keyboard-Navigation in Positionen-Tabelle
- Kontrast könnte besser sein
---
## 2. Design-Prinzipien
### 2.1 Core Principles
1. **Progressive Disclosure** - Zeige nur relevante Informationen zum richtigen Zeitpunkt
2. **Visual Hierarchy** - Klare Strukturierung durch Größe, Farbe, Spacing
3. **Immediate Feedback** - Sofortige Rückmeldung auf Benutzeraktionen
4. **Error Prevention** - Validierung bevor Fehler entstehen
5. **Consistency** - Einheitliche PrimeVue-Komponenten und Patterns
### 2.2 Design System Integration
**Verwendung bestehender Assets:**
- PrimeVue Aura Theme (Dark Mode Support)
- Tailwind CSS v4 mit PrimeUI Plugin
- Sakai Layout System
- Bestehende Farbpalette und Spacing-System
---
## 3. Design-Konzept: Invoice Form
### 3.1 Layout-Struktur (Desktop)
```
┌─────────────────────────────────────────────────────────────────┐
│ HEADER: Neue Rechnung / Rechnung bearbeiten [×] Schließen │
├─────────────────────────────────────────────────────────────────┤
│ │
│ [INFO-BANNER] Phase 1 MVP - wird erweitert (nur bei Neuerstellung) │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ SEKTION 1: STAMMDATEN │ │
│ │ ┌──────────────────┐ ┌──────────────────┐ │ │
│ │ │ Rechnungsnr. * │ │ Status │ │ │
│ │ └──────────────────┘ └──────────────────┘ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ Kunde (Dropdown mit Search) * │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ │ ┌──────────────────┐ ┌──────────────────┐ │ │
│ │ │ Rechnungsdatum * │ │ Fälligkeitsdatum*│ │ │
│ │ └──────────────────┘ └──────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ SEKTION 2: RECHNUNGSPOSITIONEN [+ Position] │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ Position 1 [Löschen] │ │ │
│ │ │ ┌──────────────────────────────────────────────┐ │ │ │
│ │ │ │ Beschreibung │ │ │ │
│ │ │ └──────────────────────────────────────────────┘ │ │ │
│ │ │ [Menge: 1.00] [Einzelpreis: 0,00 €] [MwSt: 19%] │ │ │
│ │ │ → Zwischensumme: 0,00 € │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ [+ Position hinzufügen] │ │
│ │ │ │
│ │ ┌───────────────────────────────────────────────────┐ │ │
│ │ │ ZUSAMMENFASSUNG │ │ │
│ │ │ Nettobetrag: 1.000,00 € │ │ │
│ │ │ MwSt (19%): 190,00 € │ │ │
│ │ │ MwSt (7%): 14,00 € │ │ │
│ │ │ ───────────────────────────────────────────────── │ │ │
│ │ │ GESAMTBETRAG: 1.204,00 € │ │ │
│ │ └───────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ SEKTION 3: ZUSÄTZLICHE INFORMATIONEN (Optional) │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ Notizen (z.B. Zahlungsbedingungen) │ │ │
│ │ │ │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
├─────────────────────────────────────────────────────────────────┤
│ FOOTER: [Abbrechen] [Entwurf speichern] [Speichern] │
└─────────────────────────────────────────────────────────────────┘
```
### 3.2 Visual Design Specifications
#### Spacing System
```css
/* Card/Section Spacing */
--section-gap: 2rem; /* 32px - Abstand zwischen Sektionen */
--section-padding: 1.5rem; /* 24px - Innen-Padding der Sektionen */
--field-gap: 1rem; /* 16px - Abstand zwischen Feldern */
--inline-field-gap: 0.75rem; /* 12px - Abstand zwischen Inline-Feldern */
/* Component Spacing */
--form-element-height: 2.75rem; /* 44px - Touch-friendly */
--label-margin: 0.5rem; /* 8px - Label zu Input */
```
#### Typography Hierarchy
```css
/* Heading Levels */
--h2-dialog-title: 1.5rem; /* 24px - Dialog Titel */
--h3-section-title: 1.25rem; /* 20px - Sektion Überschrift */
--h4-subsection: 1rem; /* 16px - Subsection */
/* Body Text */
--body-normal: 0.875rem; /* 14px - Standard Text */
--body-small: 0.75rem; /* 12px - Helper Text, Labels */
--body-large: 1rem; /* 16px - Wichtige Werte */
/* Font Weights */
--weight-normal: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
```
#### Color Palette (Semantic Colors)
```css
/* Status Colors (PrimeVue Aura Theme) */
--color-primary: #3B82F6; /* Blue - Primary Actions */
--color-success: #22C55E; /* Green - Success, Paid */
--color-warning: #F59E0B; /* Orange - Partial, Warning */
--color-danger: #EF4444; /* Red - Overdue, Delete */
--color-info: #06B6D4; /* Cyan - Info Messages */
--color-secondary: #6B7280; /* Gray - Secondary Actions */
/* Surface Colors */
--surface-0: #FFFFFF; /* Base Background */
--surface-50: #F9FAFB; /* Light Background */
--surface-100: #F3F4F6; /* Section Background */
--surface-200: #E5E7EB; /* Border Color */
/* Text Colors */
--text-primary: #111827; /* Primary Text */
--text-secondary: #6B7280; /* Secondary Text */
--text-muted: #9CA3AF; /* Muted Text */
```
#### Component Styling
**Card/Section Style:**
```css
.form-section {
background: var(--surface-0);
border: 1px solid var(--surface-200);
border-radius: 0.75rem; /* 12px */
padding: var(--section-padding);
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: var(--h3-section-title);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
```
**Invoice Item Card:**
```css
.invoice-item-card {
background: var(--surface-50);
border: 1px solid var(--surface-200);
border-radius: 0.5rem; /* 8px */
padding: 1rem;
margin-bottom: 0.75rem;
transition: all 0.2s ease;
}
.invoice-item-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border-color: var(--color-primary);
}
.invoice-item-card--new {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
```
**Summary Box:**
```css
.invoice-summary {
background: var(--surface-100);
border: 2px solid var(--color-primary);
border-radius: 0.75rem;
padding: 1.5rem;
margin-top: 1.5rem;
}
.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;
margin-top: 0.75rem;
border-top: 2px solid var(--surface-200);
font-size: 1.25rem;
font-weight: var(--weight-bold);
color: var(--text-primary);
}
```
### 3.3 Responsive Breakpoints
```css
/* Desktop First Approach */
/* Large Desktop (1920px+) */
.invoice-dialog--large {
width: 1200px;
max-width: 90vw;
}
/* Desktop (1200px - 1920px) */
@media (max-width: 1920px) {
.invoice-dialog {
width: 1000px;
}
}
/* Tablet Landscape (992px - 1200px) */
@media (max-width: 1200px) {
.invoice-dialog {
width: 90vw;
}
.form-grid {
grid-template-columns: 1fr 1fr; /* 2 Spalten */
}
}
/* Tablet Portrait (768px - 992px) */
@media (max-width: 992px) {
.invoice-dialog {
width: 95vw;
max-height: 90vh;
}
.form-grid {
grid-template-columns: 1fr; /* 1 Spalte */
}
.invoice-item-fields {
flex-direction: column;
}
}
/* Mobile (576px - 768px) */
@media (max-width: 768px) {
.invoice-dialog {
width: 100vw;
height: 100vh;
border-radius: 0;
margin: 0;
}
.form-section {
padding: 1rem;
}
.invoice-summary {
position: sticky;
bottom: 0;
z-index: 100;
}
}
/* Small Mobile (< 576px) */
@media (max-width: 576px) {
.form-section-title {
font-size: 1rem;
}
.invoice-item-card {
padding: 0.75rem;
}
}
```
---
## 4. Komponentenweise Design-Spezifikationen
### 4.1 InvoiceForm - Hauptformular
#### Sektion 1: Stammdaten
**Layout:**
- 2-spaltig auf Desktop (Rechnungsnr + Status)
- 1-spaltig auf Mobile
- Kunde als volle Breite für bessere Lesbarkeit
- Datum-Felder wieder 2-spaltig
**Verbesserungen:**
```vue
<div class="form-section">
<div class="form-section-header">
<h3 class="form-section-title">Rechnungsinformationen</h3>
<Tag :value="getStatusLabel(form.status)" :severity="getStatusSeverity(form.status)" />
</div>
<div class="form-grid">
<FloatLabel>
<InputText id="invoiceNumber" v-model="form.invoiceNumber"
:invalid="submitted && !form.invoiceNumber" />
<label for="invoiceNumber">Rechnungsnummer *</label>
</FloatLabel>
<FloatLabel>
<Dropdown id="status" v-model="form.status" :options="statusOptions"
option-label="label" option-value="value" />
<label for="status">Status</label>
</FloatLabel>
</div>
<div class="form-field-full">
<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="submitted && !form.contactId"
:loading="loadingContacts">
<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>
</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 *</label>
</FloatLabel>
</div>
<div class="form-grid">
<FloatLabel>
<DatePicker id="invoiceDate" v-model="form.invoiceDate"
date-format="dd.mm.yy"
:show-icon="true"
icon="pi pi-calendar" />
<label for="invoiceDate">Rechnungsdatum *</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 *</label>
</FloatLabel>
</div>
</div>
```
**Design-Rationale:**
- FloatLabel für moderneres Erscheinungsbild
- Status-Tag im Header für sofortige Sichtbarkeit
- Verbesserter Kunden-Dropdown mit Icons und Mehrfachanzeige
- Min-Date beim Fälligkeitsdatum verhindert ungültige Daten
#### Sektion 2: Rechnungspositionen
**Problem:** Aktuelle DataTable-Lösung ist zu komplex für Inline-Editing
**Lösung:** Card-basierte Positionen mit optimierter UX
```vue
<div class="form-section">
<div class="form-section-header">
<h3 class="form-section-title">
Rechnungspositionen
<span class="text-secondary text-sm">({{ form.items.length }})</span>
</h3>
<Button label="Position hinzufügen"
icon="pi pi-plus"
size="small"
@click="addItem" />
</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>
<Button label="Erste Position hinzufügen"
icon="pi pi-plus"
outlined
@click="addItem" />
</div>
<!-- Invoice Items -->
<TransitionGroup name="list" tag="div" class="invoice-items-list">
<div v-for="(item, index) in form.items"
:key="item.id || `new-${index}`"
class="invoice-item-card">
<!-- Item Header -->
<div class="invoice-item-header">
<span class="invoice-item-number">Position {{ index + 1 }}</span>
<div class="invoice-item-actions">
<Button icon="pi pi-trash"
text
rounded
severity="danger"
size="small"
@click="removeItem(index)"
v-tooltip.top="'Position entfernen'" />
</div>
</div>
<!-- Description -->
<div class="form-field-full mb-3">
<FloatLabel>
<Textarea id="`description-${index}`"
v-model="item.description"
rows="2"
:invalid="submitted && !item.description"
@input="calculateItemTotal(index)" />
<label :for="`description-${index}`">Beschreibung *</label>
</FloatLabel>
</div>
<!-- Quantity, Price, Tax in responsive grid -->
<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"
@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">{{ formatCurrency(item.subtotal) }}</span>
</div>
</div>
</TransitionGroup>
<!-- Add Button (bottom) -->
<Button label="Weitere Position hinzufügen"
icon="pi pi-plus"
outlined
class="w-full mt-3"
@click="addItem" />
<!-- Summary Box -->
<div 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 />
<div class="invoice-summary-total">
<span>Gesamtbetrag:</span>
<span>{{ formatCurrency(totals.gross) }}</span>
</div>
</div>
</div>
</div>
```
**CSS für Transitions:**
```css
/* List 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;
}
/* 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 {
color: var(--text-secondary);
margin-bottom: 1.5rem;
}
/* Invoice Item Fields */
.invoice-item-fields {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 1rem;
align-items: start;
}
.invoice-item-field--small {
max-width: 120px;
}
@media (max-width: 768px) {
.invoice-item-fields {
grid-template-columns: 1fr;
}
.invoice-item-field--small {
max-width: 100%;
}
}
```
#### Sektion 3: Notizen
**Kollapsierbar für bessere Übersicht:**
```vue
<div class="form-section">
<Panel header="Zusätzliche Informationen"
toggleable
:collapsed="!form.notes">
<FloatLabel>
<Textarea id="notes"
v-model="form.notes"
rows="4"
placeholder="z.B. Zahlungsbedingungen, Lieferbedingungen..." />
<label for="notes">Notizen</label>
</FloatLabel>
</Panel>
</div>
```
### 4.2 PaymentForm - Zahlungserfassung
**Aktuelles Problem:** Zu minimalistisch, fehlt Kontext
**Verbesserungen:**
```vue
<div class="payment-form">
<!-- Context Card -->
<div class="context-card">
<div class="context-card-header">
<h4>Rechnungsdetails</h4>
</div>
<div class="context-card-body">
<div class="context-row">
<span class="context-label">Rechnung:</span>
<span class="context-value font-semibold">{{ invoice?.invoiceNumber }}</span>
</div>
<div class="context-row">
<span class="context-label">Kunde:</span>
<span class="context-value">{{ invoice?.contact?.companyName }}</span>
</div>
<div class="context-row">
<span class="context-label">Rechnungsbetrag:</span>
<span class="context-value">{{ formatCurrency(invoice?.total) }}</span>
</div>
<div class="context-row">
<span class="context-label">Bereits gezahlt:</span>
<span class="context-value text-success">
{{ formatCurrency((invoice?.total || 0) - (invoice?.openAmount || 0)) }}
</span>
</div>
<Divider />
<div class="context-row context-row--highlight">
<span class="context-label font-semibold">Offener Betrag:</span>
<span class="context-value font-bold text-xl text-primary">
{{ formatCurrency(invoice?.openAmount) }}
</span>
</div>
</div>
</div>
<!-- Payment Form -->
<div class="form-section mt-4">
<div class="form-grid">
<FloatLabel>
<DatePicker id="paymentDate"
v-model="form.paymentDate"
date-format="dd.mm.yy"
:show-icon="true"
icon="pi pi-calendar"
:max-date="new Date()" />
<label for="paymentDate">Zahlungsdatum *</label>
</FloatLabel>
<FloatLabel>
<InputNumber id="amount"
v-model="form.amount"
mode="currency"
currency="EUR"
locale="de-DE"
:max="parseFloat(invoice?.openAmount || 0)"
:invalid="form.amount > parseFloat(invoice?.openAmount || 0)" />
<label for="amount">Betrag *</label>
</FloatLabel>
</div>
<div class="form-field-full mt-3">
<FloatLabel>
<Select id="paymentMethod"
v-model="form.paymentMethod"
:options="paymentMethods"
option-label="label"
option-value="value">
<template #value="slotProps">
<div class="flex align-items-center gap-2">
<i :class="getPaymentMethodIcon(slotProps.value)"></i>
<span>{{ getPaymentMethodLabel(slotProps.value) }}</span>
</div>
</template>
<template #option="slotProps">
<div class="flex align-items-center gap-2">
<i :class="getPaymentMethodIcon(slotProps.option.value)"></i>
<span>{{ slotProps.option.label }}</span>
</div>
</template>
</Select>
<label for="paymentMethod">Zahlungsart</label>
</FloatLabel>
</div>
<div class="form-field-full mt-3">
<FloatLabel>
<Textarea id="notes"
v-model="form.notes"
rows="3"
placeholder="Optionale Notizen zur Zahlung..." />
<label for="notes">Notizen</label>
</FloatLabel>
</div>
</div>
<!-- Quick Amount Buttons -->
<div class="quick-amounts mt-3">
<span class="text-sm text-secondary mr-2">Schnellauswahl:</span>
<Button :label="formatCurrency(invoice?.openAmount)"
size="small"
outlined
@click="form.amount = parseFloat(invoice?.openAmount || 0)" />
<Button :label="formatCurrency(invoice?.openAmount / 2)"
size="small"
outlined
@click="form.amount = parseFloat(invoice?.openAmount || 0) / 2" />
</div>
<!-- Action Buttons -->
<div class="flex gap-2 mt-4 justify-content-end">
<Button label="Abbrechen"
icon="pi pi-times"
severity="secondary"
text
@click="cancel" />
<Button label="Zahlung erfassen"
icon="pi pi-check"
:loading="saving"
:disabled="!isValid"
@click="save" />
</div>
</div>
```
**Styling:**
```css
.context-card {
background: linear-gradient(135deg, var(--primary-50) 0%, var(--primary-100) 100%);
border: 1px solid var(--primary-200);
border-radius: 0.75rem;
padding: 1.5rem;
}
.context-row {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
}
.context-row--highlight {
padding: 1rem 0 0;
margin-top: 0.5rem;
}
.context-label {
color: var(--text-secondary);
}
.context-value {
text-align: right;
}
.quick-amounts {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
```
### 4.3 PDFUploadForm - Verbesserungen
**Problem:** Zu primitiv, fehlt Drag & Drop
**Lösung:**
```vue
<div class="pdf-upload-form">
<Message v-if="!invoice?.pdfPath" severity="info">
Laden Sie die PDF-Rechnung hoch für eine vollständige Dokumentation
</Message>
<!-- Upload Area -->
<div class="upload-area"
:class="{ 'upload-area--dragover': isDragging }"
@drop.prevent="handleDrop"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false">
<div v-if="!selectedFile" class="upload-placeholder">
<i class="pi pi-cloud-upload upload-icon"></i>
<h4>PDF-Datei hochladen</h4>
<p class="text-secondary">
Datei hierher ziehen oder klicken zum Auswählen
</p>
<Button label="Datei auswählen"
icon="pi pi-folder-open"
outlined
@click="$refs.fileInput.click()" />
<p class="upload-hint">Nur PDF-Dateien, max. 10 MB</p>
</div>
<div v-else class="upload-preview">
<div class="file-info">
<i class="pi pi-file-pdf file-icon"></i>
<div class="file-details">
<p class="file-name">{{ selectedFile.name }}</p>
<p class="file-size">{{ formatFileSize(selectedFile.size) }}</p>
</div>
<Button icon="pi pi-times"
rounded
text
severity="danger"
@click="clearFile" />
</div>
<!-- Progress Bar during upload -->
<ProgressBar v-if="uploading"
:value="uploadProgress"
:show-value="true" />
</div>
<input ref="fileInput"
type="file"
accept="application/pdf"
style="display: none"
@change="handleFileChange" />
</div>
<!-- Existing PDF (if edit mode) -->
<div v-if="invoice?.pdfPath" class="existing-pdf mt-4">
<div class="existing-pdf-card">
<i class="pi pi-file-pdf text-danger text-4xl"></i>
<div class="flex-1">
<p class="font-semibold">Aktuelle PDF-Datei</p>
<p class="text-sm text-secondary">{{ invoice.pdfPath }}</p>
</div>
<Button label="Anzeigen"
icon="pi pi-external-link"
outlined
@click="viewExistingPDF" />
</div>
</div>
<!-- Actions -->
<div class="flex gap-2 mt-4 justify-content-end">
<Button label="Abbrechen"
icon="pi pi-times"
severity="secondary"
text
@click="cancel" />
<Button label="Hochladen"
icon="pi pi-upload"
:loading="uploading"
:disabled="!selectedFile"
@click="upload" />
</div>
</div>
```
**Styling:**
```css
.upload-area {
border: 2px dashed var(--surface-200);
border-radius: 0.75rem;
padding: 3rem 2rem;
text-align: center;
transition: all 0.3s ease;
cursor: pointer;
}
.upload-area:hover {
border-color: var(--primary-color);
background: var(--primary-50);
}
.upload-area--dragover {
border-color: var(--primary-color);
background: var(--primary-100);
border-width: 3px;
}
.upload-icon {
font-size: 4rem;
color: var(--primary-color);
margin-bottom: 1rem;
}
.upload-hint {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 1rem;
}
.file-info {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--surface-50);
border-radius: 0.5rem;
}
.file-icon {
font-size: 2.5rem;
color: var(--danger-color);
}
.file-details {
flex: 1;
text-align: left;
}
.file-name {
font-weight: 600;
margin-bottom: 0.25rem;
}
.file-size {
font-size: 0.875rem;
color: var(--text-secondary);
}
.existing-pdf-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.5rem;
background: var(--surface-50);
border: 1px solid var(--surface-200);
border-radius: 0.75rem;
}
```
---
## 5. Interaction Design
### 5.1 Validierung & Fehlerbehandlung
#### Echtzeit-Validierung
```javascript
// Composable: useFormValidation.js
import { ref, computed } from 'vue'
export function useFormValidation() {
const errors = ref({})
const touched = ref({})
const validateField = (fieldName, value, rules) => {
const fieldErrors = []
if (rules.required && !value) {
fieldErrors.push('Dieses Feld ist erforderlich')
}
if (rules.minLength && value?.length < rules.minLength) {
fieldErrors.push(`Mindestens ${rules.minLength} Zeichen erforderlich`)
}
if (rules.pattern && !rules.pattern.test(value)) {
fieldErrors.push('Ungültiges Format')
}
if (rules.custom) {
const customError = rules.custom(value)
if (customError) fieldErrors.push(customError)
}
errors.value[fieldName] = fieldErrors
return fieldErrors.length === 0
}
const touchField = (fieldName) => {
touched.value[fieldName] = true
}
const hasError = (fieldName) => {
return touched.value[fieldName] && errors.value[fieldName]?.length > 0
}
const getError = (fieldName) => {
return hasError(fieldName) ? errors.value[fieldName][0] : null
}
return {
errors,
touched,
validateField,
touchField,
hasError,
getError
}
}
```
**Verwendung im Template:**
```vue
<FloatLabel>
<InputText id="invoiceNumber"
v-model="form.invoiceNumber"
:invalid="hasError('invoiceNumber')"
@blur="touchField('invoiceNumber')"
@input="validateField('invoiceNumber', form.invoiceNumber, {
required: true,
minLength: 3,
pattern: /^[A-Z0-9-]+$/
})" />
<label for="invoiceNumber">Rechnungsnummer *</label>
</FloatLabel>
<small v-if="hasError('invoiceNumber')" class="p-error">
{{ getError('invoiceNumber') }}
</small>
```
#### Toast-Benachrichtigungen Strategie
```javascript
// Toast Guidelines
const toastConfig = {
success: {
severity: 'success',
life: 3000,
closable: true
},
error: {
severity: 'error',
life: 5000,
closable: true
},
warning: {
severity: 'warn',
life: 4000,
closable: true
},
info: {
severity: 'info',
life: 4000,
closable: true
}
}
// Verwendung
toast.add({
...toastConfig.success,
summary: 'Rechnung erstellt',
detail: `Rechnung ${invoice.invoiceNumber} wurde erfolgreich erstellt`
})
```
### 5.2 Loading States
**Skeleton Screens statt Spinner:**
```vue
<template>
<!-- Während Kontakte laden -->
<div v-if="loadingContacts" class="contact-skeleton">
<Skeleton height="3rem" class="mb-2"></Skeleton>
</div>
<Select v-else ...>
<!-- Während Form lädt -->
<div v-if="loadingInvoice" class="form-skeleton">
<Skeleton height="2.5rem" class="mb-3"></Skeleton>
<Skeleton height="2.5rem" class="mb-3"></Skeleton>
<Skeleton height="10rem"></Skeleton>
</div>
</template>
```
**Button Loading States:**
```vue
<Button label="Speichern"
:loading="saving"
:disabled="!isFormValid || saving">
<template #loadingicon>
<i class="pi pi-spin pi-spinner"></i>
</template>
</Button>
```
### 5.3 Keyboard Shortcuts
```javascript
// useKeyboardShortcuts.js composable
import { onMounted, onUnmounted } from 'vue'
export function useKeyboardShortcuts(shortcuts) {
const handleKeydown = (event) => {
const key = event.key.toLowerCase()
const ctrl = event.ctrlKey || event.metaKey
const shift = event.shiftKey
const shortcutKey = [
ctrl && 'ctrl',
shift && 'shift',
key
].filter(Boolean).join('+')
const action = shortcuts[shortcutKey]
if (action) {
event.preventDefault()
action()
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
}
// Verwendung im InvoiceForm
useKeyboardShortcuts({
'ctrl+s': save,
'ctrl+shift+p': addItem,
'escape': cancel
})
```
**Keyboard Hints in UI:**
```vue
<Button label="Speichern" icon="pi pi-check">
<template #default>
Speichern <kbd class="ml-2">Ctrl+S</kbd>
</template>
</Button>
```
---
## 6. Accessibility (WCAG 2.1 AA/AAA)
### 6.1 ARIA Labels & Roles
```vue
<div class="invoice-form" role="form" aria-label="Rechnungsformular">
<!-- Section with proper heading hierarchy -->
<section aria-labelledby="invoice-info-heading">
<h3 id="invoice-info-heading">Rechnungsinformationen</h3>
...
</section>
<!-- Required field indication -->
<label for="invoiceNumber">
Rechnungsnummer
<abbr title="Pflichtfeld" aria-label="Pflichtfeld">*</abbr>
</label>
<InputText id="invoiceNumber"
aria-required="true"
aria-invalid="submitted && !form.invoiceNumber"
aria-describedby="invoiceNumber-error" />
<small id="invoiceNumber-error"
class="p-error"
role="alert"
v-if="submitted && !form.invoiceNumber">
Rechnungsnummer ist erforderlich
</small>
<!-- Invoice items list -->
<div role="list" aria-label="Rechnungspositionen">
<div v-for="(item, index) in form.items"
:key="index"
role="listitem"
:aria-label="`Position ${index + 1}`">
...
</div>
</div>
<!-- Action buttons -->
<div role="group" aria-label="Formularaktionen">
<Button label="Abbrechen"
aria-label="Formular abbrechen" />
<Button label="Speichern"
aria-label="Rechnung speichern" />
</div>
</div>
```
### 6.2 Farbkontrast
**Mindestanforderungen:**
- Normaler Text (< 18pt): 4.5:1 (AA), 7:1 (AAA)
- Großer Text (≥ 18pt): 3:1 (AA), 4.5:1 (AAA)
- UI-Komponenten: 3:1
**Farb-Validierung:**
```css
/* Ensure sufficient contrast */
.p-error {
color: #DC2626; /* Contrast ratio 4.5:1 on white */
}
.text-secondary {
color: #6B7280; /* Contrast ratio 4.6:1 on white */
}
/* Never rely on color alone */
.required-field::after {
content: ' *';
color: var(--danger-color);
font-weight: bold;
}
.invoice-status--overdue {
color: var(--danger-color);
font-weight: 600;
text-decoration: underline; /* Visual indicator beyond color */
}
```
### 6.3 Focus Management
```css
/* Custom focus styles */
*:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
border-radius: 4px;
}
/* Skip to main content link */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--primary-color);
color: white;
padding: 8px;
text-decoration: none;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
```
**Focus Trap in Dialog:**
```javascript
// useFocusTrap.js
import { onMounted, onUnmounted } from 'vue'
export function useFocusTrap(containerRef) {
const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
const trapFocus = (e) => {
if (e.key !== 'Tab' || !containerRef.value) return
const focusable = containerRef.value.querySelectorAll(focusableElements)
const firstFocusable = focusable[0]
const lastFocusable = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
lastFocusable.focus()
e.preventDefault()
}
} else {
if (document.activeElement === lastFocusable) {
firstFocusable.focus()
e.preventDefault()
}
}
}
onMounted(() => {
document.addEventListener('keydown', trapFocus)
// Focus first element
const firstFocusable = containerRef.value?.querySelector(focusableElements)
firstFocusable?.focus()
})
onUnmounted(() => {
document.removeEventListener('keydown', trapFocus)
})
}
```
### 6.4 Screen Reader Support
```vue
<!-- Live region for dynamic updates -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
<span v-if="saving">Rechnung wird gespeichert...</span>
<span v-if="saved">Rechnung wurde gespeichert</span>
</div>
<!-- Descriptive button labels -->
<Button icon="pi pi-trash"
aria-label="`Position ${index + 1} entfernen`"
@click="removeItem(index)" />
<!-- Progress indication -->
<ProgressBar :value="uploadProgress"
role="progressbar"
:aria-valuenow="uploadProgress"
aria-valuemin="0"
aria-valuemax="100"
aria-label="Upload-Fortschritt" />
<!-- Screen reader only class -->
<style>
.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;
}
</style>
```
---
## 7. Performance Optimierungen
### 7.1 Lazy Loading
```javascript
// Lazy load forms in InvoiceManagement.vue
const InvoiceForm = defineAsyncComponent(() =>
import('./InvoiceForm.vue')
)
const PaymentForm = defineAsyncComponent(() =>
import('./PaymentForm.vue')
)
const PDFUploadForm = defineAsyncComponent(() =>
import('./PDFUploadForm.vue')
)
```
### 7.2 Debouncing
```javascript
// Debounce expensive calculations
import { debounce } from 'lodash-es'
const calculateTotals = debounce(() => {
// Berechnung der Gesamtsummen
totals.value = {
net: 0,
taxBreakdown: {},
gross: 0
}
form.value.items.forEach(item => {
const net = item.quantity * item.unitPrice
const tax = net * (item.taxRate / 100)
totals.value.net += net
totals.value.taxBreakdown[item.taxRate] =
(totals.value.taxBreakdown[item.taxRate] || 0) + tax
totals.value.gross += net + tax
})
}, 300)
```
### 7.3 Virtual Scrolling (für lange Listen)
```vue
<!-- Wenn viele Kontakte -->
<VirtualScroller :items="contacts"
:item-size="50"
class="contact-list">
<template v-slot:item="{ item }">
<div class="contact-option">
{{ item.companyName }}
</div>
</template>
</VirtualScroller>
```
---
## 8. Testing Strategy
### 8.1 Unit Tests
```javascript
// InvoiceForm.spec.js
import { mount } from '@vue/test-utils'
import InvoiceForm from './InvoiceForm.vue'
import PrimeVue from 'primevue/config'
describe('InvoiceForm', () => {
it('validates required fields', async () => {
const wrapper = mount(InvoiceForm, {
global: {
plugins: [PrimeVue]
}
})
await wrapper.find('button[type="submit"]').trigger('click')
expect(wrapper.text()).toContain('Rechnungsnummer ist erforderlich')
expect(wrapper.text()).toContain('Bitte wählen Sie einen Kunden aus')
})
it('calculates totals correctly', async () => {
const wrapper = mount(InvoiceForm)
await wrapper.vm.addItem()
wrapper.vm.form.items[0] = {
quantity: 2,
unitPrice: 100,
taxRate: 19
}
await wrapper.vm.$nextTick()
expect(wrapper.vm.totals.net).toBe(200)
expect(wrapper.vm.totals.gross).toBe(238)
})
})
```
### 8.2 E2E Tests (Cypress/Playwright)
```javascript
// invoice.spec.js
describe('Invoice Management', () => {
it('creates new invoice', () => {
cy.login('admin@mycrm.local', 'admin123')
cy.visit('/billing/invoices')
cy.contains('Neue Rechnung').click()
// Fill form
cy.get('#invoiceNumber').type('INV-2025-001')
cy.get('#contact').click()
cy.contains('ACME Corp').click()
// Add item
cy.contains('Position hinzufügen').click()
cy.get('[id^="description-"]').first().type('Consulting Services')
cy.get('[id^="quantity-"]').first().clear().type('10')
cy.get('[id^="unitPrice-"]').first().clear().type('100')
// Verify total
cy.contains('Gesamtbetrag:').parent().should('contain', '1.190,00 €')
// Save
cy.contains('button', 'Speichern').click()
// Verify success
cy.contains('Rechnung wurde erstellt')
cy.contains('INV-2025-001')
})
})
```
---
## 9. Implementierungs-Roadmap
### Phase 1: Foundation (Woche 1-2)
- [ ] Styling-System aufsetzen (CSS Variables, Utilities)
- [ ] Formular-Layout refactoren (Grid System, Sections)
- [ ] FloatLabel für alle Inputs implementieren
- [ ] Validation Composable erstellen
### Phase 2: Invoice Items Redesign (Woche 3)
- [ ] Card-basierte Positions-Ansicht implementieren
- [ ] Echtzeit-Summenberechnung
- [ ] Transitions für Add/Remove
- [ ] Empty State Design
### Phase 3: Enhanced Forms (Woche 4)
- [ ] PaymentForm Context Card
- [ ] PDF Upload Drag & Drop
- [ ] Quick Amount Buttons
- [ ] Keyboard Shortcuts
### Phase 4: UX Polish (Woche 5)
- [ ] Loading States & Skeletons
- [ ] Micro-Interactions & Animations
- [ ] Toast-Benachrichtigungen harmonisieren
- [ ] Error Handling verbessern
### Phase 5: Accessibility (Woche 6)
- [ ] ARIA Labels hinzufügen
- [ ] Focus Management
- [ ] Keyboard Navigation
- [ ] Screen Reader Testing
### Phase 6: Testing & Optimization (Woche 7)
- [ ] Unit Tests schreiben
- [ ] E2E Tests implementieren
- [ ] Performance Profiling
- [ ] Code Splitting optimieren
---
## 10. Design-System Komponenten
### 10.1 Wiederverwendbare Komponenten
**FormSection.vue** - Wrapper für Formular-Sektionen
```vue
<template>
<div class="form-section" :class="{'form-section--collapsible': collapsible}">
<div v-if="title" class="form-section-header">
<h3 class="form-section-title">
<slot name="title">{{ title }}</slot>
</h3>
<div class="form-section-actions">
<slot name="actions"></slot>
</div>
</div>
<div class="form-section-body">
<slot></slot>
</div>
</div>
</template>
<script setup>
defineProps({
title: String,
collapsible: Boolean
})
</script>
```
**CurrencyDisplay.vue** - Formatierte Währungsanzeige
```vue
<template>
<span class="currency-display" :class="[`currency-display--${size}`, { 'currency-display--highlight': highlight }]">
{{ formatted }}
</span>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
amount: [Number, String],
currency: { type: String, default: 'EUR' },
locale: { type: String, default: 'de-DE' },
size: { type: String, default: 'normal' }, // small, normal, large
highlight: Boolean
})
const formatted = computed(() => {
const num = parseFloat(props.amount || 0)
return new Intl.NumberFormat(props.locale, {
style: 'currency',
currency: props.currency
}).format(num)
})
</script>
<style scoped>
.currency-display--small { font-size: 0.875rem; }
.currency-display--normal { font-size: 1rem; }
.currency-display--large { font-size: 1.25rem; font-weight: 600; }
.currency-display--highlight { color: var(--primary-color); }
</style>
```
**StatusBadge.vue** - Einheitliche Status-Anzeige
```vue
<template>
<Tag :value="label"
:severity="severity"
:icon="icon"
class="status-badge">
<template #default>
<i v-if="icon" :class="icon" class="mr-1"></i>
{{ label }}
</template>
</Tag>
</template>
<script setup>
import { computed } from 'vue'
import Tag from 'primevue/tag'
const props = defineProps({
status: String,
type: { type: String, default: 'invoice' } // invoice, payment, etc.
})
const statusConfig = {
invoice: {
draft: { label: 'Entwurf', severity: 'secondary', icon: 'pi pi-file-edit' },
open: { label: 'Offen', severity: 'info', icon: 'pi pi-clock' },
paid: { label: 'Bezahlt', severity: 'success', icon: 'pi pi-check-circle' },
partial: { label: 'Teilweise bezahlt', severity: 'warning', icon: 'pi pi-exclamation-triangle' },
overdue: { label: 'Überfällig', severity: 'danger', icon: 'pi pi-times-circle' },
cancelled: { label: 'Storniert', severity: 'secondary', icon: 'pi pi-ban' }
}
}
const config = computed(() => statusConfig[props.type]?.[props.status] || {})
const label = computed(() => config.value.label || props.status)
const severity = computed(() => config.value.severity || 'info')
const icon = computed(() => config.value.icon)
</script>
```
---
## 11. Anhang
### 11.1 Checkliste für Entwickler
**Vor der Implementierung:**
- [ ] Design-System Variablen verstanden
- [ ] PrimeVue Komponenten-Dokumentation gelesen
- [ ] Accessibility Guidelines durchgegangen
- [ ] Responsive Breakpoints definiert
**Während der Implementierung:**
- [ ] Semantisches HTML verwenden
- [ ] ARIA-Attribute hinzufügen
- [ ] Keyboard-Navigation testen
- [ ] Validierung implementieren
- [ ] Loading States einbauen
- [ ] Error Handling abdecken
**Nach der Implementierung:**
- [ ] Mit Screen Reader testen
- [ ] Auf verschiedenen Bildschirmgrößen testen
- [ ] Performance messen
- [ ] Unit Tests schreiben
- [ ] Code Review durchführen
### 11.2 Browser-Kompatibilität
**Mindestanforderungen:**
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
- Mobile Safari 14+
- Chrome Android 90+
**Polyfills für:**
- Intl.NumberFormat (bereits vorhanden)
- CSS Custom Properties (bereits unterstützt)
### 11.3 Design-Assets
**Icons:** PrimeIcons (pi-*)
**Fonts:** System Font Stack
```css
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
```
**Illustrations:** Optional für Empty States (z.B. unDraw)
---
## 12. Zusammenfassung & Nächste Schritte
### Key Takeaways
1. **Visuelle Hierarchie** durch klare Sektion-Trennung und konsistentes Spacing
2. **Verbesserte Formular-UX** mit FloatLabels, Echtzeit-Validierung und besseren Fehler-Messages
3. **Card-basierte Rechnungspositionen** statt komplexer Inline-DataTable
4. **Echtzeit-Summenberechnung** für sofortiges Feedback
5. **Accessibility-First** Ansatz mit ARIA-Labels und Keyboard-Support
6. **Responsive Design** für alle Geräte optimiert
### Quick Wins (sofort umsetzbar)
1. FloatLabel für alle Formular-Felder
2. Status-Tag in Formular-Header
3. Verbesserte Kunden-Dropdown mit Icons
4. Echtzeit-Summenberechnung
5. Quick Amount Buttons im PaymentForm
### Langfristige Verbesserungen
1. Komplett neues Positions-Management mit Cards
2. Drag & Drop PDF Upload
3. Umfassendes Keyboard-Shortcut System
4. Vollständige WCAG AAA Konformität
5. Progressive Web App Features
---
**Dokument-Version:** 1.0
**Letztes Update:** 2025-12-12
**Autor:** Claude (UX Designer Agent)
**Projekt:** myCRM Billing Module