- 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.
975 lines
25 KiB
Vue
975 lines
25 KiB
Vue
<!--
|
|
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>
|