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>
458 lines
16 KiB
Vue
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>
|