Files
Caramel-CRM/resources/js/components/documents/InvoiceDialog.vue
T

928 lines
42 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, nextTick } from "vue"
import { Customer, Invoice, Contact, PaymentTerms, Address, LineItem, PaymentStatus, Unit } from "@/types"
import { newCustomer, newContact, newBillingData } from '@/types/index.d'
import { toCurrency, toLocalDate, toShortISOString, calcDueDate, toFixedRounded, hotkey } from '@/lib/utils'
import axios from "axios"
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/crm-table'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'
import { Button } from '@/components/ui/crm-button'
import { Input } from '@/components/ui/crm-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, Logs, Import } 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"
import TextEditor from "../TextEditor.vue"
const DEBUG = ref(false)
const props = defineProps<{
invoiceData: Invoice | undefined,
modelValue: boolean
}>()
const emit = defineEmits(['update:modelValue', 'save', 'cancel', 'delete'])
// Dialog state
const isOpen = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:modelValue', value)
}
})
const invoice = ref<Invoice>()
const units = ref([] as Unit[])
const customers = ref([] as Customer[])
const paymentTerms = ref([] as PaymentTerms[])
const isDirty = 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 fileInput = ref<HTMLInputElement | null>(null);
const dataLoaded = ref(false)
const title = computed<string>(_ => {
if (invoice.value && invoice.value.id !== 0) {
return `Rechnung ${invoice.value.nr || ''}`
} else {
return 'Neue Rechnung'
}
})
onMounted(async () => {
// Load additional data that wasnt provided by parent invoices view
try {
const promises: Promise<any>[] = [];
// Create an array of promises for each data request
promises.push(axios.get("/api/customers"))
promises.push(axios.get("/api/paymentterms"))
promises.push(axios.get("/api/units"))
// Wait for all promises to resolve
const responses = await Promise.all(promises)
// Process each response
customers.value = responses[0].data
paymentTerms.value = responses[1].data
units.value = responses[2].data
} catch (error) {
toast.error('Fehler beim Laden der Daten', error || String(error))
}
// Register hotkeys
hotkey('mod+i', importLineItems, null, () => isOpen.value)
hotkey('mod+e', exportPdf, null, () => isOpen.value)
hotkey('mod+p', preview, null, () => isOpen.value)
})
// Initial data from parent view
watch(() => props.invoiceData, async () => {
// Reset state flag
isDirty.value = false
dataLoaded.value = false
if (DEBUG.value) {
console.group('on parent data')
console.log(`isDirty: ${isDirty.value}`, `dataLoaded: ${dataLoaded.value}`)
}
// Get invoice data from props
invoice.value = props.invoiceData
// Load line items
if (invoice.value && invoice.value.id !== 0) {
itemsLoading.value = true
try {
await 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))
} finally {
await nextTick()
itemsLoading.value = false
}
}
/* wait until next tick to reset loading state
*
* as changes made to customer and contacts here will probably
* change the invoice and would then trigger the invoice watcher agai
*/
await nextTick()
dataLoaded.value = true
if (DEBUG.value) {
console.log(`isDirty: ${isDirty.value}`, `dataLoaded: ${dataLoaded.value}`)
console.groupEnd()
}
})
// watch changes on local invoice date
watch(invoice,
async (newValue, oldValue) => {
if (newValue == oldValue) return
if (DEBUG.value) {
console.group('watch invoice')
console.log(`isDirty: ${isDirty.value}`, `dataLoaded: ${dataLoaded.value}`)
}
if (dataLoaded.value) {
isDirty.value = true
} else {
if (!newValue) {
console.groupEnd()
return;
}
// If no billing data is store in the invoice, generate ot from customer
if (!newValue.billingData) {
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 || paymentTerms.value.length > 0 ? paymentTerms.value[2] : null,
}
}
if (newValue.customerId && newValue.customerId !== 0) {
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) {
importContact.value = contact
return true
}
})
}
})
}
}
if (DEBUG.value) {
console.log(`isDirty: ${isDirty.value}`, `dataLoaded: ${dataLoaded.value}`)
console.groupEnd()
}
},
{ deep: true }
)
watch(importCustomer,
(newValue, oldValue) => {
if (newValue == oldValue) return
if (!invoice.value) return
if (DEBUG.value) {
console.group('watch importCustomer')
console.log(`isDirty: ${isDirty.value}`)
}
// Don't overwrite these values during loading
// they can intentionally be different from customer data
if (dataLoaded.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;
}
if (DEBUG.value) {
console.log(`isDirty: ${isDirty.value}`)
console.groupEnd()
}
},
{ deep: true }
)
watch(importContact,
(newValue, oldValue) => {
if (newValue == oldValue) return
if (!invoice.value) return
if (DEBUG.value) {
console.group('watch importContact')
console.log(`isDirty: ${isDirty.value}`)
}
if (dataLoaded.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;
}
if (DEBUG.value) {
console.log(`isDirty: ${isDirty.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 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,
unitId: item.unitId,
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
await axios.put(`/api/invoices/${invoice.value.id}`, invoiceToSave);
}
emit('save', invoice.value)
} catch (error) {
toast.error("Rechnung konnte nicht gespeichert werden", {
description: (error as Error).message,
})
} finally {
// remove spinner from save button
isSaving.value = false
isDirty.value = false
}
}
}
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 exportPdf = function () {
if (!invoice.value) return;
window?.open('/invoice/' + invoice.value.id + '/pdf');
}
const exportXml = function () {
if (!invoice.value) return;
window?.open('/invoice/' + invoice.value.id + '/xml');
}
const issueInvoice = function () {
if (!invoice.value) return;
invoice.value.paymentStatus = 'issued'
save()
}
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 (to: string | undefined, cc: string | undefined) {
if (!to) 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
let request = '/api/invoices/' + invoice.value.id + '/remind?to=' + to
if (cc) request += '&cc=' + cc
await axios.get(request)
invoice.value.paymentStatus = 'reminded'
save()
toast.success("Zahlungserinnerung gesendet", { description: to })
} catch (error: any) {
toast.error(error?.title || 'Fehler', { description: error?.message || String(error) })
} finally {
reminderLoading.value = false
}
}
const updateLineItems = (newItems: LineItem[]) => {
if (!dataLoaded.value) return;
if (!invoice.value) return;
console.group('updateLineItems');
console.log(`isDirty: ${isDirty.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);
// Create a deep copy of the new items
const updatedItems = JSON.parse(JSON.stringify(sortedItems));
// Update the invoice items
invoice.value.items = updatedItems;
// Calculate the new total amount
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}`);
console.groupEnd();
}
const importLineItems = () => {
if (!isOpen) return
if (!fileInput.value) {
fileInput.value = document.createElement('input');
fileInput.value.type = 'file';
fileInput.value.accept = '.csv';
fileInput.value.addEventListener('change', handleFileUpload);
}
fileInput.value.click();
}
const handleFileUpload = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (!input.files || !input.files[0]) return;
const file = input.files[0];
const formData = new FormData();
formData.append('csv', file);
try {
const response = await axios.post(`/api/lineitems/import`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
if (invoice.value) {
invoice.value.items = invoice.value.items.concat(response.data as LineItem[]);
isDirty.value = true;
}
toast.success('Positionen erfolgreich importiert');
} catch (error) {
console.error('Fehler beim Importieren der Positionen:', error);
toast.error('Fehler beim Importieren der Positionen', {
description: error instanceof Error ? error.message : String(error)
});
} finally {
// Reset file input
if (fileInput.value) {
fileInput.value.value = '';
}
}
}
</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-12">
<div class="flex flex-col grow">
<DialogTitle class="text-primary font-bold text-left">
<h1>{{ title }}</h1>
</DialogTitle>
<DialogDescription>
<Input v-if="invoice" v-model="invoice.title" @update:model-value="isDirty = true"
:id="'invoice-title'" class="" 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" class="animate-spin" />
<Check v-else />
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 /> 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"
:class="{ 'w-0!': !reminderLoading, 'mr-2': reminderLoading }" />
Erinnern
</Button>
</TooltipTrigger>
<TooltipContent>
Zahlungserinnerung per E-Mail senden
</TooltipContent>
</Tooltip>
<!-- Ellipsis menu -->
<DropdownMenu v-if="invoice">
<DropdownMenuTrigger>
<Button variant="ghost" size="sm" class="px-0! w-7 ml-2">
<Ellipsis class="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<!-- Import Items -->
<DropdownMenuItem class="flex items-center justify-between" @click="importLineItems">
<div class="flex items-center gap-3">
<Import class="text-muted-foreground" />
<div class="mr-4 flex flex-col">
<span>Posten importieren</span>
<span class="text-xs text-muted-foreground">(CSV)</span>
</div>
</div>
<KbdGroup>
<Kbd class="visible-mac"></Kbd>
<Kbd class="visible-pc">Ctrl</Kbd>
<Kbd>I</Kbd>
</KbdGroup>
</DropdownMenuItem>
<DropdownMenuSeparator />
<!-- Preview -->
<DropdownMenuItem class="flex items-center justify-between" @click="preview">
<div class="flex items-center gap-3">
<Eye class="text-muted-foreground" />
<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="exportPdf">
<div class="flex items-center gap-3">
<FileText 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="exportXml">
<div class="flex items-center gap-3">
<CodeXml 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 v-if="invoice && invoice.paymentStatus != 'draft'" />
<!-- Audit -->
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'"
class="flex justify-between" @click="" disabled>
<div class="flex items-center gap-3">
<Logs class="text-muted-foreground" />
<span>Audit</span>
</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 class="text-muted-foreground"/> -->
<Ban 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 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
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 paymentTerms" :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" /> -->
<TextEditor v-model="invoice.text" placeholder="Anschreiben"
@change:model-value="isDirty = true"
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" :units="units" @update:lineItems="updateLineItems"
sticky-top="7" :isLoading="itemsLoading" class="mt-4" />
</div>
</div>
</DialogContent>
</Dialog>
<SendMailDialog v-model:open="reminderDialogOpen" title="Zahlungserinnerung senden?" description=""
:to="billingContactEmail" @send="(to, cc) => sendReminder(to, cc)" />
</template>
<style></style>