|
|
|
@@ -9,39 +9,38 @@
|
|
|
|
|
|
|
|
|
|
<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'
|
|
|
|
|
import { toCurrency, toLocalDate, toShortISOString, cn, calcDueDate, toFixedRounded } from '@/lib/utils'
|
|
|
|
|
import { ref, computed, watch, onMounted } from "vue"
|
|
|
|
|
import { Customer, Invoice, Contact, PaymentTerms, Address, LineItem } from "@/types"
|
|
|
|
|
import { newInvoice, newCustomer, newContact, newBillingData } from '@/types/index.d'
|
|
|
|
|
import { toCurrency, toLocalDate, toShortISOString, calcDueDate, toFixedRounded } from '@/lib/utils'
|
|
|
|
|
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 { 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, TableCaption, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'
|
|
|
|
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from '@/components/ui/select'
|
|
|
|
|
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, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
|
|
|
|
|
import { StatusBadge, statusBadgeLabels, statusTextStyle, StatusBadgeVariants } from '@/components/ui/status-badge'
|
|
|
|
|
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, CalendarIcon, MessageCircleQuestion, Loader2, Ellipsis, Check, FileCheck, FileX, Ban } from "lucide-vue-next"
|
|
|
|
|
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 { SendMailDialog } from "../ui/send-mail-dialog"
|
|
|
|
|
import { Kbd, KbdGroup } from '@/components/ui/kbd';
|
|
|
|
|
import DialogClose from "../ui/dialog/DialogClose.vue"
|
|
|
|
|
import DialogCloseButton from "../DialogCloseButton/DialogCloseButton.vue"
|
|
|
|
|
import DialogCloseButton from "../DialogCloseButton/DialogCloseButton.vue";
|
|
|
|
|
import SendMailDialog from "../ui/send-mail-dialog/SendMailDialog.vue"
|
|
|
|
|
import Skeleton from "../ui/skeleton/Skeleton.vue"
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
invoiceData: Invoice | null,
|
|
|
|
|
customers: Customer[]
|
|
|
|
|
invoiceData?: Invoice,
|
|
|
|
|
modelValue: boolean
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits(['update:modelValue', 'save', 'cancel', 'delete'])
|
|
|
|
|
|
|
|
|
|
const invoice = ref<Invoice | null>(props.invoiceData)
|
|
|
|
|
const invoice = ref<Invoice>()
|
|
|
|
|
const customers = ref([] as Customer[])
|
|
|
|
|
const paymentTermsData = ref([] as PaymentTerms[])
|
|
|
|
|
const isDirty = ref(false);
|
|
|
|
|
const isLoading = ref(false);
|
|
|
|
@@ -51,9 +50,52 @@ const alert = alertStore()
|
|
|
|
|
const reminderDialogOpen = ref(false)
|
|
|
|
|
const reminderLoading = ref(false)
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits(['update:modelValue', 'save', 'cancel', 'delete'])
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
const response = await axios.get('/api/paymentterms')
|
|
|
|
|
paymentTermsData.value = response.data as PaymentTerms[]
|
|
|
|
|
// 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))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
|
|
|
|
|
console.groupEnd()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
watch(() => props.modelValue, (open) => {
|
|
|
|
|
// on open
|
|
|
|
|
if (open) {
|
|
|
|
|
console.group('watch props.modelValue')
|
|
|
|
|
console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
|
|
|
|
|
|
|
|
|
|
isDirty.value = false;
|
|
|
|
|
isLoading.value = true;
|
|
|
|
|
|
|
|
|
|
// Get invoice data from props
|
|
|
|
|
invoice.value = props.invoiceData
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Load line items
|
|
|
|
|
try {
|
|
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// on close
|
|
|
|
|
else {
|
|
|
|
|
invoice.value = undefined
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const isOpen = computed({
|
|
|
|
@@ -63,30 +105,32 @@ const isOpen = computed({
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const title = computed<string>(() => {
|
|
|
|
|
if (isLoading.value) {
|
|
|
|
|
return 'Rechnung'
|
|
|
|
|
} else if (invoice.value && invoice.value.id > 0) {
|
|
|
|
|
return `Rechnung ${invoice.value.nr}`
|
|
|
|
|
} else {
|
|
|
|
|
return 'Neue Rechnung'
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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) => {
|
|
|
|
|
console.group('watch invoice')
|
|
|
|
|
console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
|
|
|
|
|
|
|
|
|
|
if (isLoading.value) {
|
|
|
|
|
if (!invoice.value) return;
|
|
|
|
|
|
|
|
|
|
if (invoice.value.id === 0) {
|
|
|
|
|
isLoading.value = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Initial load of invoice data
|
|
|
|
|
if (!invoice.value.billingData) {
|
|
|
|
|
// Set default billing data from customer
|
|
|
|
@@ -128,16 +172,19 @@ watch(invoice,
|
|
|
|
|
isDirty.value = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// console.log("invoice", "Dirty: " + isDirty.value, "loading: " + isLoading.value)
|
|
|
|
|
console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
|
|
|
|
|
console.groupEnd()
|
|
|
|
|
},
|
|
|
|
|
{ deep: true }
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
watch(importCustomer,
|
|
|
|
|
(newValue, oldValue) => {
|
|
|
|
|
if (!invoice.value) return
|
|
|
|
|
|
|
|
|
|
console.group('watch importCustomer')
|
|
|
|
|
console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
|
|
|
|
|
|
|
|
|
|
if (!isLoading.value) {
|
|
|
|
|
if (!invoice.value.billingData) invoice.value.billingData = newBillingData()
|
|
|
|
|
|
|
|
|
@@ -158,6 +205,8 @@ watch(importCustomer,
|
|
|
|
|
invoice.value.customer = newValue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
|
|
|
|
|
console.groupEnd()
|
|
|
|
|
},
|
|
|
|
|
{ deep: true }
|
|
|
|
|
)
|
|
|
|
@@ -166,6 +215,9 @@ watch(importContact,
|
|
|
|
|
(newValue, oldValue) => {
|
|
|
|
|
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
|
|
|
|
@@ -176,7 +228,12 @@ watch(importContact,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isDirty.value = true;
|
|
|
|
|
} else {
|
|
|
|
|
isLoading.value = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
|
|
|
|
|
console.groupEnd()
|
|
|
|
|
},
|
|
|
|
|
{ deep: true }
|
|
|
|
|
)
|
|
|
|
@@ -189,11 +246,6 @@ const billingContactEmail = computed<string | undefined>(() => {
|
|
|
|
|
else return ""
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onUpdated(() => {
|
|
|
|
|
isLoading.value = false;
|
|
|
|
|
// console.log("onUpdated", "Dirty: " + isDirty.value, "loading: " + isLoading.value)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const saveChanges = () => {
|
|
|
|
|
if (invoice.value) {
|
|
|
|
|
emit('save', invoice.value)
|
|
|
|
@@ -202,6 +254,11 @@ const saveChanges = () => {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cancelChanges = (event: Event | null) => {
|
|
|
|
|
if (!event) return
|
|
|
|
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
event.returnValue = true
|
|
|
|
|
|
|
|
|
|
if (isDirty.value) {
|
|
|
|
|
alert.show(
|
|
|
|
|
"Wirklich schließen?",
|
|
|
|
@@ -211,11 +268,6 @@ const cancelChanges = (event: Event | null) => {
|
|
|
|
|
onAction: () => {
|
|
|
|
|
emit('cancel')
|
|
|
|
|
isOpen.value = false
|
|
|
|
|
},
|
|
|
|
|
onCancel: () => {
|
|
|
|
|
if (!event) return
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
event.returnValue = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
@@ -308,30 +360,36 @@ const updateTotalAmount = () => {
|
|
|
|
|
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="cancelChanges" @interactOutside="cancelChanges">
|
|
|
|
|
|
|
|
|
|
<!-- <div :class="{ 'opacity-100': isLoading }"
|
|
|
|
|
class="absolute inset-[1rem_0_0_0] flex justify-center items-center z-10 pointer-events-none bg-background opacity-0 transition-opacity rounded-lg">
|
|
|
|
|
<div class="bg-sidebar rounded-lg p-6">
|
|
|
|
|
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" stroke-width="1.5" />
|
|
|
|
|
</div>
|
|
|
|
|
</div> -->
|
|
|
|
|
|
|
|
|
|
<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 z-1">
|
|
|
|
|
<h1 v-if="invoice.id > 0">Rechnung {{ invoice.nr }}</h1>
|
|
|
|
|
<h1 v-else>Neue Rechnung</h1>
|
|
|
|
|
<DialogTitle class="text-primary-foreground font-bold text-left">
|
|
|
|
|
<h1>{{ title }}</h1>
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
<Input
|
|
|
|
|
<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" v-model="invoice.title" placeholder="Titel" />
|
|
|
|
|
type="text" placeholder="Titel" />
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="flex gap-2 items-center">
|
|
|
|
|
<TooltipProvider>
|
|
|
|
|
<!-- Save -->
|
|
|
|
|
<Button v-if="invoice && invoice.id > 0 && isDirty" class="grow md:grow-0" size="sm"
|
|
|
|
|
@click="saveChanges">
|
|
|
|
|
<Button v-if="invoice && isDirty" class="grow md:grow-0" size="sm" @click="saveChanges">
|
|
|
|
|
<Check 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">
|
|
|
|
@@ -397,8 +455,8 @@ const updateTotalAmount = () => {
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
|
|
|
|
<!-- PDF -->
|
|
|
|
|
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'" class="flex justify-between"
|
|
|
|
|
@click="downloadPdf">
|
|
|
|
|
<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">
|
|
|
|
@@ -414,8 +472,8 @@ const updateTotalAmount = () => {
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
|
|
|
|
<!-- XML -->
|
|
|
|
|
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'" class="flex justify-between"
|
|
|
|
|
@click="downloadXml">
|
|
|
|
|
<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">
|
|
|
|
@@ -608,7 +666,8 @@ const updateTotalAmount = () => {
|
|
|
|
|
</TableHead>
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
|
|
|
|
<Select v-model="invoice.billingData.paymentTerms" by="id">
|
|
|
|
|
<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" />
|
|
|
|
@@ -667,9 +726,11 @@ const updateTotalAmount = () => {
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
<SendMailDialog v-model:open="reminderDialogOpen" title="Zahlungserinnerung senden?" description=""
|
|
|
|
|
:recipient="billingContactEmail" @send="(recipient) => sendReminder(recipient)" />
|
|
|
|
|
:recipient="billingContactEmail" @send="(recipient: string | undefined) => sendReminder(recipient)" />
|
|
|
|
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<style></style>
|