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:
parent
5ffd7bd0d1
commit
82b022ba3b
31
.claude/agents/architect-review.md
Normal file
31
.claude/agents/architect-review.md
Normal 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.
|
||||
77
.claude/agents/symfony-expert.md
Normal file
77
.claude/agents/symfony-expert.md
Normal 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
|
||||
42
.claude/agents/ux-designer.md
Normal file
42
.claude/agents/ux-designer.md
Normal 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
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
1376
docs/ARCHITECTURE_REVIEW_2025-12-12.md
Normal file
1376
docs/ARCHITECTURE_REVIEW_2025-12-12.md
Normal file
File diff suppressed because it is too large
Load Diff
1805
docs/design/INVOICE_FORM_UX_DESIGN.md
Normal file
1805
docs/design/INVOICE_FORM_UX_DESIGN.md
Normal file
File diff suppressed because it is too large
Load Diff
448
docs/design/README.md
Normal file
448
docs/design/README.md
Normal 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 ✓
|
||||
679
docs/design/VISUAL_DESIGN_GUIDE.md
Normal file
679
docs/design/VISUAL_DESIGN_GUIDE.md
Normal 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
|
||||
400
docs/design/composables/useFormValidation.js
Normal file
400
docs/design/composables/useFormValidation.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
386
docs/design/composables/useKeyboardShortcuts.js
Normal file
386
docs/design/composables/useKeyboardShortcuts.js
Normal 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
|
||||
}
|
||||
}
|
||||
974
docs/design/invoice-form-improved.vue
Normal file
974
docs/design/invoice-form-improved.vue
Normal 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>
|
||||
748
docs/design/invoice-form-styles.css
Normal file
748
docs/design/invoice-form-styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user