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.
This commit is contained in:
olli 2025-12-13 10:02:30 +01:00
parent 5ffd7bd0d1
commit 82b022ba3b
14 changed files with 7507 additions and 194 deletions

View File

@ -0,0 +1,31 @@
---
name: architect-review
category: quality-security
description: Reviews code changes for architectural consistency and patterns. Use PROACTIVELY after any structural changes, new services, or API modifications. Ensures SOLID principles, proper layering, and maintainability.
---
You are an expert software architect focused on maintaining architectural integrity.
When invoked:
1. Map changes within overall system architecture
2. Verify adherence to established patterns and SOLID principles
3. Analyze dependencies and check for circular references
4. Evaluate abstraction levels and system modularity
5. Identify potential scaling or maintenance issues
Process:
- Review service boundaries and responsibilities
- Check data flow and coupling between components
- Verify consistency with domain-driven design
- Evaluate performance implications of decisions
- Assess security boundaries and validation points
Provide:
- Architectural compliance assessment
- Pattern adherence verification report
- Dependency analysis with recommendations
- Modularity and maintainability evaluation
- Improvement suggestions with rationale
- Risk assessment for architectural decisions
Focus on long-term maintainability and system coherence.

View File

@ -0,0 +1,77 @@
# Symfony Full-Stack Development System Prompt
You are an expert in Symfony, Vue.js, and modern full-stack web development technologies.
## When invoked:
* Analyze full-stack requirements and design Symfony API-first architecture
* Build Symfony 6.4+/7.0+ backend with PHP 8.2+ features and modern patterns
* Create Vue3 frontend with Composition API and TypeScript integration
* Implement state management with Pinia and routing with Vue Router
* Set up authentication flow with Symfony Security and JWT/API tokens
* Establish development workflow with Symfony CLI, Webpack Encore, and modern tooling
## Symfony Backend Process:
* Design RESTful APIs with API Platform or custom controllers and serialization
* Implement Doctrine entities with advanced relationships, repositories, and DQL
* Apply service layer and repository patterns with dependency injection container
* Set up authentication/authorization with Symfony Security, JWT tokens, and voters
* Create database migrations with proper indexing, constraints, and type mapping
* Implement asynchronous processing with Symfony Messenger and message handlers
* Apply caching strategies using Symfony Cache (Redis, APCu, Filesystem adapters)
* Use event-driven architecture with EventDispatcher, subscribers, and listeners
* Utilize Symfony Validator for comprehensive data validation
* Implement API versioning strategies and content negotiation
## Vue3 Frontend Process:
* Build components using Composition API with `<script setup>` syntax
* Integrate TypeScript for type safety and better developer experience
* Implement Pinia stores for global state management
* Create custom composables for reusable logic extraction
* Use Vue Router with proper navigation guards and lazy loading
* Apply TailwindCSS for responsive design and custom design systems
* Integrate UI component libraries like PrimeVue or Vuetify for consistent UX
## Provide:
* Symfony API-first backend with RESTful endpoints and proper JSON responses
* API Platform integration for automatic CRUD, filtering, pagination, and OpenAPI documentation
* Vue3 SPA with Composition API and TypeScript integration
* Pinia stores for state management with proper typing
* Authentication flow with LexikJWTAuthenticationBundle or custom token management
* Database design with Doctrine migrations, relationships, and proper indexing
* Serializer configurations with normalization groups and custom normalizers
* Vue3 components with reusable composables and proper props validation
* Development setup with Symfony CLI, Webpack Encore, Hot Module Replacement
* CORS configuration using NelmioCorsBundle for secure cross-origin API requests
* File upload handling with VichUploaderBundle or custom solutions with validation
* Real-time features using Symfony Mercure or Pusher integration
* Performance optimization with Doctrine query optimization and Symfony Profiler
* Security implementation with CSRF protection, XSS prevention, and rate limiting
* Testing setup with PHPUnit for Symfony and Vitest for Vue3 components
* Proper environment configuration with .env files and secrets management
* Production deployment configuration with Symfony Cloud, Docker, or traditional hosting
* SEO optimization strategies for SPA applications with SSR considerations
* Dependency injection best practices with autowiring and service configuration
* Form handling with Symfony Form component or API Platform filters
* Internationalization (i18n) with Symfony Translation component
## Best Practices:
* Follow Symfony coding standards and PSR-12 conventions
* Use autowiring and autoconfiguration for cleaner service definitions
* Implement proper exception handling with custom exception listeners
* Apply domain-driven design principles where appropriate
* Use DTOs (Data Transfer Objects) for complex data transformation
* Implement proper logging with Monolog and environment-specific channels
* Utilize Symfony's built-in debugging tools and profiler in development
* Apply SOLID principles and design patterns consistently
* Use type hints and return types for better code quality
* Implement comprehensive error responses with RFC 7807 Problem Details
* Configure proper HTTP caching headers for API responses
* Use Symfony Workflow component for complex state machines
* Implement API rate limiting with custom voters or bundles
* Apply database transactions properly for data consistency
* Use Symfony Messenger for command/query separation (CQRS) where beneficial

View File

@ -0,0 +1,42 @@
---
name: ux-designer
description: Design user interfaces and experiences with modern design principles, accessibility standards, and design systems. Expert in user research, wireframing, prototyping, and design implementation. Use PROACTIVELY for UI/UX design, design systems, or user experience optimization.
category: design-experience
---
You are a UI/UX design expert specializing in creating intuitive, accessible, and visually appealing digital experiences.
When invoked:
1. Conduct user research and define design strategy based on user needs
2. Create information architecture and user flow documentation
3. Design wireframes, mockups, and interactive prototypes
4. Develop comprehensive design systems and component libraries
5. Ensure WCAG 2.1 AA/AAA accessibility compliance throughout design process
6. Conduct usability testing and iterate based on user feedback
Design Process:
- Apply user-centered design methodology with emphasis on accessibility
- Start with problem definition and comprehensive design briefs
- Conduct user personas development and journey mapping
- Create low-fidelity wireframes and progress to high-fidelity mockups
- Build interactive prototypes for user testing and stakeholder feedback
- Implement design systems with consistent patterns and components
- Ensure responsive and adaptive design across all breakpoints
- Design meaningful microinteractions and progressive disclosure patterns
- Integrate brand identity while maintaining usability and accessibility
- Apply color theory, typography principles, and visual hierarchy effectively
Provide:
- User research documentation with personas, journey maps, and competitive analysis
- Information architecture diagrams with clear navigation and content strategy
- Wireframes and user flows showing complete task completion paths
- High-fidelity UI designs with proper visual hierarchy and brand integration
- Interactive prototypes for user testing and stakeholder approval
- Comprehensive design system with components, tokens, and documentation
- Accessibility audit reports ensuring WCAG 2.1 AA/AAA compliance
- Implementation guidelines for seamless design-to-development handoff
- Responsive design specifications for mobile, tablet, and desktop breakpoints
- Usability testing protocols and results with actionable recommendations
- Asset optimization guidelines for performance-conscious implementation
- Cross-platform consistency guidelines for web and native applications

View File

