2025-10-20 08:57:51 +02:00
|
|
|
|
<!-- Rechnungsart: Wiederkehren, einmalig, Abschlag (siehe Chat, wirkt sich auf Leistungszeitraum aus) -->
|
|
|
|
|
|
<!-- TODO: Leistungsdatum -->
|
|
|
|
|
|
<!-- TODO: Leistungsstart -->
|
|
|
|
|
|
<!-- TODO: Leistungsende -->
|
|
|
|
|
|
|
|
|
|
|
|
<!-- TODO: Steuersatz in LineItem -->
|
|
|
|
|
|
<!-- TODO: Stunden und Tagessatz aus Settings -->
|
|
|
|
|
|
<!-- TODO: Client-side validation -->
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
import { ref, computed, watch, onMounted, onUpdated, toRaw, nextTick } from "vue"
|
2025-12-08 13:20:52 +01:00
|
|
|
|
import { Customer, Invoice, Contact, PaymentTerms, Address, LineItem, PaymentStatus, Unit } from "@/types"
|
2025-11-18 20:46:40 +01:00
|
|
|
|
import { newCustomer, newContact, newBillingData } from '@/types/index.d'
|
2026-02-17 10:35:03 +01:00
|
|
|
|
import { toCurrency, toLocalDate, toShortISOString, calcDueDate, toFixedRounded, hotkey } from '@/lib/utils'
|
|
|
|
|
|
import axios from "axios"
|
2025-11-18 10:27:49 +01:00
|
|
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"
|
2025-10-20 08:57:51 +02:00
|
|
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
2026-02-17 10:35:03 +01:00
|
|
|
|
import { Table, TableBody, TableCell, TableHead, TableRow, } from '@/components/ui/crm-table'
|
2025-11-18 10:27:49 +01:00
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'
|
2025-11-21 13:21:59 +01:00
|
|
|
|
import { Button } from '@/components/ui/crm-button'
|
|
|
|
|
|
import { Input } from '@/components/ui/crm-input';
|
2025-11-18 10:27:49 +01:00
|
|
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
|
|
|
|
|
|
import { StatusBadge, statusBadgeLabels } from '@/components/ui/status-badge'
|
2025-10-20 08:57:51 +02:00
|
|
|
|
import LineItemTable from '@/components/documents/LineItemTable.vue'
|
2025-12-08 13:20:52 +01:00
|
|
|
|
import { Eye, FileText, Trash2, BookUser, User, CodeXml, MessageCircleQuestion, Loader2, Ellipsis, Check, FileCheck, Ban, Logs, Import } from "lucide-vue-next"
|
2025-10-29 18:04:09 +01:00
|
|
|
|
import { alertStore } from "@/stores/alertStore"
|
|
|
|
|
|
import { GrowingTextarea } from '../ui/growing-textarea'
|
2025-10-30 10:15:02 +01:00
|
|
|
|
import { toast } from "vue-sonner"
|
2026-02-17 10:35:03 +01:00
|
|
|
|
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
2025-11-14 11:55:41 +01:00
|
|
|
|
import DialogClose from "../ui/dialog/DialogClose.vue"
|
2026-02-17 10:35:03 +01:00
|
|
|
|
import DialogCloseButton from "../DialogCloseButton/DialogCloseButton.vue"
|
2025-11-18 10:27:49 +01:00
|
|
|
|
import SendMailDialog from "../ui/send-mail-dialog/SendMailDialog.vue"
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
const DEBUG = ref(false)
|
|
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
const props = defineProps<{
|
2026-02-17 10:35:03 +01:00
|
|
|
|
invoiceData: Invoice | undefined,
|
2025-10-20 08:57:51 +02:00
|
|
|
|
modelValue: boolean
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
const emit = defineEmits(['update:modelValue', 'save', 'cancel', 'delete'])
|
|
|
|
|
|
|
|
|
|
|
|
// Dialog state
|
|
|
|
|
|
const isOpen = computed({
|
|
|
|
|
|
get: () => props.modelValue,
|
|
|
|
|
|
set: (value) => {
|
|
|
|
|
|
emit('update:modelValue', value)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2025-12-08 13:20:52 +01:00
|
|
|
|
|
2025-11-18 10:27:49 +01:00
|
|
|
|
const invoice = ref<Invoice>()
|
2025-12-08 13:20:52 +01:00
|
|
|
|
const units = ref([] as Unit[])
|
2025-11-18 10:27:49 +01:00
|
|
|
|
const customers = ref([] as Customer[])
|
2026-02-17 10:35:03 +01:00
|
|
|
|
const paymentTerms = ref([] as PaymentTerms[])
|
2025-11-18 20:46:40 +01:00
|
|
|
|
const isDirty = ref(false)
|
|
|
|
|
|
const isSaving = ref(false)
|
|
|
|
|
|
const itemsLoading = ref(false)
|
2025-10-20 08:57:51 +02:00
|
|
|
|
const importContact = ref(newContact() as Contact)
|
|
|
|
|
|
const importCustomer = ref(newCustomer() as Customer)
|
2025-10-29 18:04:09 +01:00
|
|
|
|
const alert = alertStore()
|
2025-11-11 11:29:17 +01:00
|
|
|
|
const reminderDialogOpen = ref(false)
|
2025-10-30 10:15:02 +01:00
|
|
|
|
const reminderLoading = ref(false)
|
2025-12-08 13:20:52 +01:00
|
|
|
|
const fileInput = ref<HTMLInputElement | null>(null);
|
2026-02-17 10:35:03 +01:00
|
|
|
|
const dataLoaded = ref(false)
|
|
|
|
|
|
const title = computed<string>(_ => {
|
2025-11-18 20:46:40 +01:00
|
|
|
|
if (invoice.value && invoice.value.id !== 0) {
|
|
|
|
|
|
return `Rechnung ${invoice.value.nr || ''}`
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return 'Neue Rechnung'
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
onMounted(async () => {
|
2026-02-17 10:35:03 +01:00
|
|
|
|
// Load additional data that wasn’t provided by parent invoices view
|
2025-11-18 10:27:49 +01:00
|
|
|
|
try {
|
2026-02-17 10:35:03 +01:00
|
|
|
|
const promises: Promise<any>[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
// Create an array of promises for each data request
|
|
|
|
|
|
promises.push(axios.get("/api/customers"))
|
|
|
|
|
|
promises.push(axios.get("/api/paymentterms"))
|
|
|
|
|
|
promises.push(axios.get("/api/units"))
|
|
|
|
|
|
|
|
|
|
|
|
// Wait for all promises to resolve
|
2025-11-18 10:27:49 +01:00
|
|
|
|
const responses = await Promise.all(promises)
|
2026-02-17 10:35:03 +01:00
|
|
|
|
|
|
|
|
|
|
// Process each response
|
|
|
|
|
|
customers.value = responses[0].data
|
|
|
|
|
|
paymentTerms.value = responses[0].data
|
|
|
|
|
|
units.value = responses[0].data
|
|
|
|
|
|
|
2025-11-18 10:27:49 +01:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast.error('Fehler beim Laden der Daten', error || String(error))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
// Register hotkeys
|
|
|
|
|
|
hotkey('mod+i', importLineItems, null, () => isOpen.value)
|
|
|
|
|
|
hotkey('mod+e', exportPdf, null, () => isOpen.value)
|
|
|
|
|
|
hotkey('mod+p', preview, null, () => isOpen.value)
|
2025-11-18 10:27:49 +01:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
// Initial data from parent view
|
|
|
|
|
|
watch(() => props.invoiceData, async () => {
|
|
|
|
|
|
// Reset state flag
|
|
|
|
|
|
isDirty.value = false
|
|
|
|
|
|
dataLoaded.value = false
|
2025-11-18 10:27:49 +01:00
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
if (DEBUG.value) {
|
|
|
|
|
|
console.group('on parent data')
|
|
|
|
|
|
console.log(`isDirty: ${isDirty.value}`, `dataLoaded: ${dataLoaded.value}`)
|
|
|
|
|
|
}
|
2025-11-18 10:27:49 +01:00
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
// Get invoice data from props
|
|
|
|
|
|
invoice.value = props.invoiceData
|
2025-11-18 10:27:49 +01:00
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
// Load line items
|
|
|
|
|
|
if (invoice.value && invoice.value.id !== 0) {
|
|
|
|
|
|
itemsLoading.value = true
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
try {
|
|
|
|
|
|
await 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))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
await nextTick()
|
2025-11-18 20:46:40 +01:00
|
|
|
|
itemsLoading.value = false
|
|
|
|
|
|
}
|
2026-02-17 10:35:03 +01:00
|
|
|
|
}
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
|
|
|
|
|
|
/* wait until next tick to reset loading state
|
|
|
|
|
|
*
|
|
|
|
|
|
* as changes made to customer and contacts here will probably
|
|
|
|
|
|
* change the invoice and would then trigger the invoice watcher agai
|
|
|
|
|
|
*/
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
dataLoaded.value = true
|
|
|
|
|
|
if (DEBUG.value) {
|
|
|
|
|
|
console.log(`isDirty: ${isDirty.value}`, `dataLoaded: ${dataLoaded.value}`)
|
|
|
|
|
|
console.groupEnd()
|
2025-11-18 10:27:49 +01:00
|
|
|
|
}
|
|
|
|
|
|
})
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
// watch changes on local invoice date
|
|
|
|
|
|
watch(invoice,
|
2026-02-17 10:35:03 +01:00
|
|
|
|
async (newValue, oldValue) => {
|
2025-11-18 20:46:40 +01:00
|
|
|
|
if (newValue == oldValue) return
|
|
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
if (DEBUG.value) {
|
|
|
|
|
|
console.group('watch invoice')
|
|
|
|
|
|
console.log(`isDirty: ${isDirty.value}`, `dataLoaded: ${dataLoaded.value}`)
|
|
|
|
|
|
}
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
if (dataLoaded.value) {
|
|
|
|
|
|
isDirty.value = true
|
|
|
|
|
|
} else {
|
2025-11-18 20:46:40 +01:00
|
|
|
|
if (!newValue) {
|
2026-02-17 10:35:03 +01:00
|
|
|
|
console.groupEnd()
|
2025-11-18 20:46:40 +01:00
|
|
|
|
return;
|
2025-11-18 10:27:49 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
// If no billing data is store in the invoice, generat ot from customer
|
2025-11-18 20:46:40 +01:00
|
|
|
|
if (!newValue.billingData) {
|
|
|
|
|
|
newValue.billingData = {
|
|
|
|
|
|
companyName: newValue.customer?.companyName || "",
|
|
|
|
|
|
vatId: newValue.customer?.vatId || "",
|
2025-10-20 08:57:51 +02:00
|
|
|
|
billingAddress: {
|
2025-11-18 20:46:40 +01:00
|
|
|
|
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 || "",
|
2025-10-20 08:57:51 +02:00
|
|
|
|
},
|
2025-11-18 20:46:40 +01:00
|
|
|
|
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,
|
2025-10-20 08:57:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-19 12:48:21 +01:00
|
|
|
|
if (newValue.customerId && newValue.customerId !== 0) {
|
|
|
|
|
|
customers.value.find(customer => {
|
|
|
|
|
|
if (customer.id === newValue.customerId) {
|
|
|
|
|
|
if (invoice.value) invoice.value.customer = customer as Customer
|
|
|
|
|
|
importCustomer.value = customer as Customer
|
|
|
|
|
|
|
|
|
|
|
|
customer.contacts.find(contact => {
|
|
|
|
|
|
if (contact.firstName === newValue?.billingData?.contactFirstName &&
|
|
|
|
|
|
contact.lastName === newValue?.billingData?.contactLastName) {
|
|
|
|
|
|
importContact.value = contact
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2025-10-20 08:57:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
if (DEBUG.value) {
|
|
|
|
|
|
console.log(`isDirty: ${isDirty.value}`, `dataLoaded: ${dataLoaded.value}`)
|
|
|
|
|
|
console.groupEnd()
|
|
|
|
|
|
}
|
2025-10-20 08:57:51 +02:00
|
|
|
|
},
|
|
|
|
|
|
{ deep: true }
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
watch(importCustomer,
|
|
|
|
|
|
(newValue, oldValue) => {
|
2025-11-18 20:46:40 +01:00
|
|
|
|
if (newValue == oldValue) return
|
|
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
if (!invoice.value) return
|
2026-02-17 10:35:03 +01:00
|
|
|
|
if (DEBUG.value) {
|
|
|
|
|
|
console.group('watch importCustomer')
|
|
|
|
|
|
console.log(`isDirty: ${isDirty.value}`)
|
|
|
|
|
|
}
|
2025-11-18 10:27:49 +01:00
|
|
|
|
|
2025-11-18 20:46:40 +01:00
|
|
|
|
// Don't overwrite these values during loading
|
|
|
|
|
|
// they can intentionally be different from customer data
|
2026-02-17 10:35:03 +01:00
|
|
|
|
if (dataLoaded.value) {
|
2025-10-20 08:57:51 +02:00
|
|
|
|
if (!invoice.value.billingData) invoice.value.billingData = newBillingData()
|
|
|
|
|
|
|
2025-11-18 20:46:40 +01:00
|
|
|
|
// console.warn('trigger invoice watcher')
|
2025-10-20 08:57:51 +02:00
|
|
|
|
invoice.value.billingData.companyName = newValue.companyName
|
|
|
|
|
|
invoice.value.billingData.vatId = newValue.vatId
|
|
|
|
|
|
|
2025-12-08 13:20:52 +01:00
|
|
|
|
// if (!invoice.value.billingData.billingAddress)
|
|
|
|
|
|
invoice.value.billingData.billingAddress = newValue.billingAddress as Address
|
|
|
|
|
|
// if (!invoice.value.billingData.contactFirstName)
|
|
|
|
|
|
invoice.value.billingData.contactFirstName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].firstName : ''
|
|
|
|
|
|
// if (!invoice.value.billingData.contactLastName)
|
|
|
|
|
|
invoice.value.billingData.contactLastName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].lastName : ''
|
|
|
|
|
|
// if (!invoice.value.billingData.paymentTerms)
|
|
|
|
|
|
invoice.value.billingData.paymentTerms = newValue.paymentTerms as PaymentTerms
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
2025-11-18 20:46:40 +01:00
|
|
|
|
// console.warn('trigger invoice watcher')
|
2025-10-22 16:13:52 +02:00
|
|
|
|
invoice.value.customer = newValue
|
2025-11-18 20:46:40 +01:00
|
|
|
|
|
|
|
|
|
|
isDirty.value = true;
|
2025-10-20 08:57:51 +02:00
|
|
|
|
}
|
2025-10-22 16:13:52 +02:00
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
if (DEBUG.value) {
|
|
|
|
|
|
console.log(`isDirty: ${isDirty.value}`)
|
|
|
|
|
|
console.groupEnd()
|
|
|
|
|
|
}
|
2025-10-20 08:57:51 +02:00
|
|
|
|
},
|
|
|
|
|
|
{ deep: true }
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
watch(importContact,
|
|
|
|
|
|
(newValue, oldValue) => {
|
2025-11-18 20:46:40 +01:00
|
|
|
|
if (newValue == oldValue) return
|
2025-10-20 08:57:51 +02:00
|
|
|
|
if (!invoice.value) return
|
|
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
if (DEBUG.value) {
|
|
|
|
|
|
console.group('watch importContact')
|
|
|
|
|
|
console.log(`isDirty: ${isDirty.value}`)
|
|
|
|
|
|
}
|
2025-11-18 10:27:49 +01:00
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
if (dataLoaded.value) {
|
2025-10-20 08:57:51 +02:00
|
|
|
|
if (newValue.id !== 0) {
|
|
|
|
|
|
invoice.value.billingData!.contactFirstName = newValue.firstName
|
|
|
|
|
|
invoice.value.billingData!.contactLastName = newValue.lastName
|
|
|
|
|
|
} else {
|
|
|
|
|
|
invoice.value.billingData!.contactFirstName = ''
|
|
|
|
|
|
invoice.value.billingData!.contactLastName = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
isDirty.value = true;
|
|
|
|
|
|
}
|
2025-11-18 10:27:49 +01:00
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
if (DEBUG.value) {
|
|
|
|
|
|
console.log(`isDirty: ${isDirty.value}`)
|
|
|
|
|
|
console.groupEnd()
|
|
|
|
|
|
}
|
2025-10-20 08:57:51 +02:00
|
|
|
|
},
|
|
|
|
|
|
{ deep: true }
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-11-14 11:55:41 +01:00
|
|
|
|
const billingContactEmail = computed<string | undefined>(() => {
|
|
|
|
|
|
// TODO: use e-mail from billing data if set
|
|
|
|
|
|
// and fallback to primary contact email
|
|
|
|
|
|
if (invoice.value?.customer && invoice.value?.customer.contacts[0])
|
|
|
|
|
|
return invoice.value?.customer?.contacts[0].email
|
|
|
|
|
|
else return ""
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-11-18 20:46:40 +01:00
|
|
|
|
const save = async () => {
|
2025-10-20 08:57:51 +02:00
|
|
|
|
if (invoice.value) {
|
2025-11-18 20:46:40 +01:00
|
|
|
|
// add spinner to save button
|
|
|
|
|
|
isSaving.value = true
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-02-17 10:35:03 +01:00
|
|
|
|
// Prepare the invoice data API request
|
2025-11-18 20:46:40 +01:00
|
|
|
|
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,
|
2025-11-19 10:12:45 +01:00
|
|
|
|
isSection: item.isSection,
|
2025-11-18 20:46:40 +01:00
|
|
|
|
type: item.type,
|
|
|
|
|
|
title: item.title,
|
|
|
|
|
|
description: item.description,
|
|
|
|
|
|
quantity: item.quantity,
|
2025-12-08 13:20:52 +01:00
|
|
|
|
unitId: item.unitId,
|
2025-11-18 20:46:40 +01:00
|
|
|
|
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
|
2026-02-17 10:35:03 +01:00
|
|
|
|
await axios.put(`/api/invoices/${invoice.value.id}`, invoiceToSave);
|
2025-11-18 20:46:40 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
emit('save', invoice.value)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast.error("Rechnung konnte nicht gespeichert werden", {
|
|
|
|
|
|
description: (error as Error).message,
|
|
|
|
|
|
})
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
// remove spinner from save button
|
|
|
|
|
|
isSaving.value = false
|
2026-02-17 10:35:03 +01:00
|
|
|
|
isDirty.value = false
|
2025-11-18 20:46:40 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-18 20:46:40 +01:00
|
|
|
|
const cancel = (event: Event | null) => {
|
2025-11-18 10:27:49 +01:00
|
|
|
|
if (!event) return
|
|
|
|
|
|
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
event.returnValue = true
|
|
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
if (isDirty.value) {
|
2025-10-30 10:15:02 +01:00
|
|
|
|
alert.show(
|
|
|
|
|
|
"Wirklich schließen?",
|
|
|
|
|
|
"Es gibt ungespeicherte Änderungen, die dann verloren gehen.",
|
|
|
|
|
|
{
|
|
|
|
|
|
actionText: "Änderungen verwerfen",
|
|
|
|
|
|
onAction: () => {
|
|
|
|
|
|
emit('cancel')
|
|
|
|
|
|
isOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2025-10-20 08:57:51 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
emit('cancel')
|
|
|
|
|
|
isOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const preview = function () {
|
|
|
|
|
|
if (!invoice.value) return;
|
|
|
|
|
|
window?.open('/invoice/' + invoice.value.id, '_blank')?.focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
const exportPdf = function () {
|
2025-10-20 08:57:51 +02:00
|
|
|
|
if (!invoice.value) return;
|
|
|
|
|
|
window?.open('/invoice/' + invoice.value.id + '/pdf');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
const exportXml = function () {
|
2025-10-20 08:57:51 +02:00
|
|
|
|
if (!invoice.value) return;
|
|
|
|
|
|
window?.open('/invoice/' + invoice.value.id + '/xml');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-14 11:55:41 +01:00
|
|
|
|
const issueInvoice = function () {
|
|
|
|
|
|
if (!invoice.value) return;
|
|
|
|
|
|
invoice.value.paymentStatus = 'issued'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
const deleteInvoice = function () {
|
2025-10-30 10:15:02 +01:00
|
|
|
|
alert.show(
|
|
|
|
|
|
"Möchtest Du diese Rechnung wirklich löschen?",
|
2025-11-18 20:46:40 +01:00
|
|
|
|
(invoice.value?.paymentStatus == "draft") ? null : "Nach GoBD musst Du alle Belege und Daten in unveränderter Form aufbewahren.",
|
2025-10-30 10:15:02 +01:00
|
|
|
|
{
|
|
|
|
|
|
actionText: "Löschen",
|
|
|
|
|
|
actionVariant: "destructive",
|
2025-11-18 20:46:40 +01:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-10-30 10:15:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2025-10-20 08:57:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-18 20:46:40 +01:00
|
|
|
|
const updateStatus = function (status: PaymentStatus) {
|
2025-11-14 11:55:41 +01:00
|
|
|
|
if (!invoice.value) return;
|
2025-11-18 20:46:40 +01:00
|
|
|
|
invoice.value.paymentStatus = status
|
|
|
|
|
|
isDirty.value = true
|
2025-11-14 11:55:41 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-11 11:29:17 +01:00
|
|
|
|
const openReminderDialog = function () {
|
|
|
|
|
|
if (!invoice.value) return
|
|
|
|
|
|
reminderDialogOpen.value = true
|
|
|
|
|
|
}
|
2025-10-30 10:15:02 +01:00
|
|
|
|
|
2025-11-19 14:30:24 +01:00
|
|
|
|
const sendReminder = async function (to: string | undefined, cc: string | undefined) {
|
|
|
|
|
|
if (!to) return
|
2025-11-11 11:29:17 +01:00
|
|
|
|
if (!invoice.value || !invoice.value.id) return
|
|
|
|
|
|
|
|
|
|
|
|
// Close dialog
|
|
|
|
|
|
reminderDialogOpen.value = false
|
|
|
|
|
|
|
|
|
|
|
|
// make button spin and disable button
|
|
|
|
|
|
reminderLoading.value = true
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// await axios call
|
2025-11-19 14:30:24 +01:00
|
|
|
|
let request = '/api/invoices/' + invoice.value.id + '/remind?to=' + to
|
|
|
|
|
|
if (cc) request += '&cc=' + cc
|
|
|
|
|
|
await axios.get(request)
|
|
|
|
|
|
invoice.value.paymentStatus = 'reminded'
|
|
|
|
|
|
save()
|
|
|
|
|
|
toast.success("Zahlungserinnerung gesendet", { description: to })
|
2025-11-11 11:29:17 +01:00
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
toast.error(error?.title || 'Fehler', { description: error?.message || String(error) })
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
reminderLoading.value = false
|
|
|
|
|
|
}
|
2025-10-22 11:58:18 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-18 20:46:40 +01:00
|
|
|
|
const updateLineItems = (newItems: LineItem[]) => {
|
2026-02-17 10:35:03 +01:00
|
|
|
|
if (!dataLoaded.value) return;
|
2025-10-20 08:57:51 +02:00
|
|
|
|
if (!invoice.value) return;
|
2025-11-18 20:46:40 +01:00
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
console.group('updateLineItems');
|
|
|
|
|
|
console.log(`isDirty: ${isDirty.value}`);
|
2025-11-18 20:46:40 +01:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
// Create a deep copy of the new items
|
2025-11-18 20:46:40 +01:00
|
|
|
|
const updatedItems = JSON.parse(JSON.stringify(sortedItems));
|
|
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
// Update the invoice items
|
2025-11-18 20:46:40 +01:00
|
|
|
|
invoice.value.items = updatedItems;
|
|
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
// Calculate the new total amount
|
2025-10-20 08:57:51 +02:00
|
|
|
|
let total = 0;
|
2025-11-18 20:46:40 +01:00
|
|
|
|
updatedItems.forEach(item => {
|
2025-10-20 08:57:51 +02:00
|
|
|
|
total += item.quantity * item.price;
|
|
|
|
|
|
});
|
|
|
|
|
|
invoice.value.totalAmount = total;
|
2025-11-18 20:46:40 +01:00
|
|
|
|
|
|
|
|
|
|
// Erzwingen Sie eine Aktualisierung der Benutzeroberfläche
|
|
|
|
|
|
invoice.value = { ...invoice.value };
|
|
|
|
|
|
|
2026-02-17 10:35:03 +01:00
|
|
|
|
console.log(`isDirty: ${isDirty.value}`);
|
|
|
|
|
|
console.groupEnd();
|
2025-10-20 08:57:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-08 13:20:52 +01:00
|
|
|
|
const importLineItems = () => {
|
2026-02-17 10:35:03 +01:00
|
|
|
|
if (!isOpen) return
|
|
|
|
|
|
|
2025-12-08 13:20:52 +01:00
|
|
|
|
if (!fileInput.value) {
|
|
|
|
|
|
fileInput.value = document.createElement('input');
|
|
|
|
|
|
fileInput.value.type = 'file';
|
|
|
|
|
|
fileInput.value.accept = '.csv';
|
|
|
|
|
|
fileInput.value.addEventListener('change', handleFileUpload);
|
|
|
|
|
|
}
|
|
|
|
|
|
fileInput.value.click();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleFileUpload = async (event: Event) => {
|
|
|
|
|
|
const input = event.target as HTMLInputElement;
|
|
|
|
|
|
if (!input.files || !input.files[0]) return;
|
|
|
|
|
|
|
|
|
|
|
|
const file = input.files[0];
|
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
|
formData.append('csv', file);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-02-17 10:35:03 +01:00
|
|
|
|
const response = await axios.post(`/api/lineitems/import`, formData, {
|
2025-12-08 13:20:52 +01:00
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'multipart/form-data'
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (invoice.value) {
|
2026-02-17 10:35:03 +01:00
|
|
|
|
invoice.value.items = invoice.value.items.concat(response.data as LineItem[]);
|
2025-12-08 13:20:52 +01:00
|
|
|
|
isDirty.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
toast.success('Positionen erfolgreich importiert');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Fehler beim Importieren der Positionen:', error);
|
|
|
|
|
|
toast.error('Fehler beim Importieren der Positionen', {
|
|
|
|
|
|
description: error instanceof Error ? error.message : String(error)
|
|
|
|
|
|
});
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
// Reset file input
|
|
|
|
|
|
if (fileInput.value) {
|
|
|
|
|
|
fileInput.value.value = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<Dialog id="invoice-dialog" v-model:open="isOpen">
|
|
|
|
|
|
<DialogContent
|
2025-11-14 11:55:41 +01:00
|
|
|
|
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"
|
2025-11-18 20:46:40 +01:00
|
|
|
|
@escapeKeyDown="cancel" @interactOutside="cancel">
|
2025-11-18 10:27:49 +01:00
|
|
|
|
|
2025-11-21 08:39:34 +01:00
|
|
|
|
<DialogHeader class="p-4 md:p-6 lg:p-12 pb-0 md:pb-2 lg:pb-8 flex flex-row items-start gap-12">
|
2025-11-14 11:55:41 +01:00
|
|
|
|
|
|
|
|
|
|
<div class="flex flex-col grow">
|
2025-11-18 10:27:49 +01:00
|
|
|
|
<DialogTitle class="text-primary-foreground font-bold text-left">
|
|
|
|
|
|
<h1>{{ title }}</h1>
|
2025-11-14 11:55:41 +01:00
|
|
|
|
</DialogTitle>
|
|
|
|
|
|
<DialogDescription>
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<Input v-if="invoice" v-model="invoice.title" :id="'invoice-title'"
|
2025-11-14 11:55:41 +01:00
|
|
|
|
class="text-foreground md:text-base text-ellipsis px-0 bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none"
|
2025-11-18 10:27:49 +01:00
|
|
|
|
type="text" placeholder="Titel" />
|
2025-11-14 11:55:41 +01:00
|
|
|
|
</DialogDescription>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex gap-2 items-center">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<TooltipProvider>
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<!-- Save -->
|
2025-11-18 20:46:40 +01:00
|
|
|
|
<Button v-if="invoice && isDirty" class="grow md:grow-0" size="sm" @click="save"
|
|
|
|
|
|
:disabled="isSaving">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<Loader2 v-if="isSaving" class="animate-spin" />
|
|
|
|
|
|
<Check v-else />
|
2025-11-14 11:55:41 +01:00
|
|
|
|
Speichern
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Issue -->
|
2025-11-18 10:27:49 +01:00
|
|
|
|
<!-- TODO: validate complete data -->
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<Tooltip v-if="invoice && invoice.paymentStatus == 'draft'">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<TooltipTrigger>
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<Button size="sm" variant="action" @click="issueInvoice">
|
|
|
|
|
|
Rechnung stellen
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</Button>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>
|
2025-11-14 11:55:41 +01:00
|
|
|
|
Bearbeitung sperren und Rechnung erstellen
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
|
2025-11-14 11:55:41 +01:00
|
|
|
|
|
|
|
|
|
|
<!-- Paid -->
|
|
|
|
|
|
<Tooltip v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<TooltipTrigger>
|
2025-11-18 20:46:40 +01:00
|
|
|
|
<Button size="sm" variant="success" @click="updateStatus('paid')">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<FileCheck /> Bezahlt
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</Button>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>
|
2025-11-14 11:55:41 +01:00
|
|
|
|
Als bezahlt markieren
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Remind -->
|
|
|
|
|
|
<Tooltip v-if="invoice && ['due', 'reminded'].includes(invoice.paymentStatus)">
|
|
|
|
|
|
<TooltipTrigger>
|
|
|
|
|
|
<Button size="sm" variant="destructive" @click="openReminderDialog"
|
|
|
|
|
|
:disabled="reminderLoading" class="gap-0">
|
|
|
|
|
|
<Loader2 class="h-4 w-4 transition-[width] ease-in-out animate-spin"
|
|
|
|
|
|
:class="{ 'w-0!': !reminderLoading, 'mr-2': reminderLoading }" />
|
|
|
|
|
|
Erinnern
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>
|
|
|
|
|
|
Zahlungserinnerung per E-Mail senden
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<!-- Ellipsis menu -->
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<DropdownMenu v-if="invoice">
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<DropdownMenuTrigger>
|
|
|
|
|
|
<Button variant="ghost" size="sm" class="px-0! w-7 ml-2">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<Ellipsis class="size-4" />
|
2025-11-14 11:55:41 +01:00
|
|
|
|
</Button>
|
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
|
|
|
|
|
|
|
<DropdownMenuContent align="end">
|
2025-12-08 13:20:52 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Import Items -->
|
|
|
|
|
|
<DropdownMenuItem class="flex items-center justify-between" @click="importLineItems">
|
|
|
|
|
|
<div class="flex items-center gap-3">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<Import class="text-muted-foreground" />
|
2025-12-08 13:20:52 +01:00
|
|
|
|
<div class="mr-4 flex flex-col">
|
|
|
|
|
|
<span>Posten importieren</span>
|
|
|
|
|
|
<span class="text-xs text-muted-foreground">(CSV)</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<KbdGroup>
|
|
|
|
|
|
<Kbd class="visible-mac">⌘</Kbd>
|
|
|
|
|
|
<Kbd class="visible-pc">Ctrl</Kbd>
|
|
|
|
|
|
<Kbd>I</Kbd>
|
|
|
|
|
|
</KbdGroup>
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
|
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
|
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<!-- Preview -->
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<DropdownMenuItem class="flex items-center justify-between" @click="preview">
|
2025-11-14 17:45:57 +01:00
|
|
|
|
<div class="flex items-center gap-3">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<Eye class="text-muted-foreground" />
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<span class="mr-4">Vorschau</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<KbdGroup>
|
|
|
|
|
|
<Kbd class="visible-mac">⌘</Kbd>
|
|
|
|
|
|
<Kbd class="visible-pc">Ctrl</Kbd>
|
|
|
|
|
|
<Kbd>P</Kbd>
|
|
|
|
|
|
</KbdGroup>
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- PDF -->
|
2025-11-18 10:27:49 +01:00
|
|
|
|
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'"
|
2026-02-17 10:35:03 +01:00
|
|
|
|
class="flex justify-between" @click="exportPdf">
|
2025-11-14 17:45:57 +01:00
|
|
|
|
<div class="flex items-center gap-3">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<FileText class="text-muted-foreground" />
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<div class="mr-4 flex flex-col">
|
|
|
|
|
|
<span>PDF exportieren</span>
|
|
|
|
|
|
<span class="text-xs text-muted-foreground">(ZUGFeRD)</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<KbdGroup>
|
|
|
|
|
|
<Kbd class="visible-mac">⌘</Kbd>
|
|
|
|
|
|
<Kbd class="visible-pc">Ctrl</Kbd>
|
|
|
|
|
|
<Kbd>E</Kbd>
|
|
|
|
|
|
</KbdGroup>
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- XML -->
|
2025-11-18 10:27:49 +01:00
|
|
|
|
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'"
|
2026-02-17 10:35:03 +01:00
|
|
|
|
class="flex justify-between" @click="exportXml">
|
2025-11-14 17:45:57 +01:00
|
|
|
|
<div class="flex items-center gap-3">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<CodeXml class="text-muted-foreground" />
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<div class="mr-4 flex flex-col">
|
|
|
|
|
|
<span>XML exportieren</span>
|
|
|
|
|
|
<span class="text-xs text-muted-foreground">(XRechnung)</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DropdownMenuItem>
|
2025-12-08 13:20:52 +01:00
|
|
|
|
|
|
|
|
|
|
<DropdownMenuSeparator v-if="invoice && invoice.paymentStatus != 'draft'" />
|
2025-11-21 08:39:34 +01:00
|
|
|
|
|
|
|
|
|
|
<!-- Audit -->
|
|
|
|
|
|
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'"
|
|
|
|
|
|
class="flex justify-between" @click="" disabled>
|
|
|
|
|
|
<div class="flex items-center gap-3">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<Logs class="text-muted-foreground" />
|
2025-11-21 08:39:34 +01:00
|
|
|
|
<span>Audit</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DropdownMenuItem>
|
2025-11-14 11:55:41 +01:00
|
|
|
|
|
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Cancel -->
|
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
|
v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)"
|
2025-11-18 20:46:40 +01:00
|
|
|
|
class="flex justify-between" @click="updateStatus('cancelled')">
|
2025-11-14 17:45:57 +01:00
|
|
|
|
<div class="flex items-center gap-3">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<!-- <FileX class="text-muted-foreground"/> -->
|
|
|
|
|
|
<Ban class="text-current" />
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<span class="mr-2">Stornieren</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Delete -->
|
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
|
class="flex justify-between text-destructive! hover:bg-red-100! dark:hover:bg-red-950!"
|
|
|
|
|
|
@click="deleteInvoice">
|
2025-11-14 17:45:57 +01:00
|
|
|
|
<div class="flex items-center gap-3">
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<Trash2 class="text-current" />
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<span class="mr-2">Löschen</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
|
|
|
|
|
|
|
<DialogClose as-child>
|
|
|
|
|
|
<DialogCloseButton />
|
|
|
|
|
|
</DialogClose>
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
2025-11-18 20:46:40 +01:00
|
|
|
|
<div class="overflow-y-scroll p-4 md:p-6 lg:p-12 pt-0!" v-if="invoice">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
<div id="document">
|
|
|
|
|
|
<div id="document-header"
|
2025-11-19 12:48:21 +01:00
|
|
|
|
class="h-19 pb-12 sticky top-0 bg-background z-1 flex flex-col md:flex-row justify-between items-center">
|
2025-11-14 11:55:41 +01:00
|
|
|
|
|
|
|
|
|
|
<!-- Status -->
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<StatusBadge size="lg" :variant="invoice.paymentStatus">{{
|
|
|
|
|
|
statusBadgeLabels[invoice.paymentStatus] }}
|
|
|
|
|
|
</StatusBadge>
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-11-14 11:55:41 +01:00
|
|
|
|
|
|
|
|
|
|
<!-- Betrag -->
|
|
|
|
|
|
<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">{{
|
2025-11-18 20:46:40 +01:00
|
|
|
|
toCurrency(invoice?.totalAmount || 0) }}</span>
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<label class="text-muted-foreground text-xs pb-[0.4rem]">Brutto</label>
|
|
|
|
|
|
<span class="text-xl font-bold place-self-end">{{
|
2025-11-18 20:46:40 +01:00
|
|
|
|
toCurrency(toFixedRounded(Number(invoice?.totalAmount || 0) *
|
|
|
|
|
|
1.19, 2)) }}</span>
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</div>
|
2025-11-14 11:55:41 +01:00
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div id="document-meta"
|
2025-11-14 11:55:41 +01:00
|
|
|
|
class="flex-none md:flex gap-12 2 p-6 bg-slate-100 dark:bg-neutral-900 rounded-lg">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
2025-10-22 11:58:18 +02:00
|
|
|
|
<div class="flex flex-col gap-1">
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<div class="flex">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<Input type="text" v-model="invoice.billingData.companyName" placeholder="Firma"
|
2025-10-22 11:58:18 +02:00
|
|
|
|
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
<Select v-model="importCustomer" by="id">
|
|
|
|
|
|
<SelectTrigger
|
|
|
|
|
|
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 border-none">
|
|
|
|
|
|
<SelectValue>
|
|
|
|
|
|
<BookUser />
|
|
|
|
|
|
</SelectValue>
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem :value="newCustomer()">
|
|
|
|
|
|
Manuell eingeben
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem v-for="customer in customers" :value="customer">
|
|
|
|
|
|
{{ customer.companyName }}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex flex-row gap-4">
|
|
|
|
|
|
<Input type="text" v-model="invoice.billingData.contactFirstName" placeholder="Vorname"
|
2025-10-22 11:58:18 +02:00
|
|
|
|
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
<Input type="text" v-model="invoice.billingData.contactLastName" placeholder="Nachname"
|
2025-10-22 11:58:18 +02:00
|
|
|
|
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
<Select v-model="importContact" by="id">
|
2025-11-19 12:48:21 +01:00
|
|
|
|
<SelectTrigger v-bind:disabled="!importCustomer || invoice.customerId === 0"
|
2025-10-20 08:57:51 +02:00
|
|
|
|
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 border-none">
|
|
|
|
|
|
<SelectValue>
|
|
|
|
|
|
<User />
|
|
|
|
|
|
</SelectValue>
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent v-if="invoice.customer && invoice.customer.id !== 0">
|
|
|
|
|
|
<SelectItem :value="{ id: 0, firstName: '', lastName: '' }">
|
|
|
|
|
|
–
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem v-for="customer in importCustomer.contacts" :value="customer">
|
|
|
|
|
|
{{ customer.firstName + ' ' + customer.lastName }}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Input type="text" v-model="invoice.billingData.billingAddress.lineOne"
|
|
|
|
|
|
placeholder="Zeile 1"
|
2025-10-22 11:58:18 +02:00
|
|
|
|
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<Input type="text" v-model="invoice.billingData.billingAddress.lineTwo"
|
|
|
|
|
|
placeholder="Zeile 2"
|
2025-10-22 11:58:18 +02:00
|
|
|
|
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
<div class="flex gap-4">
|
|
|
|
|
|
<Input type="text" v-model="invoice.billingData.billingAddress.postalCode"
|
|
|
|
|
|
placeholder="PLZ"
|
2025-10-22 11:58:18 +02:00
|
|
|
|
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 w-20 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<Input type="text" v-model="invoice.billingData.billingAddress.city" placeholder="Stadt"
|
2025-10-22 11:58:18 +02:00
|
|
|
|
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Input type="text" v-model="invoice.billingData.vatId" placeholder="USt-Id-Nr."
|
2025-10-22 11:58:18 +02:00
|
|
|
|
class="mt-6 bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 p-1 shadow-none border-0 border-b-1 border-slate-300 dark:border-neutral-800 placeholder:text-muted-foreground/50 rounded-none hover:rounded-md" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Table>
|
|
|
|
|
|
<TableBody>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Rechnungsdatum -->
|
|
|
|
|
|
<TableRow>
|
|
|
|
|
|
<TableHead>Datum</TableHead>
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
<Input type="date" :modelValue="toShortISOString(invoice.invoiceDate)"
|
|
|
|
|
|
@update:modelValue="newValue => { invoice.invoiceDate = new Date(newValue); invoice.dueDate = calcDueDate(invoice.invoiceDate, invoice.billingData?.paymentTerms?.days) }"
|
|
|
|
|
|
placeholder="Datum"
|
|
|
|
|
|
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-none" />
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Zahlungsziel -->
|
|
|
|
|
|
<TableRow>
|
|
|
|
|
|
<TableHead class="flex items-center gap-1">
|
|
|
|
|
|
Zahlungsziel
|
|
|
|
|
|
<TooltipProvider>
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger>
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<MessageCircleQuestion
|
2025-10-20 08:57:51 +02:00
|
|
|
|
class="h-[16px] w-[16px] ml-[1px] mb-2" />
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>
|
|
|
|
|
|
Zahlungsbedingungen können<br />
|
|
|
|
|
|
beim Kunden hinterlegt werden.
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
|
2025-11-18 10:27:49 +01:00
|
|
|
|
<Select v-if="invoice && invoice.billingData"
|
|
|
|
|
|
v-model="invoice.billingData.paymentTerms" by="id">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<SelectTrigger
|
|
|
|
|
|
class="w-full bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-none">
|
|
|
|
|
|
<SelectValue placeholder="Zahlungsziel" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
2026-02-17 10:35:03 +01:00
|
|
|
|
<SelectItem v-for="term in paymentTerms" :value="term">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<span v-if="term.isFixed">{{ term.description }}</span>
|
|
|
|
|
|
<span v-else>{{ term.days }} Tage</span>
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Fällig -->
|
|
|
|
|
|
<TableRow v-if="!invoice.billingData?.paymentTerms?.isFixed">
|
|
|
|
|
|
<TableHead>Fällig</TableHead>
|
|
|
|
|
|
<TableCell class="pl-5">{{ toLocalDate(invoice.dueDate) }}</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Leistungszeitraum -->
|
|
|
|
|
|
<TableRow>
|
|
|
|
|
|
<TableHead>Leistungszeitraum</TableHead>
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
<Input type="date" :modelValue="toShortISOString(invoice.serviceStartDate)"
|
|
|
|
|
|
@update:modelValue="newValue => invoice.serviceStartDate = new Date(newValue)"
|
|
|
|
|
|
placeholder="Datum"
|
|
|
|
|
|
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-none w-fit inline" />
|
|
|
|
|
|
|
|
|
|
|
|
bis
|
|
|
|
|
|
|
|
|
|
|
|
<Input type="date" :modelValue="toShortISOString(invoice.serviceEndDate)"
|
|
|
|
|
|
@update:modelValue="newValue => invoice.serviceEndDate = new Date(newValue)"
|
|
|
|
|
|
placeholder="Datum"
|
|
|
|
|
|
class="bg-transparent dark:bg-transparent hover:bg-background dark:hover:bg-background/40 shadow-none border-none w-fit inline" />
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<div id="document-text" class="mt-6 md:mt-8 lg:mt-12">
|
2025-10-22 16:13:52 +02:00
|
|
|
|
<GrowingTextarea v-model="invoice.text" placeholder="Anschreiben"
|
|
|
|
|
|
class="font-light bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-11-18 20:46:40 +01:00
|
|
|
|
|
2025-12-08 13:20:52 +01:00
|
|
|
|
<LineItemTable :lineItems="invoice.items" :units="units" @update:lineItems="updateLineItems"
|
|
|
|
|
|
sticky-top="7" :isLoading="itemsLoading" class="mt-4" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
|
|
|
|
|
|
</Dialog>
|
2025-11-18 10:27:49 +01:00
|
|
|
|
|
2025-11-11 11:29:17 +01:00
|
|
|
|
<SendMailDialog v-model:open="reminderDialogOpen" title="Zahlungserinnerung senden?" description=""
|
2025-11-19 14:30:24 +01:00
|
|
|
|
:to="billingContactEmail" @send="(to, cc) => sendReminder(to, cc)" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
2025-11-18 10:27:49 +01:00
|
|
|
|
|
2025-11-14 11:55:41 +01:00
|
|
|
|
<style></style>
|