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>
468 lines
17 KiB
Vue
468 lines
17 KiB
Vue
<script setup>
|
|
import CopyCreatedDocumentModal from "~/components/copyCreatedDocumentModal.vue";
|
|
import dayjs from "dayjs";
|
|
|
|
|
|
|
|
defineShortcuts({
|
|
'backspace': () => {
|
|
router.push("/createDocument")
|
|
},
|
|
})
|
|
|
|
const modal = useModal()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const auth = useAuthStore()
|
|
const dataStore = useDataStore()
|
|
const toast = useToast()
|
|
|
|
const itemInfo = ref({})
|
|
const linkedDocument =ref({})
|
|
const links = ref([])
|
|
const portalReleaseLoading = ref(false)
|
|
const bankBookingModalOpen = ref(false)
|
|
const costCentresOpen = ref(false)
|
|
const costCentresLoading = ref(false)
|
|
const costCentresSaving = ref(false)
|
|
const costCentres = ref([])
|
|
const costCentreDraft = ref({ costcentre: null, rows: [] })
|
|
const costCentreId = (value) => value?.id || value || null
|
|
const costCentreLabel = (id) => {
|
|
const centre = costCentres.value.find(item => item.id === id)
|
|
return centre ? [centre.number, centre.name].filter(Boolean).join(" - ") : (id ? "Bisherige Kostenstelle" : "Keine Kostenstelle")
|
|
}
|
|
|
|
const openCostCentres = async () => {
|
|
costCentresLoading.value = true
|
|
try {
|
|
costCentres.value = await useEntities("costcentres").select("*")
|
|
costCentreDraft.value = {
|
|
costcentre: costCentreId(itemInfo.value.costcentre),
|
|
rows: (itemInfo.value.rows || [])
|
|
.filter(row => row?.id != null && row.id !== "" && !["title", "subtitle", "text", "pagebreak"].includes(row.mode))
|
|
.map((row, index) => ({
|
|
rowId: String(row.id),
|
|
label: `${row.pos || index + 1}. ${row.text || row.name || row.descriptionText || "Position"}`,
|
|
costcentre: costCentreId(row.costCentre ?? row.costcentre),
|
|
})),
|
|
}
|
|
costCentresOpen.value = true
|
|
} catch (error) {
|
|
toast.add({ title: "Kostenstellen konnten nicht geladen werden", color: "error" })
|
|
} finally {
|
|
costCentresLoading.value = false
|
|
}
|
|
}
|
|
|
|
const saveCostCentres = async () => {
|
|
if (costCentresSaving.value) return
|
|
costCentresSaving.value = true
|
|
try {
|
|
const originalRows = new Map((itemInfo.value.rows || []).map(row => [String(row.id), row]))
|
|
const body = {
|
|
rowCostCentres: costCentreDraft.value.rows
|
|
.filter(row => row.costcentre !== costCentreId(originalRows.get(row.rowId)?.costCentre ?? originalRows.get(row.rowId)?.costcentre))
|
|
.map(({ rowId, costcentre }) => ({ rowId, costcentre })),
|
|
}
|
|
if (costCentreDraft.value.costcentre !== costCentreId(itemInfo.value.costcentre)) {
|
|
body.costcentre = costCentreDraft.value.costcentre
|
|
}
|
|
if (body.rowCostCentres.length || Object.hasOwn(body, "costcentre")) {
|
|
const { document } = await useNuxtApp().$api(`/api/functions/outgoing-documents/${itemInfo.value.id}/cost-centres`, {
|
|
method: "PUT",
|
|
body,
|
|
})
|
|
itemInfo.value.costcentre = document.costcentre
|
|
itemInfo.value.rows = document.rows
|
|
toast.add({ title: "Kostenstellen gespeichert", color: "success" })
|
|
}
|
|
costCentresOpen.value = false
|
|
} catch (error) {
|
|
toast.add({ title: "Kostenstellen konnten nicht gespeichert werden", description: error?.data?.error || "Bitte versuche es erneut.", color: "error" })
|
|
} finally {
|
|
costCentresSaving.value = false
|
|
}
|
|
}
|
|
|
|
const portalEligibleTypes = ["invoices", "advanceInvoices", "cancellationInvoices"]
|
|
|
|
const loadStatementAllocations = async () => {
|
|
if (!itemInfo.value?.id) return
|
|
|
|
const statementAllocations = await useEntities("statementallocations").select("*, bankstatement(*)")
|
|
itemInfo.value.statementallocations = statementAllocations.filter((allocation) => {
|
|
const createdDocumentId = allocation.createddocument?.id || allocation.createddocument
|
|
|
|
return String(createdDocumentId) === String(itemInfo.value.id)
|
|
})
|
|
}
|
|
|
|
const setupPage = async () => {
|
|
if(route.params) {
|
|
if(route.params.id) {
|
|
itemInfo.value = await useEntities("createddocuments").selectSingle(route.params.id,"*,files(*),linkedDocument(*)")
|
|
await loadStatementAllocations()
|
|
}
|
|
|
|
if(itemInfo.value.type === "invoices"){
|
|
const createddocuments = await useEntities("createddocuments").select()
|
|
links.value = createddocuments.filter(i => i.createddocument?.id === itemInfo.value.id)
|
|
}
|
|
|
|
if (itemInfo.value.files?.length > 0) {
|
|
linkedDocument.value = await useFiles().selectDocument(itemInfo.value.files[0].id)
|
|
}
|
|
}
|
|
}
|
|
|
|
const canRegeneratePdf = computed(() => itemInfo.value.id
|
|
&& itemInfo.value.state !== 'Entwurf'
|
|
&& itemInfo.value.type !== 'serialInvoices'
|
|
&& Array.isArray(itemInfo.value.files)
|
|
&& itemInfo.value.files.length === 0)
|
|
|
|
setupPage()
|
|
|
|
const getStatementLike = (allocation) => allocation?.bankstatement || (typeof allocation?.bs_id === "object" ? allocation.bs_id : null)
|
|
const getStatementId = (allocation) => allocation?.bankstatement?.id || allocation?.bs_id?.id || allocation?.bankstatement || allocation?.bs_id || null
|
|
const getBankBookingDate = (allocation) => {
|
|
const statement = getStatementLike(allocation)
|
|
|
|
return statement?.date || statement?.valueDate || allocation?.manualBookingDate || null
|
|
}
|
|
const formatDate = (value) => value ? dayjs(value).format("DD.MM.YYYY") : "-"
|
|
const formatCurrency = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} EUR`
|
|
const bankBookingDates = computed(() => [...new Set(
|
|
(itemInfo.value.statementallocations || [])
|
|
.map(getBankBookingDate)
|
|
.filter(Boolean)
|
|
)].sort())
|
|
const bankBookingDateLabel = computed(() => {
|
|
if (bankBookingDates.value.length === 0) return "Kein Bankbuchungsdatum"
|
|
if (bankBookingDates.value.length === 1) return formatDate(bankBookingDates.value[0])
|
|
|
|
return bankBookingDates.value.map(formatDate).join(", ")
|
|
})
|
|
const linkedDocumentLabel = (document) => {
|
|
const documentType = dataStore.documentTypesForCreation[document.type]?.labelSingle || "Dokument"
|
|
|
|
return `${documentType} - ${document.documentNumber || document.title || document.id}`
|
|
}
|
|
const linkedItems = computed(() => {
|
|
const items = []
|
|
|
|
if(itemInfo.value.project) {
|
|
items.push({
|
|
label: "Projekt",
|
|
icon: "i-heroicons-link",
|
|
onSelect: () => router.push(`/standardEntity/projects/show/${itemInfo.value.project?.id}`)
|
|
})
|
|
}
|
|
|
|
if(itemInfo.value.customer) {
|
|
items.push({
|
|
label: "Kunde",
|
|
icon: "i-heroicons-link",
|
|
onSelect: () => router.push(`/standardEntity/customers/show/${itemInfo.value.customer?.id}`)
|
|
})
|
|
}
|
|
|
|
if(itemInfo.value.plant) {
|
|
items.push({
|
|
label: "Objekt",
|
|
icon: "i-heroicons-link",
|
|
onSelect: () => router.push(`/standardEntity/plants/show/${itemInfo.value.plant?.id}`)
|
|
})
|
|
}
|
|
|
|
if(itemInfo.value.contract) {
|
|
items.push({
|
|
label: "Vertrag",
|
|
icon: "i-heroicons-link",
|
|
onSelect: () => router.push(`/standardEntity/contracts/show/${itemInfo.value.contract?.id}`)
|
|
})
|
|
}
|
|
|
|
if(itemInfo.value.contact) {
|
|
items.push({
|
|
label: "Ansprechpartner",
|
|
icon: "i-heroicons-link",
|
|
onSelect: () => router.push(`/standardEntity/contacts/show/${itemInfo.value.contact?.id}`)
|
|
})
|
|
}
|
|
|
|
if(itemInfo.value.createddocument) {
|
|
items.push({
|
|
label: linkedDocumentLabel(itemInfo.value.createddocument),
|
|
icon: "i-heroicons-document-duplicate",
|
|
onSelect: () => router.push(`/createDocument/show/${itemInfo.value.createddocument.id}`)
|
|
})
|
|
}
|
|
|
|
;(itemInfo.value.createddocuments || []).forEach((document) => {
|
|
items.push({
|
|
label: linkedDocumentLabel(document),
|
|
icon: "i-heroicons-document-duplicate",
|
|
onSelect: () => router.push(`/createDocument/show/${document.id}`)
|
|
})
|
|
})
|
|
|
|
if(itemInfo.value.statementallocations?.length > 0) {
|
|
items.push({
|
|
label: "Bankbuchungen öffnen",
|
|
icon: "i-heroicons-building-library",
|
|
onSelect: openBankstatements
|
|
})
|
|
items.push({
|
|
label: "Bankdetails anzeigen",
|
|
icon: "i-heroicons-calendar-days",
|
|
onSelect: () => { bankBookingModalOpen.value = true }
|
|
})
|
|
}
|
|
|
|
return [items]
|
|
})
|
|
|
|
const openEmail = () => {
|
|
if(["invoices","advanceInvoices"].includes(itemInfo.value.type)){
|
|
router.push(`/email/new?loadDocuments=["${linkedDocument.value.id}"]&bcc=${encodeURIComponent(auth.activeTenantData.standardEmailForInvoices || "")}`)
|
|
} else {
|
|
router.push(`/email/new?loadDocuments=["${linkedDocument.value.id}"]`)
|
|
}
|
|
}
|
|
|
|
const openBankstatements = () => {
|
|
const statementIds = (itemInfo.value.statementallocations || []).map(getStatementId).filter(Boolean)
|
|
|
|
if(statementIds.length === 0) return
|
|
|
|
if(statementIds.length > 1) {
|
|
navigateTo(`/banking/?filter=${JSON.stringify(statementIds)}`)
|
|
} else {
|
|
navigateTo(`/banking/statements/edit/${statementIds[0]}`)
|
|
}
|
|
}
|
|
|
|
const togglePortalRelease = async () => {
|
|
if (!itemInfo.value?.id) return
|
|
|
|
portalReleaseLoading.value = true
|
|
|
|
try {
|
|
const nextValue = !itemInfo.value.availableInPortal
|
|
await useEntities("createddocuments").update(itemInfo.value.id, {
|
|
availableInPortal: nextValue
|
|
}, true)
|
|
|
|
itemInfo.value = {
|
|
...itemInfo.value,
|
|
availableInPortal: nextValue
|
|
}
|
|
|
|
toast.add({
|
|
title: nextValue ? "Für Kundenportal freigegeben" : "Portal-Freigabe entfernt"
|
|
})
|
|
} finally {
|
|
portalReleaseLoading.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardNavbar
|
|
title="Erstelltes Dokument anzeigen"
|
|
>
|
|
|
|
</UDashboardNavbar>
|
|
<UDashboardToolbar>
|
|
<template #left>
|
|
<UButton
|
|
@click="router.push(`/createDocument/edit/${itemInfo.id}`)"
|
|
v-if="itemInfo.state === 'Entwurf'"
|
|
>
|
|
Bearbeiten
|
|
</UButton>
|
|
<UButton
|
|
v-if="itemInfo.state === 'Gebucht' && itemInfo.type !== 'serialInvoices'"
|
|
icon="i-heroicons-tag"
|
|
variant="outline"
|
|
:loading="costCentresLoading"
|
|
@click="openCostCentres"
|
|
>Kostenstellen bearbeiten</UButton>
|
|
<UButton
|
|
v-if="canRegeneratePdf"
|
|
icon="i-heroicons-document-arrow-down"
|
|
variant="outline"
|
|
@click="router.push(`/createDocument/edit/${itemInfo.id}?regeneratePdf=1`)"
|
|
>PDF nacherzeugen</UButton>
|
|
<!-- <UButton
|
|
:to="dataStore.documents.find(i => i.createdDocument === itemInfo.id) ? dataStore.documents.find(i => i.createdDocument === itemInfo.id).url : ''"
|
|
target="_blank"
|
|
>In neuen Tab anzeigen</UButton>-->
|
|
<UButton
|
|
icon="i-heroicons-arrow-right-end-on-rectangle"
|
|
@click="modal.open(CopyCreatedDocumentModal, {
|
|
id: itemInfo.id,
|
|
type: itemInfo.type
|
|
})"
|
|
variant="outline"
|
|
>
|
|
Kopieren
|
|
</UButton>
|
|
<UButton
|
|
icon="i-heroicons-document-duplicate"
|
|
variant="outline"
|
|
@click="router.push(`/createDocument/edit?templateFromDocument=${itemInfo.id}&type=${itemInfo.type}`)"
|
|
>
|
|
Als Vorlage übernehmen
|
|
</UButton>
|
|
<UButton
|
|
@click="openEmail"
|
|
icon="i-heroicons-envelope"
|
|
>
|
|
E-Mail
|
|
</UButton>
|
|
<UButton
|
|
v-if="portalEligibleTypes.includes(itemInfo.type) && itemInfo.state !== 'Entwurf'"
|
|
:icon="itemInfo.availableInPortal ? 'i-heroicons-eye-slash' : 'i-heroicons-eye'"
|
|
variant="outline"
|
|
:loading="portalReleaseLoading"
|
|
@click="togglePortalRelease"
|
|
>
|
|
{{ itemInfo.availableInPortal ? "Portal-Freigabe entfernen" : "Für Kundenportal freigeben" }}
|
|
</UButton>
|
|
<UTooltip
|
|
v-if="itemInfo.type === 'invoices' || itemInfo.type === 'advanceInvoices'"
|
|
:text="links.find(i => i.type === 'cancellationInvoices') ? 'Bereits stoniert' : ''"
|
|
>
|
|
<UButton
|
|
@click="router.push(`/createDocument/edit/?createddocument=${itemInfo.id}&loadMode=storno`)"
|
|
variant="outline"
|
|
color="error"
|
|
:disabled="links.find(i => i.type === 'cancellationInvoices')"
|
|
>
|
|
Stornieren
|
|
</UButton>
|
|
</UTooltip>
|
|
|
|
<UDropdownMenu
|
|
v-if="linkedItems[0].length > 0"
|
|
:items="linkedItems"
|
|
:content="{ align: 'start' }"
|
|
>
|
|
<UButton
|
|
icon="i-heroicons-link"
|
|
variant="outline"
|
|
trailing-icon="i-heroicons-chevron-down-20-solid"
|
|
>
|
|
Verknüpfungen
|
|
</UButton>
|
|
</UDropdownMenu>
|
|
<UBadge
|
|
v-if="itemInfo.statementallocations?.length > 0"
|
|
color="primary"
|
|
variant="soft"
|
|
class="self-center"
|
|
>
|
|
Bankbuchungsdatum: {{ bankBookingDateLabel }}
|
|
</UBadge>
|
|
|
|
</template>
|
|
</UDashboardToolbar>
|
|
<UModal v-model:open="costCentresOpen" title="Kostenstellen bearbeiten" :dismissible="!costCentresSaving" :close="!costCentresSaving" :ui="{ content: 'sm:max-w-2xl' }">
|
|
<template #body>
|
|
<p class="mb-4 text-sm text-muted">Die Belegkostenstelle und abweichende Positionskostenstellen können nachträglich geändert oder entfernt werden.</p>
|
|
<div class="space-y-4">
|
|
<UFormField label="Belegkostenstelle">
|
|
<InputGroup>
|
|
<USelectMenu v-model="costCentreDraft.costcentre" :items="costCentres" value-key="id" label-key="name" :filter-fields="['name', 'number']" class="w-full" :disabled="costCentresSaving">
|
|
<template #default>{{ costCentreLabel(costCentreDraft.costcentre) }}</template>
|
|
<template #item="{ item }">{{ [item.number, item.name].filter(Boolean).join(' - ') }}</template>
|
|
</USelectMenu>
|
|
<UButton icon="i-heroicons-x-mark" variant="outline" aria-label="Belegkostenstelle entfernen" :disabled="costCentresSaving || !costCentreDraft.costcentre" @click="costCentreDraft.costcentre = null" />
|
|
</InputGroup>
|
|
</UFormField>
|
|
<UFormField v-for="row in costCentreDraft.rows" :key="row.rowId" :label="row.label">
|
|
<InputGroup>
|
|
<USelectMenu v-model="row.costcentre" :items="costCentres" value-key="id" label-key="name" :filter-fields="['name', 'number']" class="w-full" :disabled="costCentresSaving">
|
|
<template #default>{{ costCentreLabel(row.costcentre) }}</template>
|
|
<template #item="{ item }">{{ [item.number, item.name].filter(Boolean).join(' - ') }}</template>
|
|
</USelectMenu>
|
|
<UButton icon="i-heroicons-x-mark" variant="outline" :aria-label="`Kostenstelle für ${row.label} entfernen`" :disabled="costCentresSaving || !row.costcentre" @click="row.costcentre = null" />
|
|
</InputGroup>
|
|
</UFormField>
|
|
</div>
|
|
</template>
|
|
<template #footer>
|
|
<UButton variant="outline" :disabled="costCentresSaving" @click="costCentresOpen = false">Abbrechen</UButton>
|
|
<UButton :loading="costCentresSaving" @click="saveCostCentres">Kostenstellen speichern</UButton>
|
|
</template>
|
|
</UModal>
|
|
<UModal v-model:open="bankBookingModalOpen" :ui="{ content: 'sm:max-w-2xl' }">
|
|
<template #content>
|
|
<UCard>
|
|
<template #header>
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="font-semibold">Bankbuchungen</h3>
|
|
<UButton
|
|
icon="i-heroicons-x-mark"
|
|
color="gray"
|
|
variant="ghost"
|
|
@click="bankBookingModalOpen = false"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="space-y-3">
|
|
<div
|
|
v-for="allocation in itemInfo.statementallocations"
|
|
:key="allocation.id"
|
|
class="grid grid-cols-1 gap-2 rounded-md border border-gray-200 p-3 text-sm dark:border-gray-800 md:grid-cols-3"
|
|
>
|
|
<div>
|
|
<p class="text-xs text-gray-500">Bankbuchungsdatum</p>
|
|
<p class="font-medium">{{ formatDate(getBankBookingDate(allocation)) }}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-gray-500">Betrag</p>
|
|
<p class="font-medium">{{ formatCurrency(allocation.amount) }}</p>
|
|
<p v-if="Number(allocation.cashDiscountAmount)" class="text-xs text-amber-600">+ {{ formatCurrency(allocation.cashDiscountAmount) }} Skonto</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs text-gray-500">Text</p>
|
|
<p class="font-medium">{{ getStatementLike(allocation)?.text || allocation.description || "-" }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
</template>
|
|
</UModal>
|
|
<UDashboardPanelContent>
|
|
<div
|
|
v-if="portalEligibleTypes.includes(itemInfo.type) && itemInfo.state !== 'Entwurf'"
|
|
class="px-4 py-3"
|
|
>
|
|
<UBadge :color="itemInfo.availableInPortal ? 'primary' : 'neutral'" variant="soft">
|
|
{{ itemInfo.availableInPortal ? "Im Kundenportal sichtbar" : "Nicht im Kundenportal freigegeben" }}
|
|
</UBadge>
|
|
</div>
|
|
<!-- <object
|
|
:data="linkedDocument.url"
|
|
class="w-full previewDocumentMobile"
|
|
/>-->
|
|
<PDFViewer v-if="linkedDocument.id" :file-id="linkedDocument.id" location="show_create_document" />
|
|
|
|
|
|
|
|
|
|
</UDashboardPanelContent>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.previewDocumentMobile {
|
|
aspect-ratio: 1 / 1.414;
|
|
|
|
}
|
|
</style>
|