Skonto wird an der Bankzuordnung gespeichert und gleicht den Beleg zusammen mit der Zahlung aus. Der Betrag wird anteilig nach Steuersätzen auf die Skontokonten verteilt (Standard SKR03, je Mandant einstellbar), im DATEV-Export als eigene Buchung je Steuersatz ausgegeben und in der USt-Auswertung im Zeitraum der Zahlung berücksichtigt. Schlussrechnungen verteilen den Skonto auf die Restforderung nach Abzug der Abschläge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
388 lines
12 KiB
Vue
388 lines
12 KiB
Vue
<script setup>
|
|
import { sumSettledAllocations } from "~/utils/cashDiscount"
|
|
import dayjs from "dayjs"
|
|
import {useSum} from "~/composables/useSum.js";
|
|
|
|
// Zugriff auf API und Toast
|
|
const { $api } = useNuxtApp()
|
|
const toast = useToast()
|
|
|
|
defineShortcuts({
|
|
'/': () => {
|
|
//console.log(searchinput)
|
|
//searchinput.value.focus()
|
|
document.getElementById("searchinput").focus()
|
|
},
|
|
'+': () => {
|
|
router.push("/incomingInvoices/[mode]")
|
|
},
|
|
'Enter': {
|
|
usingInput: true,
|
|
handler: () => {
|
|
const invoice = filteredRows.value[selectedItem.value]
|
|
if (invoice) {
|
|
selectIncomingInvoice(invoice)
|
|
}
|
|
}
|
|
},
|
|
'arrowdown': () => {
|
|
if(selectedItem.value < filteredRows.value.length - 1) {
|
|
selectedItem.value += 1
|
|
} else {
|
|
selectedItem.value = 0
|
|
}
|
|
},
|
|
'arrowup': () => {
|
|
if(selectedItem.value === 0) {
|
|
selectedItem.value = filteredRows.value.length - 1
|
|
} else {
|
|
selectedItem.value -= 1
|
|
}
|
|
}
|
|
})
|
|
|
|
const dataStore = useDataStore()
|
|
const tempStore = useTempStore()
|
|
const router = useRouter()
|
|
|
|
const sum = useSum()
|
|
|
|
const items = ref([])
|
|
const selectedItem = ref(0)
|
|
const sort = ref({
|
|
column: 'date',
|
|
direction: 'desc'
|
|
})
|
|
|
|
// Status für den Button
|
|
const isPreparing = ref(false)
|
|
|
|
const type = "incominginvoices"
|
|
const dataType = dataStore.dataTypes[type]
|
|
const openAmountColumnKey = "openAmount"
|
|
|
|
const setupPage = async () => {
|
|
items.value = await useEntities(type).select("*, vendor(id,name), statementallocations(id,amount)",sort.value.column,sort.value.direction === "asc")
|
|
}
|
|
|
|
// Die Belegvorbereitung läuft im Backend im Hintergrund; hier wird nur der Status abgefragt.
|
|
const preparationEndpoint = '/api/functions/services/prepareincominginvoices'
|
|
let preparationPollTimer = null
|
|
|
|
const showPreparationResult = (status) => {
|
|
const result = status?.result
|
|
if (result?.error && !result.created?.length) {
|
|
toast.add({
|
|
title: 'Fehler',
|
|
description: result.error,
|
|
icon: 'i-heroicons-exclamation-circle',
|
|
color: 'red'
|
|
})
|
|
return
|
|
}
|
|
|
|
const created = result?.created?.length || 0
|
|
const skipped = result?.skipped?.length || 0
|
|
toast.add({
|
|
title: 'Belegvorbereitung abgeschlossen',
|
|
description: `${created} Eingangsbeleg(e) vorbereitet${skipped ? `, ${skipped} Datei(en) übersprungen` : ''}.`,
|
|
icon: 'i-heroicons-check-circle',
|
|
color: 'green'
|
|
})
|
|
}
|
|
|
|
const pollPreparation = (notify) => {
|
|
clearTimeout(preparationPollTimer)
|
|
preparationPollTimer = setTimeout(async () => {
|
|
try {
|
|
const status = await $api(preparationEndpoint)
|
|
if (status?.running) {
|
|
pollPreparation(notify)
|
|
return
|
|
}
|
|
isPreparing.value = false
|
|
if (notify) showPreparationResult(status)
|
|
await setupPage()
|
|
} catch (error) {
|
|
console.error(error)
|
|
pollPreparation(notify)
|
|
}
|
|
}, 3000)
|
|
}
|
|
|
|
const prepareInvoices = async () => {
|
|
isPreparing.value = true
|
|
try {
|
|
await $api(preparationEndpoint, { method: 'POST' })
|
|
toast.add({
|
|
title: 'Belegvorbereitung gestartet',
|
|
description: 'Die Belege werden im Hintergrund vorbereitet.',
|
|
icon: 'i-heroicons-sparkles'
|
|
})
|
|
pollPreparation(true)
|
|
} catch (error) {
|
|
console.error(error)
|
|
isPreparing.value = false
|
|
toast.add({
|
|
title: 'Fehler',
|
|
description: 'Die Belegvorbereitung konnte nicht gestartet werden.',
|
|
icon: 'i-heroicons-exclamation-circle',
|
|
color: 'red'
|
|
})
|
|
}
|
|
}
|
|
|
|
const resumePreparationStatus = async () => {
|
|
try {
|
|
const status = await $api(preparationEndpoint)
|
|
if (status?.running) {
|
|
isPreparing.value = true
|
|
pollPreparation(true)
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
}
|
|
}
|
|
|
|
onBeforeUnmount(() => clearTimeout(preparationPollTimer))
|
|
|
|
setupPage()
|
|
resumePreparationStatus()
|
|
|
|
const selectedColumns = ref(tempStore.columns[type] ? [...tempStore.columns[type]] : dataType.templateColumns.filter(i => !i.disabledInTable))
|
|
if (!selectedColumns.value.find((column) => column.key === openAmountColumnKey)) {
|
|
const openAmountColumn = dataType.templateColumns.find((column) => column.key === openAmountColumnKey)
|
|
if (openAmountColumn) {
|
|
selectedColumns.value.splice(5, 0, openAmountColumn)
|
|
}
|
|
}
|
|
const columns = computed(() => dataType.templateColumns.filter((column) => !column.disabledInTable && selectedColumns.value.find(i => i.key === column.key)))
|
|
|
|
const selectableFilters = ref(dataType.filters.map(i => i.name))
|
|
const selectedFilters = ref(dataType.filters.filter(i => i.default).map(i => i.name) || [])
|
|
|
|
const searchString = ref(tempStore.searchStrings[type] ||'')
|
|
|
|
const clearSearchString = () => {
|
|
tempStore.clearSearchString(type)
|
|
searchString.value = ''
|
|
}
|
|
const filteredRows = computed(() => {
|
|
let tempItems = items.value.map(i => {
|
|
return {
|
|
...i,
|
|
class: i.archived ? 'bg-red-500/50 dark:bg-red-400/50' : null
|
|
}
|
|
})
|
|
|
|
if(selectedFilters.value.length > 0) {
|
|
selectedFilters.value.forEach(filterName => {
|
|
let filter = dataType.filters.find(i => i.name === filterName)
|
|
tempItems = tempItems.filter(filter.filterFunction)
|
|
})
|
|
}
|
|
|
|
|
|
|
|
tempItems = useSearch(searchString.value, tempItems)
|
|
|
|
|
|
return [...tempItems.filter(i => i.state === "Vorbereitet"), ...tempItems.filter(i => i.state !== "Vorbereitet")]
|
|
|
|
|
|
})
|
|
|
|
const displayCurrency = (value, currency = "€") => {
|
|
return `${Number(value).toFixed(2).replace(".",",")} ${currency}`
|
|
}
|
|
const getInvoiceSum = (invoice) => {
|
|
return useSum().getIncomingInvoiceSum(invoice)
|
|
}
|
|
|
|
const getPaidAmount = (item) => {
|
|
// Gebuchter Skonto zählt zum ausgeglichenen Betrag.
|
|
const amountPaid = sumSettledAllocations(item.statementallocations || [])
|
|
return Number(Math.abs(amountPaid).toFixed(2))
|
|
}
|
|
|
|
const isPaid = (item) => {
|
|
return getPaidAmount(item) >= Number(Math.abs(Number(getInvoiceSum(item))).toFixed(2))
|
|
}
|
|
|
|
const getOpenAmount = (item) => {
|
|
return Number(Math.max(0, Number(getInvoiceSum(item)) - getPaidAmount(item)).toFixed(2))
|
|
}
|
|
|
|
const unwrapInvoiceRow = (invoiceLike) => invoiceLike?.original || invoiceLike
|
|
|
|
const selectIncomingInvoice = (invoiceLike) => {
|
|
const invoice = unwrapInvoiceRow(invoiceLike)
|
|
|
|
if (!invoice?.id) {
|
|
return
|
|
}
|
|
|
|
if (invoice.state === "Gebucht") {
|
|
router.push(`/incomingInvoices/show/${invoice.id}`)
|
|
} else {
|
|
router.push(`/incomingInvoices/edit/${invoice.id}`)
|
|
}
|
|
}
|
|
|
|
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardNavbar title="Eingangsbelege" :badge="filteredRows.length">
|
|
<template #right>
|
|
|
|
<UButton
|
|
label="Belege vorbereiten"
|
|
icon="i-heroicons-sparkles"
|
|
color="primary"
|
|
variant="solid"
|
|
:loading="isPreparing"
|
|
@click="prepareInvoices"
|
|
class="mr-2"
|
|
/>
|
|
|
|
<UInput
|
|
id="searchinput"
|
|
v-model="searchString"
|
|
icon="i-heroicons-funnel"
|
|
autocomplete="off"
|
|
placeholder="Suche..."
|
|
class="hidden lg:block"
|
|
@keydown.esc="$event.target.blur()"
|
|
@change="tempStore.modifySearchString(type,searchString)"
|
|
>
|
|
<template #trailing>
|
|
<UKbd value="/" />
|
|
</template>
|
|
</UInput>
|
|
<UButton
|
|
icon="i-heroicons-x-mark"
|
|
variant="outline"
|
|
color="error"
|
|
@click="clearSearchString()"
|
|
v-if="searchString.length > 0"
|
|
/>
|
|
|
|
</template>
|
|
</UDashboardNavbar>
|
|
<UDashboardToolbar>
|
|
<template #right>
|
|
<USelectMenu
|
|
v-model="selectedColumns"
|
|
icon="i-heroicons-adjustments-horizontal-solid"
|
|
:items="dataType.templateColumns.filter(i => !i.disabledInTable)"
|
|
multiple
|
|
class="hidden lg:block"
|
|
by="key"
|
|
:color="selectedColumns.length !== dataType.templateColumns.filter(i => !i.disabledInTable).length ? 'primary' : 'white'"
|
|
:content="{ width: 'min-w-max' }"
|
|
@change="tempStore.modifyColumns(type,selectedColumns)"
|
|
>
|
|
<template #default>
|
|
Spalten
|
|
</template>
|
|
</USelectMenu>
|
|
<USelectMenu
|
|
v-if="selectableFilters.length > 0"
|
|
icon="i-heroicons-adjustments-horizontal-solid"
|
|
multiple
|
|
v-model="selectedFilters"
|
|
:items="selectableFilters"
|
|
:color="selectedFilters.length > 0 ? 'primary' : 'white'"
|
|
:content="{ width: 'min-w-max' }"
|
|
>
|
|
<template #default>
|
|
Filter
|
|
</template>
|
|
</USelectMenu>
|
|
</template>
|
|
</UDashboardToolbar>
|
|
|
|
<UTabs
|
|
class="m-3"
|
|
:items="[{label: 'In Bearbeitung'},{label: 'Gebucht'}]"
|
|
>
|
|
<template #default="{item}">
|
|
{{item.label}}
|
|
<UBadge
|
|
variant="outline"
|
|
class="ml-2"
|
|
>
|
|
{{filteredRows.filter(i => item.label === 'Gebucht' ? i.state === 'Gebucht' : i.state !== 'Gebucht' ).length}}
|
|
</UBadge>
|
|
</template>
|
|
<template #content="{item}">
|
|
<div style="height: 80dvh; overflow-y: scroll">
|
|
<UTable
|
|
v-model:sorting="sort"
|
|
sort-mode="manual"
|
|
@update:sorting="setupPage"
|
|
:data="filteredRows.filter(i => item.label === 'Gebucht' ? i.state === 'Gebucht' : i.state !== 'Gebucht' )"
|
|
:columns="normalizeTableColumns(columns)"
|
|
class="w-full"
|
|
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
|
|
:on-select="selectIncomingInvoice"
|
|
>
|
|
<template #reference-cell="{row}">
|
|
<span v-if="row.original === filteredRows[selectedItem]" class="text-primary-500 font-bold">{{row.original.reference}}</span>
|
|
<span v-else>{{row.original.reference}}</span>
|
|
</template>
|
|
<template #state-cell="{row}">
|
|
<span v-if="row.original.state === 'Vorbereitet'" class="text-cyan-500">{{row.original.state}}</span>
|
|
<span v-else-if="row.original.state === 'Entwurf'" class="text-red-500">{{row.original.state}}</span>
|
|
<span v-else-if="row.original.state === 'Gebucht'" class="text-primary-500">{{row.original.state}}</span>
|
|
<span v-else-if="row.original.state === 'Prüfung erforderlich'" class="text-orange-500">{{row.original.state}}</span>
|
|
<span v-else>{{row.original.state}}</span>
|
|
</template>
|
|
<template #preparationSource-cell="{row}">
|
|
<UBadge v-if="row.original.preparationSource === 'e-invoice'" color="primary" variant="soft">
|
|
{{ row.original.eInvoiceSyntax === 'cii'
|
|
? (String(row.original.eInvoiceProfile || '').toLowerCase().includes('xrechnung') ? 'XRechnung / CII' : 'ZUGFeRD / CII')
|
|
: 'XRechnung / UBL' }}
|
|
</UBadge>
|
|
<UBadge v-else-if="row.original.preparationSource === 'gpt'" color="neutral" variant="soft">
|
|
PDF / KI
|
|
</UBadge>
|
|
</template>
|
|
<template #date-cell="{row}">
|
|
{{dayjs(row.original.date).format("DD.MM.YYYY")}}
|
|
</template>
|
|
<template #vendor-cell="{row}">
|
|
{{row.original.vendor ? row.original.vendor.name : ""}}
|
|
</template>
|
|
<template #amount-cell="{row}">
|
|
{{displayCurrency(sum.getIncomingInvoiceSum(row.original))}}
|
|
</template>
|
|
<template #openAmount-cell="{row}">
|
|
<span v-if="row.original.state === 'Gebucht' && !isPaid(row.original)">
|
|
{{ displayCurrency(getOpenAmount(row.original)) }}
|
|
</span>
|
|
</template>
|
|
<template #dueDate-cell="{row}">
|
|
<span v-if="row.original.dueDate">{{dayjs(row.original.dueDate).format("DD.MM.YYYY")}}</span>
|
|
</template>
|
|
<template #paid-cell="{row}">
|
|
<span v-if="isPaid(row.original)" class="text-primary-500">Bezahlt</span>
|
|
<span v-else class="text-rose-600">Offen</span>
|
|
</template>
|
|
<template #empty>
|
|
<TableEmptyState label="Keine Belege anzuzeigen" />
|
|
</template>
|
|
</UTable>
|
|
</div>
|
|
|
|
</template>
|
|
</UTabs>
|
|
|
|
</template>
|
|
|
|
<style scoped>
|
|
|
|
</style>
|