Files
FEDEO/frontend/pages/createDocument/show/[id].vue
florianfederspiel 76e82eae4f feat(accounting): fehlende PDF von Ausgangsbelegen nacherzeugen
Auf der Belegansicht erscheint "PDF nacherzeugen", wenn ein
fertiggestellter Beleg kein angehängtes Dokument hat. Die PDF wird mit
derselben Logik wie beim Fertigstellen erzeugt und angehängt, ohne den
Datensatz zu speichern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:40:25 +01:00

467 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>
</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>