- 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.
50 KiB
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 DataTableInvoiceForm.vue- Rechnungserstellung/-bearbeitung (Hauptformular)PaymentForm.vue- ZahlungserfassungPDFUploadForm.vue- PDF-Upload (MVP-Platzhalter)
1.2 Identifizierte UX-Probleme
Schwerwiegende Probleme (P0)
-
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
-
Unklare Validierungs-Feedback
- Validierung nur beim Submit sichtbar
- Keine Echtzeit-Validierung während Eingabe
- Fehler-Messages zu klein und leicht zu übersehen
-
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
-
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)
-
Fehlende Benutzerführung
- Keine Hilfe-Tooltips für komplexe Felder
- Fehlende Feldkontexte (z.B. Steuerberechnung)
- Keine Keyboard-Shortcuts
-
Unvollständige Status-Kommunikation
- Loading-States nur beim Speichern
- Keine Progress-Indication beim Laden von Kontakten
- Fehlende Empty-States
-
Schwache Datenvisualisierung
- Keine Gesamtsummen-Anzeige
- Fehlende Berechnungsübersicht (Netto, Steuer, Brutto)
- Kein Currency-Formatting in Tabelle
Niedrige Priorität (P2)
-
Fehlende Micro-Interactions
- Keine Animationen beim Hinzufügen/Entfernen von Positionen
- Fehlende Hover-States auf interaktiven Elementen
- Keine visuellen Bestätigungen
-
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
- Progressive Disclosure - Zeige nur relevante Informationen zum richtigen Zeitpunkt
- Visual Hierarchy - Klare Strukturierung durch Größe, Farbe, Spacing
- Immediate Feedback - Sofortige Rückmeldung auf Benutzeraktionen
- Error Prevention - Validierung bevor Fehler entstehen
- 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
/* 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
/* 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)
/* 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:
.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:
.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:
.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
/* 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:
<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
<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:
/* 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:
<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:
<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:
.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:
<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:
.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
// 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:
<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
// 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:
<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:
<Button label="Speichern"
:loading="saving"
:disabled="!isFormValid || saving">
<template #loadingicon>
<i class="pi pi-spin pi-spinner"></i>
</template>
</Button>
5.3 Keyboard Shortcuts
// 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:
<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
<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:
/* 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
/* 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:
// 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
<!-- 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
// 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
// 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)
<!-- 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
// 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)
// 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
<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
<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
<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
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
- Visuelle Hierarchie durch klare Sektion-Trennung und konsistentes Spacing
- Verbesserte Formular-UX mit FloatLabels, Echtzeit-Validierung und besseren Fehler-Messages
- Card-basierte Rechnungspositionen statt komplexer Inline-DataTable
- Echtzeit-Summenberechnung für sofortiges Feedback
- Accessibility-First Ansatz mit ARIA-Labels und Keyboard-Support
- Responsive Design für alle Geräte optimiert
Quick Wins (sofort umsetzbar)
- FloatLabel für alle Formular-Felder
- Status-Tag in Formular-Header
- Verbesserte Kunden-Dropdown mit Icons
- Echtzeit-Summenberechnung
- Quick Amount Buttons im PaymentForm
Langfristige Verbesserungen
- Komplett neues Positions-Management mit Cards
- Drag & Drop PDF Upload
- Umfassendes Keyboard-Shortcut System
- Vollständige WCAG AAA Konformität
- Progressive Web App Features
Dokument-Version: 1.0 Letztes Update: 2025-12-12 Autor: Claude (UX Designer Agent) Projekt: myCRM Billing Module