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 eda81628e5
commit 250c4538aa
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>
@@ -2,23 +2,23 @@
<!-- TODO: Enter in LineItem = neue Zeile -->
<script setup lang="ts">
import { ref, watch, HTMLAttributes } from 'vue'
import { ref, watch, HTMLAttributes, onUpdated, onMounted } from 'vue'
import draggable from 'vuedraggable';
import { cn, toCurrency } from '@/lib/utils';
import { LineItem } from '@/types';
import { newLineItem } from '@/types/index.d'
import { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/table';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"
import { Table, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/table';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"
import { NumberField, NumberFieldContent, NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, } from '@/components/ui/number-field'
import { Input } from '@/components/ui/input';
import { CirclePlus, GripVertical, Trash2, Plus, TextSelect } from 'lucide-vue-next';
import Textarea from '../ui/textarea/Textarea.vue';
import { Loader2, GripVertical, Trash2, Plus, TextSelect } from 'lucide-vue-next';
import Button from '../ui/button/Button.vue';
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '@/components/ui/empty'
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '@/components/ui/empty'
import NumberInput from '../ui/number-input/NumberInput.vue';
import { GrowingTextarea } from '../ui/growing-textarea';
const props = defineProps<{
isLoading?: boolean,
lineItems: LineItem[] | undefined,
stickyTop: number | string,
class?: HTMLAttributes['class']
@@ -28,16 +28,24 @@ const emit = defineEmits<{
(e: 'update:lineItems', value: LineItem[]): void
}>()
const isLoading = ref(props.isLoading || false)
const units = ref(['Stück', 'Stunden', 'Tage', 'pauschal'])
const items = ref((props.lineItems ?? []).slice().sort(function (a, b) { return a.position - b.position })) // items only uses props.lineItems as the initial value;
onUpdated(() => {
if (isLoading.value) isLoading.value = false
})
watch(items, (newItems) => {
if (isLoading.value) return
emit('update:lineItems', newItems)
}, { deep: true })
watch(() => props.lineItems, (newLineItems) => {
isLoading.value = (newLineItems?.length || 0) > 0
items.value = (newLineItems ?? []).slice().sort(function (a, b) { return a.position - b.position })
}, { deep: true })
}, { deep: true, once: true })
const newItem = () => {
const position = items.value.length + 1
@@ -84,7 +92,8 @@ const recalculatePositions = () => {
</Table>
</div>
<Table v-if="items.length > 0" class="table-fixed">
<Table :class="{ 'opacity-100!': !isLoading && items.length >= 1 }"
class="table-fixed transition-opacity opacity-0 duration-300">
<draggable v-model="items" tag="tbody" item-key="position" handle=".handle" ghostClass="ghost"
@end="recalculatePositions">
@@ -164,7 +173,7 @@ const recalculatePositions = () => {
</template>
</draggable>
<TableFooter class="bg-transparent">
<TableFooter v-if="items.length >= 1" class="bg-transparent">
<TableRow class="hover:bg-transparent dark:hover:bg-transparent">
<TableCell colspan="8" class="text-center">
@@ -178,7 +187,9 @@ const recalculatePositions = () => {
</Table>
<Empty v-if="items.length < 1" class="md:pb-0 md:pt-8">
<Loader2 v-if="isLoading" class="mx-auto mt-8 h-6 w-6 animate-spin text-muted-foreground" stroke-width="1.5" />
<Empty v-else-if="items.length < 1" class="py-8!">
<EmptyHeader>
<EmptyMedia variant="icon">
<TextSelect class="text-muted-foreground" stroke-width="1.5" />
+28 -78
View File
@@ -2,6 +2,7 @@
import { computed, ref, onMounted } from 'vue'
import { type Invoice } from '@/types'
import { newInvoice } from '@/types/index.d'
import axios from 'axios'
import AppLayout from '@/layouts/AppLayout.vue'
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from '@/components/ui/select'
@@ -41,7 +42,7 @@ onMounted(async () => {
let queryString = window.location.search
let params = new URLSearchParams(queryString)
if (params.get('action') == 'new') editInvoice()
if (params.get('action') == 'new') createInvoice()
searchField.value = document.getElementById('search')
})
@@ -92,89 +93,37 @@ const filteredInvoices = computed(() => {
});
});
const editInvoice = (invoice?: Invoice) => {
const createInvoice = () => {
editInvoice(newInvoice())
}
const editInvoice = (invoice: Invoice) => {
// make a deep copy, so the changes in the dialog wont affect the data until saved
activeInvoice.value = invoice ? JSON.parse(JSON.stringify(invoice)) : null
activeInvoice.value = JSON.parse(JSON.stringify(invoice))
detailDialogOpen.value = true
}
const saveInvoice = async (updatedInvoice: Invoice) => {
try {
// Prepare the invoice data for API request
const invoiceToSave = {
nr: updatedInvoice.nr,
invoiceDate: updatedInvoice.invoiceDate,
dueDate: updatedInvoice.dueDate,
serviceStartDate: updatedInvoice.serviceStartDate,
serviceEndDate: updatedInvoice.serviceEndDate,
isRecurring: updatedInvoice.isRecurring,
isPartialService: updatedInvoice.isPartialService,
paymentStatus: updatedInvoice.paymentStatus,
totalAmount: updatedInvoice.totalAmount,
title: updatedInvoice.title,
text: updatedInvoice.text,
customerId: updatedInvoice.customer ? updatedInvoice.customer.id : null,
billingData: {
companyName: updatedInvoice.billingData?.companyName,
vatId: updatedInvoice.billingData?.vatId,
billingAddress: updatedInvoice.billingData?.billingAddress,
contactSalutation: updatedInvoice.billingData?.contactSalutation,
contactFirstName: updatedInvoice.billingData?.contactFirstName,
contactLastName: updatedInvoice.billingData?.contactLastName,
paymentTerms: updatedInvoice.billingData?.paymentTerms
},
const onSaveInvoice = async (updatedInvoice: Invoice) => {
// Update selectedYearIndex to ensure the new invoice is displayed
const newInvoiceYear = new Date(updatedInvoice.invoiceDate).getFullYear();
const currentYearIndex = years.value.findIndex(year => year === newInvoiceYear);
if (currentYearIndex !== -1) {
selectedYearIndex.value = currentYearIndex;
}
// Items will be handled separately in the controller
items: updatedInvoice.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
}))
};
// console.log('Saving invoice:', invoiceToSave);
if (updatedInvoice.id === 0) {
// Create new invoice
const response = await axios.post('/api/invoices', invoiceToSave);
invoicesData.value.push(response.data);
// Update selectedYearIndex to ensure the new invoice is displayed
const newInvoiceYear = new Date(response.data.invoiceDate).getFullYear();
const currentYearIndex = years.value.findIndex(year => year === newInvoiceYear);
if (currentYearIndex !== -1) {
selectedYearIndex.value = currentYearIndex;
}
} else {
// Update existing invoice
const response = await axios.put(`/api/invoices/${updatedInvoice.id}`, invoiceToSave);
const index = invoicesData.value.findIndex(inv => inv.id === updatedInvoice.id);
if (index !== -1) {
invoicesData.value[index] = response.data;
}
}
} catch (error) {
toast.error("Rechnung konnte nicht gespeichert werden", {
description: (error as Error).message,
});
// Update table
const index = invoicesData.value.findIndex(inv => inv.id === updatedInvoice.id);
if (index !== -1) {
invoicesData.value[index] = updatedInvoice;
} else {
invoicesData.value.push(updatedInvoice);
}
}
const deleteInvoice = async (id: number) => {
try {
await axios.delete('/api/invoices/' + id)
const index = invoicesData.value.findIndex(invoice => invoice.id === id)
if (index !== -1) {
invoicesData.value.splice(index, 1)
}
} catch (error) {
toast.error("Rechnung konnte nicht gelöscht werden", {
description: (error as Error).message,
});
const onDeleteInvoice = async (id: number) => {
const index = invoicesData.value.findIndex(invoice => invoice.id === id)
if (index !== -1) {
invoicesData.value.splice(index, 1)
}
}
@@ -234,7 +183,7 @@ const deleteInvoice = async (id: number) => {
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button size="sm" variant="action" @click="editInvoice()">
<Button size="sm" variant="action" @click="createInvoice">
<Plus />
Neu
</Button>
@@ -257,7 +206,8 @@ const deleteInvoice = async (id: number) => {
<DocumentTable :invoices="filteredInvoices" :onItemClicked="editInvoice" />
<!-- Invoice detail dialog -->
<InvoiceDialog :invoiceData="activeInvoice" v-model="detailDialogOpen" @save="" @delete="" />
<InvoiceDialog :invoiceData="activeInvoice" v-model="detailDialogOpen" @save="onSaveInvoice"
@delete="onDeleteInvoice" />
</AppLayout>
</template>
+3 -1
View File
@@ -138,6 +138,8 @@ export function newCustomer(): Customer {
}
}
export type PaymentStatus = 'draft' | 'issued' | 'paid' | 'due' | 'reminded' | 'cancelled'
export interface Invoice {
id: number;
nr: string;
@@ -148,7 +150,7 @@ export interface Invoice {
isRecurring: boolean;
isPartialService: boolean;
customer?: Customer | null;
paymentStatus: 'draft' | 'issued' | 'paid' | 'due' | 'reminded' | 'cancelled';
paymentStatus: PaymentStatus;
totalAmount: number;
title: string;
text: string;