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"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue" import { ref, computed, watch, onMounted, onUpdated, toRaw } from "vue"
import { Customer, Invoice, Contact, PaymentTerms, Address, LineItem } from "@/types" import { Customer, Invoice, Contact, PaymentTerms, Address, LineItem, PaymentStatus } from "@/types"
import { newInvoice, newCustomer, newContact, newBillingData } from '@/types/index.d' import { newCustomer, newContact, newBillingData } from '@/types/index.d'
import { toCurrency, toLocalDate, toShortISOString, calcDueDate, toFixedRounded } from '@/lib/utils' import { toCurrency, toLocalDate, toShortISOString, calcDueDate, toFixedRounded } from '@/lib/utils'
import axios from 'axios' import axios from 'axios'
import { type DateValue, getLocalTimeZone, fromDate } from "@internationalized/date" 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 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 SendMailDialog from "../ui/send-mail-dialog/SendMailDialog.vue"
import Skeleton from "../ui/skeleton/Skeleton.vue"
const props = defineProps<{ const props = defineProps<{
invoiceData?: Invoice, invoiceData?: Invoice,
@@ -42,16 +41,33 @@ const props = defineProps<{
const invoice = ref<Invoice>() const invoice = ref<Invoice>()
const customers = ref([] as Customer[]) const customers = ref([] as Customer[])
const paymentTermsData = ref([] as PaymentTerms[]) const paymentTermsData = ref([] as PaymentTerms[])
const isDirty = ref(false); const isDirty = ref(false)
const isLoading = ref(false); const isLoading = ref(false)
const isSaving = ref(false)
const itemsLoading = ref(false)
const importContact = ref(newContact() as Contact) const importContact = ref(newContact() as Contact)
const importCustomer = ref(newCustomer() as Customer) const importCustomer = ref(newCustomer() as Customer)
const alert = alertStore() const alert = alertStore()
const reminderDialogOpen = ref(false) const reminderDialogOpen = ref(false)
const reminderLoading = ref(false) const reminderLoading = ref(false)
const value = ref<DateValue>() // TODO: name properly
const emit = defineEmits(['update:modelValue', 'save', 'cancel', 'delete']) 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 () => { onMounted(async () => {
// Load customers and payment terms // Load customers and payment terms
try { try {
@@ -65,108 +81,103 @@ onMounted(async () => {
} catch (error) { } catch (error) {
toast.error('Fehler beim Laden der Daten', error || String(error)) toast.error('Fehler beim Laden der Daten', error || String(error))
} }
})
console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`) onUpdated(() => {
console.groupEnd() if (isLoading.value) isLoading.value = false
// console.group('onUpdated')
// console.error(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// console.groupEnd()
}) })
watch(() => props.modelValue, (open) => { watch(() => props.modelValue, (open) => {
// on open // on open
if (open) { if (open) {
console.group('watch props.modelValue') // console.group('on open')
console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`) // console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// Reset state flags
isDirty.value = false; isDirty.value = false;
isLoading.value = true; isLoading.value = true;
// Get invoice data from props // Get invoice data from props
// console.warn('trigger invoice watcher')
invoice.value = props.invoiceData invoice.value = props.invoiceData
// Load line items // Load line items
try { if (invoice.value && invoice.value.id !== 0) {
axios.get('/api/lineitems/' + invoice.value.id).then(response => { itemsLoading.value = true
if (invoice.value) invoice.value.items = response.data as LineItem[]
}) try {
} catch (error) { itemsLoading.value = true
toast.error('Fehler beim Laden der Positionen', error || String(error)) 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 // console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
else { // console.groupEnd()
invoice.value = undefined
} }
}) })
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 changes on local invoice date
watch(invoice, watch(invoice,
(newValue, oldValue) => { (newValue, oldValue) => {
if (newValue == oldValue) return
console.group('watch invoice') console.group('watch invoice')
console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`) console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
if (isLoading.value) { if (isLoading.value) {
if (!invoice.value) return; if (!newValue) {
// console.groupEnd()
if (invoice.value.id === 0) { return;
isLoading.value = false;
} }
// Initial load of invoice data // Set default billing data from customer
if (!invoice.value.billingData) { if (!newValue.billingData) {
// Set default billing data from customer // console.warn('trigger invoice watcher')
invoice.value.billingData = { newValue.billingData = {
companyName: invoice.value.customer?.companyName || "", companyName: newValue.customer?.companyName || "",
vatId: invoice.value.customer?.vatId || "", vatId: newValue.customer?.vatId || "",
billingAddress: { billingAddress: {
lineOne: invoice.value.customer?.billingAddress?.lineOne || "", lineOne: newValue.customer?.billingAddress?.lineOne || "",
lineTwo: invoice.value.customer?.billingAddress?.lineTwo || "", lineTwo: newValue.customer?.billingAddress?.lineTwo || "",
city: invoice.value.customer?.billingAddress?.city || "", city: newValue.customer?.billingAddress?.city || "",
postalCode: invoice.value.customer?.billingAddress?.postalCode || "", postalCode: newValue.customer?.billingAddress?.postalCode || "",
countryCode: invoice.value.customer?.billingAddress?.countryCode || "", countryCode: newValue.customer?.billingAddress?.countryCode || "",
}, },
contactSalutation: invoice.value.customer?.contacts && invoice.value.customer.contacts.length > 0 ? invoice.value.customer.contacts[0].salutation : "", contactSalutation: newValue.customer?.contacts && newValue.customer.contacts.length > 0 ? newValue.customer.contacts[0].salutation : "",
contactFirstName: invoice.value.customer?.contacts && invoice.value.customer.contacts.length > 0 ? invoice.value.customer.contacts[0].firstName : "", contactFirstName: newValue.customer?.contacts && newValue.customer.contacts.length > 0 ? newValue.customer.contacts[0].firstName : "",
contactLastName: invoice.value.customer?.contacts && invoice.value.customer.contacts.length > 0 ? invoice.value.customer.contacts[0].lastName : "", contactLastName: newValue.customer?.contacts && newValue.customer.contacts.length > 0 ? newValue.customer.contacts[0].lastName : "",
paymentTerms: invoice.value.customer?.paymentTerms || paymentTermsData.value.length > 0 ? paymentTermsData.value[2] : null, paymentTerms: newValue.customer?.paymentTerms || paymentTermsData.value.length > 0 ? paymentTermsData.value[2] : null,
} }
} }
if (invoice.value.customer?.id !== 0) { if (newValue.customer?.id !== 0) {
importCustomer.value = invoice.value.customer as Customer // if (importCustomer.value != newValue.customer)
// console.warn('trigger importCustomer watcher')
// console.log("billingData contact", invoice.value?.billingData?.contactFirstName, invoice.value?.billingData?.contactLastName) importCustomer.value = newValue.customer as Customer
invoice.value.customer?.contacts.find(contact => { newValue.customer?.contacts.find(contact => {
if ( if (
contact.firstName === invoice.value?.billingData?.contactFirstName && contact.firstName === newValue?.billingData?.contactFirstName &&
contact.lastName === invoice.value?.billingData?.contactLastName contact.lastName === newValue?.billingData?.contactLastName
) { ) {
// if (importContact.value != contact)
// console.warn('trigger importContact watcher')
importContact.value = contact importContact.value = contact
return true return true
} }
}) })
} }
value.value = fromDate(new Date(invoice.value.invoiceDate), getLocalTimeZone()) value.value = fromDate(new Date(newValue.invoiceDate), getLocalTimeZone())
} }
else { else {
isDirty.value = true isDirty.value = true
@@ -180,43 +191,50 @@ watch(invoice,
watch(importCustomer, watch(importCustomer,
(newValue, oldValue) => { (newValue, oldValue) => {
if (newValue == oldValue) return
if (!invoice.value) return if (!invoice.value) return
console.group('watch importCustomer') // console.group('watch importCustomer')
console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`) // 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 (!isLoading.value) {
if (!invoice.value.billingData) invoice.value.billingData = newBillingData() if (!invoice.value.billingData) invoice.value.billingData = newBillingData()
// console.warn('trigger invoice watcher')
invoice.value.billingData.companyName = newValue.companyName invoice.value.billingData.companyName = newValue.companyName
invoice.value.billingData.vatId = newValue.vatId invoice.value.billingData.vatId = newValue.vatId
// Don't overwrite these values during loading if (!invoice.value.billingData.billingAddress)
// they can intentionally be different from customer data
if (!invoice.value.billingData.billingAddress || !isLoading.value)
invoice.value.billingData.billingAddress = newValue.billingAddress as Address 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 : '' 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 : '' 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 invoice.value.billingData.paymentTerms = newValue.paymentTerms as PaymentTerms
// console.warn('trigger invoice watcher')
invoice.value.customer = newValue invoice.value.customer = newValue
isDirty.value = true;
} }
console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`) // console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
console.groupEnd() // console.groupEnd()
}, },
{ deep: true } { deep: true }
) )
watch(importContact, watch(importContact,
(newValue, oldValue) => { (newValue, oldValue) => {
if (newValue == oldValue) return
if (!invoice.value) return if (!invoice.value) return
console.group('watch importContact') // console.group('watch importContact')
console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`) // console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
if (!isLoading.value) { if (!isLoading.value) {
if (newValue.id !== 0) { if (newValue.id !== 0) {
@@ -228,12 +246,10 @@ watch(importContact,
} }
isDirty.value = true; isDirty.value = true;
} else {
isLoading.value = false;
} }
console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`) // console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
console.groupEnd() // console.groupEnd()
}, },
{ deep: true } { deep: true }
) )
@@ -246,14 +262,73 @@ const billingContactEmail = computed<string | undefined>(() => {
else return "" else return ""
}) })
const saveChanges = () => { const save = async () => {
if (invoice.value) { if (invoice.value) {
emit('save', invoice.value) // add spinner to save button
// isOpen.value = false 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 if (!event) return
event.preventDefault() event.preventDefault()
@@ -300,21 +375,30 @@ const issueInvoice = function () {
const deleteInvoice = function () { const deleteInvoice = function () {
alert.show( alert.show(
"Möchtest Du diese Rechnung wirklich löschen?", "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", actionText: "Löschen",
actionVariant: "destructive", actionVariant: "destructive",
onAction: () => { onAction: async () => {
emit('delete', invoice.value?.id) try {
isOpen.value = false 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; if (!invoice.value) return;
invoice.value.paymentStatus = 'cancelled' invoice.value.paymentStatus = status
isDirty.value = true
} }
const openReminderDialog = function () { 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; 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; let total = 0;
invoice.value.items.forEach(item => { updatedItems.forEach(item => {
total += item.quantity * item.price; total += item.quantity * item.price;
}); });
invoice.value.totalAmount = total; 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> </script>
@@ -358,14 +466,7 @@ const updateTotalAmount = () => {
<Dialog id="invoice-dialog" v-model:open="isOpen"> <Dialog id="invoice-dialog" v-model:open="isOpen">
<DialogContent <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" 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"> @escapeKeyDown="cancel" @interactOutside="cancel">
<!-- <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"> <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"> <div class="flex gap-2 items-center">
<TooltipProvider> <TooltipProvider>
<!-- Save --> <!-- Save -->
<Button v-if="invoice && isDirty" class="grow md:grow-0" size="sm" @click="saveChanges"> <Button v-if="invoice && isDirty" class="grow md:grow-0" size="sm" @click="save"
<Check stroke-width="1.5" /> :disabled="isSaving">
<Loader2 v-if="isSaving" stroke-width="1.5" class="animate-spin" />
<Check v-else stroke-width="1.5" />
Speichern Speichern
</Button> </Button>
@@ -405,7 +508,7 @@ const updateTotalAmount = () => {
<!-- Paid --> <!-- Paid -->
<Tooltip v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)"> <Tooltip v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)">
<TooltipTrigger> <TooltipTrigger>
<Button size="sm" variant="success" @click="invoice.paymentStatus = 'paid'"> <Button size="sm" variant="success" @click="updateStatus('paid')">
<FileCheck stroke-width="1.5" /> Bezahlt <FileCheck stroke-width="1.5" /> Bezahlt
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@@ -488,7 +591,7 @@ const updateTotalAmount = () => {
<!-- Cancel --> <!-- Cancel -->
<DropdownMenuItem <DropdownMenuItem
v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)" 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"> <div class="flex items-center gap-3">
<!-- <FileX stroke-width="1.5" class="text-muted-foreground"/> --> <!-- <FileX stroke-width="1.5" class="text-muted-foreground"/> -->
<Ban :strokeWidth="1.5" class="text-current" /> <Ban :strokeWidth="1.5" class="text-current" />
@@ -515,7 +618,7 @@ const updateTotalAmount = () => {
</div> </div>
</DialogHeader> </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">
<div id="document-header" <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"> <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> <label class="text-muted-foreground text-xs pb-[0.4rem]">Netto</label>
<span class="text-lg text-muted-foreground place-self-end">{{ <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> <label class="text-muted-foreground text-xs pb-[0.4rem]">Brutto</label>
<span class="text-xl font-bold place-self-end">{{ <span class="text-xl font-bold place-self-end">{{
toCurrency(toFixedRounded(Number(invoice.totalAmount * toCurrency(toFixedRounded(Number(invoice?.totalAmount || 0) *
1.19), 2)) }}</span> 1.19, 2)) }}</span>
</div> </div>
</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" /> class="font-light bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none" />
</div> </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> </div>
@@ -2,23 +2,23 @@
<!-- TODO: Enter in LineItem = neue Zeile --> <!-- TODO: Enter in LineItem = neue Zeile -->
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, HTMLAttributes } from 'vue' import { ref, watch, HTMLAttributes, onUpdated, onMounted } from 'vue'
import draggable from 'vuedraggable'; import draggable from 'vuedraggable';
import { cn, toCurrency } from '@/lib/utils'; import { cn, toCurrency } from '@/lib/utils';
import { LineItem } from '@/types'; import { LineItem } from '@/types';
import { newLineItem } from '@/types/index.d' import { newLineItem } from '@/types/index.d'
import { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Table, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/table';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"
import { NumberField, NumberFieldContent, NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, } from '@/components/ui/number-field' import { NumberField, NumberFieldContent, NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, } from '@/components/ui/number-field'
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { CirclePlus, GripVertical, Trash2, Plus, TextSelect } from 'lucide-vue-next'; import { Loader2, GripVertical, Trash2, Plus, TextSelect } from 'lucide-vue-next';
import Textarea from '../ui/textarea/Textarea.vue';
import Button from '../ui/button/Button.vue'; 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 NumberInput from '../ui/number-input/NumberInput.vue';
import { GrowingTextarea } from '../ui/growing-textarea'; import { GrowingTextarea } from '../ui/growing-textarea';
const props = defineProps<{ const props = defineProps<{
isLoading?: boolean,
lineItems: LineItem[] | undefined, lineItems: LineItem[] | undefined,
stickyTop: number | string, stickyTop: number | string,
class?: HTMLAttributes['class'] class?: HTMLAttributes['class']
@@ -28,16 +28,24 @@ const emit = defineEmits<{
(e: 'update:lineItems', value: LineItem[]): void (e: 'update:lineItems', value: LineItem[]): void
}>() }>()
const isLoading = ref(props.isLoading || false)
const units = ref(['Stück', 'Stunden', 'Tage', 'pauschal']) 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; 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) => { watch(items, (newItems) => {
if (isLoading.value) return
emit('update:lineItems', newItems) emit('update:lineItems', newItems)
}, { deep: true }) }, { deep: true })
watch(() => props.lineItems, (newLineItems) => { watch(() => props.lineItems, (newLineItems) => {
isLoading.value = (newLineItems?.length || 0) > 0
items.value = (newLineItems ?? []).slice().sort(function (a, b) { return a.position - b.position }) items.value = (newLineItems ?? []).slice().sort(function (a, b) { return a.position - b.position })
}, { deep: true }) }, { deep: true, once: true })
const newItem = () => { const newItem = () => {
const position = items.value.length + 1 const position = items.value.length + 1
@@ -84,7 +92,8 @@ const recalculatePositions = () => {
</Table> </Table>
</div> </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" <draggable v-model="items" tag="tbody" item-key="position" handle=".handle" ghostClass="ghost"
@end="recalculatePositions"> @end="recalculatePositions">
@@ -164,7 +173,7 @@ const recalculatePositions = () => {
</template> </template>
</draggable> </draggable>
<TableFooter class="bg-transparent"> <TableFooter v-if="items.length >= 1" class="bg-transparent">
<TableRow class="hover:bg-transparent dark:hover:bg-transparent"> <TableRow class="hover:bg-transparent dark:hover:bg-transparent">
<TableCell colspan="8" class="text-center"> <TableCell colspan="8" class="text-center">
@@ -178,7 +187,9 @@ const recalculatePositions = () => {
</Table> </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> <EmptyHeader>
<EmptyMedia variant="icon"> <EmptyMedia variant="icon">
<TextSelect class="text-muted-foreground" stroke-width="1.5" /> <TextSelect class="text-muted-foreground" stroke-width="1.5" />
+28 -78
View File
@@ -2,6 +2,7 @@
import { computed, ref, onMounted } from 'vue' import { computed, ref, onMounted } from 'vue'
import { type Invoice } from '@/types' import { type Invoice } from '@/types'
import { newInvoice } from '@/types/index.d'
import axios from 'axios' import axios from 'axios'
import AppLayout from '@/layouts/AppLayout.vue' import AppLayout from '@/layouts/AppLayout.vue'
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from '@/components/ui/select'
@@ -41,7 +42,7 @@ onMounted(async () => {
let queryString = window.location.search let queryString = window.location.search
let params = new URLSearchParams(queryString) let params = new URLSearchParams(queryString)
if (params.get('action') == 'new') editInvoice() if (params.get('action') == 'new') createInvoice()
searchField.value = document.getElementById('search') 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 // 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 detailDialogOpen.value = true
} }
const saveInvoice = async (updatedInvoice: Invoice) => { const onSaveInvoice = async (updatedInvoice: Invoice) => {
try { // Update selectedYearIndex to ensure the new invoice is displayed
// Prepare the invoice data for API request const newInvoiceYear = new Date(updatedInvoice.invoiceDate).getFullYear();
const invoiceToSave = { const currentYearIndex = years.value.findIndex(year => year === newInvoiceYear);
nr: updatedInvoice.nr, if (currentYearIndex !== -1) {
invoiceDate: updatedInvoice.invoiceDate, selectedYearIndex.value = currentYearIndex;
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
},
// Items will be handled separately in the controller // Update table
items: updatedInvoice.items.map(item => ({ const index = invoicesData.value.findIndex(inv => inv.id === updatedInvoice.id);
id: item.id, // Include ID for existing items if (index !== -1) {
position: item.position, invoicesData.value[index] = updatedInvoice;
type: item.type, } else {
title: item.title, invoicesData.value.push(updatedInvoice);
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,
});
} }
} }
const deleteInvoice = async (id: number) => { const onDeleteInvoice = async (id: number) => {
try { const index = invoicesData.value.findIndex(invoice => invoice.id === id)
await axios.delete('/api/invoices/' + id) if (index !== -1) {
const index = invoicesData.value.findIndex(invoice => invoice.id === id) invoicesData.value.splice(index, 1)
if (index !== -1) {
invoicesData.value.splice(index, 1)
}
} catch (error) {
toast.error("Rechnung konnte nicht gelöscht werden", {
description: (error as Error).message,
});
} }
} }
@@ -234,7 +183,7 @@ const deleteInvoice = async (id: number) => {
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<Button size="sm" variant="action" @click="editInvoice()"> <Button size="sm" variant="action" @click="createInvoice">
<Plus /> <Plus />
Neu Neu
</Button> </Button>
@@ -257,7 +206,8 @@ const deleteInvoice = async (id: number) => {
<DocumentTable :invoices="filteredInvoices" :onItemClicked="editInvoice" /> <DocumentTable :invoices="filteredInvoices" :onItemClicked="editInvoice" />
<!-- Invoice detail dialog --> <!-- Invoice detail dialog -->
<InvoiceDialog :invoiceData="activeInvoice" v-model="detailDialogOpen" @save="" @delete="" /> <InvoiceDialog :invoiceData="activeInvoice" v-model="detailDialogOpen" @save="onSaveInvoice"
@delete="onDeleteInvoice" />
</AppLayout> </AppLayout>
</template> </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 { export interface Invoice {
id: number; id: number;
nr: string; nr: string;
@@ -148,7 +150,7 @@ export interface Invoice {
isRecurring: boolean; isRecurring: boolean;
isPartialService: boolean; isPartialService: boolean;
customer?: Customer | null; customer?: Customer | null;
paymentStatus: 'draft' | 'issued' | 'paid' | 'due' | 'reminded' | 'cancelled'; paymentStatus: PaymentStatus;
totalAmount: number; totalAmount: number;
title: string; title: string;
text: string; text: string;