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>
|
<template>
|
||||||
<div class="invoice-form">
|
<div class="invoice-form">
|
||||||
<Message severity="info" v-if="!invoice">
|
<div class="flex flex-col gap-6">
|
||||||
Rechnungsformular (Phase 1 MVP - wird erweitert)
|
<!-- Basic Information Panel -->
|
||||||
</Message>
|
<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">
|
<div class="flex flex-col gap-2">
|
||||||
<!-- Rechnungsnummer -->
|
<label for="status">Status</label>
|
||||||
<div class="col-12 md:col-6">
|
<Dropdown
|
||||||
<label for="invoiceNumber">Rechnungsnummer *</label>
|
id="status"
|
||||||
<InputText
|
v-model="form.status"
|
||||||
id="invoiceNumber"
|
:options="statusOptions"
|
||||||
v-model="form.invoiceNumber"
|
option-label="label"
|
||||||
:class="{ 'p-invalid': submitted && !form.invoiceNumber }"
|
option-value="value"
|
||||||
/>
|
:disabled="saving"
|
||||||
<small v-if="submitted && !form.invoiceNumber" class="p-error">Rechnungsnummer ist erforderlich</small>
|
>
|
||||||
</div>
|
<template #value="slotProps">
|
||||||
|
<div v-if="slotProps.value" class="flex align-items-center">
|
||||||
<!-- Status -->
|
<Tag :value="getStatusLabel(slotProps.value)" :severity="getStatusSeverity(slotProps.value)" />
|
||||||
<div class="col-12 md:col-6">
|
</div>
|
||||||
<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" />
|
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
<template #option="slotProps">
|
||||||
<Column field="quantity" header="Menge">
|
<div class="flex align-items-center">
|
||||||
<template #body="slotProps">
|
<Tag :value="slotProps.option.label" :severity="getStatusSeverity(slotProps.option.value)" />
|
||||||
<InputNumber v-model="slotProps.data.quantity" :min-fraction-digits="2" class="w-full" />
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Dropdown>
|
||||||
<Column field="unitPrice" header="Einzelpreis">
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Footer Actions -->
|
||||||
<div class="flex gap-2 mt-4">
|
<div class="flex justify-end gap-3 pt-4 border-top-1 surface-border mt-6">
|
||||||
<Button label="Speichern" icon="pi pi-check" @click="save" :loading="saving" />
|
<Button label="Abbrechen" @click="cancel" text severity="secondary" :disabled="saving" />
|
||||||
<Button label="Abbrechen" icon="pi pi-times" severity="secondary" text @click="cancel" :disabled="saving" />
|
<Button label="Speichern" @click="save" :loading="saving" icon="pi pi-check" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useToast } from 'primevue/usetoast'
|
import { useToast } from 'primevue/usetoast'
|
||||||
import InputText from 'primevue/inputtext'
|
import InputText from 'primevue/inputtext'
|
||||||
import Dropdown from 'primevue/dropdown'
|
import Dropdown from 'primevue/dropdown'
|
||||||
@ -116,7 +256,9 @@ import Button from 'primevue/button'
|
|||||||
import DataTable from 'primevue/datatable'
|
import DataTable from 'primevue/datatable'
|
||||||
import Column from 'primevue/column'
|
import Column from 'primevue/column'
|
||||||
import InputNumber from 'primevue/inputnumber'
|
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({
|
const props = defineProps({
|
||||||
invoice: Object
|
invoice: Object
|
||||||
@ -127,6 +269,7 @@ const emit = defineEmits(['save', 'cancel'])
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const submitted = ref(false)
|
const submitted = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const loadingContacts = ref(false)
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
invoiceNumber: '',
|
invoiceNumber: '',
|
||||||
@ -139,6 +282,11 @@ const form = ref({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const contacts = ref([])
|
const contacts = ref([])
|
||||||
|
const calculatedTotals = ref({
|
||||||
|
netAmount: 0,
|
||||||
|
taxBreakdown: {},
|
||||||
|
total: 0
|
||||||
|
})
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ label: 'Entwurf', value: 'draft' },
|
{ label: 'Entwurf', value: 'draft' },
|
||||||
@ -151,8 +299,86 @@ const statusOptions = [
|
|||||||
|
|
||||||
const taxRates = ['0.00', '7.00', '19.00']
|
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 () => {
|
onMounted(async () => {
|
||||||
// Load contacts
|
// Load contacts
|
||||||
|
loadingContacts.value = true
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/contacts?itemsPerPage=1000', {
|
const response = await fetch('/api/contacts?itemsPerPage=1000', {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@ -178,6 +404,8 @@ onMounted(async () => {
|
|||||||
detail: 'Kontakte konnten nicht geladen werden',
|
detail: 'Kontakte konnten nicht geladen werden',
|
||||||
life: 3000
|
life: 3000
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
loadingContacts.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load existing invoice data
|
// Load existing invoice data
|
||||||
@ -201,6 +429,11 @@ onMounted(async () => {
|
|||||||
if (props.invoice.dueDate) {
|
if (props.invoice.dueDate) {
|
||||||
form.value.dueDate = new Date(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,
|
unitPrice: 0.00,
|
||||||
taxRate: '19.00'
|
taxRate: '19.00'
|
||||||
})
|
})
|
||||||
|
calculateTotals()
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeItem = (index) => {
|
const removeItem = (index) => {
|
||||||
form.value.items.splice(index, 1)
|
form.value.items.splice(index, 1)
|
||||||
|
calculateTotals()
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
@ -308,6 +543,103 @@ const cancel = () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.invoice-form {
|
.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;
|
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>
|
</style>
|
||||||
|
|||||||
@ -14,99 +14,82 @@
|
|||||||
@edit="openEditDialog"
|
@edit="openEditDialog"
|
||||||
@delete="deleteInvoice"
|
@delete="deleteInvoice"
|
||||||
>
|
>
|
||||||
<template #body="{ item }">
|
<template #body-invoiceNumber="{ data }">
|
||||||
<Column field="invoiceNumber" header="Rechnungsnummer" sortable>
|
<router-link :to="`/billing/invoices/${data.id}`" class="text-primary">
|
||||||
<template #body="slotProps">
|
{{ data.invoiceNumber }}
|
||||||
<router-link :to="`/billing/invoices/${slotProps.data.id}`" class="text-primary">
|
</router-link>
|
||||||
{{ slotProps.data.invoiceNumber }}
|
</template>
|
||||||
</router-link>
|
<template #body-contact.companyName="{ data }">
|
||||||
</template>
|
{{ data.contact?.companyName || '-' }}
|
||||||
</Column>
|
</template>
|
||||||
|
<template #body-invoiceDate="{ data }">
|
||||||
<Column field="contact.name" header="Kunde" sortable>
|
{{ formatDate(data.invoiceDate) }}
|
||||||
<template #body="slotProps">
|
</template>
|
||||||
{{ slotProps.data.contact?.name || '-' }}
|
<template #body-dueDate="{ data }">
|
||||||
</template>
|
{{ formatDate(data.dueDate) }}
|
||||||
</Column>
|
</template>
|
||||||
|
<template #body-total="{ data }">
|
||||||
<Column field="invoiceDate" header="Rechnungsdatum" sortable>
|
{{ formatCurrency(data.total) }}
|
||||||
<template #body="slotProps">
|
</template>
|
||||||
{{ formatDate(slotProps.data.invoiceDate) }}
|
<template #body-openAmount="{ data }">
|
||||||
</template>
|
{{ formatCurrency(data.openAmount) }}
|
||||||
</Column>
|
</template>
|
||||||
|
<template #body-status="{ data }">
|
||||||
<Column field="dueDate" header="Fälligkeitsdatum" sortable>
|
<Tag :value="getStatusLabel(data.status)" :severity="getStatusSeverity(data.status)" />
|
||||||
<template #body="slotProps">
|
</template>
|
||||||
{{ formatDate(slotProps.data.dueDate) }}
|
<template #body-actions="{ data }">
|
||||||
</template>
|
<div class="flex gap-2">
|
||||||
</Column>
|
<Button
|
||||||
|
v-if="data.pdfPath"
|
||||||
<Column field="total" header="Gesamtbetrag" sortable>
|
icon="pi pi-file-pdf"
|
||||||
<template #body="slotProps">
|
outlined
|
||||||
{{ formatCurrency(slotProps.data.total) }}
|
severity="secondary"
|
||||||
</template>
|
@click="viewPDF(data)"
|
||||||
</Column>
|
v-tooltip.top="'PDF anzeigen'"
|
||||||
|
/>
|
||||||
<Column field="openAmount" header="Offen" sortable>
|
<Button
|
||||||
<template #body="slotProps">
|
v-else
|
||||||
{{ formatCurrency(slotProps.data.openAmount) }}
|
icon="pi pi-upload"
|
||||||
</template>
|
outlined
|
||||||
</Column>
|
severity="info"
|
||||||
|
@click="uploadPDF(data)"
|
||||||
<Column field="status" header="Status" sortable>
|
v-tooltip.top="'PDF hochladen'"
|
||||||
<template #body="slotProps">
|
/>
|
||||||
<Tag :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
|
<Button
|
||||||
</template>
|
icon="pi pi-money-bill"
|
||||||
</Column>
|
outlined
|
||||||
|
severity="success"
|
||||||
<Column :exportable="false" style="min-width:12rem">
|
@click="addPayment(data)"
|
||||||
<template #body="slotProps">
|
v-tooltip.top="'Zahlung hinzufügen'"
|
||||||
<div class="flex gap-2">
|
/>
|
||||||
<Button
|
<Button
|
||||||
v-if="slotProps.data.pdfPath"
|
icon="pi pi-pencil"
|
||||||
icon="pi pi-file-pdf"
|
outlined
|
||||||
outlined
|
severity="info"
|
||||||
severity="secondary"
|
@click="openEditDialog(data)"
|
||||||
@click="viewPDF(slotProps.data)"
|
v-tooltip.top="'Bearbeiten'"
|
||||||
v-tooltip.top="'PDF anzeigen'"
|
/>
|
||||||
/>
|
<Button
|
||||||
<Button
|
icon="pi pi-trash"
|
||||||
v-else
|
outlined
|
||||||
icon="pi pi-upload"
|
severity="danger"
|
||||||
outlined
|
@click="deleteInvoice(data)"
|
||||||
severity="info"
|
v-tooltip.top="'Löschen'"
|
||||||
@click="uploadPDF(slotProps.data)"
|
/>
|
||||||
v-tooltip.top="'PDF hochladen'"
|
</div>
|
||||||
/>
|
|
||||||
<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>
|
</template>
|
||||||
</CrudDataTable>
|
</CrudDataTable>
|
||||||
|
|
||||||
<!-- Create/Edit Dialog -->
|
<!-- 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
|
<InvoiceForm
|
||||||
v-if="dialogVisible"
|
v-if="dialogVisible"
|
||||||
:invoice="currentInvoice"
|
:invoice="currentInvoice"
|
||||||
@ -162,13 +145,13 @@ const pdfUploadDialogVisible = ref(false)
|
|||||||
const canCreate = computed(() => authStore.hasPermission('billing', 'create'))
|
const canCreate = computed(() => authStore.hasPermission('billing', 'create'))
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ field: 'invoiceNumber', header: 'Rechnungsnummer' },
|
{ key: 'invoiceNumber', field: 'invoiceNumber', label: 'Rechnungsnummer', default: true },
|
||||||
{ field: 'contact.name', header: 'Kunde' },
|
{ key: 'contact.companyName', field: 'contact.companyName', label: 'Kunde', default: true },
|
||||||
{ field: 'invoiceDate', header: 'Rechnungsdatum' },
|
{ key: 'invoiceDate', field: 'invoiceDate', label: 'Rechnungsdatum', default: true },
|
||||||
{ field: 'dueDate', header: 'Fälligkeitsdatum' },
|
{ key: 'dueDate', field: 'dueDate', label: 'Fälligkeitsdatum', default: true },
|
||||||
{ field: 'total', header: 'Gesamtbetrag' },
|
{ key: 'total', field: 'total', label: 'Gesamtbetrag', default: true },
|
||||||
{ field: 'openAmount', header: 'Offen' },
|
{ key: 'openAmount', field: 'openAmount', label: 'Offen', default: true },
|
||||||
{ field: 'status', header: 'Status' }
|
{ key: 'status', field: 'status', label: 'Status', default: true }
|
||||||
]
|
]
|
||||||
|
|
||||||
const openCreateDialog = () => {
|
const openCreateDialog = () => {
|
||||||
@ -280,4 +263,36 @@ const getStatusSeverity = (status) => {
|
|||||||
.text-primary:hover {
|
.text-primary:hover {
|
||||||
text-decoration: underline;
|
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>
|
</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\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['contact:read', 'project:read'])]
|
#[Groups(['contact:read', 'project:read', 'invoice:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 255)]
|
#[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\NotBlank(message: 'Der Firmenname darf nicht leer sein')]
|
||||||
#[Assert\Length(max: 255)]
|
#[Assert\Length(max: 255)]
|
||||||
private ?string $companyName = null;
|
private ?string $companyName = null;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user