Manuelle Buchungen in Statementallocations integrieren

This commit is contained in:
2026-04-23 16:28:44 +02:00
parent df4b591be4
commit 743bf0660c
8 changed files with 518 additions and 39 deletions

View File

@@ -0,0 +1,10 @@
ALTER TABLE "statementallocations" ALTER COLUMN "bs_id" DROP NOT NULL;
ALTER TABLE "statementallocations" ADD COLUMN "manual_booking_date" text;
ALTER TABLE "statementallocations" ADD COLUMN "contra_account" bigint;
ALTER TABLE "statementallocations" ADD COLUMN "contra_ownaccount" uuid;
ALTER TABLE "statementallocations" ADD COLUMN "contra_customer" bigint;
ALTER TABLE "statementallocations" ADD COLUMN "contra_vendor" bigint;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_account_accounts_id_fk" FOREIGN KEY ("contra_account") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_ownaccount_ownaccounts_id_fk" FOREIGN KEY ("contra_ownaccount") REFERENCES "public"."ownaccounts"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_customer_customers_id_fk" FOREIGN KEY ("contra_customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;
ALTER TABLE "statementallocations" ADD CONSTRAINT "statementallocations_contra_vendor_vendors_id_fk" FOREIGN KEY ("contra_vendor") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -211,6 +211,13 @@
"when": 1776211200000, "when": 1776211200000,
"tag": "0029_events_quick", "tag": "0029_events_quick",
"breakpoints": true "breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1776297600000,
"tag": "0030_manual_statementallocations",
"breakpoints": true
} }
] ]
} }

View File

