This repository has been archived on 2025-12-04. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Caramel-CRM-Backup/resources/js/components/documents/InvoiceDialog.vue
T

827 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- Rechnungsart: Wiederkehren, einmalig, Abschlag (siehe Chat, wirkt sich auf Leistungszeitraum aus) -->
<!-- TODO: Leistungsdatum -->
<!-- TODO: Leistungsstart -->
<!-- TODO: Leistungsende -->
<!-- TODO: Steuersatz in LineItem -->
<!-- TODO: Stunden und Tagessatz aus Settings -->
<!-- TODO: Client-side validation -->
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUpdated, toRaw } from "vue"
import { Customer, Invoice, Contact, PaymentTerms, Address, LineItem, PaymentStatus } from "@/types"
import { newCustomer, newContact, newBillingData } from '@/types/index.d'
import { toCurrency, toLocalDate, toShortISOString, calcDueDate, toFixedRounded } from '@/lib/utils'
import axios from 'axios'
import { type DateValue, getLocalTimeZone, fromDate } from "@internationalized/date"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Table, TableBody, TableCell, TableHead, TableRow, } from '@/components/ui/table'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
import { StatusBadge, statusBadgeLabels } from '@/components/ui/status-badge'
import LineItemTable from '@/components/documents/LineItemTable.vue'
import { Eye, FileText, Trash2, BookUser, User, CodeXml, MessageCircleQuestion, Loader2, Ellipsis, Check, FileCheck, Ban } from "lucide-vue-next"
import { alertStore } from "@/stores/alertStore"
import { GrowingTextarea } from '../ui/growing-textarea'
import { toast } from "vue-sonner"
import { Kbd, KbdGroup } from '@/components/ui/kbd';
import DialogClose from "../ui/dialog/DialogClose.vue"
import DialogCloseButton from "../DialogCloseButton/DialogCloseButton.vue";
import SendMailDialog from "../ui/send-mail-dialog/SendMailDialog.vue"
const props = defineProps<{
invoiceData?: Invoice,
modelValue: boolean
}>()
const invoice = ref<Invoice>()
const customers = ref([] as Customer[])
const paymentTermsData = ref([] as PaymentTerms[])
const isDirty = ref(false)
const isLoading = ref(false)
const isSaving = ref(false)
const itemsLoading = ref(false)
const importContact = ref(newContact() as Contact)
const importCustomer = ref(newCustomer() as Customer)
const alert = alertStore()
const reminderDialogOpen = ref(false)
const reminderLoading = ref(false)
const value = ref<DateValue>() // TODO: name properly
const emit = defineEmits(['update:modelValue', 'save', 'cancel', 'delete'])
const isOpen = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:modelValue', value)
}
})
const title = computed<string>(() => {
if (invoice.value && invoice.value.id !== 0) {
return `Rechnung ${invoice.value.nr || ''}`
} else {
return 'Neue Rechnung'
}
})
onMounted(async () => {
// Load customers and payment terms
try {
const promises = [];
promises.push(axios.get('/api/customers'))
promises.push(axios.get('/api/paymentterms'))
const responses = await Promise.all(promises)
let responseIndex = 0
customers.value = responses[responseIndex].data as Customer[]
paymentTermsData.value = responses[responseIndex + 1].data as PaymentTerms[]
} catch (error) {
toast.error('Fehler beim Laden der Daten', error || String(error))
}
})
onUpdated(() => {
if (isLoading.value) isLoading.value = false
// console.group('onUpdated')
// console.error(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// console.groupEnd()
})
watch(() => props.modelValue, (open) => {
// on open
if (open) {
// console.group('on open')
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// Reset state flags
isDirty.value = false;
isLoading.value = true;
// Get invoice data from props
// console.warn('trigger invoice watcher')
invoice.value = props.invoiceData
// Load line items
if (invoice.value && invoice.value.id !== 0) {
itemsLoading.value = true
try {
itemsLoading.value = true
axios.get('/api/lineitems/' + invoice.value.id).then(response => {
if (invoice.value) invoice.value.items = response.data as LineItem[]
})
} catch (error) {
toast.error('Fehler beim Laden der Positionen', error || String(error))
}
} else {
itemsLoading.value = false
}
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// console.groupEnd()
}
})
// watch changes on local invoice date
watch(invoice,
(newValue, oldValue) => {
if (newValue == oldValue) return
// console.group('watch invoice')
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
if (isLoading.value) {
if (!newValue) {
// console.groupEnd()
return;
}
// Set default billing data from customer
if (!newValue.billingData) {
// console.warn('trigger invoice watcher')
newValue.billingData = {
companyName: newValue.customer?.companyName || "",
vatId: newValue.customer?.vatId || "",
billingAddress: {
lineOne: newValue.customer?.billingAddress?.lineOne || "",
lineTwo: newValue.customer?.billingAddress?.lineTwo || "",
city: newValue.customer?.billingAddress?.city || "",
postalCode: newValue.customer?.billingAddress?.postalCode || "",
countryCode: newValue.customer?.billingAddress?.countryCode || "",
},
contactSalutation: newValue.customer?.contacts && newValue.customer.contacts.length > 0 ? newValue.customer.contacts[0].salutation : "",
contactFirstName: newValue.customer?.contacts && newValue.customer.contacts.length > 0 ? newValue.customer.contacts[0].firstName : "",
contactLastName: newValue.customer?.contacts && newValue.customer.contacts.length > 0 ? newValue.customer.contacts[0].lastName : "",
paymentTerms: newValue.customer?.paymentTerms || paymentTermsData.value.length > 0 ? paymentTermsData.value[2] : null,
}
}
if (newValue.customerId && newValue.customerId !== 0) {
// if (importCustomer.value != newValue.customer)
// console.warn('trigger importCustomer watcher')
console.log(newValue, newValue.customerId)
customers.value.find(customer => {
if (customer.id === newValue.customerId) {
if (invoice.value) invoice.value.customer = customer as Customer
importCustomer.value = customer as Customer
customer.contacts.find(contact => {
if (contact.firstName === newValue?.billingData?.contactFirstName &&
contact.lastName === newValue?.billingData?.contactLastName) {
// if (importContact.value != contact)
// console.warn('trigger importContact watcher')
importContact.value = contact
return true
}
})
}
})
}
value.value = fromDate(new Date(newValue.invoiceDate), getLocalTimeZone())
}
else {
isDirty.value = true
}
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// console.groupEnd()
},
{ deep: true }
)
watch(importCustomer,
(newValue, oldValue) => {
if (newValue == oldValue) return
if (!invoice.value) return
// console.group('watch importCustomer')
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// Don't overwrite these values during loading
// they can intentionally be different from customer data
if (!isLoading.value) {
if (!invoice.value.billingData) invoice.value.billingData = newBillingData()
// console.warn('trigger invoice watcher')
invoice.value.billingData.companyName = newValue.companyName
invoice.value.billingData.vatId = newValue.vatId
if (!invoice.value.billingData.billingAddress)
invoice.value.billingData.billingAddress = newValue.billingAddress as Address
if (!invoice.value.billingData.contactFirstName)
invoice.value.billingData.contactFirstName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].firstName : ''
if (!invoice.value.billingData.contactLastName)
invoice.value.billingData.contactLastName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].lastName : ''
if (!invoice.value.billingData.paymentTerms)
invoice.value.billingData.paymentTerms = newValue.paymentTerms as PaymentTerms
// console.warn('trigger invoice watcher')
invoice.value.customer = newValue
isDirty.value = true;
}
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// console.groupEnd()
},
{ deep: true }
)
watch(importContact,
(newValue, oldValue) => {
if (newValue == oldValue) return
if (!invoice.value) return
// console.group('watch importContact')
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
if (!isLoading.value) {
if (newValue.id !== 0) {
invoice.value.billingData!.contactFirstName = newValue.firstName
invoice.value.billingData!.contactLastName = newValue.lastName
} else {
invoice.value.billingData!.contactFirstName = ''
invoice.value.billingData!.contactLastName = ''
}
isDirty.value = true;
}
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// console.groupEnd()
},
{ deep: true }
)
const billingContactEmail = computed<string | undefined>(() => {
// TODO: use e-mail from billing data if set
// and fallback to primary contact email
if (invoice.value?.customer && invoice.value?.customer.contacts[0])
return invoice.value?.customer?.contacts[0].email
else return ""
})
const save = async () => {
if (invoice.value) {
// add spinner to save button
isSaving.value = true
try {
// Prepare the invoice data for API request
const invoiceToSave = {
nr: invoice.value.nr,
invoiceDate: invoice.value.invoiceDate,
dueDate: invoice.value.dueDate,
serviceStartDate: invoice.value.serviceStartDate,
serviceEndDate: invoice.value.serviceEndDate,
isRecurring: invoice.value.isRecurring,
isPartialService: invoice.value.isPartialService,
paymentStatus: invoice.value.paymentStatus,
totalAmount: invoice.value.totalAmount,
title: invoice.value.title,
text: invoice.value.text,
customerId: invoice.value.customer ? invoice.value.customer.id : null,
billingData: {
companyName: invoice.value.billingData?.companyName,
vatId: invoice.value.billingData?.vatId,
billingAddress: invoice.value.billingData?.billingAddress,
contactSalutation: invoice.value.billingData?.contactSalutation,
contactFirstName: invoice.value.billingData?.contactFirstName,
contactLastName: invoice.value.billingData?.contactLastName,
paymentTerms: invoice.value.billingData?.paymentTerms
},
// Items will be handled separately in the controller
items: invoice.value.items.map(item => ({
id: item.id, // Include ID for existing items
position: item.position,
isSection: item.isSection,
type: item.type,
title: item.title,
description: item.description,
quantity: item.quantity,
unit: item.unit,
price: item.price
}))
}
if (invoice.value.id === 0) {
// Create new invoice
const response = await axios.post('/api/invoices', invoiceToSave);
invoice.value = response.data;
} else {
// Update existing invoice
const response = await axios.put(`/api/invoices/${invoice.value.id}`, invoiceToSave);
}
emit('save', invoice.value)
// isOpen.value = false
} catch (error) {
toast.error("Rechnung konnte nicht gespeichert werden", {
description: (error as Error).message,
})
} finally {
// remove spinner from save button
isSaving.value = false
setTimeout(() => { isDirty.value = false }, 1000)
}
}
}
const cancel = (event: Event | null) => {
if (!event) return
event.preventDefault()
event.returnValue = true
if (isDirty.value) {
alert.show(
"Wirklich schließen?",
"Es gibt ungespeicherte Änderungen, die dann verloren gehen.",
{
actionText: "Änderungen verwerfen",
onAction: () => {
emit('cancel')
isOpen.value = false
}
}
)
} else {
emit('cancel')
isOpen.value = false
}
}
const preview = function () {
if (!invoice.value) return;
window?.open('/invoice/' + invoice.value.id, '_blank')?.focus();
}
const downloadPdf = function () {
if (!invoice.value) return;
window?.open('/invoice/' + invoice.value.id + '/pdf');
}
const downloadXml = function () {
if (!invoice.value) return;
window?.open('/invoice/' + invoice.value.id + '/xml');
}
const issueInvoice = function () {
if (!invoice.value) return;
invoice.value.paymentStatus = 'issued'
}
const deleteInvoice = function () {
alert.show(
"Möchtest Du diese Rechnung wirklich löschen?",
(invoice.value?.paymentStatus == "draft") ? null : "Nach GoBD musst Du alle Belege und Daten in unveränderter Form aufbewahren.",
{
actionText: "Löschen",
actionVariant: "destructive",
onAction: async () => {
try {
if (!invoice.value || !invoice.value.id) return
await axios.delete('/api/invoices/' + invoice.value.id)
emit('delete', invoice.value?.id)
isOpen.value = false
} catch (error) {
toast.error("Rechnung konnte nicht gelöscht werden", {
description: (error as Error).message,
});
}
}
}
)
}
const updateStatus = function (status: PaymentStatus) {
if (!invoice.value) return;
invoice.value.paymentStatus = status
isDirty.value = true
}
const openReminderDialog = function () {
if (!invoice.value) return
reminderDialogOpen.value = true
}
const sendReminder = async function (recipient: string | undefined) {
if (!recipient) return
if (!invoice.value || !invoice.value.id) return
// Close dialog
reminderDialogOpen.value = false
// make button spin and disable button
reminderLoading.value = true
try {
// await axios call
await axios.get('/api/invoices/' + invoice.value.id + '/remind/' + recipient)
toast.success("Zahlungserinnerung gesendet", { description: recipient })
} catch (error: any) {
toast.error(error?.title || 'Fehler', { description: error?.message || String(error) })
} finally {
reminderLoading.value = false
}
}
const updateLineItems = (newItems: LineItem[]) => {
if (isLoading.value) return;
if (!invoice.value) return;
// console.group('updateLineItems');
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`);
// Konvertiere die neuen Items in normale Objekte
const rawItems = toRaw(newItems) || [];
// Sortiere die Items nach position
const sortedItems = [...rawItems].sort((a, b) => a.position - b.position);
// Erstellen Sie eine tiefe Kopie der neuen Items
const updatedItems = JSON.parse(JSON.stringify(sortedItems));
// Aktualisieren Sie die Items in der Rechnung
invoice.value.items = updatedItems;
// Berechnen Sie den neuen Gesamtbetrag
let total = 0;
updatedItems.forEach(item => {
total += item.quantity * item.price;
});
invoice.value.totalAmount = total;
// Erzwingen Sie eine Aktualisierung der Benutzeroberfläche
invoice.value = { ...invoice.value };
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`);
// console.groupEnd();
}
</script>
<template>
<Dialog id="invoice-dialog" v-model:open="isOpen">
<DialogContent
class="sm:max-w-[min((100%-2rem),1152px)] grid-rows-[auto_minmax(0,1fr)_auto] h-[calc(100dvh-2rem)] gap-0 p-0 outline-none"
@escapeKeyDown="cancel" @interactOutside="cancel">
<DialogHeader class="p-4 md:p-6 lg:p-12 pb-0 md:pb-2 lg:pb-8 flex flex-row items-start gap-6">
<div class="flex flex-col grow">
<DialogTitle class="text-primary-foreground font-bold text-left">
<h1>{{ title }}</h1>
</DialogTitle>
<DialogDescription>
<Input v-if="invoice" v-model="invoice.title"
class="text-foreground md:text-base text-ellipsis px-0 bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none"
type="text" placeholder="Titel" />
</DialogDescription>
</div>
<div class="flex gap-2 items-center">
<TooltipProvider>
<!-- Save -->
<Button v-if="invoice && isDirty" class="grow md:grow-0" size="sm" @click="save"
:disabled="isSaving">
<Loader2 v-if="isSaving" stroke-width="1.5" class="animate-spin" />
<Check v-else stroke-width="1.5" />
Speichern
</Button>
<!-- Issue -->
<!-- TODO: validate complete data -->
<Tooltip v-if="invoice && invoice.paymentStatus == 'draft'">
<TooltipTrigger>
<Button size="sm" variant="action" @click="issueInvoice">
Rechnung stellen
</Button>
</TooltipTrigger>
<TooltipContent>
Bearbeitung sperren und Rechnung erstellen
</TooltipContent>
</Tooltip>
<!-- Paid -->
<Tooltip v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)">
<TooltipTrigger>
<Button size="sm" variant="success" @click="updateStatus('paid')">
<FileCheck stroke-width="1.5" /> Bezahlt
</Button>
</TooltipTrigger>
<TooltipContent>
Als bezahlt markieren
</TooltipContent>
</Tooltip>
<!-- Remind -->
<Tooltip v-if="invoice && ['due', 'reminded'].includes(invoice.paymentStatus)">
<TooltipTrigger>
<Button size="sm" variant="destructive" @click="openReminderDialog"
:disabled="reminderLoading" class="gap-0">
<Loader2 class="h-4 w-4 transition-[width] ease-in-out animate-spin"
stroke-width="1.5"
:class="{ 'w-0!': !reminderLoading, 'mr-2': reminderLoading }" />
Erinnern
</Button>
</TooltipTrigger>
<TooltipContent>
Zahlungserinnerung per E-Mail senden
</TooltipContent>
</Tooltip>
<!-- Ellipsis menu -->
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" size="sm" class="px-0! w-7 ml-2">
<Ellipsis class="size-4" stroke-width="1.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<!-- Preview -->
<DropdownMenuItem v-if="invoice?.paymentStatus == 'draft'"
class="flex items-center justify-between" @click="preview">
<div class="flex items-center gap-3">
<Eye :strokeWidth="1.5" class="text-current" />
<span class="mr-4">Vorschau</span>
</div>
<KbdGroup>
<Kbd class="visible-mac"></Kbd>
<Kbd class="visible-pc">Ctrl</Kbd>
<Kbd>P</Kbd>
</KbdGroup>
</DropdownMenuItem>
<!-- PDF -->
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'"
class="flex justify-between" @click="downloadPdf">
<div class="flex items-center gap-3">
<FileText stroke-width="1.5" class="text-muted-foreground" />
<div class="mr-4 flex flex-col">
<span>PDF exportieren</span>
<span class="text-xs text-muted-foreground">(ZUGFeRD)</span>
</div>
</div>
<KbdGroup>
<Kbd class="visible-mac"></Kbd>
<Kbd class="visible-pc">Ctrl</Kbd>
<Kbd>E</Kbd>
</KbdGroup>
</DropdownMenuItem>
<!-- XML -->
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'"
class="flex justify-between" @click="downloadXml">
<div class="flex items-center gap-3">
<CodeXml stroke-width="1.5" class="text-muted-foreground" />
<div class="mr-4 flex flex-col">
<span>XML exportieren</span>
<span class="text-xs text-muted-foreground">(XRechnung)</span>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<!-- Cancel -->
<DropdownMenuItem
v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)"
class="flex justify-between" @click="updateStatus('cancelled')">
<div class="flex items-center gap-3">
<!-- <FileX stroke-width="1.5" class="text-muted-foreground"/> -->
<Ban :strokeWidth="1.5" class="text-current" />
<span class="mr-2">Stornieren</span>
</div>
</DropdownMenuItem>
<!-- Delete -->
<DropdownMenuItem
class="flex justify-between text-destructive! hover:bg-red-100! dark:hover:bg-red-950!"
@click="deleteInvoice">
<div class="flex items-center gap-3">
<Trash2 :strokeWidth="1.5" class="text-current" />
<span class="mr-2">Löschen</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TooltipProvider>
<DialogClose as-child>
<DialogCloseButton />
</DialogClose>
</div>
</DialogHeader>
<div class="overflow-y-scroll p-4 md:p-6 lg:p-12 pt-0!" v-if="invoice">
<div id="document">
<div id="document-header"
class="h-19 pb-12 sticky top-0 bg-background z-1 flex flex-col md:flex-row justify-between items-center">
<!-- Status -->
<div>
<StatusBadge size="lg" :variant="invoice.paymentStatus">{{
statusBadgeLabels[invoice.paymentStatus] }}
</StatusBadge>
</div>
<!-- Betrag -->
<div class="grid grid-cols-[auto_auto_auto_auto] items-end gap-x-6 gap-y-0">
<label class="text-muted-foreground text-xs pb-[0.4rem]">Netto</label>
<span class="text-lg text-muted-foreground place-self-end">{{
toCurrency(invoice?.totalAmount || 0) }}</span>
<label class="text-muted-foreground text-xs pb-[0.4rem]">Brutto</label>
<span class="text-xl font-bold place-self-end">{{
toCurrency(toFixedRounded(Number(invoice?.totalAmount || 0) *
1.19, 2)) }}</span>
</div>
</div>
<div id="document-meta"
class="flex-none md:flex gap-12 2 p-6 bg-slate-100 dark:bg-neutral-900 rounded-lg">
<div class="flex flex-col gap-1">
<div class="flex">
<Input type="text" v-model="invoice.billingData.companyName" placeholder="Firma"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
<Select v-model="importCustomer" by="id">
<SelectTrigger
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 border-none">
<SelectValue>
<BookUser />
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem :value="newCustomer()">
Manuell eingeben
</SelectItem>
<SelectItem v-for="customer in customers" :value="customer">
{{ customer.companyName }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-row gap-4">
<Input type="text" v-model="invoice.billingData.contactFirstName" placeholder="Vorname"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
<Input type="text" v-model="invoice.billingData.contactLastName" placeholder="Nachname"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
<Select v-model="importContact" by="id">
<SelectTrigger v-bind:disabled="!importCustomer || invoice.customerId === 0"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 border-none">
<SelectValue>
<User />
</SelectValue>
</SelectTrigger>
<SelectContent v-if="invoice.customer && invoice.customer.id !== 0">
<SelectItem :value="{ id: 0, firstName: '', lastName: '' }">
</SelectItem>
<SelectItem v-for="customer in importCustomer.contacts" :value="customer">
{{ customer.firstName + ' ' + customer.lastName }}
</SelectItem>
</SelectContent>
</Select>
</div>
<Input type="text" v-model="invoice.billingData.billingAddress.lineOne"
placeholder="Zeile 1"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
<Input type="text" v-model="invoice.billingData.billingAddress.lineTwo"
placeholder="Zeile 2"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
<div class="flex gap-4">
<Input type="text" v-model="invoice.billingData.billingAddress.postalCode"
placeholder="PLZ"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 w-20 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
<Input type="text" v-model="invoice.billingData.billingAddress.city" placeholder="Stadt"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
</div>
<Input type="text" v-model="invoice.billingData.vatId" placeholder="USt-Id-Nr."
class="mt-6 bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
</div>
<div>
<Table>
<TableBody>
<!-- Rechnungsdatum -->
<TableRow>
<TableHead>Datum</TableHead>
<TableCell>
<Input type="date" :modelValue="toShortISOString(invoice.invoiceDate)"
@update:modelValue="newValue => { invoice.invoiceDate = new Date(newValue); invoice.dueDate = calcDueDate(invoice.invoiceDate, invoice.billingData?.paymentTerms?.days) }"
placeholder="Datum"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-none" />
</TableCell>
</TableRow>
<!-- Zahlungsziel -->
<TableRow>
<TableHead class="flex items-center gap-1">
Zahlungsziel
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<MessageCircleQuestion strokeWidth="1.5"
class="h-[16px] w-[16px] ml-[1px] mb-2" />
</TooltipTrigger>
<TooltipContent>
Zahlungsbedingungen können<br />
beim Kunden hinterlegt werden.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableHead>
<TableCell>
<Select v-if="invoice && invoice.billingData"
v-model="invoice.billingData.paymentTerms" by="id">
<SelectTrigger
class="w-full bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-none">
<SelectValue placeholder="Zahlungsziel" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="term in paymentTermsData" :value="term">
<span v-if="term.isFixed">{{ term.description }}</span>
<span v-else>{{ term.days }} Tage</span>
</SelectItem>
</SelectContent>
</Select>
</TableCell>
</TableRow>
<!-- Fällig -->
<TableRow v-if="!invoice.billingData?.paymentTerms?.isFixed">
<TableHead>Fällig</TableHead>
<TableCell class="pl-5">{{ toLocalDate(invoice.dueDate) }}</TableCell>
</TableRow>
<!-- Leistungszeitraum -->
<TableRow>
<TableHead>Leistungszeitraum</TableHead>
<TableCell>
<Input type="date" :modelValue="toShortISOString(invoice.serviceStartDate)"
@update:modelValue="newValue => invoice.serviceStartDate = new Date(newValue)"
placeholder="Datum"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-none w-fit inline" />
bis
<Input type="date" :modelValue="toShortISOString(invoice.serviceEndDate)"
@update:modelValue="newValue => invoice.serviceEndDate = new Date(newValue)"
placeholder="Datum"
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-none w-fit inline" />
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<div id="document-text" class="mt-6 md:mt-8 lg:mt-12">
<GrowingTextarea v-model="invoice.text" placeholder="Anschreiben"
class="font-light bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none" />
</div>
<LineItemTable :lineItems="invoice.items" @update:lineItems="updateLineItems" sticky-top="7"
:isLoading="itemsLoading" class="mt-4" />
</div>
</div>
</DialogContent>
</Dialog>
<SendMailDialog v-model:open="reminderDialogOpen" title="Zahlungserinnerung senden?" description=""
:recipient="billingContactEmail" @send="(recipient: string | undefined) => sendReminder(recipient)" />
</template>
<style></style>