Files
FEDEO/frontend/pages/createDocument/index.vue
florianfederspiel af4a283eb5 feat(accounting): Skonto bei Zahlungen auf Ausgangs- und Eingangsbelege buchen
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>
2026-11-04 11:31:47 +01:00

458 lines
16 KiB
Vue

<template>
<UDashboardNavbar :badge="filteredRows.length" title="Ausgangsbelege">
<template #right>
<UInput
id="searchinput"
v-model="searchString"
autocomplete="off"
class="hidden lg:block"
icon="i-heroicons-funnel"
placeholder="Suche..."
@keydown.esc="$event.target.blur()"
>
<template #trailing>
<UKbd value="/"/>
</template>
</UInput>
<UButton
v-if="searchString.length > 0"
color="error"
icon="i-heroicons-x-mark"
variant="outline"
@click="clearSearchString()"
/>
<UButton icon="i-heroicons-plus" @click="modal.open(CreateDocumentModal)">
Dokument
</UButton>
<UButton icon="i-heroicons-document-duplicate" variant="outline" @click="modal.open(CreateDocumentFromTemplateModal)">
Dokument aus Vorlage
</UButton>
</template>
</UDashboardNavbar>
<UDashboardToolbar>
<template #right>
<USelectMenu
v-if="selectableFilters.length > 0"
v-model="selectedFilters"
:color="selectedFilters.length > 0 ? 'primary' : 'white'"
:items="selectableFilters"
:content="{ width: 'min-w-max' }"
icon="i-heroicons-adjustments-horizontal-solid"
multiple
@update:model-value="value => tempStore.modifyFilter('createddocumentsList', 'main', value)"
>
<template #label>
Filter
</template>
</USelectMenu>
</template>
</UDashboardToolbar>
<UTabs
v-model="activeTabIndex"
:items="selectedTypes"
class="m-3"
@update:model-value="storeActiveTab"
>
<template #default="{item}">
{{ item.label }}
<UBadge
class="ml-2"
variant="outline"
>
{{ getRowsForTab(item.key).length }}
</UBadge>
</template>
<template #content="{item}">
<div class="flex h-[80vh] flex-col">
<div class="min-h-0 flex-1 overflow-y-auto">
<UTable
:columns="normalizeTableColumns(getColumnsForTab(item.key))"
:data="getVisibleRowsForTab(item.key)"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
class="w-full"
:watch-options="{ deep: false }"
:on-select="selectItem"
>
<template #type-cell="{row}">
<span v-if="row.original.type === 'cancellationInvoices'" class="text-cyan-500">{{
dataStore.documentTypesForCreation[row.original.type].labelSingle
}} für {{ filteredRows.find(i => row.original.createddocument?.id === i.id)?.documentNumber }}</span>
<span v-else>{{ dataStore.documentTypesForCreation[row.original.type].labelSingle }}</span>
</template>
<template #state-cell="{row}">
<span v-if="row.original.state === 'Entwurf'" class="text-rose-500">{{ row.original.state }}</span>
<span
v-if="row.original.state === 'Gebucht' && !hasCancellationInvoice(row.original)"
class="text-primary-500"
>
{{ row.original.state }}
</span>
<span
v-else-if="row.original.state === 'Gebucht' && hasCancellationInvoice(row.original) && ['invoices','advanceInvoices'].includes(row.original.type)"
class="text-cyan-500"
>
Storniert mit {{ getCancellationInvoice(row.original)?.documentNumber }}
</span>
<span v-else-if="row.original.state === 'Gebucht'" class="text-primary-500">{{ row.original.state }}</span>
</template>
<template #partner-cell="{row}">
<span v-if="row.original.customer && row.original.customer.name.length < 21">{{ row.original.customer ? row.original.customer.name : "" }}</span>
<UTooltip v-else-if="row.original.customer && row.original.customer.name.length > 20" :text="row.original.customer.name">
{{ row.original.customer.name.substring(0, 20) }}...
</UTooltip>
</template>
<template #reference-cell="{row}">
<span v-if="row.original === filteredRows[selectedItem]" class="text-primary-500 font-bold">{{ row.original.documentNumber }}</span>
<span v-else>{{ row.original.documentNumber }}</span>
</template>
<template #date-cell="{row}">
<span v-if="row.original.date">{{ row.original.date ? dayjs(row.original.date).format("DD.MM.YY") : '' }}</span>
<span v-if="row.original.documentDate">{{ row.original.documentDate ? dayjs(row.original.documentDate).format("DD.MM.YY") : '' }}</span>
</template>
<template #dueDate-cell="{row}">
<span
v-if="row.original.state === 'Gebucht' && row.original.paymentDays && ['invoices','advanceInvoices'].includes(row.original.type) && !hasCancellationInvoice(row.original)"
:class="dayjs(row.original.documentDate).add(row.original.paymentDays,'day').diff(dayjs()) <= 0 && !isPaid(row.original) ? ['text-rose-500'] : '' "
>
{{ row.original.documentDate ? dayjs(row.original.documentDate).add(row.original.paymentDays, 'day').format("DD.MM.YY") : '' }}
</span>
</template>
<template #paid-cell="{row}">
<div
v-if="(row.original.type === 'invoices' ||row.original.type === 'advanceInvoices') && row.original.state === 'Gebucht' && !hasCancellationInvoice(row.original)">
<span v-if="isPaid(row.original)" class="text-primary-500">Bezahlt</span>
<span v-else class="text-rose-600">Offen</span>
</div>
</template>
<template #amount-cell="{row}">
<span v-if="!deliveryNoteLikeDocumentTypes.includes(row.original.type)">{{ displayCurrency(getDocumentAmount(row.original)) }}</span>
</template>
<template #amountOpen-cell="{row}">
<span
v-if="!['cancellationInvoices','confirmationOrders', ...quoteLikeDocumentTypes, ...deliveryNoteLikeDocumentTypes].includes(row.original.type) && row.original.state !== 'Entwurf' && !hasCancellationInvoice(row.original) && !isPaid(row.original) ">
{{ displayCurrency(getDocumentOpenAmount(row.original)) }}
</span>
</template>
<template #empty>
<TableEmptyState label="Keine Belege anzuzeigen" />
</template>
</UTable>
</div>
<div
v-if="getRowsForTab(item.key).length > rowsPerPage"
class="flex items-center justify-center border-t border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900"
>
<UPagination
v-model:page="tabPages[item.key]"
:items-per-page="rowsPerPage"
:total="getRowsForTab(item.key).length"
:show-edges="true"
@update:page="page => storeTabPage(item.key, page)"
/>
</div>
</div>
</template>
</UTabs>
</template>
<script setup>
import { sumSettledAllocations } from "~/utils/cashDiscount"
import CreateDocumentModal from "~/components/createDocumentModal.vue"
import CreateDocumentFromTemplateModal from "~/components/createDocumentFromTemplateModal.vue"
import dayjs from "dayjs";
import { ref, computed, reactive, watch } from 'vue';
const dataStore = useDataStore()
const tempStore = useTempStore()
const router = useRouter()
const modal = useModal()
const quoteLikeDocumentTypes = ['quotes', 'costEstimates']
const deliveryNoteLikeDocumentTypes = ['deliveryNotes', 'packingSlips']
const type = "createddocuments"
const dataType = dataStore.dataTypes[type]
const items = ref([])
const selectedItem = ref(0)
const rowsPerPage = 100
const storedListNavigation = tempStore.settings?.createddocumentsList || {}
const tabPages = reactive({
drafts: 1,
invoices: 1,
quotes: 1,
costEstimates: 1,
deliveryNotes: 1,
packingSlips: 1,
confirmationOrders: 1,
...(storedListNavigation.tabPages || {})
})
const sum = useSum()
// Debounce-Logik für die Suche
const searchString = ref(tempStore.searchStrings['createddocuments'] || '')
const debouncedSearchString = ref(searchString.value)
let debounceTimeout = null
watch(searchString, (newVal) => {
clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(() => {
debouncedSearchString.value = newVal
tempStore.modifySearchString('createddocuments', newVal)
}, 300) // 300ms warten nach dem letzten Tastendruck
})
defineShortcuts({
'/': () => {
document.getElementById("searchinput").focus()
},
'+': () => {
modal.open(CreateDocumentModal)
},
'Enter': {
usingInput: true,
handler: () => {
if (filteredRows.value[selectedItem.value]) {
router.push(`/createDocument/show/${filteredRows.value[selectedItem.value].id}`)
}
}
},
'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 setupPage = async () => {
items.value = await useNuxtApp().$api('/api/resource/createddocuments/list', {
method: 'GET'
}) || []
}
setupPage()
const templateColumns = [
{key: "reference", label: "Referenz"},
{key: 'type', label: "Typ"},
{key: 'state', label: "Status"},
{key: "amount", label: "Betrag"},
{key: 'partner', label: "Kunde"},
{key: "date", label: "Datum"},
{key: "amountOpen", label: "Offener Betrag"},
{key: "paid", label: "Bezahlt"},
{key: "dueDate", label: "Fällig"}
]
const draftColumns = [
{key: 'type', label: "Typ"},
{key: 'state', label: "Status"},
{key: 'partner', label: "Kunde"},
{key: "date", label: "Erstellt am"},
{key: "amount", label: "Betrag"}
]
const selectedColumns = ref(tempStore.columns["createddocuments"] ? tempStore.columns["createddocuments"] : templateColumns)
const columns = computed(() => templateColumns.filter((column) => selectedColumns.value.find(i => i.key === column.key)))
const getColumnsForTab = (tabKey) => {
if (tabKey === 'drafts') {
return draftColumns
}
return columns.value
}
const templateTypes = [
{ key: "drafts", label: "Entwürfe" },
{ key: "invoices", label: "Rechnungen" },
{ key: "quotes", label: "Angebote" },
{ key: "costEstimates", label: "Kostenschätzungen" },
{ key: "deliveryNotes", label: "Lieferscheine" },
{ key: "packingSlips", label: "Packscheine" },
{ key: "confirmationOrders", label: "Auftragsbestätigungen" }
]
const selectedTypes = ref(tempStore.filters["createddocuments"] ? tempStore.filters["createddocuments"] : templateTypes)
const storedTabIndex = selectedTypes.value.findIndex(type => type.key === storedListNavigation.activeTabKey)
const activeTabIndex = ref(String(storedTabIndex >= 0 ? storedTabIndex : 0))
const types = computed(() => {
return templateTypes.filter((type) => selectedTypes.value.find(i => i.key === type.key))
})
const unwrapSelectedRow = (itemLike) => itemLike?.original || itemLike
const selectItem = (itemLike) => {
const item = unwrapSelectedRow(itemLike)
if (!item?.id) {
return
}
if (item.state === "Entwurf") {
router.push(`/createDocument/edit/${item.id}`)
} else {
router.push(`/createDocument/show/${item.id}`)
}
}
const displayCurrency = (value, currency = "€") => {
return `${Number(value).toFixed(2).replace(".", ",")} ${currency}`
}
const clearSearchString = () => {
tempStore.clearSearchString('createddocuments')
searchString.value = ''
debouncedSearchString.value = ''
}
const cancellationInvoicesByDocument = computed(() => {
const result = new Map()
items.value.forEach((item) => {
const linkedDocumentId = item.createddocument?.id || item.createddocument
if (item.type === 'cancellationInvoices' && item.state !== 'Entwurf' && !item.archived && linkedDocumentId) {
result.set(linkedDocumentId, item)
}
})
return result
})
const documentAmounts = computed(() => {
const result = new Map()
items.value.forEach((item) => {
const amount = sum.getCreatedDocumentSum(item, items.value)
const amountPaid = sumSettledAllocations(item.statementallocations || [])
result.set(item.id, {
amount,
openAmount: Number((amount - amountPaid).toFixed(2))
})
})
return result
})
const getCancellationInvoice = (row) => cancellationInvoicesByDocument.value.get(row.id)
const hasCancellationInvoice = (row) => Boolean(getCancellationInvoice(row))
const getDocumentAmount = (row) => documentAmounts.value.get(row.id)?.amount || 0
const getDocumentOpenAmount = (row) => documentAmounts.value.get(row.id)?.openAmount || 0
const isPaid = (row) => getDocumentOpenAmount(row) === 0
const openUnpaidInvoicesFilter = {
name: 'Nur offene Belege',
filterFunction: (row) => {
return row.state === 'Entwurf' || (
['invoices', 'advanceInvoices'].includes(row.type)
&& row.state === 'Gebucht'
&& !hasCancellationInvoice(row)
&& !isPaid(row)
)
}
}
const availableFilters = computed(() => [...dataType.filters, openUnpaidInvoicesFilter])
const selectableFilters = computed(() => availableFilters.value.map(i => i.name))
const selectedFilters = ref(
tempStore.filters?.createddocumentsList?.main
|| dataType.filters.filter(i => i.default).map(i => i.name)
|| []
)
const filteredRows = computed(() => {
let tempItems = items.value.filter(i => types.value.find(x => {
if (x.key === 'drafts') return i.state === 'Entwurf'
if (i.state === 'Entwurf' && x.key !== 'drafts') return false
if (x.key === 'invoices') return ['invoices', 'advanceInvoices', 'cancellationInvoices'].includes(i.type)
return x.key === i.type
}))
tempItems = tempItems.filter(i => i.type !== "serialInvoices")
tempItems = tempItems.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 => {
const filter = availableFilters.value.find(i => i.name === filterName)
if (filter?.filterFunction) {
tempItems = tempItems.filter(filter.filterFunction)
}
})
}
// Hier nutzen wir nun den debounced Wert für die lokale Suche
const results = useSearch(debouncedSearchString.value, tempItems.slice().reverse())
return results
})
const rowsByTab = computed(() => {
const result = Object.fromEntries(templateTypes.map(type => [type.key, []]))
filteredRows.value.forEach(row => {
if (row.state === 'Entwurf') {
result.drafts.push(row)
} else if (['invoices', 'advanceInvoices', 'cancellationInvoices'].includes(row.type)) {
result.invoices.push(row)
} else if (result[row.type]) {
result[row.type].push(row)
}
})
return result
})
const getRowsForTab = (tabKey) => rowsByTab.value[tabKey] || []
const storeListNavigation = () => {
const activeTab = selectedTypes.value[Number(activeTabIndex.value)]
tempStore.modifySettings('createddocumentsList', {
activeTabKey: activeTab?.key || selectedTypes.value[0]?.key,
tabPages: {...tabPages}
})
}
const storeActiveTab = () => {
storeListNavigation()
}
const storeTabPage = (tabKey, page) => {
tabPages[tabKey] = page
storeListNavigation()
}
const getVisibleRowsForTab = (tabKey) => {
const rows = getRowsForTab(tabKey)
const start = (tabPages[tabKey] - 1) * rowsPerPage
return rows.slice(start, start + rowsPerPage)
}
watch([debouncedSearchString, selectedFilters, selectedTypes], () => {
Object.keys(tabPages).forEach((key) => {
tabPages[key] = 1
})
storeListNavigation()
}, { deep: true })
</script>