@@ -23,9 +23,7 @@ export const statementallocations = pgTable("statementallocations", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
// foreign keys // foreign keys
bankstatement: integer("bs_id") bankstatement: integer("bs_id").references(() => bankstatements.id),
.notNull()
.references(() => bankstatements.id),
createddocument: integer("cd_id").references(() => createddocuments.id), createddocument: integer("cd_id").references(() => createddocuments.id),
@@ -43,14 +41,22 @@ export const statementallocations = pgTable("statementallocations", {
() => accounts.id () => accounts.id
), ),
contraAccount: bigint("contra_account", { mode: "number" }).references(
() => accounts.id
),
created_at: timestamp("created_at", { created_at: timestamp("created_at", {
withTimezone: false, withTimezone: false,
}).defaultNow(), }).defaultNow(),
ownaccount: uuid("ownaccount").references(() => ownaccounts.id), ownaccount: uuid("ownaccount").references(() => ownaccounts.id),
contraOwnaccount: uuid("contra_ownaccount").references(() => ownaccounts.id),
description: text("description"), description: text("description"),
manualBookingDate: text("manual_booking_date"),
bookingMode: text("booking_mode").notNull().default("expense"), bookingMode: text("booking_mode").notNull().default("expense"),
depreciationMonths: integer("depreciation_months"), depreciationMonths: integer("depreciation_months"),
depreciationStartDate: text("depreciation_start_date"), depreciationStartDate: text("depreciation_start_date"),
@@ -65,6 +71,12 @@ export const statementallocations = pgTable("statementallocations", {
vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id), vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id),
contraCustomer: bigint("contra_customer", { mode: "number" }).references(
() => customers.id
),
contraVendor: bigint("contra_vendor", { mode: "number" }).references(() => vendors.id),
updated_at: timestamp("updated_at", { withTimezone: true }), updated_at: timestamp("updated_at", { withTimezone: true }),
updated_by: uuid("updated_by").references(() => authUsers.id), updated_by: uuid("updated_by").references(() => authUsers.id),

View File

@@ -11,10 +11,12 @@ import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
import { import {
bankrequisitions, bankrequisitions,
bankstatements, bankstatements,
accounts,
createddocuments, createddocuments,
customers, customers,
entitybankaccounts, entitybankaccounts,
incominginvoices, incominginvoices,
ownaccounts,
statementallocations, statementallocations,
vendors, vendors,
} from "../../db/schema" } from "../../db/schema"
@@ -22,10 +24,57 @@ import {
import { import {
eq, eq,
and, and,
isNull,
aliasedTable,
} from "drizzle-orm" } from "drizzle-orm"
export default async function bankingRoutes(server: FastifyInstance) { export default async function bankingRoutes(server: FastifyInstance) {
const ContraAccounts = aliasedTable(accounts, "contra_accounts")
const ContraCustomers = aliasedTable(customers, "contra_customers")
const ContraVendors = aliasedTable(vendors, "contra_vendors")
const ContraOwnaccounts = aliasedTable(ownaccounts, "contra_ownaccounts")
const normalizeManualSide = (payload: any, keys: string[]) =>
keys.filter((key) => payload[key] !== null && payload[key] !== undefined && payload[key] !== "")
const prepareStatementAllocationPayload = (payload: any) => {
const next = { ...payload }
const isManualBooking = !next.bankstatement
if (!isManualBooking) {
next.manualBookingDate = null
next.contraAccount = null
next.contraCustomer = null
next.contraVendor = null
next.contraOwnaccount = null
return { data: next }
}
const debitKeys = ["account", "customer", "vendor", "ownaccount"]
const creditKeys = ["contraAccount", "contraCustomer", "contraVendor", "contraOwnaccount"]
const debitSide = normalizeManualSide(next, debitKeys)
const creditSide = normalizeManualSide(next, creditKeys)
if (!next.manualBookingDate || !dayjs(next.manualBookingDate).isValid()) {
return { error: "Für manuelle Buchungen ist ein gültiges Buchungsdatum erforderlich." }
}
if (!Number.isFinite(Number(next.amount)) || Number(next.amount) <= 0) {
return { error: "Für manuelle Buchungen muss der Betrag größer als 0 sein." }
}
if (debitSide.length !== 1 || creditSide.length !== 1) {
return { error: "Für manuelle Buchungen muss genau ein Soll- und ein Haben-Konto ausgewählt werden." }
}
next.amount = Math.abs(Number(next.amount))
next.bankstatement = null
next.manualBookingDate = dayjs(next.manualBookingDate).format("YYYY-MM-DD")
return { data: next }
}
const normalizeIban = (value?: string | null) => const normalizeIban = (value?: string | null) =>
String(value || "").replace(/\s+/g, "").toUpperCase() String(value || "").replace(/\s+/g, "").toUpperCase()
@@ -677,6 +726,56 @@ export default async function bankingRoutes(server: FastifyInstance) {
} }
}) })
// ------------------------------------------------------------------
// 📒 List Manual Statement Allocations
// ------------------------------------------------------------------
server.get("/banking/manual-bookings", async (req, reply) => {
try {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const rows = await server.db.select({
allocation: statementallocations,
account: accounts,
customer: customers,
vendor: vendors,
ownaccount: ownaccounts,
contraAccount: ContraAccounts,
contraCustomer: ContraCustomers,
contraVendor: ContraVendors,
contraOwnaccount: ContraOwnaccounts,
})
.from(statementallocations)
.leftJoin(accounts, eq(statementallocations.account, accounts.id))
.leftJoin(customers, eq(statementallocations.customer, customers.id))
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
.leftJoin(ContraAccounts, eq(statementallocations.contraAccount, ContraAccounts.id))
.leftJoin(ContraCustomers, eq(statementallocations.contraCustomer, ContraCustomers.id))
.leftJoin(ContraVendors, eq(statementallocations.contraVendor, ContraVendors.id))
.leftJoin(ContraOwnaccounts, eq(statementallocations.contraOwnaccount, ContraOwnaccounts.id))
.where(and(
eq(statementallocations.tenant, req.user.tenant_id),
eq(statementallocations.archived, false),
isNull(statementallocations.bankstatement)
))
return reply.send(rows.map((row) => ({
...row.allocation,
account: row.account,
customer: row.customer,
vendor: row.vendor,
ownaccount: row.ownaccount,
contraAccount: row.contraAccount,
contraCustomer: row.contraCustomer,
contraVendor: row.contraVendor,
contraOwnaccount: row.contraOwnaccount,
})))
} catch (err) {
console.error(err)
return reply.code(500).send({ error: "Failed to load manual bookings" })
}
})
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 💰 Create Statement Allocation // 💰 Create Statement Allocation
@@ -686,9 +785,11 @@ export default async function bankingRoutes(server: FastifyInstance) {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
const { data: payload } = req.body as { data: any } const { data: payload } = req.body as { data: any }
const prepared = prepareStatementAllocationPayload(payload)
if (prepared.error) return reply.code(400).send({ error: prepared.error })
const inserted = await server.db.insert(statementallocations).values({ const inserted = await server.db.insert(statementallocations).values({
...payload, ...prepared.data,
tenant: req.user.tenant_id tenant: req.user.tenant_id
}).returning() }).returning()
@@ -720,16 +821,18 @@ export default async function bankingRoutes(server: FastifyInstance) {
} }
} }
await insertHistoryItem(server, { if (createdRecord.bankstatement) {
entity: "bankstatements", await insertHistoryItem(server, {
entityId: Number(createdRecord.bankstatement), entity: "bankstatements",
action: "created", entityId: Number(createdRecord.bankstatement),
created_by: req.user.user_id, action: "created",
tenant_id: req.user.tenant_id, created_by: req.user.user_id,
oldVal: null, tenant_id: req.user.tenant_id,
newVal: createdRecord, oldVal: null,
text: "Buchung erstellt", newVal: createdRecord,
}) text: "Buchung erstellt",
})
}
return reply.send(createdRecord) return reply.send(createdRecord)
@@ -763,16 +866,18 @@ export default async function bankingRoutes(server: FastifyInstance) {
.delete(statementallocations) .delete(statementallocations)
.where(eq(statementallocations.id, id)) .where(eq(statementallocations.id, id))
await insertHistoryItem(server, { if (old.bankstatement) {
entity: "bankstatements", await insertHistoryItem(server, {
entityId: Number(old.bankstatement), entity: "bankstatements",
action: "deleted", entityId: Number(old.bankstatement),
created_by: req.user.user_id, action: "deleted",
tenant_id: req.user.tenant_id, created_by: req.user.user_id,
oldVal: old, tenant_id: req.user.tenant_id,
newVal: null, oldVal: old,
text: "Buchung gelöscht", newVal: null,
}) text: "Buchung gelöscht",
})
}
return reply.send({ success: true }) return reply.send({ success: true })

View File

@@ -8,7 +8,7 @@ import { s3 } from "../s3";
import { secrets } from "../secrets"; import { secrets } from "../secrets";
// Drizzle Core Imports // Drizzle Core Imports
import { eq, and, inArray, gte, lte, asc, aliasedTable } from "drizzle-orm"; import { eq, and, or, isNull, inArray, gte, lte, asc, aliasedTable } from "drizzle-orm";
// Tabellen Imports (keine Relations nötig!) // Tabellen Imports (keine Relations nötig!)
import { import {
@@ -136,6 +136,10 @@ export async function buildExportZip(
const CdCustomer = aliasedTable(customers, "cd_customer"); const CdCustomer = aliasedTable(customers, "cd_customer");
const IiVendor = aliasedTable(vendors, "ii_vendor"); const IiVendor = aliasedTable(vendors, "ii_vendor");
const ContraAccount = aliasedTable(accounts, "contra_account");
const ContraVendor = aliasedTable(vendors, "contra_vendor");
const ContraCustomer = aliasedTable(customers, "contra_customer");
const ContraOwnaccount = aliasedTable(ownaccounts, "contra_ownaccount");
const allocRaw = await server.db.select({ const allocRaw = await server.db.select({
allocation: statementallocations, allocation: statementallocations,
@@ -148,11 +152,15 @@ export async function buildExportZip(
acc: accounts, acc: accounts,
direct_vend: vendors, // Direkte Zuordnung an Kreditor direct_vend: vendors, // Direkte Zuordnung an Kreditor
direct_cust: customers, // Direkte Zuordnung an Debitor direct_cust: customers, // Direkte Zuordnung an Debitor
own: ownaccounts own: ownaccounts,
contra_acc: ContraAccount,
contra_vend: ContraVendor,
contra_cust: ContraCustomer,
contra_own: ContraOwnaccount
}) })
.from(statementallocations) .from(statementallocations)
// JOIN 1: Bankstatement (Pflicht, für Datum Filter) // JOIN 1: Bankstatement (optional, manuelle Buchungen haben keinen Bankumsatz)
.innerJoin(bankstatements, eq(statementallocations.bankstatement, bankstatements.id)) .leftJoin(bankstatements, eq(statementallocations.bankstatement, bankstatements.id))
// JOIN 2: Bankaccount (für DATEV Nummer) // JOIN 2: Bankaccount (für DATEV Nummer)
.leftJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id)) .leftJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id))
@@ -169,13 +177,25 @@ export async function buildExportZip(
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id)) .leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
.leftJoin(customers, eq(statementallocations.customer, customers.id)) .leftJoin(customers, eq(statementallocations.customer, customers.id))
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id)) .leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
.leftJoin(ContraAccount, eq(statementallocations.contraAccount, ContraAccount.id))
.leftJoin(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.id))
.leftJoin(ContraCustomer, eq(statementallocations.contraCustomer, ContraCustomer.id))
.leftJoin(ContraOwnaccount, eq(statementallocations.contraOwnaccount, ContraOwnaccount.id))
.where(and( .where(and(
eq(statementallocations.tenant, tenantId), eq(statementallocations.tenant, tenantId),
eq(statementallocations.archived, false), eq(statementallocations.archived, false),
// Datum Filter direkt auf dem Bankstatement or(
gte(bankstatements.date, startDate), and(
lte(bankstatements.date, endDate) gte(bankstatements.date, startDate),
lte(bankstatements.date, endDate)
),
and(
isNull(statementallocations.bankstatement),
gte(statementallocations.manualBookingDate, startDate),
lte(statementallocations.manualBookingDate, endDate)
)
)
)); ));
// Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet // Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet
@@ -196,7 +216,11 @@ export async function buildExportZip(
account: r.acc, account: r.acc,
vendor: r.direct_vend, vendor: r.direct_vend,
customer: r.direct_cust, customer: r.direct_cust,
ownaccount: r.own ownaccount: r.own,
contraAccount: r.contra_acc,
contraVendor: r.contra_vend,
contraCustomer: r.contra_cust,
contraOwnaccount: r.contra_own
})); }));
// --- D) Stammdaten Accounts --- // --- D) Stammdaten Accounts ---
@@ -311,8 +335,32 @@ export async function buildExportZip(
}); });
// Bank // Bank
const getManualBookingSide = (alloc: any, side: "debit" | "credit") => {
const prefix = side === "credit" ? "contra" : "";
const account = side === "credit" ? alloc.contraAccount : alloc.account;
const vendor = side === "credit" ? alloc.contraVendor : alloc.vendor;
const customer = side === "credit" ? alloc.contraCustomer : alloc.customer;
const ownaccount = side === "credit" ? alloc.contraOwnaccount : alloc.ownaccount;
if (account) return { number: account.number, name: account.label, type: "Sachkonto" };
if (vendor) return { number: vendor.vendorNumber, name: vendor.name, type: "Kreditor" };
if (customer) return { number: customer.customerNumber, name: customer.name, type: "Debitor" };
if (ownaccount) return { number: ownaccount.number, name: ownaccount.name, type: "Eigenes Konto" };
return { number: "", name: "", type: prefix };
};
statementallocationsList.forEach(alloc => { statementallocationsList.forEach(alloc => {
const bs = alloc.bankstatement; // durch Mapping verfügbar const bs = alloc.bankstatement; // durch Mapping verfügbar
if(!bs && alloc.manualBookingDate) {
const debit = getManualBookingSide(alloc, "debit");
const credit = getManualBookingSide(alloc, "credit");
const dateManual = dayjs(alloc.manualBookingDate).format("DDMM");
const dateManualFull = dayjs(alloc.manualBookingDate).format("DD.MM.YYYY");
bookingLines.push(`${displayCurrency(alloc.amount,true)};"S";;;;;${debit.number};${credit.number};"";${dateManual};"";;;"${`MB ${debit.number} an ${credit.number} ${escapeString(alloc.description)}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${escapeString(debit.name || credit.name)}";"Kundennummer";"${debit.number}";"Belegnummer";"";"Leistungsdatum";"${dateManualFull}";"Belegdatum";"${dateManualFull}";;;;;;;;;;"";;;;;;;;Manuelle-Buchung;${alloc.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
return;
}
if(!bs) return; if(!bs) return;
let shSelector = Math.sign(alloc.amount) === -1 ? "H" : "S"; let shSelector = Math.sign(alloc.amount) === -1 ? "H" : "S";
@@ -425,4 +473,4 @@ export async function buildExportZip(
console.error("DATEV Export Error:", error); console.error("DATEV Export Error:", error);
throw error; throw error;
} }
} }

View File

@@ -35,11 +35,13 @@ const getStatementLike = (allocation) => allocation?.bankstatement || (typeof al
const getAllocationDate = (allocation) => { const getAllocationDate = (allocation) => {
const statement = getStatementLike(allocation) const statement = getStatementLike(allocation)
return statement?.date || statement?.valueDate || allocation?.bs_id?.date || allocation?.bs_id?.valueDate || null return statement?.date || statement?.valueDate || allocation?.bs_id?.date || allocation?.bs_id?.valueDate || allocation?.manualBookingDate || null
} }
const getAllocationPartner = (allocation) => { const getAllocationPartner = (allocation) => {
const statement = getStatementLike(allocation) const statement = getStatementLike(allocation)
if (!statement && allocation?.manualBookingDate) return "Manuelle Buchung"
return statement?.debName || statement?.credName || statement?.partner || allocation?.bs_id?.debName || allocation?.bs_id?.credName || "" return statement?.debName || statement?.credName || statement?.partner || allocation?.bs_id?.debName || allocation?.bs_id?.credName || ""
} }
const getAllocationDescription = (allocation) => { const getAllocationDescription = (allocation) => {
@@ -48,12 +50,20 @@ const getAllocationDescription = (allocation) => {
return allocation?.description || statement?.purpose || statement?.text || statement?.description || allocation?.bs_id?.purpose || allocation?.bs_id?.text || "" return allocation?.description || statement?.purpose || statement?.text || statement?.description || allocation?.bs_id?.purpose || allocation?.bs_id?.text || ""
} }
const hasContent = (value) => value !== null && value !== undefined && String(value).trim() !== "" const hasContent = (value) => value !== null && value !== undefined && String(value).trim() !== ""
const isContraAllocation = (allocation) =>
sameAccount(allocation.contraAccount?.id || allocation.contraAccount)
|| sameAccount(allocation.contraOwnaccount?.id || allocation.contraOwnaccount)
const touchesCurrentAccount = (allocation) =>
sameAccount(allocation.account?.id || allocation.account)
|| sameAccount(allocation.ownaccount?.id || allocation.ownaccount)
|| isContraAllocation(allocation)
const monthItems = [ const monthItems = [
{ label: "Ganzes Jahr", value: "all" }, { label: "Ganzes Jahr", value: "all" },
{ label: "Januar", value: "1" }, { label: "Januar", value: "1" },
{ label: "Februar", value: "2" }, { label: "Februar", value: "2" },
{ label: "Maerz", value: "3" }, { label: "März", value: "3" },
{ label: "April", value: "4" }, { label: "April", value: "4" },
{ label: "Mai", value: "5" }, { label: "Mai", value: "5" },
{ label: "Juni", value: "6" }, { label: "Juni", value: "6" },
@@ -73,7 +83,7 @@ const allAllocations = computed(() => {
date: getAllocationDate(allocation), date: getAllocationDate(allocation),
partner: getAllocationPartner(allocation), partner: getAllocationPartner(allocation),
description: getAllocationDescription(allocation), description: getAllocationDescription(allocation),
amount: Number(allocation.amount || 0) amount: Number(allocation.amount || 0) * (isContraAllocation(allocation) ? -1 : 1)
})) }))
const incomingInvoiceRows = incomingInvoices.value.flatMap((invoice) => { const incomingInvoiceRows = incomingInvoices.value.flatMap((invoice) => {
@@ -162,7 +172,7 @@ const setup = async () => {
loading.value = true loading.value = true
statementAllocations.value = (await useEntities("statementallocations").select("*, bankstatement(*), createddocument(*), incominginvoice(*)")) statementAllocations.value = (await useEntities("statementallocations").select("*, bankstatement(*), createddocument(*), incominginvoice(*)"))
.filter((allocation) => sameAccount(allocation.account?.id || allocation.account) || sameAccount(allocation.ownaccount?.id || allocation.ownaccount)) .filter((allocation) => touchesCurrentAccount(allocation))
incomingInvoices.value = (await useEntities("incominginvoices").select("*, vendor(*)")) incomingInvoices.value = (await useEntities("incominginvoices").select("*, vendor(*)"))
.filter((invoice) => (invoice.accounts || []).some((account) => sameAccount(account.account?.id || account.account))) .filter((invoice) => (invoice.accounts || []).some((account) => sameAccount(account.account?.id || account.account)))
@@ -248,7 +258,7 @@ const selectAllocation = (allocationLike) => {
<template #empty> <template #empty>
<div class="flex flex-col items-center justify-center py-10 text-center"> <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" /> <UIcon name="i-heroicons-circle-stack-20-solid" class="mb-2 h-10 w-10 text-gray-400" />
<p class="font-medium">Keine Buchungen im ausgewaehlten Zeitraum</p> <p class="font-medium">Keine Buchungen im ausgewählten Zeitraum</p>
</div> </div>
</template> </template>
@@ -259,7 +269,7 @@ const selectAllocation = (allocationLike) => {
</template> </template>
<template #date-cell="{ row }"> <template #date-cell="{ row }">
{{ row.original.bankstatement.date && dayjs(row.original.bankstatement.date).isValid() ? dayjs(row.original.bankstatement.date).format('DD.MM.YYYY') : '-' }} {{ row.original.date && dayjs(row.original.date).isValid() ? dayjs(row.original.date).format('DD.MM.YYYY') : '-' }}
</template> </template>
<template #partner-cell="{ row }"> <template #partner-cell="{ row }">

View File

@@ -156,6 +156,11 @@ const links = computed(() => {
to: "/accounting/bwa", to: "/accounting/bwa",
icon: "i-heroicons-chart-bar-square", icon: "i-heroicons-chart-bar-square",
} : null, } : null,
(featureEnabled("accounts") || featureEnabled("ownaccounts")) ? {
label: "Manuelle Buchungen",
to: "/accounting/manual-bookings",
icon: "i-heroicons-arrows-right-left",
} : null,
featureEnabled("banking") ? { featureEnabled("banking") ? {
label: "Liquidität", label: "Liquidität",
to: "/accounting/liquidity", to: "/accounting/liquidity",

View File

@@ -0,0 +1,282 @@
<script setup>
import dayjs from "dayjs"
const toast = useToast()
const loading = ref(true)
const saving = ref(false)
const accounts = ref([])
const customers = ref([])
const vendors = ref([])
const ownaccounts = ref([])
const bookings = ref([])
const form = reactive({
manualBookingDate: dayjs().format("YYYY-MM-DD"),
amount: null,
debit: "",
credit: "",
description: ""
})
const entryOptions = computed(() => [
...accounts.value.map((item) => ({
key: `account:${item.id}`,
label: `${item.number} - ${item.label}`,
number: item.number,
name: item.label,
type: "Sachkonto"
})),
...vendors.value.map((item) => ({
key: `vendor:${item.id}`,
label: `${item.vendorNumber || "ohne Nr."} - ${item.name}`,
number: item.vendorNumber,
name: item.name,
type: "Kreditor"
})),
...customers.value.map((item) => ({
key: `customer:${item.id}`,
label: `${item.customerNumber || "ohne Nr."} - ${item.name}`,
number: item.customerNumber,
name: item.name,
type: "Debitor"
})),
...ownaccounts.value.map((item) => ({
key: `ownaccount:${item.id}`,
label: `${item.number} - ${item.name}`,
number: item.number,
name: item.name,
type: "Zusätzliches Konto"
}))
])
const selectedDebit = computed(() => entryOptions.value.find((item) => item.key === form.debit))
const selectedCredit = computed(() => entryOptions.value.find((item) => item.key === form.credit))
const displayCurrency = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")}`
const getBookingSide = (booking, side) => {
const map = side === "credit"
? [
["contraAccount", "Sachkonto"],
["contraVendor", "Kreditor"],
["contraCustomer", "Debitor"],
["contraOwnaccount", "Zusätzliches Konto"]
]
: [
["account", "Sachkonto"],
["vendor", "Kreditor"],
["customer", "Debitor"],
["ownaccount", "Zusätzliches Konto"]
]
for (const [key, type] of map) {
const item = booking[key]
if (!item) continue
return {
type,
number: item.number || item.vendorNumber || item.customerNumber || "",
name: item.label || item.name || ""
}
}
return { type: "", number: "", name: "" }
}
const buildSidePayload = (sideKey, target) => {
const [type, id] = String(sideKey || "").split(":")
if (!type || !id) return
const numericId = type === "ownaccount" ? id : Number(id)
if (target === "debit") {
if (type === "account") return { account: numericId }
if (type === "customer") return { customer: numericId }
if (type === "vendor") return { vendor: numericId }
if (type === "ownaccount") return { ownaccount: numericId }
}
if (type === "account") return { contraAccount: numericId }
if (type === "customer") return { contraCustomer: numericId }
if (type === "vendor") return { contraVendor: numericId }
if (type === "ownaccount") return { contraOwnaccount: numericId }
}
const loadData = async () => {
loading.value = true
const [accountRows, customerRows, vendorRows, ownaccountRows, bookingRows] = await Promise.all([
useEntities("accounts").selectSpecial("*", "number", true),
useEntities("customers").select(),
useEntities("vendors").select(),
useEntities("ownaccounts").select(),
useNuxtApp().$api("/api/banking/manual-bookings")
])
accounts.value = accountRows || []
customers.value = customerRows || []
vendors.value = vendorRows || []
ownaccounts.value = ownaccountRows || []
bookings.value = (bookingRows || []).sort((a, b) => String(b.manualBookingDate || "").localeCompare(String(a.manualBookingDate || "")))
loading.value = false
}
const resetForm = () => {
form.amount = null
form.debit = ""
form.credit = ""
form.description = ""
}
const saveBooking = async () => {
if (!form.manualBookingDate || !form.amount || !form.debit || !form.credit) {
toast.add({ title: "Bitte Datum, Betrag, Soll und Haben ausfüllen.", color: "warning" })
return
}
saving.value = true
const payload = {
manualBookingDate: form.manualBookingDate,
amount: Number(form.amount),
description: form.description || "Manuelle Buchung",
...buildSidePayload(form.debit, "debit"),
...buildSidePayload(form.credit, "credit")
}
await useNuxtApp().$api("/api/banking/statements", {
method: "POST",
body: { data: payload }
})
toast.add({ title: "Manuelle Buchung erstellt." })
resetForm()
await loadData()
saving.value = false
}
const deleteBooking = async (booking) => {
await useNuxtApp().$api(`/api/banking/statements/${booking.id}`, { method: "DELETE" })
toast.add({ title: "Manuelle Buchung gelöscht." })
await loadData()
}
onMounted(loadData)
</script>
<template>
<UDashboardPanelContent>
<UDashboardNavbar title="Manuelle Buchungen" :badge="bookings.length" />
<div class="grid gap-6 lg:grid-cols-[420px_1fr]">
<UCard>
<template #header>
<div>
<h2 class="font-semibold text-gray-900 dark:text-white">Neue Soll/Haben-Buchung</h2>
<p class="text-sm text-gray-500">Zum Beispiel: Kreditor 70000 an Konto 2742.</p>
</div>
</template>
<div class="space-y-4">
<UFormField label="Buchungsdatum">
<UInput v-model="form.manualBookingDate" type="date" />
</UFormField>
<UFormField label="Betrag">
<UInput v-model="form.amount" type="number" min="0" step="0.01" placeholder="0,00" />
</UFormField>
<UFormField label="Soll">
<USelectMenu
v-model="form.debit"
:items="entryOptions"
value-key="key"
label-key="label"
:search-input="{ placeholder: 'Sachkonto, Debitor oder Kreditor suchen...' }"
placeholder="Soll-Konto auswählen"
>
<template #item-label="{ item }">
<span class="font-mono text-xs text-gray-500 mr-2">{{ item.number }}</span>
{{ item.name }}
<UBadge size="xs" variant="subtle" class="ml-2">{{ item.type }}</UBadge>
</template>
</USelectMenu>
</UFormField>
<UFormField label="Haben">
<USelectMenu
v-model="form.credit"
:items="entryOptions"
value-key="key"
label-key="label"
:search-input="{ placeholder: 'Sachkonto, Debitor oder Kreditor suchen...' }"
placeholder="Haben-Konto auswählen"
>
<template #item-label="{ item }">
<span class="font-mono text-xs text-gray-500 mr-2">{{ item.number }}</span>
{{ item.name }}
<UBadge size="xs" variant="subtle" class="ml-2">{{ item.type }}</UBadge>
</template>
</USelectMenu>
</UFormField>
<UAlert
v-if="selectedDebit && selectedCredit"
color="primary"
variant="soft"
icon="i-heroicons-arrows-right-left"
:title="`${selectedDebit.number} an ${selectedCredit.number}`"
:description="`${selectedDebit.name} wird im Soll, ${selectedCredit.name} im Haben gebucht.`"
/>
<UFormField label="Beschreibung">
<UTextarea v-model="form.description" placeholder="z. B. Versicherungsentschädigung" autoresize />
</UFormField>
<UButton block color="primary" :loading="saving" @click="saveBooking">
Manuelle Buchung erstellen
</UButton>
</div>
</UCard>
<UCard>
<template #header>
<div>
<h2 class="font-semibold text-gray-900 dark:text-white">Erfasste manuelle Buchungen</h2>
<p class="text-sm text-gray-500">Diese Buchungen laufen im DATEV-Export mit.</p>
</div>
</template>
<div v-if="loading" class="py-10 text-center text-gray-500">Lade Buchungen...</div>
<div v-else-if="bookings.length === 0" class="py-10 text-center text-gray-500">
Noch keine manuellen Buchungen erfasst.
</div>
<div v-else class="divide-y divide-gray-100 dark:divide-gray-800">
<div v-for="booking in bookings" :key="booking.id" class="py-4 flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="font-medium">{{ dayjs(booking.manualBookingDate).format("DD.MM.YYYY") }}</span>
<span class="font-mono font-semibold">{{ displayCurrency(booking.amount) }}</span>
</div>
<div class="mt-1 text-sm text-gray-700 dark:text-gray-200">
<span class="font-semibold">Soll:</span>
{{ getBookingSide(booking, "debit").number }} - {{ getBookingSide(booking, "debit").name }}
</div>
<div class="text-sm text-gray-700 dark:text-gray-200">
<span class="font-semibold">Haben:</span>
{{ getBookingSide(booking, "credit").number }} - {{ getBookingSide(booking, "credit").name }}
</div>
<div v-if="booking.description" class="mt-1 text-xs text-gray-500 truncate">
{{ booking.description }}
</div>
</div>
<UButton
icon="i-heroicons-trash"
color="error"
variant="ghost"
size="sm"
@click="deleteBooking(booking)"
/>
</div>
</div>
</UCard>
</div>
</UDashboardPanelContent>
</template>