Move REST calls from invoice table to invoice dialog, fixes #56

This commit is contained in:
2025-11-18 20:46:40 +01:00
parent 47748038e4
commit 1b76c6c61b
4 changed files with 270 additions and 203 deletions
@@ -9,9 +9,9 @@
<script setup lang="ts">
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 { 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"
@@ -32,7 +32,6 @@ 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 Skeleton from "../ui/skeleton/Skeleton.vue"
const props = defineProps<{
invoiceData?: Invoice,
@@ -42,16 +41,33 @@ const props = defineProps<{
const invoice = ref<Invoice>()
const customers = ref([] as Customer[])
const paymentTermsData = ref([] as PaymentTerms[])
const isDirty = ref(false);
const isLoading = ref(false);
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 {
@@ -65,108 +81,103 @@ onMounted(async () => {
} catch (error) {
toast.error('Fehler beim Laden der Daten', error || String(error))
}
})
console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
console.groupEnd()
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('watch props.modelValue')
console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// 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
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))
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
}
}
// on close
else {
invoice.value = undefined
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// console.groupEnd()
}
})
const isOpen = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:modelValue', value)
}
})
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 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 (!invoice.value) return;
if (invoice.value.id === 0) {
isLoading.value = false;
if (!newValue) {
// console.groupEnd()
return;
}
// Initial load of invoice data
if (!invoice.value.billingData) {
// Set default billing data from customer
invoice.value.billingData = {
companyName: invoice.value.customer?.companyName || "",
vatId: invoice.value.customer?.vatId || "",
// 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: 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 || "",
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: invoice.value.customer?.contacts && invoice.value.customer.contacts.length > 0 ? invoice.value.customer.contacts[0].salutation : "",
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,
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 (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 (newValue.customer?.id !== 0) {
// if (importCustomer.value != newValue.customer)
// console.warn('trigger importCustomer watcher')
importCustomer.value = newValue.customer as Customer
newValue.customer?.contacts.find(contact => {
if (
contact.firstName === invoice.value?.billingData?.contactFirstName &&
contact.lastName === invoice.value?.billingData?.contactLastName
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(invoice.value.invoiceDate), getLocalTimeZone())
value.value = fromDate(new Date(newValue.invoiceDate), getLocalTimeZone())
}
else {
isDirty.value = true
@@ -180,43 +191,50 @@ watch(invoice,
watch(importCustomer,
(newValue, oldValue) => {
if (newValue == oldValue) return
if (!invoice.value) return
console.group('watch importCustomer')
console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// 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
// Don't overwrite these values during loading
// they can intentionally be different from customer data
if (!invoice.value.billingData.billingAddress || !isLoading.value)
if (!invoice.value.billingData.billingAddress)
invoice.value.billingData.billingAddress = newValue.billingAddress as Address
if (!invoice.value.billingData.contactFirstName || !isLoading.value)
if (!invoice.value.billingData.contactFirstName)
invoice.value.billingData.contactFirstName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].firstName : ''
if (!invoice.value.billingData.contactLastName || !isLoading.value)
if (!invoice.value.billingData.contactLastName)
invoice.value.billingData.contactLastName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].lastName : ''
if (!invoice.value.billingData.paymentTerms || !isLoading.value)
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()
// 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}`)
// console.group('watch importContact')
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
if (!isLoading.value) {
if (newValue.id !== 0) {
@@ -228,12 +246,10 @@ watch(importContact,
}
isDirty.value = true;
} else {
isLoading.value = false;
}
console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
console.groupEnd()
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// console.groupEnd()
},
{ deep: true }
)
@@ -246,14 +262,73 @@ const billingContactEmail = computed<string | undefined>(() => {
else return ""
})
const saveChanges = () => {
const save = async () => {
if (invoice.value) {
emit('save', invoice.value)
// isOpen.value = false
// 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,
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
}
}
}
const cancelChanges = (event: Event | null) => {
const cancel = (event: Event | null) => {
if (!event) return
event.preventDefault()
@@ -300,21 +375,30 @@ const issueInvoice = function () {
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",
(invoice.value?.paymentStatus == "draft") ? null : "Nach GoBD musst Du alle Belege und Daten in unveränderter Form aufbewahren.",
{
actionText: "Löschen",
actionVariant: "destructive",
onAction: () => {
emit('delete', invoice.value?.id)
isOpen.value = false
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 cancelInvoice = function () {
const updateStatus = function (status: PaymentStatus) {
if (!invoice.value) return;
invoice.value.paymentStatus = 'cancelled'
invoice.value.paymentStatus = status
isDirty.value = true
}
const openReminderDialog = function () {
@@ -343,13 +427,37 @@ const sendReminder = async function (recipient: string | undefined) {
}
}
const updateTotalAmount = () => {
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;
invoice.value.items.forEach(item => {
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>
@@ -358,14 +466,7 @@ const updateTotalAmount = () => {
<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="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> -->
@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">
@@ -383,8 +484,10 @@ const updateTotalAmount = () => {
<div class="flex gap-2 items-center">
<TooltipProvider>
<!-- Save -->
<Button v-if="invoice && isDirty" class="grow md:grow-0" size="sm" @click="saveChanges">
<Check stroke-width="1.5" />
<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>
@@ -405,7 +508,7 @@ const updateTotalAmount = () => {
<!-- Paid -->
<Tooltip v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)">
<TooltipTrigger>
<Button size="sm" variant="success" @click="invoice.paymentStatus = 'paid'">
<Button size="sm" variant="success" @click="updateStatus('paid')">
<FileCheck stroke-width="1.5" /> Bezahlt
</Button>
</TooltipTrigger>
@@ -488,7 +591,7 @@ const updateTotalAmount = () => {
<!-- Cancel -->
<DropdownMenuItem
v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)"
class="flex justify-between" @click="deleteInvoice">
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" />
@@ -515,7 +618,7 @@ const updateTotalAmount = () => {
</div>
</DialogHeader>
<div class="overflow-y-auto p-4 md:p-6 lg:p-12 pt-0!" v-if="invoice">
<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"
@@ -553,11 +656,11 @@ const updateTotalAmount = () => {
<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) }}</span>
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 *
1.19), 2)) }}</span>
toCurrency(toFixedRounded(Number(invoice?.totalAmount || 0) *
1.19, 2)) }}</span>
</div>
</div>
@@ -716,8 +819,9 @@ const updateTotalAmount = () => {
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="updateTotalAmount" sticky-top="7"
class="mt-4" />
<LineItemTable :lineItems="invoice.items" @update:lineItems="updateLineItems" sticky-top="7"
:isLoading="itemsLoading" class="mt-4" />
</div>