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