2025-10-20 08:57:51 +02:00
|
|
|
|
<!-- 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, useTemplateRef } from "vue"
|
|
|
|
|
|
import { Customer, Invoice, Contact, PaymentTerms, Address } from "@/types"
|
|
|
|
|
|
import { newCustomer, newContact, newBillingData } from '@/types/index.d'
|
2025-10-29 18:04:09 +01:00
|
|
|
|
import { toCurrency, toLocalDate, toShortISOString, cn, calcDueDate, toFixedRounded } from '@/lib/utils'
|
2025-10-20 08:57:51 +02:00
|
|
|
|
import axios from 'axios'
|
|
|
|
|
|
import { type DateValue, DateFormatter, getLocalTimeZone, parseDate, fromDate } from "@internationalized/date"
|
|
|
|
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"
|
|
|
|
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
|
|
|
|
|
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'
|
|
|
|
|
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from '@/components/ui/select'
|
|
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
|
|
|
|
|
|
import { StatusBadge, statusBadgeLabels, statusBadgeTextColor, StatusBadgeVariants } from '@/components/ui/status-badge'
|
|
|
|
|
|
import LineItemTable from '@/components/documents/LineItemTable.vue'
|
2025-10-29 18:04:09 +01:00
|
|
|
|
import { Eye, FileText, CircleEllipsis, Trash, Trash2, BookUser, User, CodeXml, CalendarIcon, MessageCircleQuestion, X, CircleX, Logs, ListCheck, ClipboardCheck, ClipboardList } from "lucide-vue-next"
|
|
|
|
|
|
import { alertStore } from "@/stores/alertStore"
|
2025-10-20 08:57:51 +02:00
|
|
|
|
import { Calendar } from "@/components/ui/calendar"
|
|
|
|
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
2025-10-29 18:04:09 +01:00
|
|
|
|
import { exportPdf, exportXml } from "@/routes/invoice"
|
2025-10-20 08:57:51 +02:00
|
|
|
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, } from '@/components/ui/sheet'
|
2025-10-29 18:04:09 +01:00
|
|
|
|
import { GrowingTextarea } from '../ui/growing-textarea'
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
invoiceData: Invoice | null,
|
|
|
|
|
|
customers: Customer[]
|
|
|
|
|
|
modelValue: boolean
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
2025-10-23 08:27:10 +02:00
|
|
|
|
const emit = defineEmits(['update:modelValue', 'save', 'cancel', 'delete'])
|
|
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
const invoice = ref<Invoice | null>(props.invoiceData)
|
|
|
|
|
|
const paymentTermsData = ref([] as PaymentTerms[])
|
|
|
|
|
|
const isDirty = ref(false);
|
|
|
|
|
|
const isLoading = ref(false);
|
|
|
|
|
|
const importContact = ref(newContact() as Contact)
|
|
|
|
|
|
const importCustomer = ref(newCustomer() as Customer)
|
2025-10-29 18:04:09 +01:00
|
|
|
|
const alert = alertStore()
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
const response = await axios.get('/api/paymentterms')
|
|
|
|
|
|
paymentTermsData.value = response.data as PaymentTerms[]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const isOpen = computed({
|
|
|
|
|
|
get: () => props.modelValue,
|
|
|
|
|
|
set: (value) => {
|
|
|
|
|
|
emit('update:modelValue', value)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const value = ref<DateValue>()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// watch for new external invoice data
|
|
|
|
|
|
watch(() => props.invoiceData as Invoice,
|
|
|
|
|
|
(newValue, oldValue) => {
|
|
|
|
|
|
if (newValue == oldValue) return
|
|
|
|
|
|
invoice.value = newValue
|
|
|
|
|
|
|
|
|
|
|
|
// Set initial state for a newly opened document
|
|
|
|
|
|
isDirty.value = false
|
|
|
|
|
|
isLoading.value = true
|
|
|
|
|
|
|
|
|
|
|
|
// console.log("invoiceData", "Dirty: " + isDirty.value, "loading: " + isLoading.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// watch changes on local invoice date
|
|
|
|
|
|
watch(invoice,
|
|
|
|
|
|
(newValue, oldValue) => {
|
|
|
|
|
|
|
|
|
|
|
|
if (isLoading.value) {
|
|
|
|
|
|
// Initial load of invoice data
|
|
|
|
|
|
if (invoice.value && !invoice.value.billingData) {
|
|
|
|
|
|
// Set default billing data from customer
|
|
|
|
|
|
invoice.value.billingData = {
|
|
|
|
|
|
companyName: invoice.value.customer?.companyName || "",
|
|
|
|
|
|
vatId: invoice.value.customer?.vatId || "",
|
|
|
|
|
|
billingAddress: {
|
|
|
|
|
|
lineOne: invoice.value.customer?.billingAddress?.lineOne || "",
|
|
|
|
|
|
lineTwo: invoice.value.customer?.billingAddress?.lineTwo || "",
|
|
|
|
|
|
city: invoice.value.customer?.billingAddress?.city || "",
|
|
|
|
|
|
postalCode: invoice.value.customer?.billingAddress?.postalCode || "",
|
|
|
|
|
|
countryCode: invoice.value.customer?.billingAddress?.countryCode || "",
|
|
|
|
|
|
},
|
|
|
|
|
|
contactFirstName: invoice.value.customer?.contacts && invoice.value.customer.contacts.length > 0 ? invoice.value.customer.contacts[0].firstName : "",
|
|
|
|
|
|
contactLastName: invoice.value.customer?.contacts && invoice.value.customer.contacts.length > 0 ? invoice.value.customer.contacts[0].lastName : "",
|
|
|
|
|
|
paymentTerms: invoice.value.customer?.paymentTerms || paymentTermsData.value.length > 0 ? paymentTermsData.value[2] : null,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (invoice.value && invoice.value.customer?.id !== 0) {
|
|
|
|
|
|
importCustomer.value = invoice.value.customer as Customer
|
|
|
|
|
|
|
|
|
|
|
|
// console.log("billingData contact", invoice.value?.billingData?.contactFirstName, invoice.value?.billingData?.contactLastName)
|
|
|
|
|
|
invoice.value.customer?.contacts.find(contact => {
|
|
|
|
|
|
if (
|
|
|
|
|
|
contact.firstName === invoice.value?.billingData?.contactFirstName &&
|
|
|
|
|
|
contact.lastName === invoice.value?.billingData?.contactLastName
|
|
|
|
|
|
) {
|
|
|
|
|
|
importContact.value = contact
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
value.value = fromDate(new Date(invoice.value.invoiceDate), getLocalTimeZone())
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
else {
|
|
|
|
|
|
isDirty.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// console.log("invoice", "Dirty: " + isDirty.value, "loading: " + isLoading.value)
|
|
|
|
|
|
},
|
|
|
|
|
|
{ deep: true }
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
watch(importCustomer,
|
|
|
|
|
|
(newValue, oldValue) => {
|
|
|
|
|
|
if (!invoice.value) return
|
|
|
|
|
|
|
|
|
|
|
|
if (!isLoading.value) {
|
|
|
|
|
|
if (!invoice.value.billingData) invoice.value.billingData = newBillingData()
|
|
|
|
|
|
|
|
|
|
|
|
invoice.value.billingData.companyName = newValue.companyName
|
|
|
|
|
|
invoice.value.billingData.vatId = newValue.vatId
|
|
|
|
|
|
|
|
|
|
|
|
// Don't overwrite these values during loading
|
|
|
|
|
|
// they can intentionally be different from customer data
|
|
|
|
|
|
if (!invoice.value.billingData.billingAddress || !isLoading.value)
|
|
|
|
|
|
invoice.value.billingData.billingAddress = newValue.billingAddress as Address
|
|
|
|
|
|
if (!invoice.value.billingData.contactFirstName || !isLoading.value)
|
|
|
|
|
|
invoice.value.billingData.contactFirstName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].firstName : ''
|
|
|
|
|
|
if (!invoice.value.billingData.contactLastName || !isLoading.value)
|
|
|
|
|
|
invoice.value.billingData.contactLastName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].lastName : ''
|
|
|
|
|
|
if (!invoice.value.billingData.paymentTerms || !isLoading.value)
|
|
|
|
|
|
invoice.value.billingData.paymentTerms = newValue.paymentTerms as PaymentTerms
|
|
|
|
|
|
|
2025-10-22 16:13:52 +02:00
|
|
|
|
invoice.value.customer = newValue
|
2025-10-20 08:57:51 +02:00
|
|
|
|
}
|
2025-10-22 16:13:52 +02:00
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
},
|
|
|
|
|
|
{ deep: true }
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
watch(importContact,
|
|
|
|
|
|
(newValue, oldValue) => {
|
|
|
|
|
|
if (!invoice.value) return
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
{ deep: true }
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
onUpdated(() => {
|
|
|
|
|
|
isLoading.value = false;
|
|
|
|
|
|
// console.log("onUpdated", "Dirty: " + isDirty.value, "loading: " + isLoading.value)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const saveChanges = () => {
|
|
|
|
|
|
if (invoice.value) {
|
|
|
|
|
|
emit('save', invoice.value)
|
|
|
|
|
|
isOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const cancelChanges = (event: Event | null) => {
|
|
|
|
|
|
if (isDirty.value) {
|
2025-10-29 18:04:09 +01:00
|
|
|
|
alert.title = "Wirklich schließen?"
|
|
|
|
|
|
alert.message = "Es gibt ungespeicherte Änderungen, die dann verloren gehen."
|
|
|
|
|
|
alert.cancelText = "Abbrechen"
|
|
|
|
|
|
alert.onCancel = () => {
|
|
|
|
|
|
console.log('cancel')
|
2025-10-20 08:57:51 +02:00
|
|
|
|
event?.preventDefault()
|
|
|
|
|
|
event.returnValue = true
|
2025-10-29 18:04:09 +01:00
|
|
|
|
alert.open = false
|
2025-10-20 08:57:51 +02:00
|
|
|
|
}
|
2025-10-29 18:04:09 +01:00
|
|
|
|
alert.actionText = "Schließen"
|
|
|
|
|
|
// alert.actionVariant = "destructive"
|
|
|
|
|
|
alert.onAction = () => {
|
|
|
|
|
|
console.log('action')
|
2025-10-20 08:57:51 +02:00
|
|
|
|
emit('cancel')
|
|
|
|
|
|
isOpen.value = false
|
2025-10-29 18:04:09 +01:00
|
|
|
|
alert.open = false
|
2025-10-20 08:57:51 +02:00
|
|
|
|
}
|
2025-10-29 18:04:09 +01:00
|
|
|
|
alert.open = true
|
2025-10-20 08:57:51 +02:00
|
|
|
|
} 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 deleteInvoice = function () {
|
|
|
|
|
|
let confirm = window.confirm('Möchtest Du diese Rechnung wirklich löschen?')
|
|
|
|
|
|
if (!confirm) return
|
|
|
|
|
|
emit('delete', invoice.value?.id)
|
|
|
|
|
|
isOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-22 11:58:18 +02:00
|
|
|
|
const remind = function () {
|
|
|
|
|
|
// await axios call
|
|
|
|
|
|
// make button spin
|
|
|
|
|
|
// success -> set new status and save
|
|
|
|
|
|
// error -> toast
|
|
|
|
|
|
if (!invoice.value) return;
|
|
|
|
|
|
window?.open('/api/invoices/' + invoice.value.id + '/remind', '_blank')?.focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
const updateTotalAmount = () => {
|
|
|
|
|
|
if (!invoice.value) return;
|
|
|
|
|
|
let total = 0;
|
|
|
|
|
|
invoice.value.items.forEach(item => {
|
|
|
|
|
|
total += item.quantity * item.price;
|
|
|
|
|
|
});
|
|
|
|
|
|
invoice.value.totalAmount = total;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
</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] p-0 h-[calc(100dvh-2rem)]"
|
|
|
|
|
|
@escapeKeyDown="cancelChanges" @interactOutside="cancelChanges">
|
|
|
|
|
|
|
|
|
|
|
|
<DialogHeader class="px-3 pt-3 flex flex-row justify-end">
|
|
|
|
|
|
<DialogTitle class="sr-only">Rechnung</DialogTitle>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="invoice && invoice.id > 0" class="hidden md:flex mr-4">
|
|
|
|
|
|
<Button :size="'sm'" :variant="'ghost'" @click="preview">
|
2025-10-23 08:27:10 +02:00
|
|
|
|
<Eye :strokeWidth="1.5" class="text-current" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<span>Vorschau</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<TooltipProvider>
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger>
|
|
|
|
|
|
<Button :size="'sm'" :variant="'ghost'" @click="downloadPdf">
|
2025-10-23 08:27:10 +02:00
|
|
|
|
<FileText :strokeWidth="1.5" class="text-current" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<span>PDF</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>
|
|
|
|
|
|
ZUGFeRD
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
|
|
|
|
|
|
|
<TooltipProvider>
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger>
|
|
|
|
|
|
<Button :size="'sm'" :variant="'ghost'" @click="downloadXml">
|
2025-10-23 08:27:10 +02:00
|
|
|
|
<CodeXml :strokeWidth="1.5" class="text-current" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<span>XML</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>
|
|
|
|
|
|
XRechnung
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
|
|
|
|
|
|
|
<Sheet as-child class="relativ">
|
|
|
|
|
|
<SheetTrigger>
|
|
|
|
|
|
<Button :size="'sm'" :variant="'ghost'">
|
2025-10-23 08:27:10 +02:00
|
|
|
|
<ClipboardList :strokeWidth="1.5" class="text-current" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<span>Audit</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</SheetTrigger>
|
|
|
|
|
|
<SheetContent>
|
|
|
|
|
|
<SheetHeader>
|
|
|
|
|
|
<SheetTitle>Are you absolutely sure?</SheetTitle>
|
|
|
|
|
|
<SheetDescription>
|
|
|
|
|
|
This action cannot be undone. This will permanently delete your account
|
|
|
|
|
|
and remove your data from our servers.
|
|
|
|
|
|
</SheetDescription>
|
|
|
|
|
|
</SheetHeader>
|
|
|
|
|
|
</SheetContent>
|
|
|
|
|
|
</Sheet>
|
|
|
|
|
|
|
|
|
|
|
|
<Button :size="'sm'" :variant="'ghost'" @click="deleteInvoice"
|
|
|
|
|
|
class="text-destructive hover:bg-destructive/5 hover:text-destructive">
|
2025-10-23 08:27:10 +02:00
|
|
|
|
<Trash2 :strokeWidth="1.5" class="text-current" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<span>Löschen</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="md:hidden">
|
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
|
<DropdownMenuTrigger>
|
|
|
|
|
|
<Button :variant="'ghost'" :size="'lg'">
|
|
|
|
|
|
<CircleEllipsis class="size-6" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
|
<DropdownMenuContent class="mr-8">
|
|
|
|
|
|
<DropdownMenuItem class="flex justify-between" @click="preview">
|
|
|
|
|
|
<span class="mr-2">Vorschau</span>
|
2025-10-23 08:27:10 +02:00
|
|
|
|
<Eye :strokeWidth="1.5" class="text-current" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
<DropdownMenuSeparator />
|
2025-10-22 16:13:52 +02:00
|
|
|
|
<DropdownMenuItem class="flex justify-between" @click="downloadPdf">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<div class="mr-2 flex flex-col">
|
2025-10-22 16:13:52 +02:00
|
|
|
|
<span>PDF speichern</span>
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<span class="text-xs text-muted-foreground">(ZUGFeRD)</span>
|
|
|
|
|
|
</div>
|
2025-10-23 08:27:10 +02:00
|
|
|
|
<FileText :strokeWidth="1.5" class="text-current" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</DropdownMenuItem>
|
2025-10-22 16:13:52 +02:00
|
|
|
|
<DropdownMenuItem class="flex justify-between" @click="downloadXml">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<div class="mr-2 flex flex-col">
|
2025-10-22 16:13:52 +02:00
|
|
|
|
<span>XML speichern</span>
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<span class="text-xs text-muted-foreground">(XRechnung)</span>
|
|
|
|
|
|
</div>
|
2025-10-23 08:27:10 +02:00
|
|
|
|
<CodeXml :strokeWidth="1.5" class="text-current" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
|
<DropdownMenuItem class="flex justify-between text-destructive">
|
|
|
|
|
|
<span class="mr-2">Löschen</span>
|
2025-10-23 08:27:10 +02:00
|
|
|
|
<Trash :strokeWidth="1.5" class="text-current" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="overflow-y-auto px-6" v-if="invoice">
|
|
|
|
|
|
|
|
|
|
|
|
<div id="document">
|
|
|
|
|
|
<div id="document-header"
|
|
|
|
|
|
class="block sticky top-0 py-4 bg-slate-100 bg-white dark:bg-neutral-800 z-1 flex items-end gap-12">
|
|
|
|
|
|
<div class="grow">
|
|
|
|
|
|
|
|
|
|
|
|
<h1 class="text-xl text-primary-foreground font-bold" v-if="invoice.id > 0">
|
|
|
|
|
|
Rechnung {{ invoice.nr
|
|
|
|
|
|
}}</h1>
|
|
|
|
|
|
<h1 class="text-xl text-primary-foreground font-bold" v-else>Neue
|
|
|
|
|
|
Rechnung
|
|
|
|
|
|
</h1>
|
|
|
|
|
|
|
|
|
|
|
|
<Input
|
|
|
|
|
|
class="md:text-xl px-0 bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none"
|
|
|
|
|
|
type="text" v-model="invoice.title" placeholder="Titel" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex flex-col m-0 items-end gap-0">
|
|
|
|
|
|
<span class="text-md text-muted-foreground">{{ toCurrency(invoice.totalAmount) }}</span>
|
2025-10-29 18:04:09 +01:00
|
|
|
|
<span class="text-2xl font-bold">{{ toCurrency(toFixedRounded(Number(invoice.totalAmount *
|
|
|
|
|
|
1.19), 2)) }}</span>
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div id="document-meta"
|
|
|
|
|
|
class="flex-none md:flex gap-12 mt-6 2 p-6 bg-slate-100 dark:bg-neutral-900 rounded-lg">
|
|
|
|
|
|
|
2025-10-22 11:58:18 +02:00
|
|
|
|
<div class="flex flex-col gap-1">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<div class="flex flex-row gap-4">
|
|
|
|
|
|
<Input type="text" v-model="invoice.billingData.companyName" placeholder="Firma"
|
2025-10-22 11:58:18 +02:00
|
|
|
|
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" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
<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"
|
2025-10-22 11:58:18 +02:00
|
|
|
|
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" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
<Input type="text" v-model="invoice.billingData.contactLastName" placeholder="Nachname"
|
2025-10-22 11:58:18 +02:00
|
|
|
|
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" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
<Select v-model="importContact" by="id">
|
|
|
|
|
|
<SelectTrigger v-bind:disabled="!importCustomer || invoice.customer.id === 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"
|
2025-10-22 11:58:18 +02:00
|
|
|
|
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" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<Input type="text" v-model="invoice.billingData.billingAddress.lineTwo"
|
|
|
|
|
|
placeholder="Zeile 2"
|
2025-10-22 11:58:18 +02:00
|
|
|
|
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" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
<div class="flex gap-4">
|
|
|
|
|
|
<Input type="text" v-model="invoice.billingData.billingAddress.postalCode"
|
|
|
|
|
|
placeholder="PLZ"
|
2025-10-22 11:58:18 +02:00
|
|
|
|
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" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<Input type="text" v-model="invoice.billingData.billingAddress.city" placeholder="Stadt"
|
2025-10-22 11:58:18 +02:00
|
|
|
|
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" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Input type="text" v-model="invoice.billingData.vatId" placeholder="USt-Id-Nr."
|
2025-10-22 11:58:18 +02:00
|
|
|
|
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" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Table>
|
|
|
|
|
|
<TableBody>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Status -->
|
|
|
|
|
|
<TableRow>
|
|
|
|
|
|
<TableHead class="w-1">Status</TableHead>
|
|
|
|
|
|
<TableCell class="flex items-center gap-4">
|
|
|
|
|
|
<StatusBadge size="sm" :variant="invoice.paymentStatus">{{
|
|
|
|
|
|
statusBadgeLabels[invoice.paymentStatus] }}
|
|
|
|
|
|
</StatusBadge>
|
|
|
|
|
|
<Select v-model="invoice.paymentStatus">
|
|
|
|
|
|
<SelectTrigger
|
|
|
|
|
|
class="w-full bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-0 h-8!">
|
|
|
|
|
|
<SelectValue placeholder="Status" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectGroup>
|
|
|
|
|
|
<SelectItem v-for="(label, value) in statusBadgeLabels"
|
|
|
|
|
|
:value="value">
|
|
|
|
|
|
<SelectLabel>{{ label }}</SelectLabel>
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectGroup>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
|
|
|
|
|
|
<Button v-if="['due', 'reminded'].includes(invoice.paymentStatus)"
|
2025-10-22 11:58:18 +02:00
|
|
|
|
:size="'sm'" :variant="'destructive'" @click="remind">Mahnen</Button>
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 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-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>
|
|
|
|
|
|
|
2025-10-22 16:13:52 +02:00
|
|
|
|
<div id="document-text" class="px-4 mt-6">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<LineItemTable :lineItems="invoice.items" @update:lineItems="updateTotalAmount" />
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<DialogFooter class="p-6 pt-0 flex-row">
|
|
|
|
|
|
<div :size="'sm'" class="hidden md:block flex-grow-4"></div>
|
|
|
|
|
|
<Button class="grow md:grow-0" @click="cancelChanges">
|
|
|
|
|
|
Abbrechen
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button class="grow md:grow-0" :variant="'action'" @click="saveChanges"
|
|
|
|
|
|
:disabled="!isDirty">Speichern</Button>
|
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
|
/* Remove close X */
|
|
|
|
|
|
[data-slot=dialog-content] button.ring-offset-background {
|
|
|
|
|
|
/* display: none; */
|
|
|
|
|
|
border-radius: 100%;
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
left: 1rem;
|
|
|
|
|
|
width: 1rem;
|
|
|
|
|
|
height: 1rem;
|
|
|
|
|
|
color: var(--color-destructive);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Backdrop */
|
|
|
|
|
|
[data-slot=dialog-overlay] {
|
|
|
|
|
|
backdrop-filter: blur(var(--blur-sm));
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|