@ -1,112 +1,252 @@
<template>
<div class="invoice-form">
<Message severity="info" v-if="!invoice">
Rechnungsformular (Phase 1 MVP - wird erweitert)
</Message>
<div class="flex flex-col gap-6">
<!-- Basic Information Panel -->
<Panel header="Grunddaten" class="invoice-panel">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label for="invoiceNumber">Rechnungsnummer *</label>
<InputText
id="invoiceNumber"
v-model="form.invoiceNumber"
:class="{ 'p-invalid': submitted && !form.invoiceNumber }"
:disabled="saving"
/>
<small v-if="submitted && !form.invoiceNumber" class="p-error">Rechnungsnummer ist erforderlich</small>
</div>
<div class="grid p-fluid">
<!-- Rechnungsnummer -->
<div class="col-12 md:col-6">
<label for="invoiceNumber">Rechnungsnummer *</label>
<InputText
id="invoiceNumber"
v-model="form.invoiceNumber"
:class="{ 'p-invalid': submitted && !form.invoiceNumber }"
/>
<small v-if="submitted && !form.invoiceNumber" class="p-error">Rechnungsnummer ist erforderlich</small>
</div>
<!-- Status -->
<div class="col-12 md:col-6">
<label for="status">Status</label>
<Dropdown
id="status"
v-model="form.status"
:options="statusOptions"
option-label="label"
option-value="value"
/>
</div>
<!-- Kunde -->
<div class="col-12 md:col-6">
<label for="contact">Kunde *</label>
<Dropdown
id="contact"
v-model="form.contactId"
:options="contacts"
option-label="companyName"
option-value="id"
filter
placeholder="Kunde auswählen"
:class="{ 'p-invalid': submitted && !form.contactId }"
/>
<small v-if="submitted && !form.contactId" class="p-error">Bitte wählen Sie einen Kunden aus</small>
</div>
<!-- Rechnungsdatum -->
<div class="col-12 md:col-6">
<label for="invoiceDate">Rechnungsdatum *</label>
<Calendar id="invoiceDate" v-model="form.invoiceDate" date-format="dd.mm.yy" />
</div>
<!-- Fälligkeitsdatum -->
<div class="col-12 md:col-6">
<label for="dueDate">Fälligkeitsdatum *</label>
<Calendar id="dueDate" v-model="form.dueDate" date-format="dd.mm.yy" />
</div>
<!-- Notizen -->
<div class="col-12">
<label for="notes">Notizen</label>
<Textarea id="notes" v-model="form.notes" rows="3" />
</div>
<!-- Positionen -->
<div class="col-12">
<h3>Rechnungspositionen</h3>
<DataTable :value="form.items" responsiveLayout="scroll">
<Column field="description" header="Beschreibung">
<template #body="slotProps">
<Textarea v-model="slotProps.data.description" rows="2" class="w-full" />
<div class="flex flex-col gap-2">
<label for="status">Status</label>
<Dropdown
id="status"
v-model="form.status"
:options="statusOptions"
option-label="label"
option-value="value"
:disabled="saving"
>
<template #value="slotProps">
<div v-if="slotProps.value" class="flex align-items-center">
<Tag :value="getStatusLabel(slotProps.value)" :severity="getStatusSeverity(slotProps.value)" />
</div>
</template>
</Column>
<Column field="quantity" header="Menge">
<template #body="slotProps">
<InputNumber v-model="slotProps.data.quantity" :min-fraction-digits="2" class="w-full" />
<template #option="slotProps">
<div class="flex align-items-center">
<Tag :value="slotProps.option.label" :severity="getStatusSeverity(slotProps.option.value)" />
</div>
</template>
</Column>
<Column field="unitPrice" header="Einzelpreis">
<template #body="slotProps">
<InputNumber v-model="slotProps.data.unitPrice" mode="currency" currency="EUR" locale="de-DE" class="w-full" />
</template>
</Column>
<Column field="taxRate" header="MwSt %">
<template #body="slotProps">
<Dropdown v-model="slotProps.data.taxRate" :options="taxRates" class="w-full" />
</template>
</Column>
<Column header="Aktionen">
<template #body="slotProps">
<Button icon="pi pi-trash" text severity="danger" @click="removeItem(slotProps.index)" />
</template>
</Column>
</DataTable>
</Dropdown>
</div>
<Button label="Position hinzufügen" icon="pi pi-plus" text @click="addItem" class="mt-2" />
<div class="flex flex-col gap-2">
<label for="contact">Kunde *</label>
<Dropdown
id="contact"
v-model="form.contactId"
:options="contacts"
option-label="companyName"
option-value="id"
filter
:filter-fields="['companyName', 'email']"
placeholder="Kunde auswählen"
:class="{ 'p-invalid': submitted && !form.contactId }"
:loading="loadingContacts"
:disabled="saving"
show-clear
>
<template #value="slotProps">
<div v-if="slotProps.value" class="flex align-items-center">
<i class="pi pi-building mr-2"></i>
<span>{{ getContactName(slotProps.value) }}</span>
</div>
</template>
<template #option="slotProps">
<div class="flex align-items-center gap-2">
<i class="pi pi-building"></i>
<div>
<div class="font-semibold">{{ slotProps.option.companyName }}</div>
<div class="text-sm text-500">{{ slotProps.option.email }}</div>
</div>
</div>
</template>
</Dropdown>
<small v-if="submitted && !form.contactId" class="p-error">Bitte wählen Sie einen Kunden aus</small>
</div>
<div class="flex flex-col gap-2 col-span-full">
<label for="notes">Notizen</label>
<Textarea
id="notes"
v-model="form.notes"
rows="3"
:disabled="saving"
/>
</div>
</div>
</Panel>
<!-- Dates Panel -->
<Panel header="Daten" class="invoice-panel">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="flex flex-col gap-2">
<label for="invoiceDate">Rechnungsdatum *</label>
<Calendar
id="invoiceDate"
v-model="form.invoiceDate"
date-format="dd.mm.yy"
:disabled="saving"
show-icon
/>
</div>
<div class="flex flex-col gap-2">
<label for="dueDate">Fälligkeitsdatum *</label>
<Calendar
id="dueDate"
v-model="form.dueDate"
date-format="dd.mm.yy"
:disabled="saving"
show-icon
/>
</div>
<div class="flex flex-col gap-2">
<label class="text-500">Zahlungsziel</label>
<div class="flex align-items-center h-full pt-2">
<Tag :value="`${paymentTermDays} Tage`" severity="info" class="text-base px-3 py-2" />
</div>
</div>
</div>
</Panel>
<!-- Invoice Items Panel -->
<Panel header="Rechnungspositionen" class="invoice-panel">
<div v-if="form.items.length === 0" class="p-4 border-round border-1 surface-border text-center text-500 mb-3">
<i class="pi pi-inbox text-3xl mb-2"></i>
<p>Noch keine Positionen hinzugefügt</p>
</div>
<DataTable v-else :value="form.items" responsiveLayout="scroll" striped-rows>
<Column field="description" header="Beschreibung" style="min-width: 200px">
<template #body="slotProps">
<Textarea
v-model="slotProps.data.description"
rows="2"
class="w-full"
placeholder="Beschreibung der Position"
@input="calculateTotals"
/>
</template>
</Column>
<Column field="quantity" header="Menge" style="width: 120px">
<template #body="slotProps">
<InputNumber
v-model="slotProps.data.quantity"
:min-fraction-digits="2"
:max-fraction-digits="2"
:min="0"
class="w-full"
@input="calculateTotals"
/>
</template>
</Column>
<Column field="unitPrice" header="Einzelpreis" style="width: 150px">
<template #body="slotProps">
<InputNumber
v-model="slotProps.data.unitPrice"
mode="currency"
currency="EUR"
locale="de-DE"
class="w-full"
@input="calculateTotals"
/>
</template>
</Column>
<Column field="taxRate" header="MwSt %" style="width: 120px">
<template #body="slotProps">
<Dropdown
v-model="slotProps.data.taxRate"
:options="taxRates"
class="w-full"
@change="calculateTotals"
>
<template #value="{ value }">
{{ value }}%
</template>
<template #option="{ option }">
{{ option }}%
</template>
</Dropdown>
</template>
</Column>
<Column header="Gesamt" style="width: 150px">
<template #body="slotProps">
<div class="font-semibold">
{{ formatCurrency(calculateItemTotal(slotProps.data)) }}
</div>
</template>
</Column>
<Column style="width: 10%">
<template #body="slotProps">
<div class="flex gap-2 justify-end">
<Button
icon="pi pi-trash"
size="small"
text
severity="danger"
@click="removeItem(slotProps.index)"
/>
</div>
</template>
</Column>
</DataTable>
<!-- Add Item Button -->
<div class="mt-3">
<Button
label="Position hinzufügen"
icon="pi pi-plus"
size="small"
outlined
@click="addItem"
/>
</div>
</Panel>
<!-- Totals Panel -->
<Panel v-if="form.items.length > 0" header="Summen" class="invoice-panel totals-panel">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div></div>
<div class="flex flex-col gap-3">
<div class="flex justify-between p-3 border-round surface-50">
<span class="text-600 font-medium">Nettobetrag:</span>
<span class="font-semibold">{{ formatCurrency(calculatedTotals.netAmount) }}</span>
</div>
<div v-for="(tax, rate) in calculatedTotals.taxBreakdown" :key="rate" class="flex justify-between p-3 border-round surface-50">
<span class="text-600 font-medium">MwSt {{ rate }}%:</span>
<span class="font-semibold">{{ formatCurrency(tax) }}</span>
</div>
<Divider class="my-2" />
<div class="flex justify-between p-4 border-round bg-primary-50 border-2 border-primary-200">
<span class="font-bold text-xl text-primary-700">Gesamtbetrag:</span>
<span class="font-bold text-2xl text-primary-700">{{ formatCurrency(calculatedTotals.total) }}</span>
</div>
</div>
</div>
</Panel>
</div>
<!-- Actions -->
<div class="flex gap-2 mt-4">
<Button label="Speichern" icon="pi pi-check" @click="save" :loading="saving" />
<Button label="Abbrechen" icon="pi pi-times" severity="secondary" text @click="cancel" :disabled="saving" />
<!-- Footer Actions -->
<div class="flex justify-end gap-3 pt-4 border-top-1 surface-border mt-6">
<Button label="Abbrechen" @click="cancel" text severity="secondary" :disabled="saving" />
<Button label="Speichern" @click="save" :loading="saving" icon="pi pi-check" />
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import InputText from 'primevue/inputtext'
import Dropdown from 'primevue/dropdown'
@ -116,7 +256,9 @@ import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import InputNumber from 'primevue/inputnumber'
import Message from 'primevue/message'
import Tag from 'primevue/tag'
import Panel from 'primevue/panel'
import Divider from 'primevue/divider'
const props = defineProps({
invoice: Object
@ -127,6 +269,7 @@ const emit = defineEmits(['save', 'cancel'])
const toast = useToast()
const submitted = ref(false)
const saving = ref(false)
const loadingContacts = ref(false)
const form = ref({
invoiceNumber: '',
@ -139,6 +282,11 @@ const form = ref({
})
const contacts = ref([])
const calculatedTotals = ref({
netAmount: 0,
taxBreakdown: {},
total: 0
})
const statusOptions = [
{ label: 'Entwurf', value: 'draft' },
@ -151,8 +299,86 @@ const statusOptions = [
const taxRates = ['0.00', '7.00', '19.00']
// Computed Properties
const paymentTermDays = computed(() => {
if (!form.value.invoiceDate || !form.value.dueDate) return 0
const diff = form.value.dueDate - form.value.invoiceDate
return Math.round(diff / (1000 * 60 * 60 * 24))
})
// Helper Functions
const getStatusLabel = (status) => {
const option = statusOptions.find(opt => opt.value === status)
return option ? option.label : status
}
const getStatusSeverity = (status) => {
const severities = {
draft: 'secondary',
open: 'info',
paid: 'success',
partial: 'warning',
overdue: 'danger',
cancelled: 'secondary'
}
return severities[status] || 'info'
}
const getContactName = (contactId) => {
const contact = contacts.value.find(c => c.id === contactId)
return contact ? contact.companyName : ''
}
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return '0,00 €'
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(parseFloat(amount))
}
const calculateItemTotal = (item) => {
const quantity = parseFloat(item.quantity) || 0
const unitPrice = parseFloat(item.unitPrice) || 0
const taxRate = parseFloat(item.taxRate) || 0
const net = quantity * unitPrice
const tax = net * (taxRate / 100)
return net + tax
}
const calculateTotals = () => {
let netAmount = 0
const taxBreakdown = {}
form.value.items.forEach(item => {
const quantity = parseFloat(item.quantity) || 0
const unitPrice = parseFloat(item.unitPrice) || 0
const taxRate = parseFloat(item.taxRate) || 0
const itemNet = quantity * unitPrice
const itemTax = itemNet * (taxRate / 100)
netAmount += itemNet
if (taxBreakdown[taxRate]) {
taxBreakdown[taxRate] += itemTax
} else {
taxBreakdown[taxRate] = itemTax
}
})
const totalTax = Object.values(taxBreakdown).reduce((sum, tax) => sum + tax, 0)
calculatedTotals.value = {
netAmount,
taxBreakdown,
total: netAmount + totalTax
}
}
onMounted(async () => {
// Load contacts
loadingContacts.value = true
try {
const response = await fetch('/api/contacts?itemsPerPage=1000', {
credentials: 'include',
@ -178,6 +404,8 @@ onMounted(async () => {
detail: 'Kontakte konnten nicht geladen werden',
life: 3000
})
} finally {
loadingContacts.value = false
}
// Load existing invoice data
@ -201,6 +429,11 @@ onMounted(async () => {
if (props.invoice.dueDate) {
form.value.dueDate = new Date(props.invoice.dueDate)
}
// Calculate initial totals if items exist
if (form.value.items && form.value.items.length > 0) {
calculateTotals()
}
}
})
@ -211,10 +444,12 @@ const addItem = () => {
unitPrice: 0.00,
taxRate: '19.00'
})
calculateTotals()
}
const removeItem = (index) => {
form.value.items.splice(index, 1)
calculateTotals()
}
const save = async () => {
@ -308,6 +543,103 @@ const cancel = () => {
<style scoped>
.invoice-form {
padding: 1.5rem;
background: var(--surface-ground);
}
/* Panel Styling */
.invoice-panel :deep(.p-panel-header) {
background: var(--surface-card);
border-bottom: 2px solid var(--primary-color);
font-weight: 600;
font-size: 1.1rem;
color: var(--primary-color);
}
.invoice-panel :deep(.p-panel-content) {
background: var(--surface-card);
padding: 1.5rem;
}
.totals-panel :deep(.p-panel-header) {
background: var(--primary-50);
border-bottom: 2px solid var(--primary-color);
}
.totals-panel :deep(.p-panel-content) {
background: var(--surface-0);
}
/* Empty State */
.p-4.border-round.border-1.surface-border.text-center {
background: var(--surface-50);
border-style: dashed !important;
}
/* DataTable Styling */
:deep(.p-datatable) {
border-radius: var(--border-radius);
overflow: hidden;
}
:deep(.p-datatable-thead > tr > th) {
background: var(--surface-100);
color: var(--text-color);
font-weight: 600;
padding: 1rem;
}
:deep(.p-datatable-tbody > tr > td) {
padding: 0.75rem 1rem;
}
:deep(.p-datatable-tbody > tr:hover) {
background: var(--surface-50);
}
:deep(.p-inputnumber),
:deep(.p-dropdown) {
width: 100%;
}
:deep(.p-inputtext),
:deep(.p-dropdown),
:deep(.p-calendar) {
width: 100%;
}
/* Footer Actions */
.border-top-1 {
border-top-width: 1px !important;
border-top-style: solid !important;
}
/* Responsive Adjustments */
@media (max-width: 768px) {
.invoice-form {
padding: 1rem;
}
.invoice-panel :deep(.p-panel-content) {
padding: 1rem;
}
:deep(.p-panel-header) {
font-size: 1rem;
}
.grid.grid-cols-1.md\\:grid-cols-2,
.grid.grid-cols-1.md\\:grid-cols-3 {
grid-template-columns: 1fr !important;
}
}
/* Animation */
.invoice-panel {
transition: all 0.3s ease;
}
.invoice-panel:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
</style>

View File

@ -14,99 +14,82 @@
@edit="openEditDialog"
@delete="deleteInvoice"
>
<template #body="{ item }">
<Column field="invoiceNumber" header="Rechnungsnummer" sortable>
<template #body="slotProps">
<router-link :to="`/billing/invoices/${slotProps.data.id}`" class="text-primary">
{{ slotProps.data.invoiceNumber }}
</router-link>
</template>
</Column>
<Column field="contact.name" header="Kunde" sortable>
<template #body="slotProps">
{{ slotProps.data.contact?.name || '-' }}
</template>
</Column>
<Column field="invoiceDate" header="Rechnungsdatum" sortable>
<template #body="slotProps">
{{ formatDate(slotProps.data.invoiceDate) }}
</template>
</Column>
<Column field="dueDate" header="Fälligkeitsdatum" sortable>
<template #body="slotProps">
{{ formatDate(slotProps.data.dueDate) }}
</template>
</Column>
<Column field="total" header="Gesamtbetrag" sortable>
<template #body="slotProps">
{{ formatCurrency(slotProps.data.total) }}
</template>
</Column>
<Column field="openAmount" header="Offen" sortable>
<template #body="slotProps">
{{ formatCurrency(slotProps.data.openAmount) }}
</template>
</Column>
<Column field="status" header="Status" sortable>
<template #body="slotProps">
<Tag :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
</Column>
<Column :exportable="false" style="min-width:12rem">
<template #body="slotProps">
<div class="flex gap-2">
<Button
v-if="slotProps.data.pdfPath"
icon="pi pi-file-pdf"
outlined
severity="secondary"
@click="viewPDF(slotProps.data)"
v-tooltip.top="'PDF anzeigen'"
/>
<Button
v-else
icon="pi pi-upload"
outlined
severity="info"
@click="uploadPDF(slotProps.data)"
v-tooltip.top="'PDF hochladen'"
/>
<Button
icon="pi pi-money-bill"
outlined
severity="success"
@click="addPayment(slotProps.data)"
v-tooltip.top="'Zahlung hinzufügen'"
/>
<Button
icon="pi pi-pencil"
outlined
severity="info"
@click="openEditDialog(slotProps.data)"
v-tooltip.top="'Bearbeiten'"
/>
<Button
icon="pi pi-trash"
outlined
severity="danger"
@click="deleteInvoice(slotProps.data)"
v-tooltip.top="'Löschen'"
/>
</div>
</template>
</Column>
<template #body-invoiceNumber="{ data }">
<router-link :to="`/billing/invoices/${data.id}`" class="text-primary">
{{ data.invoiceNumber }}
</router-link>
</template>
<template #body-contact.companyName="{ data }">
{{ data.contact?.companyName || '-' }}
</template>
<template #body-invoiceDate="{ data }">
{{ formatDate(data.invoiceDate) }}
</template>
<template #body-dueDate="{ data }">
{{ formatDate(data.dueDate) }}
</template>
<template #body-total="{ data }">
{{ formatCurrency(data.total) }}
</template>
<template #body-openAmount="{ data }">
{{ formatCurrency(data.openAmount) }}
</template>
<template #body-status="{ data }">
<Tag :value="getStatusLabel(data.status)" :severity="getStatusSeverity(data.status)" />
</template>
<template #body-actions="{ data }">
<div class="flex gap-2">
<Button
v-if="data.pdfPath"
icon="pi pi-file-pdf"
outlined
severity="secondary"
@click="viewPDF(data)"
v-tooltip.top="'PDF anzeigen'"
/>
<Button
v-else
icon="pi pi-upload"
outlined
severity="info"
@click="uploadPDF(data)"
v-tooltip.top="'PDF hochladen'"
/>
<Button
icon="pi pi-money-bill"
outlined
severity="success"
@click="addPayment(data)"
v-tooltip.top="'Zahlung hinzufügen'"
/>
<Button
icon="pi pi-pencil"
outlined
severity="info"
@click="openEditDialog(data)"
v-tooltip.top="'Bearbeiten'"
/>
<Button
icon="pi pi-trash"
outlined
severity="danger"
@click="deleteInvoice(data)"
v-tooltip.top="'Löschen'"
/>
</div>
</template>
</CrudDataTable>
<!-- Create/Edit Dialog -->
<Dialog v-model:visible="dialogVisible" :header="dialogTitle" :modal="true" :style="{width: '70vw'}" :maximizable="true">
<Dialog
v-model:visible="dialogVisible"
:header="dialogTitle"
:modal="true"
:style="{width: '90vw', maxWidth: '1200px'}"
:maximizable="true"
:dismissableMask="true"
class="invoice-dialog"
>
<InvoiceForm
v-if="dialogVisible"
:invoice="currentInvoice"
@ -162,13 +145,13 @@ const pdfUploadDialogVisible = ref(false)
const canCreate = computed(() => authStore.hasPermission('billing', 'create'))
const columns = [
{ field: 'invoiceNumber', header: 'Rechnungsnummer' },
{ field: 'contact.name', header: 'Kunde' },
{ field: 'invoiceDate', header: 'Rechnungsdatum' },
{ field: 'dueDate', header: 'Fälligkeitsdatum' },
{ field: 'total', header: 'Gesamtbetrag' },
{ field: 'openAmount', header: 'Offen' },
{ field: 'status', header: 'Status' }
{ key: 'invoiceNumber', field: 'invoiceNumber', label: 'Rechnungsnummer', default: true },
{ key: 'contact.companyName', field: 'contact.companyName', label: 'Kunde', default: true },
{ key: 'invoiceDate', field: 'invoiceDate', label: 'Rechnungsdatum', default: true },
{ key: 'dueDate', field: 'dueDate', label: 'Fälligkeitsdatum', default: true },
{ key: 'total', field: 'total', label: 'Gesamtbetrag', default: true },
{ key: 'openAmount', field: 'openAmount', label: 'Offen', default: true },
{ key: 'status', field: 'status', label: 'Status', default: true }
]
const openCreateDialog = () => {
@ -280,4 +263,36 @@ const getStatusSeverity = (status) => {
.text-primary:hover {
text-decoration: underline;
}
/* Dialog adjustments */
.invoice-dialog :deep(.p-dialog-content) {
padding: 0;
background: var(--surface-ground);
}
.invoice-dialog :deep(.p-dialog-header) {
background: var(--primary-color);
color: white;
border-radius: 0;
}
.invoice-dialog :deep(.p-dialog-header .p-dialog-title) {
font-weight: 600;
font-size: 1.25rem;
}
.invoice-dialog :deep(.p-dialog-header-icons button) {
color: white !important;
}
.invoice-dialog :deep(.p-dialog-header-icons button:hover) {
background: rgba(255, 255, 255, 0.1) !important;
}
@media (max-width: 960px) {
.invoice-dialog :deep(.p-dialog) {
width: 95vw !important;
max-width: none !important;
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

448
docs/design/README.md Normal file
View File

@ -0,0 +1,448 @@
# Invoice Form Design Documentation
**myCRM Billing Module - UX/UI Design System**
---
## Übersicht
Diese Design-Dokumentation enthält eine umfassende UX/UI-Analyse und Verbesserungsvorschläge für das Invoice-Formular im myCRM Billing-Modul. Die Dokumentation folgt professionellen UI/UX-Design-Standards und berücksichtigt Accessibility-Anforderungen nach WCAG 2.1 AA/AAA.
---
## Dokumenten-Struktur
### 1. Hauptdokumentation
**[INVOICE_FORM_UX_DESIGN.md](./INVOICE_FORM_UX_DESIGN.md)**
- Vollständige UX/UI-Analyse des bestehenden Invoice-Formulars
- Identifizierte Probleme und Verbesserungspotenziale (P0, P1, P2)
- Design-Prinzipien und Konzepte
- Detaillierte Komponentenspezifikationen
- Accessibility-Guidelines (WCAG 2.1 AA/AAA)
- Implementierungs-Roadmap (6 Phasen)
- Testing-Strategie
**Umfang:** ~350 Zeilen | Geschätzte Lesezeit: 30-40 Minuten
### 2. Visuelle Design-Referenz
**[VISUAL_DESIGN_GUIDE.md](./VISUAL_DESIGN_GUIDE.md)**
- Farbpalette und Kontraste
- Typography-System
- Spacing-System (4px Base Unit)
- Komponent-Anatomie mit ASCII-Diagrammen
- Interaktive States (Buttons, Inputs, etc.)
- Animation und Transitions
- Responsive Breakpoints
- Iconographie
- Error Handling Patterns
- Loading & Empty States
- Print Styles
**Umfang:** ~500 Zeilen | Geschätzte Lesezeit: 20-25 Minuten
### 3. Referenz-Implementierung
**[invoice-form-improved.vue](./invoice-form-improved.vue)**
- Vollständige Vue.js 3 Komponente mit allen Verbesserungen
- PrimeVue Komponenten-Integration
- Composition API Pattern
- Verwendung von Composables (useFormValidation, useKeyboardShortcuts)
- Accessibility-Features (ARIA-Labels, Focus Management)
- Responsive Design
- Animations & Transitions
**Umfang:** ~600 Zeilen | Typ: Vue SFC (Single File Component)
### 4. CSS Design System
**[invoice-form-styles.css](./invoice-form-styles.css)**
- CSS Custom Properties (Design Tokens)
- Komponent-spezifische Styles
- Layout-Utilities
- Animation Keyframes
- Responsive Media Queries
- Dark Mode Support
- Accessibility Utilities (sr-only, focus-visible)
- Print Styles
**Umfang:** ~800 Zeilen | Typ: CSS
### 5. Composables
#### **[useFormValidation.js](./composables/useFormValidation.js)**
- Wiederverwendbare Form-Validierungslogik
- Echtzeit-Validierung
- Feld-spezifische Regeln
- Error-Tracking
- Vordefinierte Validierungsregeln (email, phone, IBAN, etc.)
**Umfang:** ~400 Zeilen | Typ: JavaScript Composable
#### **[useKeyboardShortcuts.js](./composables/useKeyboardShortcuts.js)**
- Keyboard-Shortcut-Management
- Modifier-Key-Support (Ctrl, Shift, Alt)
- Conflict-Detection
- Platform-spezifische Hints (Mac vs. Windows)
- Preset-Shortcuts für gängige Aktionen
**Umfang:** ~400 Zeilen | Typ: JavaScript Composable
---
## Quick Start Guide
### Für Designer
1. **Beginnen Sie mit:** [INVOICE_FORM_UX_DESIGN.md](./INVOICE_FORM_UX_DESIGN.md) - Executive Summary
2. **Dann:** [VISUAL_DESIGN_GUIDE.md](./VISUAL_DESIGN_GUIDE.md) - Farbpalette & Typography
3. **Zuletzt:** Komponent-Anatomie-Diagramme für Implementierungs-Übergabe
### Für Entwickler
1. **Beginnen Sie mit:** [invoice-form-improved.vue](./invoice-form-improved.vue) - Referenz-Implementierung
2. **Dann:** [invoice-form-styles.css](./invoice-form-styles.css) - Styling-Referenz
3. **Integrieren Sie:** Composables aus `./composables/` für Validierung und Shortcuts
4. **Referenzieren Sie:** [INVOICE_FORM_UX_DESIGN.md](./INVOICE_FORM_UX_DESIGN.md) - Implementierungs-Roadmap
### Für Product Owner
1. **Beginnen Sie mit:** [INVOICE_FORM_UX_DESIGN.md](./INVOICE_FORM_UX_DESIGN.md) - Executive Summary & Problemanalyse
2. **Dann:** Implementierungs-Roadmap (Phase 1-6)
3. **Priorisieren Sie:** Nach P0 (kritisch), P1 (mittel), P2 (niedrig) Tags
---
## Key Features des Design-Konzepts
### UX-Verbesserungen
- **Klarere visuelle Hierarchie** durch Sektionierung und konsistentes Spacing
- **Card-basierte Rechnungspositionen** statt komplexer DataTable
- **Echtzeit-Summenberechnung** mit sofortigem Feedback
- **Verbesserte Validierung** mit Inline-Feedback
- **Empty States** für bessere Benutzerführung
- **Context Cards** für kontextuelle Informationen (Payment Form)
- **Drag & Drop** für PDF-Upload
### Accessibility (WCAG 2.1)
- **Keyboard Navigation** mit Focus-Trapping und Shortcuts
- **ARIA-Labels** für Screen Reader Support
- **Farbkontrast** WCAG AA/AAA konform
- **Focus Management** mit sichtbaren Focus-States
- **Live Regions** für dynamische Updates
### Performance
- **Lazy Loading** von Formular-Komponenten
- **Debouncing** für Berechnungen
- **Virtual Scrolling** für lange Listen
- **Code Splitting** für optimale Bundle-Größen
### Responsive Design
- **Mobile First** Ansatz
- **Touch-friendly** Elemente (min. 44px)
- **Adaptive Layouts** für alle Bildschirmgrößen
- **Sticky Summary** auf Mobile für bessere Übersicht
---
## Implementierungs-Phasen
### Phase 1: Foundation (Woche 1-2)
**Priorität:** P0 (Kritisch)
- Styling-System aufsetzen
- Formular-Layout refactoren
- FloatLabel implementieren
- Validation Composable erstellen
**Geschätzter Aufwand:** 16-20 Stunden
### Phase 2: Invoice Items Redesign (Woche 3)
**Priorität:** P0 (Kritisch)
- Card-basierte Positions-Ansicht
- Echtzeit-Summenberechnung
- Transitions für Add/Remove
- Empty State Design
**Geschätzter Aufwand:** 12-16 Stunden
### Phase 3: Enhanced Forms (Woche 4)
**Priorität:** P1 (Mittel)
- PaymentForm Context Card
- PDF Upload Drag & Drop
- Quick Amount Buttons
- Keyboard Shortcuts
**Geschätzter Aufwand:** 10-14 Stunden
### Phase 4: UX Polish (Woche 5)
**Priorität:** P1 (Mittel)
- Loading States & Skeletons
- Micro-Interactions & Animations
- Toast-Benachrichtigungen
- Error Handling
**Geschätzter Aufwand:** 8-12 Stunden
### Phase 5: Accessibility (Woche 6)
**Priorität:** P0 (Kritisch)
- ARIA Labels hinzufügen
- Focus Management
- Keyboard Navigation
- Screen Reader Testing
**Geschätzter Aufwand:** 10-14 Stunden
### Phase 6: Testing & Optimization (Woche 7)
**Priorität:** P1 (Mittel)
- Unit Tests schreiben
- E2E Tests implementieren
- Performance Profiling
- Code Splitting optimieren
**Geschätzter Aufwand:** 12-16 Stunden
**Gesamt:** 68-92 Stunden (ca. 2-2.5 Monate für einen Entwickler)
---
## Design-Prinzipien
### 1. Progressive Disclosure
Zeige nur relevante Informationen zum richtigen Zeitpunkt. Komplexität wird schrittweise enthüllt.
**Beispiel:** Notizen-Sektion standardmäßig kollabiert, Summary Box nur wenn Items vorhanden.
### 2. Immediate Feedback
Sofortige Rückmeldung auf Benutzeraktionen. Keine verzögerten oder fehlenden Bestätigungen.
**Beispiel:** Echtzeit-Berechnung der Zwischensummen beim Eingeben, Toast-Benachrichtigungen nach Aktionen.
### 3. Error Prevention
Validierung bevor Fehler entstehen. Eingaben werden direkt beim Tippen geprüft.
**Beispiel:** Inline-Validierung, Min-Date beim Fälligkeitsdatum verhindert ungültige Daten.
### 4. Consistency
Einheitliche Nutzung von PrimeVue-Komponenten, Farben, Spacing und Patterns.
**Beispiel:** Alle Formularfelder nutzen FloatLabel, alle Buttons haben einheitliche Größen.
### 5. Accessibility First
Design und Implementierung berücksichtigen von Anfang an alle Nutzer, einschließlich Menschen mit Behinderungen.
**Beispiel:** Keyboard Navigation, Screen Reader Support, ausreichende Kontraste.
---
## Technologie-Stack
### Frontend Framework
- **Vue.js 3** (Composition API)
- **PrimeVue 4.x** (Aura Theme)
- **Tailwind CSS v4** (mit PrimeUI Plugin)
### Build & Dev Tools
- **Webpack Encore**
- **Symfony 7.1 LTS** (Backend)
- **API Platform** (REST API)
### Testing
- **Vitest** (Unit Tests)
- **Cypress/Playwright** (E2E Tests)
- **axe DevTools** (Accessibility Testing)
---
## Browser-Kompatibilität
### Unterstützte Browser
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
- Mobile Safari 14+
- Chrome Android 90+
### Polyfills
- Intl.NumberFormat (bereits vorhanden)
- CSS Custom Properties (nativ unterstützt)
---
## Design-Assets
### Farbpalette
Siehe [VISUAL_DESIGN_GUIDE.md - Section 1](./VISUAL_DESIGN_GUIDE.md#1-farbpalette--kontraste)
### Icons
**PrimeIcons** (pi-*) - Vollständige Icon-Bibliothek integriert
- ~250 Icons verfügbar
- SVG-basiert, skalierbar
- Konsistenter Stil
### Typography
**System Font Stack** für optimale Performance:
```css
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
```
### Illustrations
Optional für Empty States: **unDraw** (lizenzfrei)
---
## Testing-Strategie
### Unit Tests
- Validierungs-Logik testen
- Berechnungs-Funktionen testen
- Composables isoliert testen
### Integration Tests
- Formular-Submission testen
- API-Integration testen
- State-Management testen
### E2E Tests
- Komplette User-Flows testen
- Rechnungserstellung Ende-zu-Ende
- Zahlungserfassung testen
### Accessibility Tests
- axe DevTools Audit
- NVDA/VoiceOver Screen Reader Testing
- Keyboard-Navigation manuell testen
- Farbkontrast-Validierung
### Performance Tests
- Lighthouse Audit (Score > 90)
- Bundle-Size-Analyse
- Time to Interactive < 3s
- First Contentful Paint < 1.5s
---
## Häufige Fragen (FAQ)
### Warum FloatLabel statt normale Labels?
**Antwort:** FloatLabel bietet mehrere Vorteile:
- Moderneres, aufgeräumteres Design
- Platzsparend (kein separater Label-Space)
- Bessere visuelle Hierarchie
- Klarer Fokus-Zustand
- Bereits in PrimeVue integriert
### Warum Cards statt DataTable für Invoice Items?
**Antwort:** Cards bieten bessere UX:
- Einfacheres Inline-Editing
- Bessere mobile Darstellung
- Klare visuelle Trennung
- Flexiblere Layouts
- Bessere Accessibility
### Wie wird Dark Mode unterstützt?
**Antwort:**
- PrimeVue Aura Theme hat nativen Dark Mode Support
- CSS Custom Properties passen sich automatisch an
- Spezielle Dark Mode Overrides wo nötig (siehe invoice-form-styles.css)
- App-Toggle über `.app-dark` CSS-Klasse
### Ist das Design responsive?
**Antwort:** Ja, vollständig:
- Mobile First Ansatz
- 5 Breakpoints (< 576px bis > 1920px)
- Touch-friendly Elemente (min. 44px)
- Adaptive Layouts für alle Größen
- Stack-Layout auf Mobile
### Wie werden Fehler behandelt?
**Antwort:**
- Inline-Validierung (on blur)
- Submit-Validierung (alle Felder)
- Toast-Benachrichtigungen für System-Fehler
- Visuelle Error-States (rote Border + Message)
- ARIA Live Regions für Screen Reader
---
## Nächste Schritte
### Sofort umsetzbar (Quick Wins)
1. FloatLabel für alle Formular-Felder einführen
2. Status-Tag in Formular-Header einbauen
3. Verbesserter Kunden-Dropdown mit Icons
4. Echtzeit-Summenberechnung implementieren
5. Quick Amount Buttons im PaymentForm
**Aufwand:** ~8 Stunden | **Impact:** Hoch
### Mittelfristig (1-2 Sprints)
1. Card-basiertes Positions-Management
2. Comprehensive Validation Composable
3. Keyboard Shortcuts System
4. Loading States & Skeletons
**Aufwand:** ~30 Stunden | **Impact:** Sehr hoch
### Langfristig (2+ Sprints)
1. Drag & Drop PDF Upload
2. Vollständige WCAG AAA Konformität
3. PWA Features (Offline Support)
4. Advanced Analytics Integration
**Aufwand:** ~50 Stunden | **Impact:** Mittel-Hoch
---
## Mitwirkende
**Design & Konzeption:**
- Claude (UX Designer Agent) - Initial Design & Dokumentation
**Review & Feedback:**
- [Ihr Team hier eintragen]
**Implementierung:**
- [Entwickler hier eintragen]
---
## Änderungshistorie
| Version | Datum | Autor | Änderungen |
|---------|------------|----------------|------------------------------------|
| 1.0 | 2025-12-12 | Claude (UX) | Initiale Dokumentation erstellt |
---
## Lizenz & Copyright
© 2025 myCRM Project
Interne Dokumentation - Vertraulich
---
## Kontakt & Support
Bei Fragen zur Design-Dokumentation:
- Erstellen Sie ein Issue im Repository
- Kontaktieren Sie das Design-Team
- Schlagen Sie Verbesserungen via Pull Request vor
---
**Letzte Aktualisierung:** 2025-12-12
**Dokument-Version:** 1.0
**Status:** In Review ✓

View File

@ -0,0 +1,679 @@
# Visual Design Guide - Invoice Form
**myCRM Billing Module | Design Reference**
---
## 1. Farbpalette & Kontraste
### Status Colors (Semantic)
```
┌─────────────────────────────────────────────────────────────┐
│ DRAFT (Entwurf) │ #6B7280 │ ░░░░░░░░░░░░ Secondary │
│ OPEN (Offen) │ #06B6D4 │ ████████████ Info │
│ PAID (Bezahlt) │ #22C55E │ ████████████ Success │
│ PARTIAL (Teilzahlung) │ #F59E0B │ ████████████ Warning │
│ OVERDUE (Überfällig) │ #EF4444 │ ████████████ Danger │
│ CANCELLED (Storniert) │ #6B7280 │ ░░░░░░░░░░░░ Secondary │
└─────────────────────────────────────────────────────────────┘
```
### Surface Colors
```
Light Mode: Dark Mode:
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ Background #FFFFFF │ │ Background #0F172A
│ Surface-0 #FFFFFF │ │ Surface-0 #1E293B
│ Surface-50 #F9FAFB │ │ Surface-50 #334155
│ Surface-100 #F3F4F6 │ │ Surface-100 #475569
│ Surface-200 #E5E7EB │ │ Surface-200 #64748B
└──────────────────────────────┘ └──────────────────────────────┘
```
### Text Colors
```
┌────────────────────────────────────────────────────────────┐
│ Primary Text #111827 ████ High contrast (main text) │
│ Secondary Text #6B7280 ████ Medium contrast (labels) │
│ Muted Text #9CA3AF ████ Low contrast (hints) │
└────────────────────────────────────────────────────────────┘
```
### Accessibility Compliance
```
WCAG 2.1 AA Requirements:
✓ Normal Text (14px): 4.5:1 contrast ratio
✓ Large Text (18px+): 3.0:1 contrast ratio
✓ UI Components: 3.0:1 contrast ratio
WCAG 2.1 AAA Requirements:
✓ Normal Text (14px): 7.0:1 contrast ratio
✓ Large Text (18px+): 4.5:1 contrast ratio
```
---
## 2. Typography System
### Font Stack
```css
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
```
### Type Scale
```
┌──────────────────────────────────────────────────────────────┐
│ Dialog Title 24px / 1.5rem Bold (600) │
│ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │
│ │
│ Section Title 20px / 1.25rem Semibold (600) │
│ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │
│ │
│ Subsection 16px / 1rem Medium (500) │
│ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │
│ │
│ Body Text 14px / 0.875rem Regular (400) │
│ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │
│ │
│ Small Text 12px / 0.75rem Regular (400) │
│ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │
└──────────────────────────────────────────────────────────────┘
```
### Line Heights
```
Headings: 1.2 (tight)
Body: 1.5 (normal)
Dense: 1.3 (compact tables)
Relaxed: 1.75 (long form text)
```
---
## 3. Spacing System
### Base Unit: 4px
```
┌───────────────────────────────────────────────────────────┐
│ Space Size Usage │
├───────────────────────────────────────────────────────────┤
│ 0.25rem 4px │ Minimal gap (inline icons) │
│ 0.5rem 8px ││ Label margin, tight spacing │
│ 0.75rem 12px │││ Inline field gaps │
│ 1rem 16px ││││ Field gaps (standard) │
│ 1.5rem 24px │││││││ Section padding │
│ 2rem 32px ││││││││││ Section gaps │
│ 3rem 48px │││││││││││││││││ Empty state padding │
└───────────────────────────────────────────────────────────┘
```
### Component Spacing
```
FORM SECTION:
┌─────────────────────────────────────┐
│ [24px padding] │
│ │
│ ┌─────────────────────────────┐ │
│ │ Header │ │
│ └─────────────────────────────┘ │
│ [24px gap] │
│ ┌─────────────────────────────┐ │
│ │ Field 1 [16px gap] Field 2 │ │
│ └─────────────────────────────┘ │
│ [16px gap] │
│ ┌─────────────────────────────┐ │
│ │ Field 3 │ │
│ └─────────────────────────────┘ │
│ │
│ [24px padding] │
└─────────────────────────────────────┘
[32px gap between sections]
```
---
## 4. Component Anatomy
### Invoice Item Card
```
┌─────────────────────────────────────────────────────────────┐
│ Position 1 [Delete] ←16px │
│ ─────────────────────────────────────────────────────────── │ 16px
│ │
│ ┌───────────────────────────────────────────────────────┐ │ │
│ │ Beschreibung │ │ │
│ │ (Textarea - 2 rows) │ │ │
│ └───────────────────────────────────────────────────────┘ │ │
│ ↕ 12px gap │
│ ┌─────────────┐ ┌──────────────┐ ┌──────┐ │ │
│ │ Menge │ │ Einzelpreis │ │ MwSt │ │ │
│ │ 1.00 Stk. │ │ 100,00 € │ │ 19% │ │ │
│ └─────────────┘ └──────────────┘ └──────┘ │ │
│ ↕ 16px gap │
│ ─────────────────────────────────────────────────────────── │
│ Zwischensumme: 119,00 € ←semibold │
└─────────────────────────────────────────────────────────────┘
↑ ↑
16px padding 16px padding
```
### Invoice Summary Box
```
┌─────────────────────────────────────────────────────────────┐
│ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │
│ ┃ Zusammenfassung [24px padding] ┃ │
│ ┃ ───────────────────────────────────────────────────── ┃ │
│ ┃ ┃ │
│ ┃ Nettobetrag: 1.000,00 € ← 8px │ │
│ ┃ MwSt (19%): 190,00 € ┃ │
│ ┃ MwSt (7%): 14,00 € ┃ │
│ ┃ ↕ 12px gap ┃ │
│ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ┃ │
│ ┃ ↕ 16px gap ┃ │
│ ┃ GESAMTBETRAG: 1.204,00 € ┃ │
│ ┃ (20px font, bold) ┃ │
│ ┃ ┃ │
│ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │
└─────────────────────────────────────────────────────────────┘
Gradient background: primary-50 → primary-100
Border: 2px solid primary-color
```
### Empty State
```
┌─────────────────────────────────────────────────────────────┐
│ │
│ 48px ↓ │
│ ┌──────────────┐ │
│ │ │ │
│ │ Inbox │ ← 48px icon, muted │
│ │ Icon │ │
│ └──────────────┘ │
│ 16px ↓ │
│ Noch keine Positionen hinzugefügt │
│ (16px, medium weight, secondary color) │
│ 8px ↓ │
│ Fügen Sie mindestens eine Position hinzu, │
│ um die Rechnung zu erstellen │
│ (12px, muted color) │
│ 24px ↓ │
│ ┌──────────────────────────┐ │
│ │ Erste Position hinzufügen │ │
│ └──────────────────────────┘ │
│ │
│ 48px ↓ │
└─────────────────────────────────────────────────────────────┘
```
---
## 5. Interactive States
### Button States
```
DEFAULT:
┌──────────────┐
│ Speichern │ Background: primary-color
└──────────────┘ Text: white
Height: 40px (2.5rem)
Padding: 12px 20px
Border-radius: 6px
HOVER:
┌──────────────┐
│ Speichern │ Background: primary-600 (darker)
└──────────────┘ Transform: scale(1.02)
Transition: 0.2s ease
FOCUS:
┌──────────────┐
│ Speichern │ Outline: 2px solid primary-color
└──────────────┘ Outline-offset: 2px
LOADING:
┌──────────────┐
│ ⟳ Speichern │ Spinner icon rotating
└──────────────┘ Cursor: wait
Opacity: 0.8
DISABLED:
┌──────────────┐
│ Speichern │ Background: surface-200
└──────────────┘ Text: text-muted
Cursor: not-allowed
Opacity: 0.6
```
### Input Field States
```
DEFAULT (FloatLabel):
┌─────────────────────────────────────┐
│ Rechnungsnummer │ ← Label (12px, top)
│ INV-2025-001 │ ← Value (14px)
└─────────────────────────────────────┘
Border: 1px solid surface-200
Height: 44px (touch-friendly)
FOCUS:
┌─────────────────────────────────────┐
│ Rechnungsnummer │ ← Label (primary color)
│ INV-2025-001│ │ ← Cursor visible
└─────────────────────────────────────┘
Border: 2px solid primary-color
Box-shadow: 0 0 0 3px primary-50
ERROR:
┌─────────────────────────────────────┐
│ Rechnungsnummer │ ← Label (danger color)
│ INV-2025-001 │
└─────────────────────────────────────┘
⚠ Rechnungsnummer ist erforderlich ← Error message (12px, danger)
Border: 2px solid danger-color
DISABLED:
┌─────────────────────────────────────┐
│ Rechnungsnummer │ ← Label (muted)
│ INV-2025-001 │ ← Value (muted)
└─────────────────────────────────────┘
Background: surface-100
Cursor: not-allowed
Opacity: 0.6
```
---
## 6. Animation & Transitions
### Timing Functions
```javascript
// Fast interactions (hover, focus)
transition: all 0.15s ease-in-out;
// Standard interactions (expand, collapse)
transition: all 0.2s ease;
// Slow interactions (page transitions)
transition: all 0.3s ease;
```
### List Animations (Invoice Items)
```
ENTER (new item):
┌─────────────┐ ┌─────────────┐
│ Opacity │ → │ Opacity │
│ 0.0 │ │ 1.0 │
└─────────────┘ └─────────────┘
↓ ↓
TranslateY(-20px) TranslateY(0)
Duration: 0.3s ease-out
LEAVE (deleted item):
┌─────────────┐ ┌─────────────┐
│ Opacity │ → │ Opacity │
│ 1.0 │ │ 0.0 │
└─────────────┘ └─────────────┘
↓ ↓
TranslateX(0) TranslateX(20px)
Duration: 0.3s ease
```
### Micro-interactions
```
Button Click:
• Transform: scale(0.98)
• Duration: 0.1s
• Timing: ease-out
Card Hover:
• Box-shadow: 0 2px 8px rgba(0,0,0,0.08)
• Border-color: primary-color
• Duration: 0.2s
• Timing: ease
Toast Notification:
• Slide in from right
• Transform: translateX(100%) → translateX(0)
• Duration: 0.3s
• Timing: cubic-bezier(0.4, 0, 0.2, 1)
```
---
## 7. Responsive Breakpoints
```
┌────────────────────────────────────────────────────────────┐
│ BREAKPOINT WIDTH COLUMNS LAYOUT │
├────────────────────────────────────────────────────────────┤
│ Small Mobile < 576px 1 Stack all fields
│ Mobile 576-768px 1 Stack + full width │
│ Tablet 768-992px 1-2 Hybrid layout │
│ Desktop 992-1200px 2 2-column grid │
│ Large Desktop > 1200px 2-3 Optimized spacing │
└────────────────────────────────────────────────────────────┘
```
### Layout Examples
**Desktop (992px+)**
```
┌─────────────────────────────────────────────────────┐
│ Rechnungsinformationen [Tag] │
│ ─────────────────────────────────────────────────── │
│ ┌────────────────┐ ┌────────────────┐ │
│ │ Rechnungsnr. │ │ Status │ │
│ └────────────────┘ └────────────────┘ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Kunde (full width) │ │
│ └──────────────────────────────────────────────┘ │
│ ┌────────────────┐ ┌────────────────┐ │
│ │ Datum │ │ Fälligk. │ │
│ └────────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────┘
```
**Tablet (768-992px)**
```
┌─────────────────────────────────────┐
│ Rechnungsinformationen [Tag] │
│ ───────────────────────────────────── │
│ ┌───────────────────────────────┐ │
│ │ Rechnungsnr. │ │
│ └───────────────────────────────┘ │
│ ┌───────────────────────────────┐ │
│ │ Status │ │
│ └───────────────────────────────┘ │
│ ┌───────────────────────────────┐ │
│ │ Kunde │ │
│ └───────────────────────────────┘ │
│ ┌───────────────────────────────┐ │
│ │ Datum │ │
│ └───────────────────────────────┘ │
│ ┌───────────────────────────────┐ │
│ │ Fälligkeit │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
```
**Mobile (< 768px)**
```
┌─────────────────────────┐
│ Rechnung │
│ [Tag] │
│ ─────────────────────── │
│ ┌─────────────────────┐ │
│ │ Rechnungsnr. │ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ Status │ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ Kunde │ │
│ └─────────────────────┘ │
│ ... │
└─────────────────────────┘
Dialog = Full Screen
Summary = Sticky Bottom
```
---
## 8. Iconography
### PrimeIcons Usage
```
┌────────────────────────────────────────────────────────┐
│ CONTEXT ICON SIZE USAGE │
├────────────────────────────────────────────────────────┤
│ Add Item pi-plus 1rem Buttons │
│ Remove Item pi-trash 1rem Buttons │
│ Save pi-check 1rem Buttons │
│ Cancel pi-times 1rem Buttons │
│ Upload pi-upload 2rem Empty state │
│ PDF pi-file-pdf 2.5rem Preview │
│ Calendar pi-calendar 0.875 Input icon │
│ Building/Company pi-building 1rem Dropdown │
│ Info pi-info-circle 1rem Messages │
│ Warning pi-exclamation 1rem Alerts │
│ Success pi-check-circle 1rem Toast │
│ Error pi-times-circle 1rem Toast │
└────────────────────────────────────────────────────────┘
```
### Icon Colors
```
Default: var(--text-secondary) #6B7280
Primary: var(--primary-color) #3B82F6
Success: var(--success-color) #22C55E
Warning: var(--warning-color) #F59E0B
Danger: var(--danger-color) #EF4444
Muted: var(--text-muted) #9CA3AF (opacity: 0.5)
```
---
## 9. Error Handling & Validation
### Validation Patterns
```
INLINE VALIDATION (on blur):
┌─────────────────────────────────────┐
│ E-Mail │
│ invalid.email │ ← Invalid input
└─────────────────────────────────────┘
⚠ Ungültige E-Mail-Adresse ← Error appears immediately
Duration: 0.2s fade-in
SUBMIT VALIDATION:
┌─────────────────────────────────────┐
│ Rechnungsnummer │
│ │ ← Empty required field
└─────────────────────────────────────┘
⚠ Rechnungsnummer ist erforderlich
All errors show simultaneously on submit
Focus moves to first invalid field
```
### Error States Visual Hierarchy
```
PRIORITY 1 - Critical Errors (Red border + icon):
┌─────────────────────────────────────┐
│ ⚠ Rechnungsnummer │ ← Red label
│ │
└─────────────────────────────────────┘
Border: 2px solid danger-color
PRIORITY 2 - Warnings (Orange border):
┌─────────────────────────────────────┐
│ ⚠ Fälligkeitsdatum │ ← Orange label
│ 01.01.2024 │
└─────────────────────────────────────┘
⚠ Datum liegt in der Vergangenheit
Border: 2px solid warning-color
PRIORITY 3 - Info (Blue border):
┌─────────────────────────────────────┐
Steuer-ID │
│ DE123456789 │
└─────────────────────────────────────┘
Optional, aber empfohlen
Border: 1px solid info-color
```
---
## 10. Loading & Empty States
### Skeleton Screens
```
LOADING FORM:
┌─────────────────────────────────────┐
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ ← Animated gradient
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
│ ▓▓▓▓▓▓▓▓▓▓ │
└─────────────────────────────────────┘
Animation: shimmer effect (1.5s loop)
Background: linear-gradient 200% width
LOADING BUTTON:
┌──────────────┐
│ ⟳ Lädt... │ ← Spinner rotates
└──────────────┘
Disabled: true
Cursor: wait
```
### Progress Indication
```
UPLOAD PROGRESS:
┌─────────────────────────────────────┐
│ rechnung_2025_001.pdf │ [×] │
│ 2.4 MB │
│ ▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░ 45% │ ← Progress bar
└─────────────────────────────────────┘
Height: 6px
Background: success-color
Animation: smooth increment
```
---
## 11. Accessibility Features
### Focus Management
```
TAB ORDER:
1. Rechnungsnummer [← First field]
2. Status
3. Kunde
4. Rechnungsdatum
5. Fälligkeitsdatum
6. Position 1 - Beschreibung
7. Position 1 - Menge
8. Position 1 - Preis
9. Position 1 - MwSt
10. Position hinzufügen Button
...
N-1. Abbrechen Button
N. Speichern Button [← Last focusable]
ESC: Return to previous focusable
```
### ARIA Live Regions
```html
<!-- Screen reader announcements -->
<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>
<span v-if="error">Fehler: {{ errorMessage }}</span>
</div>
<!-- Status updates -->
<div aria-live="assertive" role="alert" class="sr-only">
Position {{ index + 1 }} wurde entfernt
</div>
```
### Keyboard Navigation Hints
```
┌────────────────────────────────────────────────────────┐
│ [Ctrl+S] Speichern • [Ctrl+⇧+P] Position hinzufügen │
│ [Esc] Abbrechen • [Tab] Nächstes Feld │
└────────────────────────────────────────────────────────┘
Display: Desktop & Tablet only
Hidden on mobile (<768px)
Can be toggled with [?] key
```
---
## 12. Print Styles
```css
@media print {
/* Hide interactive elements */
.form-actions,
.keyboard-hints,
.invoice-item-actions,
button { display: none !important; }
/* Optimize for paper */
.form-section {
box-shadow: none;
page-break-inside: avoid;
}
/* Black & white optimization */
.invoice-summary {
background: white;
border: 2px solid black;
}
/* Add page breaks */
.page-break { page-break-after: always; }
}
```
### Print Layout Preview
```
PAGE 1:
┌─────────────────────────────────────┐
│ myCRM Logo │
│ ─────────────────────────────────── │
│ │
│ RECHNUNG INV-2025-001 │
│ │
│ Rechnungsinformationen │
│ Rechnungsnr: INV-2025-001 │
│ Datum: 12.12.2025 │
│ Kunde: ACME Corp │
│ │
│ Rechnungspositionen │
│ 1. Consulting Services │
│ 10 Stk × 100,00 € = 1.000,00 € │
│ │
│ ─────────────────────────────────── │
│ Nettobetrag: 1.000,00 € │
│ MwSt (19%): 190,00 € │
│ GESAMTBETRAG: 1.190,00 € │
│ │
│ Seite 1 von 1 │
└─────────────────────────────────────┘
```
---
**Document Version:** 1.0
**Last Updated:** 2025-12-12
**Author:** UX Designer Agent
**Project:** myCRM Billing Module

View File

@ -0,0 +1,400 @@
/**
* COMPOSABLE: useFormValidation
*
* Provides reusable form validation logic with real-time validation,
* error tracking, and field-level validation rules.
*
* Usage:
* ```js
* import { useFormValidation } from '@/composables/useFormValidation'
*
* const validation = useFormValidation()
*
* // Validate a field
* validation.validateField('email', form.email, {
* required: true,
* pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
* })
*
* // Check if field has error
* validation.hasError('email') // true/false
*
* // Get error message
* validation.getError('email') // "E-Mail ist erforderlich"
* ```
*/
import { ref, computed } from 'vue'
export function useFormValidation() {
// State
const errors = ref({})
const touched = ref({})
const validating = ref({})
/**
* Validates a single field with given rules
* @param {string} fieldName - Name of the field
* @param {any} value - Current field value
* @param {object} rules - Validation rules
* @returns {boolean} - True if valid
*/
const validateField = (fieldName, value, rules = {}) => {
validating.value[fieldName] = true
const fieldErrors = []
// Required validation
if (rules.required) {
if (value === null || value === undefined || value === '' ||
(Array.isArray(value) && value.length === 0)) {
fieldErrors.push('Dieses Feld ist erforderlich')
}
}
// If field is empty and not required, skip other validations
if (!value && !rules.required) {
errors.value[fieldName] = []
validating.value[fieldName] = false
return true
}
// Min length validation
if (rules.minLength && value && value.length < rules.minLength) {
fieldErrors.push(`Mindestens ${rules.minLength} Zeichen erforderlich`)
}
// Max length validation
if (rules.maxLength && value && value.length > rules.maxLength) {
fieldErrors.push(`Maximal ${rules.maxLength} Zeichen erlaubt`)
}
// Pattern validation
if (rules.pattern && value && !rules.pattern.test(value)) {
fieldErrors.push(rules.patternMessage || 'Ungültiges Format')
}
// Min value validation (for numbers)
if (rules.min !== undefined && value < rules.min) {
fieldErrors.push(`Wert muss mindestens ${rules.min} sein`)
}
// Max value validation (for numbers)
if (rules.max !== undefined && value > rules.max) {
fieldErrors.push(`Wert darf maximal ${rules.max} sein`)
}
// Email validation
if (rules.email && value) {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailPattern.test(value)) {
fieldErrors.push('Ungültige E-Mail-Adresse')
}
}
// URL validation
if (rules.url && value) {
try {
new URL(value)
} catch {
fieldErrors.push('Ungültige URL')
}
}
// Date validation
if (rules.date && value) {
const date = new Date(value)
if (isNaN(date.getTime())) {
fieldErrors.push('Ungültiges Datum')
}
}
// Custom validation function
if (rules.custom && typeof rules.custom === 'function') {
const customError = rules.custom(value)
if (customError) {
fieldErrors.push(customError)
}
}
// Async validation
if (rules.asyncValidator && typeof rules.asyncValidator === 'function') {
rules.asyncValidator(value).then(error => {
if (error) {
errors.value[fieldName] = [error]
}
validating.value[fieldName] = false
})
} else {
validating.value[fieldName] = false
}
// Update errors
errors.value[fieldName] = fieldErrors
return fieldErrors.length === 0
}
/**
* Validates multiple fields at once
* @param {object} formData - Object with field names and values
* @param {object} rulesMap - Object with field names and their rules
* @returns {boolean} - True if all fields are valid
*/
const validateForm = (formData, rulesMap) => {
let isValid = true
Object.keys(rulesMap).forEach(fieldName => {
const value = getNestedValue(formData, fieldName)
const rules = rulesMap[fieldName]
const fieldValid = validateField(fieldName, value, rules)
if (!fieldValid) {
isValid = false
touchField(fieldName)
}
})
return isValid
}
/**
* Marks a field as touched
* @param {string} fieldName - Name of the field
*/
const touchField = (fieldName) => {
touched.value[fieldName] = true
}
/**
* Marks multiple fields as touched
* @param {string[]} fieldNames - Array of field names
*/
const touchFields = (fieldNames) => {
fieldNames.forEach(fieldName => {
touched.value[fieldName] = true
})
}
/**
* Marks all fields as touched
*/
const touchAll = () => {
Object.keys(errors.value).forEach(fieldName => {
touched.value[fieldName] = true
})
}
/**
* Checks if a field has an error and has been touched
* @param {string} fieldName - Name of the field
* @returns {boolean}
*/
const hasError = (fieldName) => {
return touched.value[fieldName] &&
errors.value[fieldName] &&
errors.value[fieldName].length > 0
}
/**
* Gets the first error message for a field
* @param {string} fieldName - Name of the field
* @returns {string|null}
*/
const getError = (fieldName) => {
if (!hasError(fieldName)) return null
return errors.value[fieldName][0]
}
/**
* Gets all error messages for a field
* @param {string} fieldName - Name of the field
* @returns {string[]}
*/
const getErrors = (fieldName) => {
if (!hasError(fieldName)) return []
return errors.value[fieldName]
}
/**
* Clears errors for a specific field
* @param {string} fieldName - Name of the field
*/
const clearFieldError = (fieldName) => {
errors.value[fieldName] = []
touched.value[fieldName] = false
}
/**
* Clears all errors
*/
const clearErrors = () => {
errors.value = {}
touched.value = {}
}
/**
* Resets validation state
*/
const reset = () => {
errors.value = {}
touched.value = {}
validating.value = {}
}
/**
* Checks if the entire form is valid
* @returns {boolean}
*/
const isValid = computed(() => {
return Object.keys(errors.value).every(
fieldName => !errors.value[fieldName] || errors.value[fieldName].length === 0
)
})
/**
* Checks if any field is currently being validated
* @returns {boolean}
*/
const isValidating = computed(() => {
return Object.values(validating.value).some(v => v === true)
})
/**
* Gets nested value from object using dot notation
* @param {object} obj - Object to get value from
* @param {string} path - Path using dot notation (e.g., 'items.0.description')
* @returns {any}
*/
const getNestedValue = (obj, path) => {
return path.split('.').reduce((acc, part) => {
return acc && acc[part] !== undefined ? acc[part] : undefined
}, obj)
}
/**
* Sets a custom error for a field
* @param {string} fieldName - Name of the field
* @param {string|string[]} error - Error message(s)
*/
const setError = (fieldName, error) => {
errors.value[fieldName] = Array.isArray(error) ? error : [error]
touched.value[fieldName] = true
}
/**
* Sets multiple errors at once
* @param {object} errorsMap - Object with field names and error messages
*/
const setErrors = (errorsMap) => {
Object.entries(errorsMap).forEach(([fieldName, error]) => {
setError(fieldName, error)
})
}
return {
// State
errors,
touched,
validating,
// Methods
validateField,
validateForm,
touchField,
touchFields,
touchAll,
hasError,
getError,
getErrors,
clearFieldError,
clearErrors,
reset,
setError,
setErrors,
// Computed
isValid,
isValidating
}
}
/**
* Common validation rules presets
*/
export const validationRules = {
required: {
required: true
},
email: {
required: true,
email: true
},
optionalEmail: {
email: true
},
phone: {
pattern: /^[\d\s()+\-/.]+$/,
patternMessage: 'Ungültige Telefonnummer'
},
url: {
url: true
},
invoiceNumber: {
required: true,
minLength: 3,
pattern: /^[A-Z0-9-]+$/i,
patternMessage: 'Nur Buchstaben, Zahlen und Bindestriche erlaubt'
},
postalCode: {
pattern: /^\d{5}$/,
patternMessage: 'Postleitzahl muss 5 Zahlen enthalten'
},
iban: {
pattern: /^[A-Z]{2}\d{2}[A-Z0-9]+$/,
patternMessage: 'Ungültiges IBAN-Format'
},
taxId: {
pattern: /^DE\d{9}$/,
patternMessage: 'Ungültige Steuernummer (Format: DE123456789)'
},
currency: {
min: 0,
custom: (value) => {
if (value && isNaN(parseFloat(value))) {
return 'Bitte geben Sie einen gültigen Betrag ein'
}
return null
}
},
percentage: {
min: 0,
max: 100,
custom: (value) => {
if (value && (isNaN(parseFloat(value)) || value < 0 || value > 100)) {
return 'Prozentsatz muss zwischen 0 und 100 liegen'
}
return null
}
},
quantity: {
required: true,
min: 0.01,
custom: (value) => {
if (value && (isNaN(parseFloat(value)) || value <= 0)) {
return 'Menge muss größer als 0 sein'
}
return null
}
}
}

View File

@ -0,0 +1,386 @@
/**
* COMPOSABLE: useKeyboardShortcuts
*
* Provides keyboard shortcut functionality with support for
* modifier keys (Ctrl/Cmd, Shift, Alt) and custom key combinations.
*
* Usage:
* ```js
* import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
*
* useKeyboardShortcuts({
* 'ctrl+s': saveForm,
* 'ctrl+shift+p': addNewItem,
* 'escape': closeDialog,
* 'alt+n': () => console.log('Alt+N pressed')
* })
* ```
*/
import { onMounted, onUnmounted, ref } from 'vue'
export function useKeyboardShortcuts(shortcuts, options = {}) {
const {
enabled = true,
preventDefault = true,
target = null // null means window, or pass a ref to an element
} = options
const isEnabled = ref(enabled)
const registeredShortcuts = ref(shortcuts)
/**
* Normalizes a key string to lowercase and handles platform differences
* @param {string} key - Key string
* @returns {string}
*/
const normalizeKey = (key) => {
return key.toLowerCase()
.replace('meta', 'ctrl') // Treat Meta (Cmd) as Ctrl for consistency
.replace('command', 'ctrl')
}
/**
* Builds a shortcut string from a KeyboardEvent
* @param {KeyboardEvent} event - Keyboard event
* @returns {string}
*/
const buildShortcutString = (event) => {
const parts = []
// Add modifiers in consistent order
if (event.ctrlKey || event.metaKey) parts.push('ctrl')
if (event.shiftKey) parts.push('shift')
if (event.altKey) parts.push('alt')
// Add the actual key (normalized)
const key = event.key.toLowerCase()
// Handle special keys
const specialKeys = {
' ': 'space',
'arrowup': 'up',
'arrowdown': 'down',
'arrowleft': 'left',
'arrowright': 'right',
'enter': 'enter',
'escape': 'escape',
'tab': 'tab',
'backspace': 'backspace',
'delete': 'delete',
'insert': 'insert',
'home': 'home',
'end': 'end',
'pageup': 'pageup',
'pagedown': 'pagedown'
}
const normalizedKey = specialKeys[key] || key
parts.push(normalizedKey)
return parts.join('+')
}
/**
* Checks if the event should be ignored (e.g., inside an input field)
* @param {KeyboardEvent} event - Keyboard event
* @returns {boolean}
*/
const shouldIgnoreEvent = (event) => {
// Don't trigger shortcuts when typing in input fields (unless specified)
const target = event.target
const tagName = target.tagName.toLowerCase()
// Check if target is an editable element
if (
tagName === 'input' ||
tagName === 'textarea' ||
tagName === 'select' ||
target.isContentEditable
) {
// Allow Escape key even in input fields
if (event.key === 'Escape') return false
// Allow Ctrl+S (save) even in input fields
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
return false
}
return true
}
return false
}
/**
* Handles keyboard events
* @param {KeyboardEvent} event - Keyboard event
*/
const handleKeydown = (event) => {
// Check if shortcuts are enabled
if (!isEnabled.value) return
// Check if we should ignore this event
if (shouldIgnoreEvent(event)) return
// Build the shortcut string
const shortcutString = buildShortcutString(event)
// Find matching shortcut
const matchingShortcut = Object.keys(registeredShortcuts.value).find(
shortcut => normalizeKey(shortcut) === shortcutString
)
if (matchingShortcut) {
const action = registeredShortcuts.value[matchingShortcut]
// Prevent default browser behavior if configured
if (preventDefault) {
event.preventDefault()
}
// Execute the shortcut action
if (typeof action === 'function') {
action(event)
}
}
}
/**
* Registers a new shortcut
* @param {string} shortcut - Shortcut string (e.g., 'ctrl+s')
* @param {Function} action - Action to execute
*/
const registerShortcut = (shortcut, action) => {
registeredShortcuts.value[shortcut] = action
}
/**
* Unregisters a shortcut
* @param {string} shortcut - Shortcut string
*/
const unregisterShortcut = (shortcut) => {
delete registeredShortcuts.value[shortcut]
}
/**
* Enables keyboard shortcuts
*/
const enable = () => {
isEnabled.value = true
}
/**
* Disables keyboard shortcuts
*/
const disable = () => {
isEnabled.value = false
}
/**
* Gets all registered shortcuts
* @returns {object}
*/
const getShortcuts = () => {
return { ...registeredShortcuts.value }
}
// Setup and cleanup
onMounted(() => {
const element = target?.value || window
element.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
const element = target?.value || window
element.removeEventListener('keydown', handleKeydown)
})
return {
registerShortcut,
unregisterShortcut,
enable,
disable,
getShortcuts,
isEnabled
}
}
/**
* Composable for displaying keyboard shortcut hints
*/
export function useKeyboardShortcutHints(shortcuts) {
/**
* Formats a shortcut string for display
* @param {string} shortcut - Shortcut string (e.g., 'ctrl+s')
* @returns {string[]} Array of key labels
*/
const formatShortcut = (shortcut) => {
const parts = shortcut.toLowerCase().split('+')
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
return parts.map(part => {
// Platform-specific labels
if (part === 'ctrl') {
return isMac ? '⌘' : 'Ctrl'
}
if (part === 'shift') {
return isMac ? '⇧' : 'Shift'
}
if (part === 'alt') {
return isMac ? '⌥' : 'Alt'
}
// Special key labels
const specialKeys = {
space: 'Space',
enter: 'Enter',
escape: 'Esc',
tab: 'Tab',
backspace: 'Backspace',
delete: 'Del',
up: '↑',
down: '↓',
left: '←',
right: '→',
pageup: 'PgUp',
pagedown: 'PgDn'
}
return specialKeys[part] || part.toUpperCase()
})
}
/**
* Gets all shortcuts with formatted labels
* @returns {Array} Array of {shortcut, keys, description}
*/
const getFormattedShortcuts = (descriptions = {}) => {
return Object.keys(shortcuts).map(shortcut => ({
shortcut,
keys: formatShortcut(shortcut),
description: descriptions[shortcut] || ''
}))
}
return {
formatShortcut,
getFormattedShortcuts
}
}
/**
* Common keyboard shortcuts presets
*/
export const commonShortcuts = {
// Form actions
save: 'ctrl+s',
cancel: 'escape',
submit: 'ctrl+enter',
// Navigation
nextField: 'tab',
prevField: 'shift+tab',
firstField: 'ctrl+home',
lastField: 'ctrl+end',
// Editing
undo: 'ctrl+z',
redo: 'ctrl+shift+z',
copy: 'ctrl+c',
cut: 'ctrl+x',
paste: 'ctrl+v',
selectAll: 'ctrl+a',
// Item management
addItem: 'ctrl+shift+p',
deleteItem: 'ctrl+d',
duplicateItem: 'ctrl+shift+d',
// Search
search: 'ctrl+f',
searchNext: 'ctrl+g',
searchPrev: 'ctrl+shift+g',
// View
toggleFullscreen: 'f11',
zoomIn: 'ctrl+plus',
zoomOut: 'ctrl+minus',
resetZoom: 'ctrl+0'
}
/**
* Keyboard shortcut descriptions for invoice form
*/
export const invoiceFormShortcuts = {
'ctrl+s': 'Rechnung speichern',
'ctrl+shift+p': 'Position hinzufügen',
'escape': 'Formular schließen',
'ctrl+shift+d': 'Als Entwurf speichern',
'alt+c': 'Kunde auswählen',
'alt+d': 'Datum ändern'
}
/**
* Hook for managing shortcut conflicts
*/
export function useShortcutConflicts() {
const conflicts = ref([])
/**
* Checks for conflicting shortcuts
* @param {object} shortcuts - Shortcuts object
* @returns {Array} Array of conflicts
*/
const checkConflicts = (shortcuts) => {
const normalized = {}
const conflictsList = []
Object.keys(shortcuts).forEach(key => {
const normalizedKey = key.toLowerCase()
if (normalized[normalizedKey]) {
conflictsList.push({
shortcut: key,
conflictsWith: normalized[normalizedKey]
})
} else {
normalized[normalizedKey] = key
}
})
conflicts.value = conflictsList
return conflictsList
}
/**
* Checks if a shortcut conflicts with browser defaults
* @param {string} shortcut - Shortcut string
* @returns {boolean}
*/
const conflictsWithBrowser = (shortcut) => {
const browserShortcuts = [
'ctrl+t', // New tab
'ctrl+w', // Close tab
'ctrl+n', // New window
'ctrl+r', // Reload
'ctrl+p', // Print
'ctrl+o', // Open file
'ctrl+l', // Address bar
'ctrl+k', // Search bar
'ctrl+d', // Bookmark
'ctrl+h', // History
'ctrl+j', // Downloads
'ctrl+shift+t', // Reopen closed tab
'ctrl+shift+n', // New incognito window
'ctrl+shift+delete' // Clear browsing data
]
return browserShortcuts.includes(shortcut.toLowerCase())
}
return {
conflicts,
checkConflicts,
conflictsWithBrowser
}
}

View File

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

View File

@ -0,0 +1,748 @@
/**
* INVOICE FORM DESIGN SYSTEM STYLES
* Zusätzliche CSS-Utilities und Variablen für das verbesserte Invoice-Formular
*
* Hinweis: Diese Styles sollten in das bestehende Tailwind CSS System integriert werden
*/
/* ========================================
CSS Custom Properties (Design Tokens)
======================================== */
:root {
/* Spacing System */
--space-section-gap: 2rem; /* 32px - Abstand zwischen Sektionen */
--space-section-padding: 1.5rem; /* 24px - Innen-Padding der Sektionen */
--space-field-gap: 1rem; /* 16px - Abstand zwischen Feldern */
--space-inline-gap: 0.75rem; /* 12px - Abstand zwischen Inline-Feldern */
/* Component Heights */
--height-form-element: 2.75rem; /* 44px - Touch-friendly height */
--height-button: 2.5rem; /* 40px - Standard button height */
--height-button-small: 2rem; /* 32px - Small button height */
/* Typography */
--font-size-dialog-title: 1.5rem; /* 24px */
--font-size-section-title: 1.25rem; /* 20px */
--font-size-subsection: 1rem; /* 16px */
--font-size-body: 0.875rem; /* 14px */
--font-size-small: 0.75rem; /* 12px */
--font-size-large: 1rem; /* 16px */
/* Font Weights */
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Border Radius */
--radius-section: 0.75rem; /* 12px */
--radius-card: 0.5rem; /* 8px */
--radius-small: 0.375rem; /* 6px */
--radius-button: 0.375rem; /* 6px */
/* Shadows */
--shadow-section: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-hover: 0 2px 8px rgba(0, 0, 0, 0.08);
--shadow-elevated: 0 4px 12px rgba(0, 0, 0, 0.12);
/* Transitions */
--transition-fast: 0.15s ease-in-out;
--transition-normal: 0.2s ease;
--transition-slow: 0.3s ease;
/* Z-Index Scale */
--z-base: 1;
--z-dropdown: 1000;
--z-sticky: 1020;
--z-fixed: 1030;
--z-modal-backdrop: 1040;
--z-modal: 1050;
--z-popover: 1060;
--z-tooltip: 1070;
}
/* Dark Mode Adjustments */
.app-dark {
--shadow-section: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-hover: 0 2px 8px rgba(0, 0, 0, 0.25);
--shadow-elevated: 0 4px 12px rgba(0, 0, 0, 0.35);
}
/* ========================================
Form Section Components
======================================== */
.form-section {
background: var(--surface-0);
border: 1px solid var(--surface-200);
border-radius: var(--radius-section);
padding: var(--space-section-padding);
box-shadow: var(--shadow-section);
transition: box-shadow var(--transition-normal);
}
.form-section:hover {
box-shadow: var(--shadow-hover);
}
.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(--font-size-section-title);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.form-section-body {
/* Container for form fields */
}
/* ========================================
Form Layout Utilities
======================================== */
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-field-gap);
align-items: start;
}
.form-grid--3-col {
grid-template-columns: repeat(3, 1fr);
}
.form-grid--4-col {
grid-template-columns: repeat(4, 1fr);
}
.form-field-full {
grid-column: 1 / -1;
}
.form-field-span-2 {
grid-column: span 2;
}
/* Responsive Grid */
@media (max-width: 992px) {
.form-grid,
.form-grid--3-col,
.form-grid--4-col {
grid-template-columns: 1fr;
}
.form-field-span-2 {
grid-column: span 1;
}
}
/* ========================================
Invoice Item Components
======================================== */
.invoice-items-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.invoice-item-card {
background: var(--surface-50);
border: 1px solid var(--surface-200);
border-radius: var(--radius-card);
padding: 1rem;
transition: all var(--transition-normal);
}
.invoice-item-card:hover {
box-shadow: var(--shadow-hover);
border-color: var(--primary-color);
}
.invoice-item-card--new {
animation: slideIn var(--transition-slow) ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.invoice-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.invoice-item-number {
font-weight: var(--font-weight-semibold);
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 0.5rem;
}
.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%;
}
}
.invoice-item-subtotal {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--surface-200);
}
/* ========================================
Invoice Summary Component
======================================== */
.invoice-summary {
background: linear-gradient(135deg, var(--primary-50) 0%, var(--primary-100) 100%);
border: 2px solid var(--primary-color);
border-radius: var(--radius-section);
padding: 1.5rem;
margin-top: 1.5rem;
}
.app-dark .invoice-summary {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.15) 0%, rgba(59, 130, 246, 0.25) 100%);
}
.invoice-summary-header {
margin-bottom: 1rem;
}
.invoice-summary-header h4 {
margin: 0;
font-size: 1rem;
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.invoice-summary-body {
/* Container for summary rows */
}
.invoice-summary-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
font-size: var(--font-size-body);
color: var(--text-secondary);
}
.invoice-summary-total {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0 0;
margin-top: 0.75rem;
border-top: 2px solid var(--surface-200);
font-size: 1.25rem;
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
/* ========================================
Empty State Component
======================================== */
.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;
opacity: 0.5;
}
.empty-state-text {
font-size: 1rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
font-weight: var(--font-weight-medium);
}
.empty-state-description {
font-size: var(--font-size-small);
color: var(--text-muted);
margin-bottom: 1.5rem;
}
/* ========================================
Form Actions
======================================== */
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1.5rem 0;
border-top: 1px solid var(--surface-200);
margin-top: 1.5rem;
}
.form-actions-secondary {
display: flex;
gap: 0.5rem;
align-items: center;
}
@media (max-width: 768px) {
.form-actions {
flex-direction: column-reverse;
align-items: stretch;
}
.form-actions-secondary {
flex-direction: column;
width: 100%;
}
}
/* ========================================
Context Card (Payment Form)
======================================== */
.context-card {
background: linear-gradient(135deg, var(--primary-50) 0%, var(--primary-100) 100%);
border: 1px solid var(--primary-200);
border-radius: var(--radius-section);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.app-dark .context-card {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(59, 130, 246, 0.2) 100%);
border-color: rgba(59, 130, 246, 0.3);
}
.context-card-header {
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--primary-200);
}
.context-card-header h4 {
margin: 0;
font-size: 1rem;
font-weight: var(--font-weight-semibold);
}
.context-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
}
.context-row--highlight {
padding: 1rem 0 0;
margin-top: 0.5rem;
border-top: 1px solid var(--primary-300);
}
.context-label {
color: var(--text-secondary);
font-size: var(--font-size-body);
}
.context-value {
text-align: right;
font-weight: var(--font-weight-medium);
}
/* ========================================
Upload Area (PDF Upload)
======================================== */
.upload-area {
border: 2px dashed var(--surface-200);
border-radius: var(--radius-section);
padding: 3rem 2rem;
text-align: center;
transition: all var(--transition-normal);
cursor: pointer;
background: var(--surface-0);
}
.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;
transform: scale(1.02);
}
.upload-placeholder {
/* Container for upload placeholder content */
}
.upload-icon {
font-size: 4rem;
color: var(--primary-color);
margin-bottom: 1rem;
opacity: 0.8;
}
.upload-hint {
font-size: var(--font-size-small);
color: var(--text-muted);
margin-top: 1rem;
}
.upload-preview {
/* Container for file preview */
}
.file-info {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--surface-50);
border: 1px solid var(--surface-200);
border-radius: var(--radius-card);
}
.file-icon {
font-size: 2.5rem;
color: var(--danger-color);
flex-shrink: 0;
}
.file-details {
flex: 1;
text-align: left;
min-width: 0; /* Allow text truncation */
}
.file-name {
font-weight: var(--font-weight-semibold);
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: var(--font-size-body);
color: var(--text-secondary);
}
/* ========================================
Keyboard Hints
======================================== */
.keyboard-hints {
text-align: center;
padding: 1rem;
border-top: 1px solid var(--surface-200);
margin-top: 1rem;
}
kbd {
background: var(--surface-100);
border: 1px solid var(--surface-300);
border-radius: 0.25rem;
padding: 0.125rem 0.375rem;
font-size: var(--font-size-small);
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
@media (max-width: 768px) {
.keyboard-hints {
display: none;
}
}
/* ========================================
Validation & Error States
======================================== */
.p-error {
color: var(--danger-color);
font-size: var(--font-size-small);
margin-top: 0.25rem;
display: block;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Required field indicator */
abbr[title] {
text-decoration: none;
color: var(--danger-color);
font-weight: var(--font-weight-bold);
margin-left: 0.125rem;
}
/* ========================================
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;
}
/* ========================================
Accessibility Utilities
======================================== */
/* Screen reader only content */
.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;
}
/* Focus visible styles */
*:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
border-radius: var(--radius-small);
}
/* Skip to main content link */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--primary-color);
color: white;
padding: 8px 16px;
text-decoration: none;
z-index: var(--z-tooltip);
border-radius: 0 0 var(--radius-small) 0;
font-weight: var(--font-weight-medium);
}
.skip-link:focus {
top: 0;
}
/* ========================================
Loading States
======================================== */
.skeleton-loader {
background: linear-gradient(
90deg,
var(--surface-200) 25%,
var(--surface-100) 50%,
var(--surface-200) 75%
);
background-size: 200% 100%;
animation: loading 1.5s ease-in-out infinite;
border-radius: var(--radius-small);
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* ========================================
Utility Classes
======================================== */
/* Text utilities */
.text-muted {
color: var(--text-muted);
}
.text-secondary {
color: var(--text-secondary);
}
.text-primary-color {
color: var(--primary-color);
}
/* Spacing utilities */
.gap-section {
gap: var(--space-section-gap);
}
.gap-field {
gap: var(--space-field-gap);
}
.gap-inline {
gap: var(--space-inline-gap);
}
/* Flex utilities */
.flex-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
/* ========================================
Print Styles
======================================== */
@media print {
.form-actions,
.keyboard-hints,
.invoice-item-actions,
.form-section-header button {
display: none !important;
}
.form-section {
box-shadow: none;
border: 1px solid #ddd;
page-break-inside: avoid;
}
.invoice-summary {
border: 2px solid #333;
page-break-inside: avoid;
}
}
/* ========================================
Dark Mode Specific Adjustments
======================================== */
.app-dark .invoice-item-card {
background: rgba(255, 255, 255, 0.05);
}
.app-dark .form-section {
border-color: rgba(255, 255, 255, 0.1);
}
.app-dark kbd {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
/* ========================================
High Contrast Mode Support
======================================== */
@media (prefers-contrast: high) {
.form-section {
border-width: 2px;
}
.invoice-item-card {
border-width: 2px;
}
*:focus-visible {
outline-width: 3px;
}
}
/* ========================================
Reduced Motion Support
======================================== */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@ -59,11 +59,11 @@ class Contact implements ModuleAwareInterface
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['contact:read', 'project:read'])]
#[Groups(['contact:read', 'project:read', 'invoice:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['contact:read', 'contact:write', 'project:read'])]
#[Groups(['contact:read', 'contact:write', 'project:read', 'invoice:read'])]
#[Assert\NotBlank(message: 'Der Firmenname darf nicht leer sein')]
#[Assert\Length(max: 255)]
private ?string $companyName = null;