Move REST calls from invoice table to invoice dialog, fixes #56
This commit is contained in:
@@ -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" />
|
||||||
|
|||||||
@@ -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 won’t affect the data until saved
|
// make a deep copy, so the changes in the dialog won’t 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>
|
||||||
|
|||||||
Vendored
+3
-1
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user