3. Zwischenstand

This commit is contained in:
2026-03-22 13:53:29 +01:00
parent 03bcc1a939
commit 9f665fc3b8
26 changed files with 2037 additions and 583 deletions

View File

@@ -0,0 +1,545 @@
<script setup lang="ts">
import dayjs from "dayjs"
import {
getCreatedDocumentTaxBreakdown,
getIncomingInvoiceTaxBreakdown
} from "~/composables/useTaxEvaluation"
const router = useRouter()
const loading = ref(true)
const createdDocuments = ref<any[]>([])
const incomingInvoices = ref<any[]>([])
const accounts = ref<any[]>([])
const ownAccounts = ref<any[]>([])
const statementAllocations = ref<any[]>([])
const selectedYear = ref(String(dayjs().year()))
const selectedMonth = ref("all")
const monthItems = [
{ label: "Ganzes Jahr", value: "all" },
{ label: "Januar", value: "1" },
{ label: "Februar", value: "2" },
{ label: "Maerz", value: "3" },
{ label: "April", value: "4" },
{ label: "Mai", value: "5" },
{ label: "Juni", value: "6" },
{ label: "Juli", value: "7" },
{ label: "August", value: "8" },
{ label: "September", value: "9" },
{ label: "Oktober", value: "10" },
{ label: "November", value: "11" },
{ label: "Dezember", value: "12" }
]
const accountColumns = [
{ accessorKey: "number", header: "Nummer" },
{ accessorKey: "label", header: "Konto" },
{ accessorKey: "bookings", header: "Buchungen" },
{ accessorKey: "net", header: "Netto" },
{ accessorKey: "tax", header: "Steuer" },
{ accessorKey: "gross", header: "Brutto" }
]
const ownAccountColumns = [
{ accessorKey: "number", header: "Nummer" },
{ accessorKey: "label", header: "Konto" },
{ accessorKey: "bookings", header: "Buchungen" },
{ accessorKey: "income", header: "Einnahmen" },
{ accessorKey: "expenses", header: "Ausgaben" },
{ accessorKey: "balance", header: "Saldo" }
]
const isRelevantOutputDocument = (doc: any) => {
return doc?.state === "Gebucht" && ["invoices", "advanceInvoices", "cancellationInvoices"].includes(doc?.type)
}
const isRelevantInputInvoice = (invoice: any) => {
return invoice?.state === "Gebucht" && !!invoice?.date
}
const sameId = (left: any, right: any) => String(left ?? "") === String(right ?? "")
const getStatementDate = (allocation: any) => {
return allocation?.bankstatement?.date || allocation?.bankstatement?.valueDate || allocation?.date || allocation?.created_at || null
}
const matchesSelectedPeriod = (dateValue: any) => {
const parsed = dayjs(dateValue)
if (!parsed.isValid()) {
return false
}
if (String(parsed.year()) !== selectedYear.value) {
return false
}
if (selectedMonth.value !== "all" && parsed.month() + 1 !== Number(selectedMonth.value)) {
return false
}
return true
}
const computeDocumentNet = (doc: any) => {
return Number((doc?.rows || []).reduce((sum: number, row: any) => {
if (!row || ["pagebreak", "title", "text"].includes(row.mode)) {
return sum
}
const quantity = Number(row.quantity || 0)
const price = Number(row.price || 0)
const discountPercent = Number(row.discountPercent || 0)
return sum + (quantity * price * (1 - discountPercent / 100))
}, 0).toFixed(2))
}
const computeIncomingInvoiceGross = (invoice: any) => {
return Number((invoice?.accounts || []).reduce((sum: number, account: any) => {
const amountNet = Number(account?.amountNet || 0)
const amountTax = Number(account?.amountTax || 0)
const amountGross = Number(account?.amountGross)
return sum + (Number.isFinite(amountGross) ? amountGross : amountNet + amountTax)
}, 0).toFixed(2))
}
const yearItems = computed(() => {
const years = new Set<string>([String(dayjs().year())])
createdDocuments.value.forEach((doc) => {
const parsed = dayjs(doc.documentDate)
if (parsed.isValid()) {
years.add(String(parsed.year()))
}
})
incomingInvoices.value.forEach((invoice) => {
const parsed = dayjs(invoice.date)
if (parsed.isValid()) {
years.add(String(parsed.year()))
}
})
statementAllocations.value.forEach((allocation) => {
const parsed = dayjs(getStatementDate(allocation))
if (parsed.isValid()) {
years.add(String(parsed.year()))
}
})
return Array.from(years)
.sort((a, b) => Number(b) - Number(a))
.map((year) => ({ label: year, value: year }))
})
const filteredDocuments = computed(() => {
return createdDocuments.value.filter((doc) => matchesSelectedPeriod(doc.documentDate))
})
const filteredIncomingInvoices = computed(() => {
return incomingInvoices.value.filter((invoice) => matchesSelectedPeriod(invoice.date))
})
const filteredStatementAllocations = computed(() => {
return statementAllocations.value.filter((allocation) => matchesSelectedPeriod(getStatementDate(allocation)))
})
const incomeTotal = computed(() => {
return Number(filteredDocuments.value.reduce((sum, doc) => sum + computeDocumentNet(doc), 0).toFixed(2))
})
const expenseNetTotal = computed(() => {
return Number(filteredIncomingInvoices.value.reduce((sum, invoice) => {
return sum + (invoice.accounts || []).reduce((accountSum: number, account: any) => accountSum + Number(account.amountNet || 0), 0)
}, 0).toFixed(2))
})
const expenseGrossTotal = computed(() => {
return Number(filteredIncomingInvoices.value.reduce((sum, invoice) => sum + computeIncomingInvoiceGross(invoice), 0).toFixed(2))
})
const taxSummary = computed(() => {
const output = filteredDocuments.value.reduce((sum, doc) => {
const breakdown = getCreatedDocumentTaxBreakdown(doc)
return {
net19: sum.net19 + breakdown.net19,
tax19: sum.tax19 + breakdown.tax19,
net7: sum.net7 + breakdown.net7,
tax7: sum.tax7 + breakdown.tax7,
net0: sum.net0 + breakdown.net0
}
}, { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 })
const input = filteredIncomingInvoices.value.reduce((sum, invoice) => {
const breakdown = getIncomingInvoiceTaxBreakdown(invoice)
return {
net19: sum.net19 + breakdown.net19,
tax19: sum.tax19 + breakdown.tax19,
net7: sum.net7 + breakdown.net7,
tax7: sum.tax7 + breakdown.tax7,
net0: sum.net0 + breakdown.net0
}
}, { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 })
const outputTax = Number((output.tax19 + output.tax7).toFixed(2))
const inputTax = Number((input.tax19 + input.tax7).toFixed(2))
return {
output,
input,
outputTax,
inputTax,
balance: Number((outputTax - inputTax).toFixed(2))
}
})
const operatingResult = computed(() => {
return Number((incomeTotal.value - expenseNetTotal.value).toFixed(2))
})
const accountRows = computed(() => {
return accounts.value
.map((account) => {
const bookings = filteredIncomingInvoices.value.flatMap((invoice) => {
return (invoice.accounts || [])
.filter((invoiceAccount: any) => sameId(invoiceAccount.account?.id || invoiceAccount.account, account.id))
})
if (bookings.length === 0) {
return null
}
const net = bookings.reduce((sum, booking: any) => sum + Number(booking.amountNet || 0), 0)
const tax = bookings.reduce((sum, booking: any) => sum + Number(booking.amountTax || 0), 0)
const gross = bookings.reduce((sum, booking: any) => {
const amountGross = Number(booking.amountGross)
return sum + (Number.isFinite(amountGross) ? amountGross : Number(booking.amountNet || 0) + Number(booking.amountTax || 0))
}, 0)
return {
id: account.id,
number: account.number || "-",
label: account.label || account.name || "-",
bookings: bookings.length,
net: Number(net.toFixed(2)),
tax: Number(tax.toFixed(2)),
gross: Number(gross.toFixed(2))
}
})
.filter(Boolean)
.sort((left: any, right: any) => Number(right.gross) - Number(left.gross))
})
const ownAccountRows = computed(() => {
return ownAccounts.value
.map((account) => {
const bookings = filteredStatementAllocations.value.filter((allocation) => sameId(allocation.ownaccount?.id || allocation.ownaccount, account.id))
if (bookings.length === 0) {
return null
}
const income = bookings.reduce((sum, booking) => {
const amount = Number(booking.amount || 0)
return amount > 0 ? sum + amount : sum
}, 0)
const expenses = bookings.reduce((sum, booking) => {
const amount = Number(booking.amount || 0)
return amount < 0 ? sum + Math.abs(amount) : sum
}, 0)
const balance = bookings.reduce((sum, booking) => sum + Number(booking.amount || 0), 0)
return {
id: account.id,
number: account.number || "-",
label: account.name || account.label || "-",
bookings: bookings.length,
income: Number(income.toFixed(2)),
expenses: Number(expenses.toFixed(2)),
balance: Number(balance.toFixed(2))
}
})
.filter(Boolean)
.sort((left: any, right: any) => Math.abs(Number(right.balance)) - Math.abs(Number(left.balance)))
})
const setupPage = async () => {
loading.value = true
try {
const [docs, invoices, accountItems, ownAccountItems, allocationItems] = await Promise.all([
useEntities("createddocuments").select(),
useEntities("incominginvoices").select("*, vendor(*)"),
useEntities("accounts").selectSpecial(),
useEntities("ownaccounts").select(),
useEntities("statementallocations").select("*, bankstatement(*), createddocument(*), incominginvoice(*)")
])
createdDocuments.value = (docs || []).filter(isRelevantOutputDocument)
incomingInvoices.value = (invoices || []).filter(isRelevantInputInvoice)
accounts.value = accountItems || []
ownAccounts.value = ownAccountItems || []
statementAllocations.value = allocationItems || []
const firstYear = yearItems.value[0]?.value
if (firstYear && !yearItems.value.some((item) => item.value === selectedYear.value)) {
selectedYear.value = firstYear
}
} finally {
loading.value = false
}
}
const openAccount = (rowLike: any) => {
const row = rowLike?.original || rowLike
if (row?.id) {
router.push(`/accounts/show/${row.id}`)
}
}
const openOwnAccount = (rowLike: any) => {
const row = rowLike?.original || rowLike
if (row?.id) {
router.push(`/standardEntity/ownaccounts/show/${row.id}`)
}
}
onMounted(setupPage)
</script>
<template>
<UDashboardNavbar title="BWA">
<template #right>
<UButton
icon="i-heroicons-arrow-path"
variant="outline"
:loading="loading"
@click="setupPage"
>
Aktualisieren
</UButton>
</template>
</UDashboardNavbar>
<UDashboardPanelContent class="min-w-0 space-y-6 overflow-x-hidden overflow-y-auto p-4 md:p-6">
<div class="flex flex-col gap-3 md:flex-row md:items-end">
<UFormField label="Jahr" class="w-full md:w-48">
<USelectMenu
v-model="selectedYear"
:items="yearItems"
value-key="value"
label-key="label"
class="w-full"
/>
</UFormField>
<UFormField label="Monat" class="w-full md:w-56">
<USelectMenu
v-model="selectedMonth"
:items="monthItems"
value-key="value"
label-key="label"
class="w-full"
/>
</UFormField>
</div>
<div class="grid min-w-0 gap-4 md:grid-cols-2 xl:grid-cols-4">
<UCard class="min-w-0">
<div class="text-sm text-gray-500 dark:text-gray-400">Einnahmen netto</div>
<div class="mt-2 text-2xl font-semibold">{{ useCurrency(incomeTotal) }}</div>
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{{ filteredDocuments.length }} gebuchte Ausgangsbelege
</div>
</UCard>
<UCard class="min-w-0">
<div class="text-sm text-gray-500 dark:text-gray-400">Ausgaben netto</div>
<div class="mt-2 text-2xl font-semibold">{{ useCurrency(expenseNetTotal) }}</div>
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Brutto: {{ useCurrency(expenseGrossTotal) }}
</div>
</UCard>
<UCard class="min-w-0">
<div class="text-sm text-gray-500 dark:text-gray-400">Betriebsergebnis</div>
<div class="mt-2 text-2xl font-semibold" :class="operatingResult >= 0 ? 'text-primary-500' : 'text-error'">
{{ useCurrency(operatingResult) }}
</div>
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Einnahmen minus Ausgaben netto
</div>
</UCard>
<UCard class="min-w-0">
<div class="text-sm text-gray-500 dark:text-gray-400">USt-Saldo</div>
<div class="mt-2 text-2xl font-semibold" :class="taxSummary.balance >= 0 ? 'text-amber-600 dark:text-amber-400' : 'text-primary-500'">
{{ useCurrency(taxSummary.balance) }}
</div>
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
USt {{ useCurrency(taxSummary.outputTax) }} | Vorsteuer {{ useCurrency(taxSummary.inputTax) }}
</div>
</UCard>
</div>
<div class="grid min-w-0 gap-4 xl:grid-cols-2">
<UCard class="min-w-0">
<template #header>
<div class="font-semibold">USt-Details</div>
</template>
<div class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<span>Netto 19% Ausgangsbelege</span>
<span>{{ useCurrency(taxSummary.output.net19) }}</span>
</div>
<div class="flex items-center justify-between">
<span>USt 19%</span>
<span>{{ useCurrency(taxSummary.output.tax19) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Netto 7% Ausgangsbelege</span>
<span>{{ useCurrency(taxSummary.output.net7) }}</span>
</div>
<div class="flex items-center justify-between">
<span>USt 7%</span>
<span>{{ useCurrency(taxSummary.output.tax7) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Steuerfrei</span>
<span>{{ useCurrency(taxSummary.output.net0 + taxSummary.input.net0) }}</span>
</div>
</div>
</UCard>
<UCard class="min-w-0">
<template #header>
<div class="font-semibold">Vorsteuer-Details</div>
</template>
<div class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<span>Netto 19% Eingangsbelege</span>
<span>{{ useCurrency(taxSummary.input.net19) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Vorsteuer 19%</span>
<span>{{ useCurrency(taxSummary.input.tax19) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Netto 7% Eingangsbelege</span>
<span>{{ useCurrency(taxSummary.input.net7) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Vorsteuer 7%</span>
<span>{{ useCurrency(taxSummary.input.tax7) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Steuerfrei</span>
<span>{{ useCurrency(taxSummary.input.net0) }}</span>
</div>
</div>
</UCard>
</div>
<div class="grid min-w-0 gap-4 xl:grid-cols-2">
<UCard class="min-w-0">
<template #header>
<div class="flex items-center justify-between gap-3">
<span class="font-semibold">Buchungskonten</span>
<UBadge color="neutral" variant="soft">{{ accountRows.length }}</UBadge>
</div>
</template>
<div class="min-w-0">
<UTable
:data="accountRows"
:columns="normalizeTableColumns(accountColumns)"
:loading="loading"
:on-select="openAccount"
>
<template #empty>
<div class="flex flex-col items-center justify-center py-10 text-center">
<UIcon name="i-heroicons-circle-stack-20-solid" class="mb-2 h-10 w-10 text-gray-400" />
<p class="font-medium">Keine Buchungskonten im ausgewaehlten Zeitraum</p>
</div>
</template>
<template #label-cell="{ row }">
<div class="truncate font-medium">{{ row.original.label }}</div>
</template>
<template #bookings-cell="{ row }">
<div class="text-right">{{ row.original.bookings }}</div>
</template>
<template #net-cell="{ row }">
<div class="text-right tabular-nums">{{ useCurrency(row.original.net) }}</div>
</template>
<template #tax-cell="{ row }">
<div class="text-right tabular-nums">{{ useCurrency(row.original.tax) }}</div>
</template>
<template #gross-cell="{ row }">
<div class="text-right font-medium tabular-nums">{{ useCurrency(row.original.gross) }}</div>
</template>
</UTable>
</div>
</UCard>
<UCard class="min-w-0">
<template #header>
<div class="flex items-center justify-between gap-3">
<span class="font-semibold">Eigene Buchungskonten</span>
<UBadge color="neutral" variant="soft">{{ ownAccountRows.length }}</UBadge>
</div>
</template>
<div class="min-w-0">
<UTable
:data="ownAccountRows"
:columns="normalizeTableColumns(ownAccountColumns)"
:loading="loading"
:on-select="openOwnAccount"
>
<template #empty>
<div class="flex flex-col items-center justify-center py-10 text-center">
<UIcon name="i-heroicons-circle-stack-20-solid" class="mb-2 h-10 w-10 text-gray-400" />
<p class="font-medium">Keine eigenen Buchungen im ausgewaehlten Zeitraum</p>
</div>
</template>
<template #label-cell="{ row }">
<div class="truncate font-medium">{{ row.original.label }}</div>
</template>
<template #bookings-cell="{ row }">
<div class="text-right">{{ row.original.bookings }}</div>
</template>
<template #income-cell="{ row }">
<div class="text-right text-primary-500 tabular-nums">{{ useCurrency(row.original.income) }}</div>
</template>
<template #expenses-cell="{ row }">
<div class="text-right text-error tabular-nums">{{ useCurrency(row.original.expenses) }}</div>
</template>
<template #balance-cell="{ row }">
<div class="text-right font-medium tabular-nums" :class="row.original.balance >= 0 ? 'text-primary-500' : 'text-error'">
{{ useCurrency(row.original.balance) }}
</div>
</template>
</UTable>
</div>
</UCard>
</div>
</UDashboardPanelContent>
</template>

View File

@@ -198,7 +198,7 @@ setupPage()
:columns="normalizeTableColumns(columns)"
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
:on-select="(i) => router.push(`/accounts/show/${i.id}`)"
:on-select="(row) => router.push(`/accounts/show/${row.original.id}`)"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }"
>
<template #allocations-cell="{row}">

View File

@@ -1,62 +1,44 @@
<script setup>
import dayjs from "dayjs";
const route = useRoute()
const router = useRouter()
const itemInfo = ref(null)
const statementallocations = ref([])
const incominginvoices = ref([])
const currentAccountId = computed(() => String(route.params.id))
const sameAccount = (value) => String(value ?? "") === currentAccountId.value
const setup = async () => {
itemInfo.value = (await useEntities("accounts").selectSpecial("*")).find(i => i.id === Number(route.params.id))
statementallocations.value = (await useEntities("statementallocations").select("*, bs_id(*)")).filter(i => i.account === Number(route.params.id))
incominginvoices.value = (await useEntities("incominginvoices").select("*, vendor(*)")).filter(i => i.accounts.find(x => x.account === Number(route.params.id)))
statementallocations.value = (await useEntities("statementallocations").select("*, bankstatement(*)"))
.filter((allocation) => sameAccount(allocation.account?.id || allocation.account))
incominginvoices.value = (await useEntities("incominginvoices").select("*, vendor(*)"))
.filter((invoice) => (invoice.accounts || []).some((account) => sameAccount(account.account?.id || account.account)))
}
setup()
const selectAllocation = (allocation) => {
if(allocation.type === "statementallocation") {
router.push(`/banking/statements/edit/${allocation.bs_id.id}`)
} else if(allocation.type === "incominginvoice") {
router.push(`/incominginvoices/show/${allocation.incominginvoiceid}`)
}
}
const renderedAllocations = computed(() => {
const statementRows = statementallocations.value.map((allocation) => ({
...allocation,
type: "statementallocation",
amount: Number(allocation.amount || 0)
}))
let tempstatementallocations = statementallocations.value.map(i => {
return {
...i,
type: "statementallocation",
date: i.bs_id.date,
partner: i.bs_id ? (i.bs_id.debName ? i.bs_id.debName : (i.bs_id.credName ? i.bs_id.credName : '')) : ''
}
const incomingInvoiceRows = incominginvoices.value.flatMap((invoice) => {
return (invoice.accounts || [])
.filter((account) => sameAccount(account.account?.id || account.account))
.map((account, index) => ({
id: `${invoice.id}-${index}`,
incominginvoiceid: invoice.id,
type: "incominginvoice",
amount: Number(account.amountGross || account.amountNet || 0),
expense: invoice.expense
}))
})
let incominginvoicesallocations = []
incominginvoices.value.forEach(i => {
incominginvoicesallocations.push(...i.accounts.filter(x => x.account == route.params.id).map(x => {
return {
...x,
incominginvoiceid: i.id,
type: "incominginvoice",
amount: x.amountGross ? x.amountGross : x.amountNet,
date: i.date,
partner: i.vendor.name,
description: i.description,
color: i.expense ? "red" : "green",
expense: i.expense
}
}))
})
return [...tempstatementallocations, ... incominginvoicesallocations]
return [...statementRows, ...incomingInvoiceRows]
})
const saldo = computed(() => {
@@ -135,25 +117,12 @@ const saldo = computed(() => {
</div>
</UCard>
<UCard class="mt-5" v-if="item.label === 'Buchungen'">
<UTable
v-if="statementallocations"
:data="renderedAllocations"
:columns="normalizeTableColumns([{key:'amount', label:'Betrag'},{key:'date', label:'Datum'},{key:'partner', label:'Partner'},{key:'description', label:'Beschreibung'}])"
:on-select="(i) => selectAllocation(i)"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }"
>
<template #amount-cell="{row}">
<span class="text-right text-rose-600" v-if="row.original.amount < 0 || row.original.color === 'red'">{{useCurrency(row.original.amount)}}</span>
<span class="text-right text-primary-500" v-else-if="row.original.amount > 0 || row.original.color === 'green'">{{useCurrency(row.original.amount)}}</span>
<span v-else>{{useCurrency(row.original.amount)}}</span>
</template>
<template #date-cell="{row}">
{{row.original.date ? dayjs(row.original.date).format('DD.MM.YYYY') : ''}}
</template>
<template #description-cell="{row}">
{{row.original.description ? row.original.description : ''}}
</template>
</UTable>
<EntityShowSubOwnAccountsStatements
v-if="itemInfo"
:item="itemInfo"
top-level-type="accounts"
platform="desktop"
/>
</UCard>
</template>
</UTabs>

File diff suppressed because it is too large Load Diff

View File

@@ -98,7 +98,7 @@
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
:on-select="(row) => router.push(`/createDocument/edit/${row.id}`)"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
empty="Keine Belege anzuzeigen"
>
<template #actions-cell="{ row }">
<div @click.stop>

View File

@@ -1,6 +1,7 @@
<script setup>
import InputGroup from "~/components/InputGroup.vue";
import dayjs from "dayjs";
import { parseDate } from "@internationalized/date"
import { useDraggable } from '@vueuse/core'
// --- Standard Setup & Data ---
@@ -44,6 +45,9 @@ const costcentres = ref([])
const vendors = ref([])
const accounts = ref([])
const loadedFileId = ref(null)
const invoiceFiles = ref([])
const paymentTypeItems = ['Überweisung', 'Lastschrift', 'Kreditkarte', 'PayPal', 'Bar', 'Sonstiges']
const files = useFiles()
const setup = async () => {
// 1. Daten laden
@@ -67,7 +71,9 @@ const setup = async () => {
// Datei laden
if (itemInfo.value.files && itemInfo.value.files.length > 0) {
loadedFileId.value = itemInfo.value.files[itemInfo.value.files.length-1].id
invoiceFiles.value = await files.selectSomeDocuments(itemInfo.value.files.map((file) => file.id))
const latestPdf = [...invoiceFiles.value].reverse().find((file) => file?.path?.toLowerCase().includes('.pdf'))
loadedFileId.value = latestPdf?.id || null
}
if(itemInfo.value.date && !itemInfo.value.dueDate) itemInfo.value.dueDate = itemInfo.value.date
@@ -98,6 +104,23 @@ const taxOptions = ref([
{ label: "Keine USt", percentage: 0, key: "null" },
])
const getCalendarValue = (value) => {
if (!value) return undefined
const formatted = dayjs(value).format('YYYY-MM-DD')
return formatted ? parseDate(formatted) : undefined
}
const setDateField = (field, value) => {
itemInfo.value[field] = value ? dayjs(value.toString()).toDate() : null
}
const setDateFieldToToday = (field) => {
itemInfo.value[field] = dayjs().toDate()
}
const getDateButtonLabel = (value, emptyLabel = "Kein Datum") => value ? dayjs(value).format('DD.MM.YYYY') : emptyLabel
const totalCalculated = computed(() => {
let totalNet = 0
let totalAmount19Tax = 0
@@ -335,18 +358,18 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
<USelectMenu
class="w-full"
v-model="itemInfo.vendor"
:options="vendors"
option-attribute="name"
value-attribute="id"
searchable
:items="vendors"
label-key="name"
value-key="id"
:search-input="{ placeholder: 'Lieferant suchen...' }"
:disabled="mode === 'show'"
:search-attributes="['name', 'vendorNumber']"
placeholder="Lieferant suchen..."
:filter-fields="['name', 'vendorNumber']"
:color="itemInfo.vendor ? 'primary' : 'error'"
>
<template #label>
<template #default>
{{ vendors.find(v => v.id === itemInfo.vendor)?.name || 'Bitte wählen' }}
</template>
<template #option="{ option }">
<template #item="{ item: option }">
<span class="font-mono text-xs opacity-75 mr-2">{{ option.vendorNumber }}</span> {{ option.name }}
</template>
</USelectMenu>
@@ -368,33 +391,81 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
</UFormField>
<UFormField label="Rechnungsnummer">
<UInput v-model="itemInfo.reference" icon="i-heroicons-hashtag" :disabled="mode === 'show'" />
<UInput class="w-full" v-model="itemInfo.reference" icon="i-heroicons-hashtag" :disabled="mode === 'show'" :color="itemInfo.reference ? 'primary' : 'error'" />
</UFormField>
<UFormField label="Zahlart">
<USelectMenu v-model="itemInfo.paymentType" :options="['Überweisung', 'Lastschrift', 'Kreditkarte', 'PayPal', 'Bar', 'Sonstiges']" :disabled="mode === 'show'" />
<USelectMenu v-model="itemInfo.paymentType" :items="paymentTypeItems" :disabled="mode === 'show'" class="w-full" />
</UFormField>
<UFormField label="Rechnungsdatum">
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton block color="white" icon="i-heroicons-calendar" :label="itemInfo.date ? dayjs(itemInfo.date).format('DD.MM.YYYY') : '-'" :disabled="mode === 'show'" />
<template #panel="{ close }">
<LazyDatePicker v-model="itemInfo.date" @close="() => { if(!itemInfo.dueDate) itemInfo.dueDate = itemInfo.date; close() }" />
<UPopover class="w-full" :content="{ side: 'bottom', align: 'start' }">
<UButton
block
icon="i-heroicons-calendar"
:label="getDateButtonLabel(itemInfo.date)"
:disabled="mode === 'show'"
:color="itemInfo.date ? 'neutral' : 'error'"
variant="outline"
class="w-full justify-start"
/>
<template #content>
<div class="p-2">
<UCalendar
:model-value="getCalendarValue(itemInfo.date)"
@update:model-value="(value) => { setDateField('date', value); if (!itemInfo.dueDate) itemInfo.dueDate = itemInfo.date }"
:week-starts-on="1"
/>
<div class="flex justify-end px-2 pb-2">
<UButton
size="xs"
color="gray"
variant="soft"
icon="i-heroicons-calendar-days"
label="Heute"
@click="() => { setDateFieldToToday('date'); if (!itemInfo.dueDate) itemInfo.dueDate = itemInfo.date }"
/>
</div>
</div>
</template>
</UPopover>
</UFormField>
<UFormField label="Fälligkeitsdatum">
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton block color="white" icon="i-heroicons-calendar" :label="itemInfo.dueDate ? dayjs(itemInfo.dueDate).format('DD.MM.YYYY') : '-'" :disabled="mode === 'show'" />
<template #panel="{ close }">
<LazyDatePicker v-model="itemInfo.dueDate" @close="close" />
<UPopover class="w-full" :content="{ side: 'bottom', align: 'start' }">
<UButton
block
icon="i-heroicons-calendar"
:label="getDateButtonLabel(itemInfo.dueDate)"
:disabled="mode === 'show'"
:color="itemInfo.dueDate ? 'neutral' : 'error'"
variant="outline"
class="w-full justify-start"
/>
<template #content>
<div class="p-2">
<UCalendar
:model-value="getCalendarValue(itemInfo.dueDate)"
@update:model-value="(value) => setDateField('dueDate', value)"
:week-starts-on="1"
/>
<div class="flex justify-end px-2 pb-2">
<UButton
size="xs"
color="gray"
variant="soft"
icon="i-heroicons-calendar-days"
label="Heute"
@click="setDateFieldToToday('dueDate')"
/>
</div>
</div>
</template>
</UPopover>
</UFormField>
<UFormField label="Beschreibung / Notiz" class="md:col-span-2">
<UTextarea v-model="itemInfo.description" :rows="2" autoresize :disabled="mode === 'show'" />
<UTextarea class="w-full" v-model="itemInfo.description" :rows="2" autoresize :disabled="mode === 'show'" />
</UFormField>
</div>
</UCard>
@@ -430,19 +501,20 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
<div class="col-span-12 md:col-span-6">
<UFormField label="Konto / Kategorie">
<USelectMenu
class="w-full"
v-model="item.account"
:options="accounts"
searchable
placeholder="Kategorie wählen"
option-attribute="label"
value-attribute="id"
:items="accounts"
:search-input="{ placeholder: 'Kategorie wählen' }"
label-key="label"
value-key="id"
:disabled="mode === 'show'"
:search-attributes="['label', 'number']"
:filter-fields="['label', 'number']"
:color="item.account ? 'primary' : 'error'"
>
<template #option="{ option }">
<template #item="{ item: option }">
<span class="font-mono text-xs text-gray-500 mr-2">{{ option.number }}</span> {{ option.label }}
</template>
<template #label>
<template #default>
{{ accounts.find(a => a.id === item.account)?.label || 'Auswählen' }}
</template>
</USelectMenu>
@@ -452,15 +524,15 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
<div class="col-span-12 md:col-span-6">
<UFormField label="Kostenstelle">
<USelectMenu
class="w-full"
v-model="item.costCentre"
:options="costcentres"
searchable
option-attribute="name"
value-attribute="id"
placeholder="Optional"
:items="costcentres"
:search-input="{ placeholder: 'Optional' }"
label-key="name"
value-key="id"
:disabled="mode === 'show'"
>
<template #label>
<template #default>
{{ costcentres.find(c => c.id === item.costCentre)?.name || 'Keine' }}
</template>
</USelectMenu>
@@ -470,6 +542,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
<div class="col-span-12 md:col-span-3">
<UFormField label="Betrag (Netto)">
<UInput
class="w-full"
type="number"
step="0.01"
:disabled="mode === 'show' || !useNetMode"
@@ -484,6 +557,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
<div class="col-span-12 md:col-span-3">
<UFormField label="Betrag (Brutto)">
<UInput
class="w-full"
type="number"
step="0.01"
:disabled="mode === 'show' || useNetMode"
@@ -498,19 +572,21 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
<div class="col-span-6 md:col-span-3">
<UFormField label="Steuerschlüssel">
<USelectMenu
class="w-full"
v-model="item.taxType"
:options="taxOptions"
value-attribute="key"
option-attribute="label"
:items="taxOptions"
value-key="key"
label-key="label"
:disabled="mode === 'show'"
@change="recalculateItem(item, 'taxType')"
@update:model-value="recalculateItem(item, 'taxType')"
:color="item.taxType ? 'primary' : 'error'"
/>
</UFormField>
</div>
<div class="col-span-6 md:col-span-3">
<UFormField label="Steuerbetrag" help="Automatisch berechnet">
<UInput :model-value="item.amountTax" disabled color="gray" >
<UInput class="w-full" :model-value="item.amountTax" disabled color="gray" >
<template #trailing>€</template>
</UInput>
</UFormField>
@@ -538,7 +614,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
</div>
<div class="col-span-12">
<UInput v-model="item.description" :disabled="mode === 'show'" placeholder="Positionstext (optional)" icon="i-heroicons-bars-3-bottom-left" variant="none" class="border-b border-gray-100 dark:border-gray-800" />
<UInput v-model="item.description" :disabled="mode === 'show'" placeholder="Positionstext (optional)" icon="i-heroicons-bars-3-bottom-left" variant="none" class="w-full border-b border-gray-100 dark:border-gray-800" />
</div>
</div>
</UCard>

View File

@@ -148,7 +148,15 @@ const isPaid = (item) => {
return Math.abs(amountPaid) === Math.abs(Number(getInvoiceSum(item)))
}
const selectIncomingInvoice = (invoice) => {
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 {
@@ -254,7 +262,7 @@ const selectIncomingInvoice = (invoice) => {
:columns="normalizeTableColumns(columns)"
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
:on-select="(i) => selectIncomingInvoice(i) "
:on-select="selectIncomingInvoice"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
>
<template #reference-cell="{row}">

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { FormSubmitEvent } from '#ui/types'
definePageMeta({
layout: "notLoggedIn"
})
@@ -7,16 +9,23 @@ const auth = useAuthStore()
const toast = useToast()
const router = useRouter()
const doLogin = async (data:any) => {
const state = reactive({
email: '',
password: ''
})
const loading = ref(false)
const doLogin = async (event: FormSubmitEvent<typeof state>) => {
loading.value = true
try {
await auth.login(data.email, data.password)
// Weiterleiten nach erfolgreichem Login
await auth.login(event.data.email, event.data.password)
toast.add({title:"Einloggen erfolgreich"})
await router.push("/")
} catch (err: any) {
toast.add({title:"Zugangsdaten falsch. Bitte überprüfen Sie Ihre Eingaben",color:"error"})
} finally {
loading.value = false
}
}
</script>
@@ -29,60 +38,42 @@ const doLogin = async (data:any) => {
dark="/Logo_Dark.png"
/>
<UAuthForm
title="Login"
description="Geben Sie Ihre Anmeldedaten ein um Zugriff auf Ihren Account zu erhalten."
align="bottom"
:fields="[{
name: 'email',
type: 'text',
label: 'Email',
placeholder: 'Deine E-Mail Adresse'
}, {
name: 'password',
label: 'Passwort',
type: 'password',
placeholder: 'Dein Passwort'
}]"
:loading="false"
@submit="doLogin"
:submit-button="{label: 'Weiter'}"
divider="oder"
>
<template #password-hint>
<NuxtLink to="/password-reset" class="text-primary font-medium">Passwort vergessen?</NuxtLink>
</template>
</UAuthForm>
</UCard>
<!-- <div v-else class="mt-20 m-2 p-2">
<UColorModeImage
light="/Logo.png"
dark="/Logo_Dark.png"
/>
<div class="mt-6 space-y-5">
<div class="space-y-1">
<h1 class="text-xl font-semibold">Login</h1>
<p class="text-sm text-muted">
Geben Sie Ihre Anmeldedaten ein um Zugriff auf Ihren Account zu erhalten.
</p>
</div>
<UAuthForm
title="Login"
description="Geben Sie Ihre Anmeldedaten ein um Zugriff auf Ihren Account zu erhalten."
align="bottom"
:fields="[{
name: 'email',
type: 'text',
label: 'Email',
placeholder: 'Deine E-Mail Adresse'
}, {
name: 'password',
label: 'Passwort',
type: 'password',
placeholder: 'Dein Passwort'
}]"
:loading="false"
@submit="doLogin"
:submit-button="{label: 'Weiter'}"
divider="oder"
>
<template #password-hint>
<NuxtLink to="/password-reset" class="text-primary font-medium">Passwort vergessen?</NuxtLink>
</template>
</UAuthForm>
</div>-->
</template>
<UForm :state="state" class="space-y-4" @submit="doLogin">
<UFormField label="E-Mail" name="email">
<UInput
v-model="state.email"
type="email"
class="w-full"
placeholder="Deine E-Mail Adresse"
autocomplete="email"
/>
</UFormField>
<UFormField label="Passwort" name="password">
<template #hint>
<NuxtLink to="/password-reset" class="text-primary font-medium">Passwort vergessen?</NuxtLink>
</template>
<UInput
v-model="state.password"
type="password"
class="w-full"
placeholder="Dein Passwort"
autocomplete="current-password"
/>
</UFormField>
<UButton type="submit" block class="w-full" :loading="loading">
Weiter
</UButton>
</UForm>
</div>
</UCard>
</template>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { FormSubmitEvent } from '#ui/types'
definePageMeta({
layout: "notLoggedIn"
})
@@ -6,25 +8,31 @@ definePageMeta({
const auth = useAuthStore()
const toast = useToast()
const state = reactive({
oldPassword: '',
newPassword: ''
})
const loading = ref(false)
const doChange = async (data:any) => {
const doChange = async (event: FormSubmitEvent<typeof state>) => {
loading.value = true
try {
const res = await useNuxtApp().$api("/api/auth/password/change", {
await useNuxtApp().$api("/api/auth/password/change", {
method: "POST",
body: {
old_password: data.oldPassword,
new_password: data.newPassword,
old_password: event.data.oldPassword,
new_password: event.data.newPassword,
}
})
// Weiterleiten nach erfolgreichem Login
toast.add({title:"Ändern erfolgreich"})
await auth.logout()
return navigateTo("/login")
} catch (err: any) {
toast.add({title:"Es gab ein Problem beim ändern",color:"error"})
} finally {
loading.value = false
}
}
</script>
@@ -37,26 +45,39 @@ const doChange = async (data:any) => {
dark="/Logo_Dark.png"
/>
<UAuthForm
title="Passwort zurücksetzen"
description="Geben Sie Ihre E-Mail ein um ein neues Passwort per E-Mail zu erhalten."
align="bottom"
:fields="[{
name: 'oldPassword',
label: 'Altes Passwort',
type: 'password',
placeholder: 'Dein altes Passwort'
},{
name: 'newPassword',
label: 'Neues Passwort',
type: 'password',
placeholder: 'Dein neues Passwort'
}]"
:loading="false"
@submit="doChange"
:submit-button="{label: 'Ändern'}"
divider="oder"
>
</UAuthForm>
<div class="mt-6 space-y-5">
<div class="space-y-1">
<h1 class="text-xl font-semibold">Passwort ändern</h1>
<p class="text-sm text-muted">
Geben Sie Ihr aktuelles und Ihr neues Passwort ein.
</p>
</div>
<UForm :state="state" class="space-y-4" @submit="doChange">
<UFormField label="Altes Passwort" name="oldPassword">
<UInput
v-model="state.oldPassword"
type="password"
class="w-full"
placeholder="Dein altes Passwort"
autocomplete="current-password"
/>
</UFormField>
<UFormField label="Neues Passwort" name="newPassword">
<UInput
v-model="state.newPassword"
type="password"
class="w-full"
placeholder="Dein neues Passwort"
autocomplete="new-password"
/>
</UFormField>
<UButton type="submit" block class="w-full" :loading="loading">
Ändern
</UButton>
</UForm>
</div>
</UCard>
</template>
</template>

View File

@@ -1,28 +1,34 @@
<script setup lang="ts">
import type { FormSubmitEvent } from '#ui/types'
definePageMeta({
layout: "notLoggedIn"
})
const auth = useAuthStore()
const toast = useToast()
const state = reactive({
email: ''
})
const loading = ref(false)
const doReset = async (data:any) => {
const doReset = async (event: FormSubmitEvent<typeof state>) => {
loading.value = true
try {
const res = await useNuxtApp().$api("/auth/password/reset", {
await useNuxtApp().$api("/auth/password/reset", {
method: "POST",
body: {
email: data.email
email: event.data.email
}
})
// Weiterleiten nach erfolgreichem Login
toast.add({title:"Zurücksetzen erfolgreich"})
return navigateTo("/login")
} catch (err: any) {
toast.add({title:"Problem beim zurücksetzen",color:"error"})
} finally {
loading.value = false
}
}
</script>
@@ -35,21 +41,29 @@ const doReset = async (data:any) => {
dark="/Logo_Dark.png"
/>
<UAuthForm
title="Passwort zurücksetzen"
description="Geben Sie Ihre E-Mail ein um ein neues Passwort per E-Mail zu erhalten."
align="bottom"
:fields="[{
name: 'email',
type: 'text',
label: 'Email',
placeholder: 'Deine E-Mail Adresse'
}]"
:loading="false"
@submit="doReset"
:submit-button="{label: 'Zurücksetzen'}"
divider="oder"
>
</UAuthForm>
<div class="mt-6 space-y-5">
<div class="space-y-1">
<h1 class="text-xl font-semibold">Passwort zurücksetzen</h1>
<p class="text-sm text-muted">
Geben Sie Ihre E-Mail ein um ein neues Passwort per E-Mail zu erhalten.
</p>
</div>
<UForm :state="state" class="space-y-4" @submit="doReset">
<UFormField label="E-Mail" name="email">
<UInput
v-model="state.email"
type="email"
class="w-full"
placeholder="Deine E-Mail Adresse"
autocomplete="email"
/>
</UFormField>
<UButton type="submit" block class="w-full" :loading="loading">
Zurücksetzen
</UButton>
</UForm>
</div>
</UCard>
</template>
</template>

View File

@@ -173,6 +173,8 @@ setupPage()
<UAlert
class="mt-5"
title="DOKUBOX"
color="neutral"
variant="outline"
>
<template #description>
<p>Die Dokubox ist eine E-Mail Inbox um deine Anhänge direkt als Dokumente zu importieren. Leite Deine E-Mails einfach an die folgende E-Mail Adresse weiter, diese ist für dein Unternehmen einzigartig. Die E-Mails werden dann alle 5 min abgerufen.</p>

View File

@@ -313,6 +313,20 @@ const truncateValue = (value, maxLength) => {
return `${stringValue.substring(0, maxLength)}...`
}
const getDistinctFilterItems = (columnKey) => {
return (itemsMeta.value?.distinctValues?.[columnKey] || []).map((value) => ({
label: String(value),
value
}))
}
const isDistinctFilterActive = (columnKey) => {
const available = itemsMeta.value?.distinctValues?.[columnKey] || []
const selected = columnsToFilter.value[columnKey] || []
return selected.length > 0 && selected.length !== available.length
}
</script>
@@ -371,7 +385,7 @@ const truncateValue = (value, maxLength) => {
v-model="pageLimit"
value-key="value"
label-key="value"
@change="setupPage"
@update:model-value="setupPage"
/>
</UTooltip>
<UPagination
@@ -400,7 +414,7 @@ const truncateValue = (value, maxLength) => {
by="key"
:color="selectedColumns.length !== dataType.templateColumns.filter(i => !i.disabledInTable).length ? 'primary' : 'white'"
:content="{ width: 'min-w-max' }"
@change="tempStore.modifyColumns(type,selectedColumns)"
@update:model-value="tempStore.modifyColumns(type, selectedColumns)"
>
<template #default>
Spalten
@@ -442,32 +456,26 @@ const truncateValue = (value, maxLength) => {
:text="!columnsToFilter[column.key]?.length > 0 ? `Keine Einträge für ${column.label} verfügbar` : `${column.label} Spalte nach Einträgen filtern`"
>
<USelectMenu
:items="(itemsMeta?.distinctValues?.[column.key] || []).map(value => ({ label: value, value }))"
class="min-w-0"
:items="getDistinctFilterItems(column.key)"
v-model="columnsToFilter[column.key]"
multiple
@change="handleFilterChange('change', column.key)"
@update:model-value="handleFilterChange('change', column.key)"
:search-input="{ placeholder: 'Suche...' }"
value-key="value"
label-key="label"
:content="{ width: 'min-w-max' }"
:disabled="getDistinctFilterItems(column.key).length === 0"
>
<template #empty>
Keine Einträge in der Spalte {{column.label}}
</template>
<template #default="slotProps">
<UButton
:disabled="!columnsToFilter[column.key]?.length > 0"
:variant="columnsToFilter[column.key]?.length !== itemsMeta.distinctValues?.[column.key]?.length ? 'outline' : 'solid'"
:color="columnsToFilter[column.key]?.length !== itemsMeta.distinctValues?.[column.key]?.length ? 'primary' : 'white'"
>
<span class="inline-flex min-w-0 items-center">
<span class="truncate">{{ column.label }}</span>
<UIcon name="i-heroicons-chevron-right-20-solid" class="w-5 h-5 transition-transform text-gray-400 dark:text-gray-500" :class="[slotProps?.open && 'transform rotate-90']" />
</UButton>
</span>
</template>
</USelectMenu>
</UTooltip>
<UButton
@@ -700,12 +708,15 @@ const truncateValue = (value, maxLength) => {
<USelectMenu
v-model="columnsToFilter[column.key]"
:options="itemsMeta?.distinctValues?.[column.key]"
:items="getDistinctFilterItems(column.key)"
multiple
searchable
:search-attributes="[column.key]"
value-key="value"
label-key="label"
:search-input="{ placeholder: `${column.label} filtern...` }"
:filter-fields="['label']"
placeholder="Auswählen"
:ui-menu="{ width: '100%' }"
:content="{ width: 'w-full' }"
class="w-full"
/>
</div>