Compare commits

..

30 Commits

Author SHA1 Message Date
a021d3d15c Time Changes
Some checks failed
Build and Push Docker Images / verify-docs-sync (push) Failing after 9s
Build and Push Docker Images / build-backend (push) Has been skipped
Build and Push Docker Images / build-frontend (push) Has been skipped
Build and Push Docker Images / build-docs (push) Has been skipped
2026-04-27 09:17:36 +02:00
bb61caed6d Unterkostenstellen in der Kostenstellenauswertung korrekt auflösen 2026-04-24 23:03:23 +02:00
d3ab03da7e Kostenstellenhierarchie und Auswertung mit Unterkostenstellen ergänzt 2026-04-24 22:56:33 +02:00
5869f88c1a Vereinheitliche Empty States in Tabellen 2026-04-24 14:42:53 +02:00
50c76b67c7 Serienvorlagen in Liquiditätsprognose einplanen 2026-04-23 22:15:50 +02:00
f4edcc2d44 Rechnungsentwürfe optional wie Rechnungen verwenden 2026-04-23 21:49:45 +02:00
35ef3a7cf8 USt-Auswertung in Liquiditätsprognose integrieren 2026-04-23 21:44:59 +02:00
4783971000 Rechnungsentwürfe optional in Prognose anzeigen
Zeigt Rechnungsentwürfe eingeklammert als optionalen Einfluss in der Liquiditätsprognose an, ohne den eigentlichen Endstand zu verändern.
2026-04-23 21:29:14 +02:00
c085b1e4d5 Liquiditätsprognose besser nachvollziehbar machen
Ergänzt Herleitung, Einflussfaktoren, größte Treiber und eine Tagesaufschlüsselung, damit die Prognosewerte Schritt für Schritt nachvollzogen werden können.
2026-04-23 21:21:15 +02:00
46b08b29b9 Regelmäßige Bewegungen auf Ausgaben begrenzen
Berücksichtigt in der Liquiditätsprognose nur noch negative, also ausgehende, wiederkehrende Bankbewegungen aus Heuristik und KI-Erkennung.
2026-04-23 21:18:11 +02:00
5cc41f9a2d Manuelle Buchungen in Liquiditätsprognose korrekt verrechnen
Berechnet offene Eingangsbelege jetzt mit Absolutbeträgen, damit manuelle Buchungen und Bankzuordnungen unabhängig vom gespeicherten Vorzeichen korrekt in der Prognose erscheinen.
2026-04-23 21:13:04 +02:00
edec670ee0 Eingangsbelege um offenen Betrag ergänzen 2026-04-23 21:06:44 +02:00
41e5a4021b Manuelle Buchungen um zuweisbare Eingangsbelege erweitern 2026-04-23 17:52:56 +02:00
9c608cbf71 Manuelle Buchungen im Menü hinter Bank verschieben 2026-04-23 17:39:22 +02:00
543952dbf8 Manuelle Buchungen Select ohne Leerwert absichern 2026-04-23 17:36:32 +02:00
2f7819e309 Manuelle Buchungen um funktionsfähigen Steuerschlüssel und volle Beschreibungsbreite korrigieren 2026-04-23 17:35:32 +02:00
7799cbce80 Manuelle Buchungen um volle Beschreibungsbreite und einklappbare Kontenlisten ergänzen 2026-04-23 17:33:35 +02:00
0284ea8726 Manuelle Buchungen um DATEV-Steuerschlüssel und getrennte Soll/Haben-Auswahl erweitern 2026-04-23 17:24:58 +02:00
743bf0660c Manuelle Buchungen in Statementallocations integrieren 2026-04-23 16:28:44 +02:00
df4b591be4 Liquiditätsprognose zwischenspeichern
Lädt die Liquiditätsprognose aus einem lokalen Cache und erstellt sie nur noch manuell über den Refresh-Button in der Toolbar neu.
2026-04-23 16:15:04 +02:00
86e0743cbb Bezahlte Eingangsbelege aus Liquiditätsprognose entfernen
Berücksichtigt das Bezahlt-Kennzeichen und korrigiert die Verrechnung von Bankzuordnungen bei offenen Eingangsbelegen.
2026-04-23 16:06:57 +02:00
aaf91ea15e Belege und Bankmuster in Liquiditätsprognose verwalten
Ermöglicht das Öffnen von Belegen aus der Liquiditätsprognose und das Abschließen erkannter regelmäßiger Bankbewegungen, die anschließend aus der Prognose herausgerechnet werden.
2026-04-23 16:03:37 +02:00
cb71e9d294 Archivierte Belege aus Liquiditätsprognose ausschließen
Sichert die Liquiditätsprognose zusätzlich gegen archivierte Ausgangs- und Eingangsbelege sowie deren Zuordnungen ab.
2026-04-23 15:56:46 +02:00
75148b2718 Liquiditätsprognose für Auswertungen ergänzt
Erstellt einen KI-gestützten Backend-Endpunkt für die Liquiditätsprognose, ergänzt die Auswertungsseite mit Verlauf, offenen Belegen und regelmäßigen Bankbewegungen und verlinkt die Funktion in der Navigation.
2026-04-23 15:52:41 +02:00
81b4eee1e8 Vereinfache Bedienungsdoku und ergänze Bankportal-Anleitung 2026-04-22 19:43:37 +02:00
0fbda27609 Verschlanke Dokumentation auf Bedienung und leite Einstieg direkt dorthin 2026-04-22 19:27:17 +02:00
3562d55a12 Richte Nutzerdoku auf Bedienungsanleitung mit Frontend-Seitenkategorie aus 2026-04-22 19:21:32 +02:00
6224a25c38 Gestalte Docs-Landing und Header im Nuxt-UI-Template-Stil 2026-04-22 19:13:11 +02:00
63b1c563c1 Stelle docs-site auf Nuxt-UI-Docs-Template-Stil um 2026-04-22 19:07:15 +02:00
76f86e87c1 Behebe 404 im Nuxt-Devserver durch _path-basierte Content-Abfrage 2026-04-22 18:59:11 +02:00
89 changed files with 20003 additions and 1412 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

@@ -0,0 +1 @@
ALTER TABLE "statementallocations" ADD COLUMN "datev_tax_key" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "statementallocations" ADD COLUMN "manual_invoice_side" text;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "costcentres" ADD COLUMN "parent_costcentre" uuid;
ALTER TABLE "costcentres" ADD CONSTRAINT "costcentres_parent_costcentre_costcentres_id_fk" FOREIGN KEY ("parent_costcentre") REFERENCES "public"."costcentres"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -211,6 +211,34 @@
"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
},
{
"idx": 31,
"version": "7",
"when": 1776298200000,
"tag": "0031_manual_statementallocations_tax_key",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1776298800000,
"tag": "0032_manual_statementallocations_invoice_side",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1777003200000,
"tag": "0033_costcentres_parent",
"breakpoints": true
} }
] ]
} }

View File

@@ -29,6 +29,8 @@ export const costcentres = pgTable("costcentres", {
number: text("number").notNull(), number: text("number").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
parentCostcentre: uuid("parent_costcentre").references(() => costcentres.id),
vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.id), vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.id),
project: bigint("project", { mode: "number" }).references(() => projects.id), project: bigint("project", { mode: "number" }).references(() => projects.id),

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),
@@ -34,6 +32,7 @@ export const statementallocations = pgTable("statementallocations", {
incominginvoice: bigint("ii_id", { mode: "number" }).references( incominginvoice: bigint("ii_id", { mode: "number" }).references(
() => incominginvoices.id () => incominginvoices.id
), ),
manualInvoiceSide: text("manual_invoice_side"),
tenant: bigint("tenant", { mode: "number" }) tenant: bigint("tenant", { mode: "number" })
.notNull() .notNull()
@@ -43,14 +42,23 @@ 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"),
datevTaxKey: text("datev_tax_key"),
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 +73,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

@@ -9,12 +9,15 @@ export type DerivedSpan = {
sourceEventIds: string[]; sourceEventIds: string[];
status: SpanStatus; status: SpanStatus;
statusActorId?: string; statusActorId?: string;
payload?: Record<string, any> | null;
description?: string;
}; };
type TimeEvent = { type TimeEvent = {
id: string; id: string;
eventtype: string; eventtype: string;
eventtime: Date; eventtime: Date;
payload?: Record<string, any> | null;
}; };
// Liste aller faktischen Event-Typen, die die Zustandsmaschine beeinflussen // Liste aller faktischen Event-Typen, die die Zustandsmaschine beeinflussen
@@ -45,9 +48,17 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
let currentStart: Date | null = null; let currentStart: Date | null = null;
let currentType: DerivedSpan["type"] | null = null; let currentType: DerivedSpan["type"] | null = null;
let sourceEventIds: string[] = []; let sourceEventIds: string[] = [];
let currentPayload: Record<string, any> | null = null;
const closeSpan = (end: Date) => { const closeSpan = (end: Date) => {
if (!currentStart || !currentType) return; if (!currentStart || !currentType) return;
if (end.getTime() <= currentStart.getTime()) {
currentStart = null;
currentType = null;
sourceEventIds = [];
currentPayload = null;
return;
}
spans.push({ spans.push({
type: currentType, type: currentType,
@@ -55,12 +66,15 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
endedAt: end, endedAt: end,
sourceEventIds: [...sourceEventIds], sourceEventIds: [...sourceEventIds],
// Standardstatus ist "factual", wird später angereichert // Standardstatus ist "factual", wird später angereichert
status: "factual" status: "factual",
payload: currentPayload,
description: currentPayload?.description || ""
}); });
currentStart = null; currentStart = null;
currentType = null; currentType = null;
sourceEventIds = []; sourceEventIds = [];
currentPayload = null;
}; };
const closeOpenSpanAsRunning = () => { const closeOpenSpanAsRunning = () => {
@@ -72,12 +86,15 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
endedAt: null, endedAt: null,
sourceEventIds: [...sourceEventIds], sourceEventIds: [...sourceEventIds],
// Standardstatus ist "factual", wird später angereichert // Standardstatus ist "factual", wird später angereichert
status: "factual" status: "factual",
payload: currentPayload,
description: currentPayload?.description || ""
}); });
currentStart = null; currentStart = null;
currentType = null; currentType = null;
sourceEventIds = []; sourceEventIds = [];
currentPayload = null;
}; };
for (const event of events) { for (const event of events) {
@@ -96,6 +113,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
state = "WORKING"; state = "WORKING";
currentStart = event.eventtime; currentStart = event.eventtime;
currentType = "work"; currentType = "work";
currentPayload = event.payload || null;
break; break;
case "pause_start": case "pause_start":
@@ -104,6 +122,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
state = "PAUSED"; state = "PAUSED";
currentStart = event.eventtime; currentStart = event.eventtime;
currentType = "pause"; currentType = "pause";
currentPayload = event.payload || null;
} }
break; break;
@@ -113,6 +132,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
state = "WORKING"; state = "WORKING";
currentStart = event.eventtime; currentStart = event.eventtime;
currentType = "work"; currentType = "work";
currentPayload = event.payload || null;
} }
break; break;
@@ -140,6 +160,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
state = "ABSENT"; state = "ABSENT";
currentStart = event.eventtime; currentStart = event.eventtime;
currentType = newType; currentType = newType;
currentPayload = event.payload || null;
break; break;
case "vacation_end": case "vacation_end":

View File

@@ -1,7 +1,7 @@
// src/services/loadValidEvents.ts // src/services/loadValidEvents.ts
import { stafftimeevents } from "../../../db/schema"; import { stafftimeevents } from "../../../db/schema";
import {sql, and, eq, gte, lte, inArray} from "drizzle-orm"; import {sql, and, eq, gte, lte, inArray, asc} from "drizzle-orm";
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
export type TimeType = "work_start" | "work_end" | "pause_start" | "pause_end" | "sick_start" | "sick_end" | "vacation_start" | "vacation_end" | "ovetime_compensation_start" | "overtime_compensation_end"; export type TimeType = "work_start" | "work_end" | "pause_start" | "pause_end" | "sick_start" | "sick_end" | "vacation_start" | "vacation_end" | "ovetime_compensation_start" | "overtime_compensation_end";
@@ -12,11 +12,43 @@ export type TimeEvent = {
id: string; id: string;
eventtype: string; eventtype: string;
eventtime: Date; eventtime: Date;
actoruser_id: string; actoruser_id?: string;
related_event_id: string | null; related_event_id: string | null;
// Fügen Sie hier alle weiteren Felder hinzu, die Sie benötigen payload?: Record<string, any> | null;
created_at?: Date | null;
}; };
const EVENT_TYPE_ORDER: Record<string, number> = {
auto_stop: 10,
work_end: 10,
pause_end: 10,
vacation_end: 10,
sick_end: 10,
overtime_compensation_end: 10,
work_start: 20,
pause_start: 20,
vacation_start: 20,
sick_start: 20,
overtime_compensation_start: 20,
submitted: 30,
approved: 30,
rejected: 30,
invalidated: 40,
};
export function compareTimeEvents(a: TimeEvent, b: TimeEvent) {
const eventTimeDiff = a.eventtime.getTime() - b.eventtime.getTime();
if (eventTimeDiff !== 0) return eventTimeDiff;
const typeOrderDiff = (EVENT_TYPE_ORDER[a.eventtype] ?? 999) - (EVENT_TYPE_ORDER[b.eventtype] ?? 999);
if (typeOrderDiff !== 0) return typeOrderDiff;
const createdAtDiff = (a.created_at?.getTime() ?? 0) - (b.created_at?.getTime() ?? 0);
if (createdAtDiff !== 0) return createdAtDiff;
return a.id.localeCompare(b.id);
}
export async function loadValidEvents( export async function loadValidEvents(
server: FastifyInstance, server: FastifyInstance,
tenantId: number, tenantId: number,
@@ -62,10 +94,9 @@ export async function loadValidEvents(
) )
) )
.orderBy( .orderBy(
// Wichtig für die deriveTimeSpans Logik: Event-Zeit muss primär sein asc(baseEvents.eventtime),
baseEvents.eventtime, asc(baseEvents.created_at),
baseEvents.created_at, // Wenn Eventtime identisch ist, das später erstellte zuerst asc(baseEvents.id)
baseEvents.id
); );
// Mapping auf den sauberen TimeEvent Typ // Mapping auf den sauberen TimeEvent Typ
@@ -73,8 +104,10 @@ export async function loadValidEvents(
id: e.id, id: e.id,
eventtype: e.eventtype, eventtype: e.eventtype,
eventtime: e.eventtime, eventtime: e.eventtime,
// Fügen Sie hier alle weiteren benötigten Felder hinzu (z.B. metadata, actoruser_id) actoruser_id: e.actoruser_id,
// ... related_event_id: e.related_event_id,
payload: e.payload,
created_at: e.created_at,
})) as TimeEvent[]; })) as TimeEvent[];
} }
@@ -99,7 +132,11 @@ export async function loadRelatedAdminEvents(server, eventIds) {
) )
) )
// Muss nach Zeit sortiert sein, um den Status korrekt zu bestimmen! // Muss nach Zeit sortiert sein, um den Status korrekt zu bestimmen!
.orderBy(stafftimeevents.eventtime); .orderBy(
asc(stafftimeevents.eventtime),
asc(stafftimeevents.created_at),
asc(stafftimeevents.id)
);
return adminEvents; return adminEvents as TimeEvent[];
} }

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,71 @@ 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 ManualInvoices = aliasedTable(incominginvoices, "manual_invoices")
const ManualInvoiceVendors = aliasedTable(vendors, "manual_invoice_vendors")
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
next.datevTaxKey = next.datevTaxKey ? String(next.datevTaxKey).trim() : null
next.manualInvoiceSide = null
return { data: next }
}
const debitKeys = ["account", "customer", "vendor", "ownaccount"]
const creditKeys = ["contraAccount", "contraCustomer", "contraVendor", "contraOwnaccount"]
const hasManualInvoice = next.incominginvoice !== null && next.incominginvoice !== undefined && next.incominginvoice !== ""
const debitSide = normalizeManualSide(next, debitKeys)
const creditSide = normalizeManualSide(next, creditKeys)
if (hasManualInvoice) {
if (next.manualInvoiceSide === "debit") debitSide.push("incominginvoice")
else if (next.manualInvoiceSide === "credit") creditSide.push("incominginvoice")
else return { error: "Für zugewiesene Eingangsbelege muss Soll oder Haben ausgewählt sein." }
} else {
next.manualInvoiceSide = null
}
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")
next.datevTaxKey = next.datevTaxKey ? String(next.datevTaxKey).trim() : null
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 +740,64 @@ 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,
incominginvoice: ManualInvoices,
incominginvoiceVendor: ManualInvoiceVendors,
})
.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))
.leftJoin(ManualInvoices, eq(statementallocations.incominginvoice, ManualInvoices.id))
.leftJoin(ManualInvoiceVendors, eq(ManualInvoices.vendor, ManualInvoiceVendors.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,
incominginvoice: row.incominginvoice ? {
...row.incominginvoice,
vendor: row.incominginvoiceVendor,
} : null,
})))
} catch (err) {
console.error(err)
return reply.code(500).send({ error: "Failed to load manual bookings" })
}
})
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 💰 Create Statement Allocation // 💰 Create Statement Allocation
@@ -686,9 +807,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 +843,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 +888,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

@@ -24,6 +24,7 @@ import {executeManualGeneration, finishManualGeneration} from "../modules/serial
import { s3 } from "../utils/s3"; import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets"; import { secrets } from "../utils/secrets";
import { storeExtractedTextForFile } from "../utils/documentText"; import { storeExtractedTextForFile } from "../utils/documentText";
import { generateLiquidityForecast } from "../utils/liquidityForecast";
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
dayjs.extend(isoWeek) dayjs.extend(isoWeek)
dayjs.extend(isBetween) dayjs.extend(isBetween)
@@ -306,6 +307,21 @@ export default async function functionRoutes(server: FastifyInstance) {
await server.services.prepareIncomingInvoices.run(req.user.tenant_id) await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
}) })
server.get('/functions/liquidity-forecast', async (req, reply) => {
const { ignoredRecurringKeys } = req.query as { ignoredRecurringKeys?: string }
const ignoredKeys = String(ignoredRecurringKeys || "")
.split(",")
.map((key) => key.trim())
.filter(Boolean)
try {
return await generateLiquidityForecast(server, req.user.tenant_id, ignoredKeys)
} catch (err) {
req.log.error(err)
return reply.code(500).send({ error: "Liquiditätsprognose konnte nicht erstellt werden." })
}
})
server.post('/functions/services/backfillfiletext', async (req, reply) => { server.post('/functions/services/backfillfiletext', async (req, reply) => {
const tenantId = req.user.tenant_id const tenantId = req.user.tenant_id

View File

@@ -11,7 +11,7 @@ import {
desc desc
} from "drizzle-orm" } from "drizzle-orm"
import {stafftimeevents} from "../../../db/schema/staff_time_events"; import {stafftimeevents} from "../../../db/schema/staff_time_events";
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service"; import {compareTimeEvents, loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service"; import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service"; import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
import {z} from "zod"; import {z} from "zod";
@@ -102,7 +102,7 @@ export default async function staffTimeRoutesInternal(server: FastifyInstance) {
const combinedEvents = [ const combinedEvents = [
...factualEvents, ...factualEvents,
...relatedAdminEvents, ...relatedAdminEvents,
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime()); ].sort(compareTimeEvents);
// SCHRITT 5: Spans ableiten // SCHRITT 5: Spans ableiten
const derivedSpans = deriveTimeSpans(combinedEvents); const derivedSpans = deriveTimeSpans(combinedEvents);

View File

@@ -11,7 +11,7 @@ import {
sql, sql,
} from "drizzle-orm" } from "drizzle-orm"
import { authProfiles } from "../../../db/schema"; import { authProfiles, costcentres } from "../../../db/schema";
import { resourceConfig } from "../../utils/resource.config"; import { resourceConfig } from "../../utils/resource.config";
import { useNextNumberRangeNumber } from "../../utils/functions"; import { useNextNumberRangeNumber } from "../../utils/functions";
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history"; import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
@@ -260,6 +260,59 @@ function validateMemberPayload(payload: Record<string, any>) {
return null return null
} }
async function validateCostCentreParent(
server: FastifyInstance,
tenantId: number,
costCentreId: string | null,
parentCostcentreId: string | null
) {
if (!parentCostcentreId) {
return null
}
const hierarchyRows = await server.db
.select({
id: costcentres.id,
parentCostcentre: costcentres.parentCostcentre,
})
.from(costcentres)
.where(eq(costcentres.tenant, tenantId))
const hierarchyMap = new Map(
hierarchyRows.map((row) => [row.id, row.parentCostcentre || null])
)
if (!hierarchyMap.has(parentCostcentreId)) {
return "Die übergeordnete Kostenstelle wurde nicht gefunden."
}
if (costCentreId && parentCostcentreId === costCentreId) {
return "Eine Kostenstelle kann nicht sich selbst als übergeordnete Kostenstelle haben."
}
if (!costCentreId) {
return null
}
let currentParentId: string | null = parentCostcentreId
const visited = new Set<string>()
while (currentParentId) {
if (currentParentId === costCentreId) {
return "Die ausgewählte übergeordnete Kostenstelle würde einen Zyklus erzeugen."
}
if (visited.has(currentParentId)) {
break
}
visited.add(currentParentId)
currentParentId = hierarchyMap.get(currentParentId) || null
}
return null
}
function maskIban(iban: string) { function maskIban(iban: string) {
if (!iban) return "" if (!iban) return ""
const cleaned = iban.replace(/\s+/g, "") const cleaned = iban.replace(/\s+/g, "")
@@ -730,6 +783,19 @@ export default async function resourceRoutes(server: FastifyInstance) {
createData = prepared.data! createData = prepared.data!
} }
if (resource === "costcentres") {
const validationError = await validateCostCentreParent(
server,
req.user.tenant_id,
null,
createData.parentCostcentre || null
)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
if (config.numberRangeHolder && !body[config.numberRangeHolder]) { if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
const numberRangeResource = resource === "members" ? "customers" : resource const numberRangeResource = resource === "members" ? "customers" : resource
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource) const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
@@ -836,6 +902,21 @@ export default async function resourceRoutes(server: FastifyInstance) {
} }
} }
if (resource === "costcentres") {
const validationError = await validateCostCentreParent(
server,
tenantId,
oldRecord.id,
Object.prototype.hasOwnProperty.call(data, "parentCostcentre")
? data.parentCostcentre || null
: oldRecord.parentCostcentre || null
)
if (validationError) {
return reply.code(400).send({ error: validationError })
}
}
Object.keys(data).forEach((key) => { Object.keys(data).forEach((key) => {
const value = data[key] const value = data[key]
const shouldNormalize = const shouldNormalize =

View File

@@ -11,7 +11,7 @@ import {
desc desc
} from "drizzle-orm" } from "drizzle-orm"
import {stafftimeevents} from "../../../db/schema/staff_time_events"; import {stafftimeevents} from "../../../db/schema/staff_time_events";
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service"; import {compareTimeEvents, loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service"; import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service"; import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
import {z} from "zod"; import {z} from "zod";
@@ -354,7 +354,7 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
const combinedEvents = [ const combinedEvents = [
...factualEvents, ...factualEvents,
...relatedAdminEvents, ...relatedAdminEvents,
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime()); ].sort(compareTimeEvents);
// SCHRITT 5: Spans ableiten // SCHRITT 5: Spans ableiten
const derivedSpans = deriveTimeSpans(combinedEvents); const derivedSpans = deriveTimeSpans(combinedEvents);
@@ -424,7 +424,7 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
const combinedEvents = [ const combinedEvents = [
...factualEvents, ...factualEvents,
...relatedAdminEvents, ...relatedAdminEvents,
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime()); ].sort(compareTimeEvents);
// SCHRITT 4: Ableiten und Anreichern // SCHRITT 4: Ableiten und Anreichern
const derivedSpans = deriveTimeSpans(combinedEvents); const derivedSpans = deriveTimeSpans(combinedEvents);

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,42 @@ 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;
const incominginvoice = alloc.manualInvoiceSide === side ? alloc.incominginvoice : null;
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" };
if (incominginvoice) {
return {
number: incominginvoice.vendor?.vendorNumber || "",
name: `${incominginvoice.reference || "Eingangsbeleg"} ${incominginvoice.vendor?.name || ""}`.trim(),
type: "Eingangsbeleg",
reference: incominginvoice.reference || "",
};
}
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");
const belegnummer = debit.reference || credit.reference || "";
bookingLines.push(`${displayCurrency(alloc.amount,true)};"S";;;;;${debit.number};${credit.number};"${alloc.datevTaxKey || ""}";${dateManual};"${belegnummer}";;;"${`MB ${debit.number} an ${credit.number} ${escapeString(alloc.description)}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${escapeString(debit.name || credit.name)}";"Kundennummer";"${debit.number}";"Belegnummer";"${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";

View File

@@ -0,0 +1,839 @@
import dayjs from "dayjs";
import OpenAI from "openai";
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";
import { and, desc, eq, gte } from "drizzle-orm";
import { FastifyInstance } from "fastify";
import { createHash } from "node:crypto";
import {
bankaccounts,
bankstatements,
createddocuments,
incominginvoices,
statementallocations,
tenants,
} from "../../db/schema";
import { secrets } from "./secrets";
type ForecastEventSource = "open_createddocument" | "open_incominginvoice" | "recurring_bankstatement" | "draft_createddocument" | "tax_settlement" | "serial_template";
type TaxEvaluationPeriod = "monthly" | "quarterly" | "yearly";
type ForecastEvent = {
date: string;
amount: number;
label: string;
source: ForecastEventSource;
sourceId?: number | string | null;
recurringKey?: string;
confidence?: number;
};
type TaxBreakdown = {
net19: number;
tax19: number;
net7: number;
tax7: number;
net0: number;
};
type TaxForecastPeriod = {
key: string;
label: string;
range: string;
dueDate: string;
outputTax: number;
inputTax: number;
balance: number;
outputCount: number;
inputCount: number;
};
type RecurringCandidate = {
key?: string;
label: string;
amount: number;
interval: "weekly" | "monthly" | "quarterly" | "yearly";
nextDate: string;
confidence: number;
evidence: string;
};
const AiRecurringFormat = z.object({
candidates: z.array(z.object({
label: z.string(),
amount: z.number(),
interval: z.enum(["weekly", "monthly", "quarterly", "yearly"]),
nextDate: z.string(),
confidence: z.number().min(0).max(1),
evidence: z.string(),
})),
});
const FORECAST_DAYS = 90;
const HISTORY_MONTHS = 12;
const roundMoney = (value: number) => Number(Number(value || 0).toFixed(2));
const createZeroTaxBreakdown = (): TaxBreakdown => ({
net19: 0,
tax19: 0,
net7: 0,
tax7: 0,
net0: 0,
});
const normalizeText = (value: unknown) => String(value || "")
.toLowerCase()
.replace(/[^a-z0-9äöüß]+/gi, " ")
.replace(/\s+/g, " ")
.trim();
const normalizeTaxEvaluationPeriod = (value?: string | null): TaxEvaluationPeriod => {
if (value === "quarterly" || value === "yearly") return value;
return "monthly";
};
const isTaxFreeDocument = (taxType?: string | null) => {
return ["13b UStG", "19 UStG", "12.3 UStG"].includes(String(taxType || ""));
};
const getTaxEvaluationPeriodBounds = (
referenceDate: dayjs.ConfigType,
period: TaxEvaluationPeriod
) => {
const base = dayjs(referenceDate);
if (period === "yearly") {
return {
start: base.startOf("year"),
end: base.endOf("year"),
};
}
if (period === "quarterly") {
const quarterStartMonth = Math.floor(base.month() / 3) * 3;
const start = base.month(quarterStartMonth).startOf("month");
return {
start,
end: start.add(2, "month").endOf("month"),
};
}
return {
start: base.startOf("month"),
end: base.endOf("month"),
};
};
const shiftTaxEvaluationPeriodStart = (
periodStart: dayjs.ConfigType,
period: TaxEvaluationPeriod,
offset: number
) => {
const base = dayjs(periodStart);
if (period === "yearly") return base.add(offset, "year").startOf("year");
if (period === "quarterly") return base.add(offset * 3, "month").startOf("month");
return base.add(offset, "month").startOf("month");
};
const formatTaxEvaluationPeriodLabel = (
periodStart: dayjs.ConfigType,
period: TaxEvaluationPeriod
) => {
const { start } = getTaxEvaluationPeriodBounds(periodStart, period);
if (period === "yearly") {
return start.format("YYYY");
}
if (period === "quarterly") {
return `Q${Math.floor(start.month() / 3) + 1} ${start.format("YYYY")}`;
}
return start.format("MMMM YYYY");
};
const formatTaxEvaluationPeriodRange = (
periodStart: dayjs.ConfigType,
period: TaxEvaluationPeriod
) => {
const { start, end } = getTaxEvaluationPeriodBounds(periodStart, period);
return `${start.format("DD.MM.YYYY")} - ${end.format("DD.MM.YYYY")}`;
};
const getTaxSettlementDate = (periodEnd: dayjs.Dayjs) => {
return periodEnd.add(1, "month").date(10).startOf("day");
};
const getRecurringKey = (candidate: RecurringCandidate) => {
const rawKey = [
Math.sign(candidate.amount),
Math.round(Math.abs(candidate.amount) * 100),
normalizeText(candidate.label),
candidate.interval,
].join("|");
return createHash("sha1").update(rawKey).digest("hex").slice(0, 16);
};
const addInterval = (date: dayjs.Dayjs, interval: RecurringCandidate["interval"]) => {
if (interval === "weekly") return date.add(1, "week");
if (interval === "quarterly") return date.add(3, "month");
if (interval === "yearly") return date.add(1, "year");
return date.add(1, "month");
};
const addSerialInterval = (date: dayjs.Dayjs, interval: string) => {
if (interval === "wöchentlich") return date.add(1, "week");
if (interval === "2 - wöchentlich") return date.add(2, "week");
if (interval === "vierteljährlich") return date.add(3, "month");
if (interval === "halbjährlich") return date.add(6, "month");
if (interval === "jährlich") return date.add(1, "year");
return date.add(1, "month");
};
const getStatementPartner = (statement: any) => {
return statement.amount < 0
? statement.credName || statement.debName || statement.text || "Regelmäßige Ausgabe"
: statement.debName || statement.credName || statement.text || "Regelmäßige Einnahme";
};
const getCreatedDocumentGrossAmount = (document: any, allDocuments: any[] = []) => {
let totalNet = 0;
let totalTax = 0;
(document.rows || []).forEach((row: any) => {
if (["pagebreak", "title", "text"].includes(row.mode)) return;
const rowNet = Number(
(Number(row.quantity || 0) * Number(row.price || 0) * (1 - Number(row.discountPercent || 0) / 100)).toFixed(3)
);
const taxPercent = Number(row.taxPercent);
totalNet += rowNet;
totalTax += rowNet * (Number.isFinite(taxPercent) ? taxPercent : 0) / 100;
});
let advancePayments = 0;
(document.usedAdvanceInvoices || []).forEach((advanceInvoiceId: number) => {
const advanceInvoice = allDocuments.find((item) => item.id === advanceInvoiceId);
const advanceRow = advanceInvoice?.rows?.find((row: any) => row.advanceInvoiceData);
if (!advanceRow) return;
advancePayments += Number(advanceRow.price || 0) * ((100 + Number(advanceRow.taxPercent || 0)) / 100);
});
return roundMoney(Number(totalNet.toFixed(2)) + Number(totalTax.toFixed(2)) - advancePayments);
};
const getCreatedDocumentTaxBreakdown = (document: any): TaxBreakdown => {
const breakdown = createZeroTaxBreakdown();
if (!document || isTaxFreeDocument(document.taxType)) {
return breakdown;
}
(document.rows || []).forEach((row: any) => {
if (!row || ["pagebreak", "title", "text"].includes(row.mode)) return;
const quantity = Number(row.quantity || 0);
const price = Number(row.price || 0);
const discountPercent = Number(row.discountPercent || 0);
const taxPercent = Number(row.taxPercent || 0);
const net = Number((quantity * price * (1 - discountPercent / 100)).toFixed(2));
if (!Number.isFinite(net) || net === 0) return;
if (taxPercent === 19) {
breakdown.net19 += net;
breakdown.tax19 += Number((net * 0.19).toFixed(2));
} else if (taxPercent === 7) {
breakdown.net7 += net;
breakdown.tax7 += Number((net * 0.07).toFixed(2));
} else {
breakdown.net0 += net;
}
});
return {
net19: roundMoney(breakdown.net19),
tax19: roundMoney(breakdown.tax19),
net7: roundMoney(breakdown.net7),
tax7: roundMoney(breakdown.tax7),
net0: roundMoney(breakdown.net0),
};
};
const getIncomingInvoiceTaxBreakdown = (invoice: any): TaxBreakdown => {
const breakdown = createZeroTaxBreakdown();
(invoice?.accounts || []).forEach((account: any) => {
const taxType = String(account?.taxType || "");
const amountNet = Number(account?.amountNet || 0);
const amountTax = Number(account?.amountTax || 0);
if (taxType === "19") {
breakdown.net19 += amountNet;
breakdown.tax19 += amountTax;
} else if (taxType === "7") {
breakdown.net7 += amountNet;
breakdown.tax7 += amountTax;
} else {
breakdown.net0 += amountNet;
}
});
return {
net19: roundMoney(breakdown.net19),
tax19: roundMoney(breakdown.tax19),
net7: roundMoney(breakdown.net7),
tax7: roundMoney(breakdown.tax7),
net0: roundMoney(breakdown.net0),
};
};
const getIncomingInvoiceSignedAmount = (invoice: any) => {
const amount = (invoice.accounts || []).reduce((sum: number, account: any) => {
return sum + Number(account.amountNet || 0) + Number(account.amountTax || 0);
}, 0);
return roundMoney(invoice.expense === false ? amount : amount * -1);
};
const getRemainingSignedAmount = (signedAmount: number, allocatedAmount: number) => {
const remainingAbsolute = Math.max(0, Math.abs(Number(signedAmount || 0)) - Math.abs(Number(allocatedAmount || 0)));
if (remainingAbsolute <= 0.01) return 0;
return roundMoney(Math.sign(Number(signedAmount || 0)) * remainingAbsolute);
};
const findCancellationDocumentIds = (documents: any[]) => {
return new Set(
documents
.filter((document) => document.type === "cancellationInvoices" && document.state !== "Entwurf" && !document.archived)
.map((document) => typeof document.createddocument === "object" ? document.createddocument?.id : document.createddocument)
.filter(Boolean)
);
};
const inferInterval = (dates: dayjs.Dayjs[]): RecurringCandidate["interval"] | null => {
if (dates.length < 3) return null;
const gaps = dates
.slice(1)
.map((date, index) => date.diff(dates[index], "day"))
.filter((gap) => gap > 0);
const averageGap = gaps.reduce((sum, gap) => sum + gap, 0) / Math.max(gaps.length, 1);
if (averageGap >= 6 && averageGap <= 8) return "weekly";
if (averageGap >= 25 && averageGap <= 35) return "monthly";
if (averageGap >= 80 && averageGap <= 100) return "quarterly";
if (averageGap >= 340 && averageGap <= 390) return "yearly";
return null;
};
const detectRecurringHeuristically = (statements: any[]): RecurringCandidate[] => {
const groups = new Map<string, any[]>();
statements.forEach((statement) => {
const parsedDate = dayjs(statement.valueDate || statement.date);
if (!parsedDate.isValid() || Number(statement.amount || 0) >= 0) return;
const partner = normalizeText(getStatementPartner(statement));
const purpose = normalizeText(statement.text).split(" ").slice(0, 5).join(" ");
const amountBucket = Math.round(Number(statement.amount || 0) * 100);
const key = [Math.sign(amountBucket), Math.abs(amountBucket), partner || purpose].join("|");
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(statement);
});
return [...groups.values()]
.map((group) => {
const sorted = group
.map((statement) => ({ ...statement, parsedDate: dayjs(statement.valueDate || statement.date) }))
.sort((a, b) => a.parsedDate.valueOf() - b.parsedDate.valueOf());
const interval = inferInterval(sorted.map((statement) => statement.parsedDate));
if (!interval) return null;
let next = addInterval(sorted[sorted.length - 1].parsedDate, interval);
while (next.isBefore(dayjs(), "day")) {
next = addInterval(next, interval);
}
const amount = roundMoney(sorted.reduce((sum, statement) => sum + Number(statement.amount || 0), 0) / sorted.length);
const partner = getStatementPartner(sorted[sorted.length - 1]);
return {
label: partner,
amount,
interval,
nextDate: next.format("YYYY-MM-DD"),
confidence: Math.min(0.9, 0.55 + sorted.length * 0.08),
evidence: `${sorted.length} ähnliche Bankbewegungen erkannt`,
};
})
.filter(Boolean) as RecurringCandidate[];
};
const detectRecurringWithAi = async (server: FastifyInstance, statements: any[]): Promise<RecurringCandidate[]> => {
if (!secrets.OPENAI_API_KEY || statements.length < 6) return [];
const openai = new OpenAI({ apiKey: secrets.OPENAI_API_KEY });
const compactStatements = statements.slice(0, 220).map((statement) => ({
date: statement.valueDate || statement.date,
amount: roundMoney(Number(statement.amount || 0)),
partner: getStatementPartner(statement),
text: String(statement.text || "").slice(0, 160),
}));
try {
const completion = await openai.chat.completions.parse({
model: "gpt-4o",
store: true,
response_format: zodResponseFormat(AiRecurringFormat as any, "liquidity_recurring_transactions"),
messages: [
{
role: "system",
content: "Du erkennst wiederkehrende Bankbewegungen für eine Liquiditätsprognose. Gib nur Muster zurück, die durch die gelieferten Umsätze plausibel belegt sind.",
},
{
role: "user",
content: JSON.stringify({
today: dayjs().format("YYYY-MM-DD"),
horizonDays: FORECAST_DAYS,
bankStatements: compactStatements,
rules: [
"amount ist aus Sicht des Bankkontos: negative Werte sind Auszahlungen, positive Werte Einzahlungen.",
"nextDate muss in der Zukunft liegen.",
"Nutze keine einmaligen oder unsicheren Bewegungen.",
],
}),
},
],
});
return (completion.choices[0].message.parsed?.candidates || [])
.filter((candidate) => dayjs(candidate.nextDate).isValid())
.filter((candidate) => Number(candidate.amount || 0) < 0)
.map((candidate) => ({
...candidate,
amount: roundMoney(candidate.amount),
confidence: Math.max(0, Math.min(1, candidate.confidence)),
}));
} catch (error) {
server.log.warn("KI-Erkennung für regelmäßige Bankbewegungen konnte nicht ausgeführt werden.");
server.log.warn(error);
return [];
}
};
const mergeRecurringCandidates = (heuristic: RecurringCandidate[], ai: RecurringCandidate[]) => {
const merged = new Map<string, RecurringCandidate>();
[...heuristic, ...ai].forEach((candidate) => {
const key = [
Math.sign(candidate.amount),
Math.round(Math.abs(candidate.amount) * 100),
normalizeText(candidate.label).slice(0, 32),
candidate.interval,
].join("|");
const existing = merged.get(key);
if (!existing || candidate.confidence > existing.confidence) {
merged.set(key, candidate);
}
});
return [...merged.values()]
.map((candidate) => ({
...candidate,
key: getRecurringKey(candidate),
}))
.filter((candidate) => Number(candidate.amount || 0) < 0)
.filter((candidate) => Math.abs(candidate.amount) >= 1)
.sort((a, b) => a.nextDate.localeCompare(b.nextDate));
};
const expandRecurringEvents = (recurring: RecurringCandidate[], endDate: dayjs.Dayjs): ForecastEvent[] => {
const events: ForecastEvent[] = [];
recurring.forEach((candidate) => {
let date = dayjs(candidate.nextDate);
let guard = 0;
while (date.isValid() && !date.isAfter(endDate, "day") && guard < 40) {
events.push({
date: date.format("YYYY-MM-DD"),
amount: roundMoney(candidate.amount),
label: candidate.label,
source: "recurring_bankstatement",
recurringKey: candidate.key,
confidence: candidate.confidence,
});
date = addInterval(date, candidate.interval);
guard += 1;
}
});
return events;
};
const buildTaxForecastPeriods = (
documents: any[],
incomingInvoices: any[],
periodType: TaxEvaluationPeriod,
today: dayjs.Dayjs,
endDate: dayjs.Dayjs
) => {
const currentBounds = getTaxEvaluationPeriodBounds(today, periodType);
const periods: TaxForecastPeriod[] = [];
let offset = 0;
while (offset < 12) {
const periodStart = shiftTaxEvaluationPeriodStart(currentBounds.start, periodType, offset);
const bounds = getTaxEvaluationPeriodBounds(periodStart, periodType);
const dueDate = getTaxSettlementDate(bounds.end);
if (dueDate.isAfter(endDate, "day")) break;
const outputDocs = documents.filter((document) => {
if (document?.state !== "Gebucht") return false;
if (!["invoices", "advanceInvoices", "cancellationInvoices"].includes(document?.type)) return false;
const date = dayjs(document.documentDate);
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day");
});
const inputDocs = incomingInvoices.filter((invoice) => {
if (invoice?.state !== "Gebucht" || !invoice?.date) return false;
const date = dayjs(invoice.date);
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day");
});
const outputTax = roundMoney(outputDocs.reduce((sum, document) => {
const breakdown = getCreatedDocumentTaxBreakdown(document);
return sum + breakdown.tax19 + breakdown.tax7;
}, 0));
const inputTax = roundMoney(inputDocs.reduce((sum, invoice) => {
const breakdown = getIncomingInvoiceTaxBreakdown(invoice);
return sum + breakdown.tax19 + breakdown.tax7;
}, 0));
const balance = roundMoney(outputTax - inputTax);
periods.push({
key: bounds.start.format("YYYY-MM-DD"),
label: formatTaxEvaluationPeriodLabel(bounds.start, periodType),
range: formatTaxEvaluationPeriodRange(bounds.start, periodType),
dueDate: dueDate.format("YYYY-MM-DD"),
outputTax,
inputTax,
balance,
outputCount: outputDocs.length,
inputCount: inputDocs.length,
});
offset += 1;
}
return periods;
};
const buildSerialTemplateEvents = (
documents: any[],
today: dayjs.Dayjs,
endDate: dayjs.Dayjs
) => {
const events: ForecastEvent[] = [];
documents
.filter((document) => document.type === "serialInvoices")
.filter((document) => document.serialConfig?.active)
.forEach((document) => {
const firstExecution = dayjs(document.serialConfig?.firstExecution);
const executionUntil = dayjs(document.serialConfig?.executionUntil);
if (!firstExecution.isValid() || !executionUntil.isValid()) return;
const amount = getCreatedDocumentGrossAmount(document, documents);
if (amount <= 0.01) return;
let executionDate = firstExecution.startOf("day");
let guard = 0;
while (executionDate.isBefore(today, "day") && guard < 240) {
executionDate = addSerialInterval(executionDate, String(document.serialConfig?.intervall || ""));
guard += 1;
}
while (
executionDate.isValid()
&& !executionDate.isAfter(executionUntil, "day")
&& !executionDate.isAfter(endDate, "day")
&& guard < 400
) {
const dueDate = executionDate.add(Number(document.paymentDays || 0), "day");
events.push({
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
amount,
label: document.documentNumber || document.title || `Serienvorlage ${document.id}`,
source: "serial_template",
sourceId: document.id,
confidence: 0.75,
});
executionDate = addSerialInterval(executionDate, String(document.serialConfig?.intervall || ""));
guard += 1;
}
});
return events.sort((a, b) => a.date.localeCompare(b.date));
};
export const generateLiquidityForecast = async (
server: FastifyInstance,
tenantId: number,
ignoredRecurringKeys: string[] = []
) => {
const today = dayjs().startOf("day");
const endDate = today.add(FORECAST_DAYS, "day");
const historyStart = today.subtract(HISTORY_MONTHS, "month").format("YYYY-MM-DD");
const [accounts, statements, documents, incomingInvoices, allocations, tenantSettings] = await Promise.all([
server.db
.select()
.from(bankaccounts)
.where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))),
server.db
.select()
.from(bankstatements)
.where(and(eq(bankstatements.tenant, tenantId), eq(bankstatements.archived, false), gte(bankstatements.valueDate, historyStart)))
.orderBy(desc(bankstatements.valueDate)),
server.db
.select()
.from(createddocuments)
.where(and(eq(createddocuments.tenant, tenantId), eq(createddocuments.archived, false))),
server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.tenant, tenantId), eq(incominginvoices.archived, false))),
server.db
.select()
.from(statementallocations)
.where(and(eq(statementallocations.tenant, tenantId), eq(statementallocations.archived, false))),
server.db
.select({
taxEvaluationPeriod: tenants.taxEvaluationPeriod,
})
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1),
]);
const activeAccounts = accounts.filter((account) => !account.archived);
const activeStatements = statements.filter((statement) => !statement.archived);
const activeDocuments = documents.filter((document) => !document.archived);
const activeIncomingInvoices = incomingInvoices.filter((invoice) => !invoice.archived);
const activeDocumentIds = new Set(activeDocuments.map((document) => document.id));
const activeIncomingInvoiceIds = new Set(activeIncomingInvoices.map((invoice) => invoice.id));
const taxPeriodType = normalizeTaxEvaluationPeriod(tenantSettings[0]?.taxEvaluationPeriod);
const activeAllocations = allocations.filter((allocation) => {
if (allocation.archived) return false;
if (allocation.createddocument && !activeDocumentIds.has(allocation.createddocument)) return false;
if (allocation.incominginvoice && !activeIncomingInvoiceIds.has(allocation.incominginvoice)) return false;
return true;
});
const startingBalance = roundMoney(
activeAccounts
.filter((account) => !account.expired)
.reduce((sum, account) => sum + Number(account.balance || 0), 0)
);
const allocationByDocument = new Map<number, number>();
const allocationByIncomingInvoice = new Map<number, number>();
activeAllocations.forEach((allocation) => {
if (allocation.createddocument) {
allocationByDocument.set(
allocation.createddocument,
roundMoney((allocationByDocument.get(allocation.createddocument) || 0) + Number(allocation.amount || 0))
);
}
if (allocation.incominginvoice) {
allocationByIncomingInvoice.set(
allocation.incominginvoice,
roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0))
);
}
});
const cancelledDocumentIds = findCancellationDocumentIds(activeDocuments);
const openEvents: ForecastEvent[] = [];
const draftEvents: ForecastEvent[] = [];
activeDocuments
.filter((document) => ["invoices", "advanceInvoices"].includes(document.type))
.filter((document) => document.state === "Gebucht" && !cancelledDocumentIds.has(document.id))
.forEach((document) => {
const total = getCreatedDocumentGrossAmount(document, activeDocuments);
const openAmount = roundMoney(total - (allocationByDocument.get(document.id) || 0));
if (openAmount <= 0.01) return;
const dueDate = dayjs(document.documentDate).isValid()
? dayjs(document.documentDate).add(Number(document.paymentDays || 0), "day")
: today;
openEvents.push({
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
amount: openAmount,
label: document.documentNumber || document.title || `Ausgangsbeleg ${document.id}`,
source: "open_createddocument",
sourceId: document.id,
confidence: 1,
});
});
activeDocuments
.filter((document) => ["invoices", "advanceInvoices"].includes(document.type))
.filter((document) => document.state === "Entwurf" && !cancelledDocumentIds.has(document.id))
.forEach((document) => {
const total = getCreatedDocumentGrossAmount(document, activeDocuments);
if (total <= 0.01) return;
const dueDate = dayjs(document.documentDate).isValid()
? dayjs(document.documentDate).add(Number(document.paymentDays || 0), "day")
: today;
draftEvents.push({
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
amount: total,
label: document.documentNumber || document.title || `Rechnungsentwurf ${document.id}`,
source: "draft_createddocument",
sourceId: document.id,
confidence: 0.35,
});
});
activeIncomingInvoices
.filter((invoice) => invoice.state === "Gebucht" || invoice.state === "Vorbereitet")
.filter((invoice) => !invoice.paid)
.forEach((invoice) => {
const signedAmount = getIncomingInvoiceSignedAmount(invoice);
const openAmount = getRemainingSignedAmount(signedAmount, allocationByIncomingInvoice.get(invoice.id) || 0);
if (Math.abs(openAmount) <= 0.01) return;
const dueDate = dayjs(invoice.dueDate || invoice.date).isValid()
? dayjs(invoice.dueDate || invoice.date)
: today;
openEvents.push({
date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"),
amount: openAmount,
label: invoice.reference || invoice.description || `Eingangsbeleg ${invoice.id}`,
source: "open_incominginvoice",
sourceId: invoice.id,
confidence: invoice.state === "Gebucht" ? 1 : 0.8,
});
});
const heuristicRecurring = detectRecurringHeuristically(activeStatements);
const aiRecurring = await detectRecurringWithAi(server, activeStatements);
const ignoredRecurringKeySet = new Set(ignoredRecurringKeys.filter(Boolean));
const recurring = mergeRecurringCandidates(heuristicRecurring, aiRecurring)
.filter((candidate) => !candidate.key || !ignoredRecurringKeySet.has(candidate.key));
const taxPeriods = buildTaxForecastPeriods(activeDocuments, activeIncomingInvoices, taxPeriodType, today, endDate);
const taxEvents: ForecastEvent[] = taxPeriods
.filter((period) => Math.abs(period.balance) > 0.01)
.map((period) => ({
date: period.dueDate,
amount: roundMoney(period.balance * -1),
label: `USt ${period.label}`,
source: "tax_settlement" as const,
sourceId: period.key,
confidence: 0.95,
}));
const serialTemplateEvents = buildSerialTemplateEvents(activeDocuments, today, endDate);
const events = [
...openEvents,
...taxEvents,
...serialTemplateEvents,
...expandRecurringEvents(recurring, endDate),
].filter((event) => {
const date = dayjs(event.date);
return date.isValid() && !date.isAfter(endDate, "day");
});
const dailyEvents = new Map<string, ForecastEvent[]>();
events.forEach((event) => {
if (!dailyEvents.has(event.date)) dailyEvents.set(event.date, []);
dailyEvents.get(event.date)!.push(event);
});
let runningBalance = startingBalance;
const points = [];
for (let offset = 0; offset <= FORECAST_DAYS; offset += 1) {
const date = today.add(offset, "day").format("YYYY-MM-DD");
const dayEvents = dailyEvents.get(date) || [];
const income = roundMoney(dayEvents.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
const expense = roundMoney(dayEvents.filter((event) => event.amount < 0).reduce((sum, event) => sum + event.amount, 0));
runningBalance = roundMoney(runningBalance + income + expense);
points.push({
date,
balance: runningBalance,
income,
expense,
events: dayEvents.sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount)),
});
}
const lowestPoint = points.reduce((lowest, point) => point.balance < lowest.balance ? point : lowest, points[0]);
const totalIncome = roundMoney(events.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
const totalExpense = roundMoney(events.filter((event) => event.amount < 0).reduce((sum, event) => sum + event.amount, 0));
const draftIncome = roundMoney(draftEvents.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0));
return {
generatedAt: new Date().toISOString(),
horizonDays: FORECAST_DAYS,
startingBalance,
endingBalance: points[points.length - 1]?.balance || startingBalance,
lowestBalance: lowestPoint?.balance || startingBalance,
lowestBalanceDate: lowestPoint?.date || today.format("YYYY-MM-DD"),
totalIncome,
totalExpense,
accounts: activeAccounts.map((account) => ({
id: account.id,
name: account.name,
iban: account.iban,
balance: roundMoney(Number(account.balance || 0)),
expired: account.expired,
syncedAt: account.syncedAt,
})),
recurring,
events: events.sort((a, b) => a.date.localeCompare(b.date)),
draftEvents: draftEvents.sort((a, b) => a.date.localeCompare(b.date)),
draftIncome,
tax: {
periodType: taxPeriodType,
periods: taxPeriods,
totalBalance: roundMoney(taxPeriods.reduce((sum, period) => sum + period.balance, 0)),
},
points,
ai: {
enabled: Boolean(secrets.OPENAI_API_KEY),
candidates: aiRecurring.length,
},
};
};

View File

@@ -164,9 +164,13 @@ export const resourceConfig = {
costcentres: { costcentres: {
table: costcentres, table: costcentres,
searchColumns: ["name","number","description"], searchColumns: ["name","number","description"],
mtoLoad: ["vehicle","project","inventoryitem","branch"], mtoLoad: ["parentCostcentre","vehicle","project","inventoryitem","branch"],
numberRangeHolder: "number", numberRangeHolder: "number",
}, },
parentCostcentre: {
table: costcentres,
searchColumns: ["name", "number", "description"],
},
branches: { branches: {
table: branches, table: branches,
searchColumns: ["name","number","description"], searchColumns: ["name","number","description"],

3
docs-site/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.nuxt
.output

View File

@@ -1,8 +1,8 @@
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
WORKDIR /app/docs-site WORKDIR /app/docs-site
COPY docs-site/package.json ./ COPY docs-site/package.json docs-site/package-lock.json ./
RUN npm install RUN npm ci
COPY docs-site ./ COPY docs-site ./
COPY docs /app/docs COPY docs /app/docs

View File

@@ -1,6 +1,6 @@
# FEDEO Docs Site (Nuxt Content) # FEDEO Docs Site (Nuxt UI + Nuxt Content)
Diese Docs-App basiert auf Nuxt Content und rendert die Inhalte aus dem Repository-Ordner `docs/`. Diese Docs-App nutzt den Standardstil des offiziellen Nuxt-UI-Docs-Templates und rendert Inhalte aus `docs/`.
## Lokale Entwicklung ## Lokale Entwicklung
@@ -11,20 +11,21 @@ npm install
npm run dev npm run dev
``` ```
Die App ist danach unter `http://localhost:3005` erreichbar. Danach ist die App unter `http://localhost:3005` erreichbar.
## Build ## Build
```bash ```bash
npm run build npm run build
npm run preview
``` ```
## Production-Deploy ## Production-Deploy
Das Docker-Image startet einen Node-Server auf Port `3000`. Das Docker-Image startet einen Nuxt Node-Server auf Port `3000`.
In der Haupt-`docker-compose.yml` wird die App hinter Traefik unter `/docs` veröffentlicht. In der Haupt-`docker-compose.yml` ist der Service hinter Traefik unter `/docs` veröffentlicht.
## Content-Quelle ## Content-Synchronisierung
Vor `dev` und `build` wird automatisch synchronisiert: Vor `dev` und `build` wird automatisch synchronisiert:

View File

@@ -1,3 +0,0 @@
<template>
<NuxtPage />
</template>

View File

@@ -0,0 +1,56 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'green',
neutral: 'slate'
},
footer: {
slots: {
root: 'border-t border-default',
left: 'text-sm text-muted'
}
}
},
seo: {
siteName: 'FEDEO Bedienung'
},
header: {
title: 'FEDEO Bedienung',
to: '/bedienung',
search: true,
colorMode: true,
links: [
{
icon: 'i-simple-icons-github',
to: 'https://git.federspiel.tech/flfeders/FEDEO',
target: '_blank',
'aria-label': 'Repository'
}
]
},
footer: {
credits: `Built with Nuxt UI • © ${new Date().getFullYear()} FEDEO`,
colorMode: false,
links: [
{
icon: 'i-simple-icons-github',
to: 'https://git.federspiel.tech/flfeders/FEDEO',
target: '_blank',
'aria-label': 'Repository'
}
]
},
toc: {
title: 'Inhaltsverzeichnis',
bottom: {
title: 'Links',
links: [
{
icon: 'i-lucide-book-open',
label: 'Bedienung',
to: '/bedienung'
}
]
}
}
})

44
docs-site/app/app.vue Normal file
View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
const { seo } = useAppConfig()
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs'))
const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSections('docs'), {
server: false
})
useHead({
meta: [{ name: 'viewport', content: 'width=device-width, initial-scale=1' }],
htmlAttrs: { lang: 'de' }
})
useSeoMeta({
titleTemplate: `%s - ${seo?.siteName}`,
ogSiteName: seo?.siteName,
twitterCard: 'summary_large_image'
})
provide('navigation', navigation)
</script>
<template>
<UApp>
<NuxtLoadingIndicator />
<AppHeader />
<UMain>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UMain>
<AppFooter />
<ClientOnly>
<LazyUContentSearch
:files="files"
:navigation="navigation"
/>
</ClientOnly>
</UApp>
</template>

View File

@@ -0,0 +1,25 @@
@import "tailwindcss";
@import "@nuxt/ui";
@source "../../../content/**/*";
@theme static {
--container-8xl: 90rem;
--font-sans: 'Public Sans', sans-serif;
--color-green-50: #EFFDF5;
--color-green-100: #D9FBE8;
--color-green-200: #B3F5D1;
--color-green-300: #75EDAE;
--color-green-400: #00DC82;
--color-green-500: #00C16A;
--color-green-600: #00A155;
--color-green-700: #007F45;
--color-green-800: #016538;
--color-green-900: #0A5331;
--color-green-950: #052E16;
}
:root {
--ui-container: var(--container-8xl);
}

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
const { footer } = useAppConfig()
</script>
<template>
<UFooter>
<template #left>
{{ footer.credits }}
</template>
<template #right>
<UColorModeButton v-if="footer?.colorMode" />
<template v-if="footer?.links">
<UButton
v-for="(link, index) of footer?.links"
:key="index"
v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
/>
</template>
</template>
</UFooter>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content'
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
const { header } = useAppConfig()
</script>
<template>
<UHeader
:ui="{ center: 'flex-1' }"
:to="header?.to || '/'"
>
<UContentSearchButton
v-if="header?.search"
:collapsed="false"
class="w-full"
/>
<template #left>
<NuxtLink :to="header?.to || '/'">
<AppLogo class="w-auto h-6 shrink-0" />
</NuxtLink>
</template>
<template #right>
<UContentSearchButton
v-if="header?.search"
class="lg:hidden"
/>
<UColorModeButton v-if="header?.colorMode" />
<template v-if="header?.links">
<UButton
v-for="(link, index) of header.links"
:key="index"
v-bind="{ color: 'neutral', variant: 'ghost', ...link }"
/>
</template>
</template>
<template #body>
<UContentNavigation
highlight
:navigation="navigation"
/>
</template>
</UHeader>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<span class="font-semibold text-primary">FEDEO Docs</span>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
const route = useRoute()
const repoBase = 'https://git.federspiel.tech/flfeders/FEDEO/src/branch/dev/docs'
const sourceUrl = computed(() => {
if (route.path === '/') {
return `${repoBase}/README.md`
}
return `${repoBase}${route.path}.md`
})
</script>
<template>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-file-text"
label="Quellseite"
:to="sourceUrl"
target="_blank"
/>
</template>

31
docs-site/app/error.vue Normal file
View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { NuxtError } from '#app'
defineProps<{
error: NuxtError
}>()
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs'))
const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSections('docs'), {
server: false
})
provide('navigation', navigation)
</script>
<template>
<UApp>
<AppHeader />
<UError :error="error" />
<AppFooter />
<ClientOnly>
<LazyUContentSearch
:files="files"
:navigation="navigation"
/>
</ClientOnly>
</UApp>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content'
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
</script>
<template>
<UContainer>
<UPage>
<template #left>
<UPageAside>
<UContentNavigation
highlight
:navigation="navigation"
/>
</UPageAside>
</template>
<slot />
</UPage>
</UContainer>
</template>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content'
import { findPageHeadline } from '@nuxt/content/utils'
definePageMeta({
layout: 'docs'
})
const route = useRoute()
const { toc } = useAppConfig()
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
const { data: page } = await useAsyncData(route.path, () => queryCollection('docs').path(route.path).first())
if (!page.value) {
throw createError({ statusCode: 404, statusMessage: 'Seite nicht gefunden', fatal: true })
}
const { data: surround } = await useAsyncData(`${route.path}-surround`, () => {
return queryCollectionItemSurroundings('docs', route.path, {
fields: ['description']
})
})
const title = page.value.seo?.title || page.value.title
const description = page.value.seo?.description || page.value.description
useSeoMeta({
title,
ogTitle: title,
description,
ogDescription: description
})
const headline = computed(() => findPageHeadline(navigation?.value, page.value?.path))
const links = computed(() => [...(toc?.bottom?.links || [])].filter(Boolean))
</script>
<template>
<UPage v-if="page">
<UPageHeader
:title="page.title"
:description="page.description"
:headline="headline"
>
<template #links>
<UButton
v-for="(link, index) in page.links"
:key="index"
v-bind="link"
/>
<PageHeaderLinks />
</template>
</UPageHeader>
<UPageBody>
<ContentRenderer
v-if="page"
:value="page"
/>
<USeparator v-if="surround?.length" />
<UContentSurround :surround="surround" />
</UPageBody>
<template
v-if="page?.body?.toc?.links?.length"
#right
>
<UContentToc
:title="toc?.title"
:links="page.body?.toc?.links"
>
<template
v-if="toc?.bottom"
#bottom
>
<div
class="hidden lg:block space-y-6"
:class="{ 'mt-6!': page.body?.toc?.links?.length }"
>
<USeparator
v-if="page.body?.toc?.links?.length"
type="dashed"
/>
<UPageLinks
:title="toc.bottom.title"
:links="links"
/>
</div>
</template>
</UContentToc>
</template>
</UPage>
</template>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
await navigateTo('/bedienung', { redirectCode: 302 })
</script>
<template>
<div />
</template>

View File

@@ -1,106 +0,0 @@
:root {
--bg: #f6f8f7;
--panel: #ffffff;
--text: #1f2937;
--muted: #5f6b7a;
--accent: #0b6e4f;
--line: #d8e0dc;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
color: var(--text);
background: radial-gradient(circle at 10% 10%, #e7f2ed, transparent 35%), var(--bg);
}
.docs-layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
min-height: 100vh;
}
.docs-aside {
border-right: 1px solid var(--line);
background: var(--panel);
padding: 1rem;
position: sticky;
top: 0;
height: 100vh;
overflow: auto;
}
.docs-brand {
display: block;
font-weight: 700;
color: var(--accent);
text-decoration: none;
margin-bottom: 1rem;
}
.docs-sidebar ul {
list-style: none;
margin: 0;
padding: 0 0 0 0.8rem;
}
.docs-sidebar > ul {
padding-left: 0;
}
.docs-sidebar li {
margin: 0.3rem 0;
}
.docs-sidebar a {
color: var(--text);
text-decoration: none;
}
.docs-sidebar a.router-link-active {
color: var(--accent);
font-weight: 600;
}
.docs-main {
padding: 2rem;
}
.docs-article {
max-width: 900px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
padding: 2rem;
}
.docs-article h1,
.docs-article h2,
.docs-article h3 {
margin-top: 1.5rem;
}
@media (max-width: 900px) {
.docs-layout {
grid-template-columns: 1fr;
}
.docs-aside {
position: static;
height: auto;
border-right: none;
border-bottom: 1px solid var(--line);
}
.docs-main {
padding: 1rem;
}
.docs-article {
padding: 1rem;
}
}

View File

@@ -1,21 +0,0 @@
<script setup lang="ts">
interface NavItem {
title?: string
_path?: string
children?: NavItem[]
}
defineProps<{ items: NavItem[] }>()
</script>
<template>
<nav class="docs-sidebar">
<ul>
<li v-for="item in items" :key="item._path || item.title">
<NuxtLink v-if="item._path" :to="item._path">{{ item.title }}</NuxtLink>
<span v-else>{{ item.title }}</span>
<DocsSidebar v-if="item.children?.length" :items="item.children" />
</li>
</ul>
</nav>
</template>

View File

@@ -0,0 +1,18 @@
import { defineCollection, defineContentConfig, z } from '@nuxt/content'
export default defineContentConfig({
collections: {
docs: defineCollection({
type: 'page',
source: '**',
schema: z.object({
links: z.array(z.object({
label: z.string(),
icon: z.string(),
to: z.string(),
target: z.string().optional()
})).optional()
})
})
}
})

View File

@@ -0,0 +1,42 @@
# Ausgangsbelege erstellen und bearbeiten
Diese Anleitung hilft dir beim Erstellen von Rechnungen, Angeboten, Lieferscheinen und ähnlichen Belegen.
## Typischer Ablauf
1. Neuen Beleg anlegen.
2. Belegart wählen (z. B. Rechnung oder Angebot).
3. Kunden- und Adressdaten prüfen.
4. Positionen eintragen.
5. Daten wie Belegdatum und Zahlungsziel prüfen.
6. Beleg speichern und bei Bedarf buchen.
## Wichtige Eingaben einfach erklärt
- `Belegart`: Legt fest, welche Art von Dokument erstellt wird.
- `Kunde`: Empfänger des Belegs.
- `Ansprechpartner`: Person beim Kunden für Rückfragen.
- `Adresse`: Zieladresse auf dem Dokument.
- `Belegnummer`: Eindeutige Nummer zur Wiedererkennung.
- `Belegdatum`: Offizielles Ausstellungsdatum.
- `Liefer-/Leistungsdatum`: Zeitraum oder Datum der Leistung.
- `Zahlungsziel`: Frist für den Zahlungseingang.
- `Zahlungsart`: Überweisung oder Lastschrift.
- `Positionen`: Leistungen oder Artikel mit Menge, Preis und Steuersatz.
## Empfehlungen für fehlerfreie Belege
- Vor dem Buchen immer Kunde, Datum und Belegnummer prüfen.
- Bei Rechnungen Zahlungsziel und Zahlungsart kontrollieren.
- Bei Zeiträumen Start- und Enddatum vollständig setzen.
- Vorschau prüfen, bevor der Beleg verschickt wird.
## Häufige Fragen
### Warum kann ich nicht buchen?
Meist fehlt eine Pflichtangabe wie Kunde, Briefpapier, Datum oder eine gültige Position.
### Wann ist ein Beleg im Kundenportal sichtbar?
Nur wenn die Freigabe aktiv ist und der Beleg nicht mehr im Entwurfsstatus steht.

View File

@@ -0,0 +1,41 @@
# Bankportal nutzen
Im Bankportal verbindest du Konten, prüfst Umsätze und unterstützt die Zuordnung zu Belegen.
## Ziele im Bankportal
- Kontobewegungen aktuell halten
- Offene Zahlungsein- und -ausgänge schneller zuordnen
- Buchhaltungsprozesse vorbereiten
## Typischer Arbeitsablauf
1. Kontoverbindung prüfen oder aktualisieren.
2. Neue Umsätze abrufen.
3. Offene Bewegungen sichten.
4. Vorschläge zur Zuordnung prüfen.
5. Passende Belege oder Konten zuweisen.
6. Ergebnis kontrollieren.
## Wichtige Bereiche
- `Umsatzliste`: Zeigt alle importierten Bankbewegungen.
- `Filter/Suche`: Hilft beim schnellen Finden einzelner Vorgänge.
- `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien.
- `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist.
## Gute Praxis
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
- Unklare Buchungen zeitnah klären.
- Bei wiederkehrenden Zahlungen auf konsistente Bezeichnung achten.
## Häufige Probleme
### Ein Umsatz wird nicht automatisch zugeordnet
Prüfe Betrag, Datum, Verwendungszweck und ob ein passender Beleg im System vorhanden ist.
### Es erscheinen doppelte oder fehlende Umsätze
Kontoverbindung aktualisieren und den Zeitraum der Synchronisation prüfen.

View File

@@ -0,0 +1,13 @@
# Bedienung
Diese Anleitung erklärt die wichtigsten Arbeitsabläufe in FEDEO in verständlicher, praxisnaher Form.
## Bereiche
- [Ausgangsbelege erstellen und bearbeiten](./ausgangsbelege.md)
- [Serienrechnungen planen und ausführen](./serienrechnungen.md)
- [Bankportal nutzen](./bankportal.md)
## Für wen ist diese Anleitung?
Für Anwenderinnen und Anwender, die mit FEDEO im Tagesgeschäft arbeiten und klare Schritt-für-Schritt-Hinweise benötigen.

View File

@@ -0,0 +1,31 @@
# Serienrechnungen planen und ausführen
Mit Serienrechnungen kannst du wiederkehrende Abrechnungen automatisieren.
## Wofür nutzt man Serienrechnungen?
- Regelmäßige Leistungen (z. B. monatliche Betreuung)
- Verträge mit festen Abständen
- Einheitliche Abrechnung ohne manuelles Neuerstellen jedes Belegs
## So legst du eine Serienrechnung an
1. Neue Serienrechnung öffnen.
2. Kunde, Leistungen und Preise eintragen.
3. Intervall festlegen (z. B. monatlich, quartalsweise).
4. Start- und Endzeitraum definieren.
5. Vorlage aktivieren und speichern.
## Manuelle Ausführung eines Laufs
1. Serienrechnungsübersicht öffnen.
2. Ausführungsdatum setzen.
3. Gewünschte Vorlagen auswählen.
4. Lauf starten.
5. Ergebnis prüfen und ggf. abschließen.
## Wichtige Hinweise
- Das Ausführungsdatum wirkt sich auf den Leistungszeitraum aus.
- Vor dem Lauf prüfen, ob alle Vorlagen aktiv und vollständig sind.
- Bei ungewöhnlichen Ergebnissen zuerst Beträge und Intervalle der Vorlage prüfen.

View File

@@ -1,238 +0,0 @@
# Backend API Funktionskatalog
Automatisch generiert (deterministisch, ohne Zeitstempel).
Hinweis: Diese Datei wird durch `docs/scripts/sync-funktionsdoku.mjs` erzeugt.
## backend/src/routes/admin.ts
| Methode | Pfad |
|---|---|
| POST | `/admin/add-user-to-tenant` |
| POST | `/admin/customers/:customerId/invite-portal-user` |
| GET | `/admin/overview` |
| POST | `/admin/tenants` |
| PUT | `/admin/tenants/:tenant_id` |
| GET | `/admin/user-tenants/:user_id` |
| POST | `/admin/users` |
| PUT | `/admin/users/:user_id` |
| PUT | `/admin/users/:user_id/access` |
## backend/src/routes/auth/auth-authenticated.ts
| Methode | Pfad |
|---|---|
| POST | `/auth/password/change` |
| POST | `/auth/refresh` |
## backend/src/routes/auth/auth.ts
| Methode | Pfad |
|---|---|
| POST | `/auth/login` |
| POST | `/auth/logout` |
| POST | `/auth/password/reset` |
| POST | `/auth/register` |
## backend/src/routes/auth/me.ts
| Methode | Pfad |
|---|---|
| GET | `/me` |
## backend/src/routes/auth/user.ts
| Methode | Pfad |
|---|---|
| GET | `/user/:id` |
| PUT | `/user/:id/profile` |
## backend/src/routes/banking.ts
| Methode | Pfad |
|---|---|
| GET | `/banking/iban/:iban` |
| GET | `/banking/institutions/:bic` |
| GET | `/banking/link/:institutionid` |
| GET | `/banking/requisitions/:reqId` |
| POST | `/banking/statements` |
| DELETE | `/banking/statements/:id` |
| GET | `/banking/statements/:id/suggestions` |
## backend/src/routes/devices/rfid.ts
| Methode | Pfad |
|---|---|
| POST | `/rfid/createevent/:terminal_id` |
## backend/src/routes/emailAsUser.ts
| Methode | Pfad |
|---|---|
| GET | `/email/accounts/:id?` |
| POST | `/email/accounts/:id?` |
| POST | `/email/send` |
## backend/src/routes/exports.ts
| Methode | Pfad |
|---|---|
| GET | `/exports` |
| POST | `/exports/datev` |
| POST | `/exports/sepa` |
## backend/src/routes/files.ts
| Methode | Pfad |
|---|---|
| GET | `/files/:id?` |
| POST | `/files/download/:id?` |
| POST | `/files/presigned/:id?` |
| POST | `/files/upload` |
## backend/src/routes/functions.ts
| Methode | Pfad |
|---|---|
| GET | `/functions/changelog` |
| GET | `/functions/check-zip/:zip` |
| POST | `/functions/pdf/:type` |
| POST | `/functions/serial/finish/:execution_id` |
| POST | `/functions/serial/start` |
| POST | `/functions/services/backfillfiletext` |
| POST | `/functions/services/bankstatementsync` |
| POST | `/functions/services/prepareincominginvoices` |
| POST | `/functions/services/syncdokubox` |
| GET | `/functions/timeevaluation/:user_id` |
| GET | `/functions/usenextnumber/:numberrange` |
| POST | `/print/label` |
## backend/src/routes/health.ts
| Methode | Pfad |
|---|---|
| GET | `/ping` |
## backend/src/routes/helpdesk.inbound.email.ts
| Methode | Pfad |
|---|---|
| POST | `/helpdesk/inbound-email` |
## backend/src/routes/helpdesk.inbound.ts
| Methode | Pfad |
|---|---|
| POST | `/helpdesk/inbound/:public_token` |
## backend/src/routes/helpdesk.ts
| Methode | Pfad |
|---|---|
| POST | `/helpdesk/channels` |
| POST | `/helpdesk/contacts` |
| GET | `/helpdesk/conversations` |
| POST | `/helpdesk/conversations` |
| GET | `/helpdesk/conversations/:id` |
| GET | `/helpdesk/conversations/:id/messages` |
| POST | `/helpdesk/conversations/:id/messages` |
| POST | `/helpdesk/conversations/:id/reply` |
| PATCH | `/helpdesk/conversations/:id/status` |
## backend/src/routes/history.ts
| Methode | Pfad |
|---|---|
| GET | `/history` |
## backend/src/routes/internal/auth.m2m.ts
| Methode | Pfad |
|---|---|
| POST | `/auth/m2m/token` |
## backend/src/routes/internal/tenant.ts
| Methode | Pfad |
|---|---|
| GET | `/tenant/:id` |
| GET | `/tenant/:id/profiles` |
| GET | `/tenant/users` |
## backend/src/routes/internal/time.ts
| Methode | Pfad |
|---|---|
| POST | `/staff/time/event` |
| GET | `/staff/time/spans` |
## backend/src/routes/notifications.ts
| Methode | Pfad |
|---|---|
| POST | `/notifications/trigger` |
## backend/src/routes/profiles.ts
| Methode | Pfad |
|---|---|
| GET | `/profiles/:id` |
| PUT | `/profiles/:id` |
## backend/src/routes/publiclinks/publiclinks-authenticated.ts
| Methode | Pfad |
|---|---|
| POST | `/publiclinks` |
## backend/src/routes/publiclinks/publiclinks-non-authenticated.ts
| Methode | Pfad |
|---|---|
| GET | `/workflows/context/:token` |
| POST | `/workflows/submit/:token` |
## backend/src/routes/resources/main.ts
| Methode | Pfad |
|---|---|
| GET | `/resource/:resource` |
| POST | `/resource/:resource` |
| PUT | `/resource/:resource/:id` |
| GET | `/resource/:resource/:id/:no_relations?` |
| GET | `/resource/:resource/paginated` |
## backend/src/routes/resourcesSpecial.ts
| Methode | Pfad |
|---|---|
| GET | `/resource-special/:resource` |
## backend/src/routes/staff/time.ts
| Methode | Pfad |
|---|---|
| POST | `/staff/time/approve` |
| POST | `/staff/time/edit` |
| GET | `/staff/time/evaluation` |
| POST | `/staff/time/event` |
| POST | `/staff/time/reject` |
| GET | `/staff/time/spans` |
| POST | `/staff/time/submit` |
## backend/src/routes/tenant.ts
| Methode | Pfad |
|---|---|
| GET | `/tenant` |
| GET | `/tenant/api-keys` |
| POST | `/tenant/api-keys` |
| DELETE | `/tenant/api-keys/:id` |
| PATCH | `/tenant/api-keys/:id` |
| PUT | `/tenant/numberrange/:numberrange` |
| PUT | `/tenant/other/:id` |
| GET | `/tenant/profiles` |
| POST | `/tenant/switch` |
| GET | `/tenant/users` |
Gesamtzahl erkannter Endpunkte: **93**

View File

@@ -1,70 +0,0 @@
# Frontend Web Funktionskatalog
Automatisch generiert (deterministisch, ohne Zeitstempel).
Hinweis: Diese Datei wird durch `docs/scripts/sync-funktionsdoku.mjs` erzeugt.
| Route (Nuxt) | Datei |
|---|---|
| `/` | `frontend/pages/index.client.vue` |
| `/accounting/bwa` | `frontend/pages/accounting/bwa.vue` |
| `/accounting/depreciation` | `frontend/pages/accounting/depreciation.vue` |
| `/accounting/tax` | `frontend/pages/accounting/tax.vue` |
| `/accounts` | `frontend/pages/accounts/index.vue` |
| `/accounts/show/:id` | `frontend/pages/accounts/show/[id].vue` |
| `/administration/tenants` | `frontend/pages/administration/tenants/index.vue` |
| `/administration/tenants/:id` | `frontend/pages/administration/tenants/[id].vue` |
| `/administration/users` | `frontend/pages/administration/users/index.vue` |
| `/administration/users/:id` | `frontend/pages/administration/users/[id].vue` |
| `/banking` | `frontend/pages/banking/index.vue` |
| `/banking/statements/:mode/:id?` | `frontend/pages/banking/statements/[mode]/[[id]].vue` |
| `/calendar/:mode` | `frontend/pages/calendar/[mode].vue` |
| `/createdletters/:mode/:id?` | `frontend/pages/createdletters/[mode]/[[id]].vue` |
| `/createDocument` | `frontend/pages/createDocument/index.vue` |
| `/createDocument/edit/:id?` | `frontend/pages/createDocument/edit/[[id]].vue` |
| `/createDocument/serialInvoice` | `frontend/pages/createDocument/serialInvoice.vue` |
| `/createDocument/show/:id` | `frontend/pages/createDocument/show/[id].vue` |
| `/customer-portal` | `frontend/pages/customer-portal.vue` |
| `/email/new` | `frontend/pages/email/new.vue` |
| `/export` | `frontend/pages/export/index.vue` |
| `/export/create/sepa` | `frontend/pages/export/create/sepa.vue` |
| `/files` | `frontend/pages/files/index.vue` |
| `/forms` | `frontend/pages/forms.vue` |
| `/helpdesk/:id?` | `frontend/pages/helpdesk/[[id]].vue` |
| `/historyitems` | `frontend/pages/historyitems/index.vue` |
| `/incomingInvoices` | `frontend/pages/incomingInvoices/index.vue` |
| `/incomingInvoices/:mode/:id` | `frontend/pages/incomingInvoices/[mode]/[id].vue` |
| `/login` | `frontend/pages/login.vue` |
| `/organisation/plantafel` | `frontend/pages/organisation/plantafel.vue` |
| `/password-change` | `frontend/pages/password-change.vue` |
| `/password-reset` | `frontend/pages/password-reset.vue` |
| `/projecttypes` | `frontend/pages/projecttypes/index.vue` |
| `/projecttypes/:mode/:id?` | `frontend/pages/projecttypes/[mode]/[[id]].vue` |
| `/roles` | `frontend/pages/roles/index.vue` |
| `/roles/:mode/:id?` | `frontend/pages/roles/[mode]/[[id]].vue` |
| `/settings` | `frontend/pages/settings/index.vue` |
| `/settings/admin` | `frontend/pages/settings/admin.vue` |
| `/settings/banking` | `frontend/pages/settings/banking/index.vue` |
| `/settings/emailaccounts` | `frontend/pages/settings/emailaccounts/index.vue` |
| `/settings/emailaccounts/:mode/:id?` | `frontend/pages/settings/emailaccounts/[mode]/[[id]].vue` |
| `/settings/externalDevices` | `frontend/pages/settings/externalDevices.vue` |
| `/settings/numberRanges` | `frontend/pages/settings/numberRanges.vue` |
| `/settings/ownfields` | `frontend/pages/settings/ownfields.vue` |
| `/settings/tenant` | `frontend/pages/settings/tenant.vue` |
| `/settings/texttemplates` | `frontend/pages/settings/texttemplates.vue` |
| `/staff/profiles` | `frontend/pages/staff/profiles/index.vue` |
| `/staff/profiles/:id` | `frontend/pages/staff/profiles/[id].vue` |
| `/staff/time` | `frontend/pages/staff/time/index.vue` |
| `/staff/time/:id/evaluate` | `frontend/pages/staff/time/[id]/evaluate.vue` |
| `/standardEntity/:type` | `frontend/pages/standardEntity/[type]/index.vue` |
| `/standardEntity/:type/:mode/:id?` | `frontend/pages/standardEntity/[type]/[mode]/[[id]].vue` |
| `/support` | `frontend/pages/support/index.vue` |
| `/support/:id` | `frontend/pages/support/[id].vue` |
| `/support/create` | `frontend/pages/support/create.vue` |
| `/tasks` | `frontend/pages/tasks/index.vue` |
| `/tasks/:mode/:id?` | `frontend/pages/tasks/[mode]/[[id]].vue` |
| `/test` | `frontend/pages/test.vue` |
| `/wiki/:id?` | `frontend/pages/wiki/[[id]].vue` |
| `/workflows/:token` | `frontend/pages/workflows/[token].vue` |
Gesamtzahl erkannter Web-Routen: **60**

View File

@@ -1,23 +0,0 @@
# Funktionen
Diese Sektion dokumentiert alle Funktionen der FEDEO-Software in drei Ebenen:
- Fachliche Übersicht: `uebersicht.md`
- Technischer API-Katalog: `backend-api.md`
- Technischer Web-Katalog: `frontend-web.md`
- Technischer Mobile-Katalog: `mobile-app.md`
## Empfohlene Lesereihenfolge
1. `uebersicht.md`
2. `backend-api.md`
3. `frontend-web.md`
4. `mobile-app.md`
## Aktualisierung
Die technischen Kataloge werden mit folgendem Befehl aktualisiert:
```bash
node docs/scripts/sync-funktionsdoku.mjs
```

View File

@@ -1,29 +0,0 @@
# Mobile App Funktionskatalog
Automatisch generiert (deterministisch, ohne Zeitstempel).
Hinweis: Diese Datei wird durch `docs/scripts/sync-funktionsdoku.mjs` erzeugt.
| Route (Expo Router) | Datei |
|---|---|
| `/` | `mobile/app/index.tsx` |
| `/(tabs)` | `mobile/app/(tabs)/index.tsx` |
| `/(tabs)/explore` | `mobile/app/(tabs)/explore.tsx` |
| `/(tabs)/projects` | `mobile/app/(tabs)/projects.tsx` |
| `/(tabs)/tasks` | `mobile/app/(tabs)/tasks.tsx` |
| `/(tabs)/time` | `mobile/app/(tabs)/time.tsx` |
| `/login` | `mobile/app/login.tsx` |
| `/modal` | `mobile/app/modal.tsx` |
| `/more/account` | `mobile/app/more/account.tsx` |
| `/more/customer/:id` | `mobile/app/more/customer/[id].tsx` |
| `/more/customers` | `mobile/app/more/customers.tsx` |
| `/more/inventory` | `mobile/app/more/inventory.tsx` |
| `/more/nimbot` | `mobile/app/more/nimbot.tsx` |
| `/more/plant/:id` | `mobile/app/more/plant/[id].tsx` |
| `/more/plants` | `mobile/app/more/plants.tsx` |
| `/more/settings` | `mobile/app/more/settings.tsx` |
| `/more/wiki` | `mobile/app/more/wiki.tsx` |
| `/project/:id` | `mobile/app/project/[id].tsx` |
| `/tenant-select` | `mobile/app/tenant-select.tsx` |
Gesamtzahl erkannter Mobile-Screens: **19**

View File

@@ -1,70 +0,0 @@
# Funktionsübersicht
## Zielbild
FEDEO besteht funktional aus drei zentralen Schichten:
- Backend-API (Geschäftslogik, Daten, Integrationen)
- Web-Frontend (administrative und operative Arbeitsoberfläche)
- Mobile-App (mobile Nutzung für operative Prozesse)
Die technische Detailauflistung wird automatisiert erzeugt:
- [Backend-API](./backend-api.md)
- [Frontend Web](./frontend-web.md)
- [Mobile-App](./mobile-app.md)
## Funktionsbereiche
### 1) Authentifizierung und Mandantenfähigkeit
- Login, Session, Nutzerkontext
- Rollen, Rechte, Profile
- Mandantenbezogene Datenabgrenzung
### 2) Stammdaten und Ressourcen
- Kunden, Kontakte, Projekte, Teams
- Materialien, Leistungen, Fahrzeuge, Standorte
- Erweiterbare Standard-Entitäten
### 3) Operative Prozesse
- Aufgabenmanagement
- Zeiterfassung und Zeitauswertung
- Dokumentenerstellung und Ablage
- Verlauf/Historie
### 4) Finanz- und Abrechnungsfunktionen
- Buchhaltungssichten
- Bankdaten und Zuordnungen
- Exporte (z. B. DATEV/SEPA)
- Rechnungskontexte
### 5) Kommunikation und Service
- Helpdesk und Nachrichten
- Benachrichtigungen
- E-Mail-bezogene Prozesse
### 6) Wissensmanagement
- Wiki-Seiten
- Strukturierte Inhalte für internes Wissen
### 7) Geräteschnittstellen und Integrationen
- RFID-/Geräteendpunkte
- S3-Dateispeicher
- Mail- und externe API-Integrationen
## Dokumentationsprinzip
- Fachliche Beschreibung in dieser Datei
- Technische Vollständigkeit in den automatisch erzeugten Katalogen
- Änderungsnachweis über die Doku-Versionierung
## Pflegehinweis
Wenn Funktionen hinzugefügt, umbenannt oder entfernt werden, ist die technische Dokumentation immer per Skript zu aktualisieren und zu committen.

View File

@@ -1,46 +1,7 @@
# FEDEO Funktionsdokumentation # Bedienungsanleitung
Diese Dokumentation bildet alle Funktionen der Software zentral ab und ist für die Nutzung in der Nuxt-Content-Docs-Homepage vorbereitet. Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
## Ziel ## Einstieg
- Vollständige Übersicht über Funktionen in Backend, Web-Frontend und Mobile-App - [Bedienung](./bedienung/README.md)
- Nachvollziehbare Versionierung der Doku
- Einfache Aktualisierung bei Funktionsänderungen
## Struktur
- `docs/funktionen/uebersicht.md`: Fachliche Gesamtübersicht der Bereiche
- `docs/funktionen/backend-api.md`: Automatisch erzeugte API-Funktionsliste
- `docs/funktionen/frontend-web.md`: Automatisch erzeugte Seiten-/Funktionsliste des Web-Frontends
- `docs/funktionen/mobile-app.md`: Automatisch erzeugte Screens-/Funktionsliste der Mobile-App
- `docs/versionen/docs-versionen.md`: Versionierung der Dokumentation
- `docs/wartung/dokumentationsprozess.md`: Prozess, damit die Doku dauerhaft aktuell bleibt
- `docs/scripts/sync-funktionsdoku.mjs`: Skript zur automatischen Aktualisierung
## Aktualisierung bei Funktionsänderungen
Bei jeder Funktionsänderung bitte ausführen:
```bash
node docs/scripts/sync-funktionsdoku.mjs
```
Danach:
1. Änderungen in `docs/funktionen/*.md` prüfen
2. Falls nötig fachliche Texte in `docs/funktionen/uebersicht.md` ergänzen
3. Eintrag in `docs/versionen/docs-versionen.md` ergänzen
4. Alles gemeinsam committen
## Verwendung mit Nuxt Content
Empfohlene Vorgehensweise:
1. `docs/` in das Content-Verzeichnis übernehmen (oder per Sync einbinden)
2. Navigation anhand der Ordner `funktionen`, `wartung`, `versionen` aufbauen
3. `backend-api.md`, `frontend-web.md`, `mobile-app.md` als referenzierende Funktionskataloge einbinden
## Hinweis
Die Dateien `backend-api.md`, `frontend-web.md` und `mobile-app.md` werden automatisch generiert. Manuelle Änderungen in diesen Dateien werden bei der nächsten Synchronisation überschrieben.

View File

@@ -1,25 +0,0 @@
# Doku-Versionen
## Version 0.1.0 (2026-04-21)
- Grundstruktur der vollständigen Funktionsdokumentation erstellt
- Automatische Erzeugung für Backend-API, Frontend-Web und Mobile-App eingeführt
- Wartungsprozess für laufende Aktualisierung definiert
## Versionsschema
Empfohlenes Schema: `MAJOR.MINOR.PATCH`
- `MAJOR`: Grundlegende Umstrukturierung der Doku
- `MINOR`: Neue Funktionsbereiche oder größere Ergänzungen
- `PATCH`: Korrekturen, Präzisierungen, kleinere Ergänzungen
## Eintragsvorlage
```md
## Version X.Y.Z (YYYY-MM-DD)
- Änderung 1
- Änderung 2
- Änderung 3
```

View File

@@ -1,35 +0,0 @@
# Dokumentationsprozess
## Zweck
Dieser Prozess stellt sicher, dass die Funktionsdokumentation bei jeder Änderung aktuell bleibt.
## Verbindlicher Ablauf bei Funktionsänderungen
1. Funktion implementieren oder ändern
2. Technische Doku synchronisieren:
```bash
node docs/scripts/sync-funktionsdoku.mjs
```
3. Fachliche Beschreibung in `docs/funktionen/uebersicht.md` ergänzen, falls ein neuer Bereich entsteht
4. Neue Doku-Version in `docs/versionen/docs-versionen.md` eintragen
5. Code und Doku gemeinsam committen
## Was als Funktionsänderung gilt
- Neue API-Route oder geänderte API-Route
- Neue Web-Seite oder geänderte Seitenstruktur
- Neuer Mobile-Screen oder geänderte Navigationsstruktur
- Größere fachliche Änderung in bestehenden Modulen
## Qualitätsregeln
- Automatisch erzeugte Dateien nicht manuell pflegen
- Fachliche Begriffe konsistent halten
- Jede Doku-Version erhält Datum, Änderungszusammenfassung und Bezug zu Commits
## CI-Empfehlung
Optional kann in CI geprüft werden, ob die generierten Doku-Dateien aktuell sind (z. B. per Diff nach Skriptlauf), damit keine Funktionsänderung ohne Doku-Update gemerged wird.

View File

@@ -1,22 +1,27 @@
export default defineNuxtConfig({ export default defineNuxtConfig({
modules: ['@nuxt/content'], modules: [
'@nuxt/image',
'@nuxt/ui',
'@nuxt/content'
],
css: ['~/assets/css/main.css'], css: ['~/assets/css/main.css'],
app: {
head: {
title: 'FEDEO Docs',
meta: [
{ name: 'description', content: 'Versionierte FEDEO-Dokumentation auf Nuxt Content.' }
]
}
},
content: { content: {
documentDriven: false, build: {
highlight: { markdown: {
theme: 'github-light' toc: {
searchDepth: 1
}
}
} }
}, },
experimental: {
asyncContext: true
},
compatibilityDate: '2024-07-11',
nitro: { nitro: {
preset: 'node-server' preset: 'node-server'
}, },
compatibilityDate: '2025-01-01' icon: {
provider: 'iconify'
}
}) })

16365
docs-site/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,24 @@
{ {
"name": "fedeo-docs-site", "name": "fedeo-docs-site",
"version": "2.0.0",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"dev": "node ./scripts/sync-content.mjs && nuxi dev --host 0.0.0.0 --port 3005", "build": "node ./scripts/sync-content.mjs && nuxt build",
"build": "node ./scripts/sync-content.mjs && nuxi build", "dev": "node ./scripts/sync-content.mjs && nuxt dev --host 0.0.0.0 --port 3005",
"preview": "nuxi preview --host 0.0.0.0 --port 3005" "preview": "nuxt preview --host 0.0.0.0 --port 3005",
"postinstall": "nuxt prepare"
}, },
"dependencies": { "dependencies": {
"nuxt": "^3.17.7", "@iconify-json/lucide": "^1.2.102",
"@nuxt/content": "^2.13.4" "@iconify-json/simple-icons": "^1.2.78",
"@nuxt/content": "^3.12.0",
"@nuxt/image": "^2.0.0",
"@nuxt/ui": "^4.6.1",
"better-sqlite3": "^12.9.0",
"nuxt": "^4.4.2"
},
"devDependencies": {
"typescript": "^6.0.2"
}, },
"engines": { "engines": {
"node": ">=20.0" "node": ">=20.0"

View File

@@ -1,30 +0,0 @@
<script setup lang="ts">
const route = useRoute()
const contentPath = computed(() => route.path)
const { data: navigation } = await useAsyncData('docs-navigation', () => fetchContentNavigation())
const { data: page } = await useAsyncData(
() => `docs-page-${contentPath.value}`,
() => queryContent(contentPath.value).findOne(),
{ watch: [contentPath] }
)
if (!page.value) {
throw createError({ statusCode: 404, statusMessage: 'Seite nicht gefunden' })
}
</script>
<template>
<div class="docs-layout">
<aside class="docs-aside">
<NuxtLink class="docs-brand" to="/">FEDEO Docs</NuxtLink>
<DocsSidebar :items="navigation || []" />
</aside>
<main class="docs-main">
<article class="docs-article">
<ContentRenderer :value="page" />
</article>
</main>
</div>
</template>

View File

@@ -1,23 +0,0 @@
<script setup lang="ts">
const { data: navigation } = await useAsyncData('docs-navigation', () => fetchContentNavigation())
const { data: page } = await useAsyncData('docs-page-index', () => queryContent('/index').findOne())
if (!page.value) {
throw createError({ statusCode: 404, statusMessage: 'Seite nicht gefunden' })
}
</script>
<template>
<div class="docs-layout">
<aside class="docs-aside">
<NuxtLink class="docs-brand" to="/">FEDEO Docs</NuxtLink>
<DocsSidebar :items="navigation || []" />
</aside>
<main class="docs-main">
<article class="docs-article">
<ContentRenderer :value="page" />
</article>
</main>
</div>
</template>

View File

@@ -1,5 +1,3 @@
{ {
"compilerOptions": { "extends": "./.nuxt/tsconfig.json"
"types": ["@types/node"]
}
} }

View File

@@ -1,46 +1,7 @@
# FEDEO Funktionsdokumentation # Bedienungsanleitung
Diese Dokumentation bildet alle Funktionen der Software zentral ab und ist für die Nutzung in der Nuxt-Content-Docs-Homepage vorbereitet. Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
## Ziel ## Einstieg
- Vollständige Übersicht über Funktionen in Backend, Web-Frontend und Mobile-App - [Bedienung](./bedienung/README.md)
- Nachvollziehbare Versionierung der Doku
- Einfache Aktualisierung bei Funktionsänderungen
## Struktur
- `docs/funktionen/uebersicht.md`: Fachliche Gesamtübersicht der Bereiche
- `docs/funktionen/backend-api.md`: Automatisch erzeugte API-Funktionsliste
- `docs/funktionen/frontend-web.md`: Automatisch erzeugte Seiten-/Funktionsliste des Web-Frontends
- `docs/funktionen/mobile-app.md`: Automatisch erzeugte Screens-/Funktionsliste der Mobile-App
- `docs/versionen/docs-versionen.md`: Versionierung der Dokumentation
- `docs/wartung/dokumentationsprozess.md`: Prozess, damit die Doku dauerhaft aktuell bleibt
- `docs/scripts/sync-funktionsdoku.mjs`: Skript zur automatischen Aktualisierung
## Aktualisierung bei Funktionsänderungen
Bei jeder Funktionsänderung bitte ausführen:
```bash
node docs/scripts/sync-funktionsdoku.mjs
```
Danach:
1. Änderungen in `docs/funktionen/*.md` prüfen
2. Falls nötig fachliche Texte in `docs/funktionen/uebersicht.md` ergänzen
3. Eintrag in `docs/versionen/docs-versionen.md` ergänzen
4. Alles gemeinsam committen
## Verwendung mit Nuxt Content
Empfohlene Vorgehensweise:
1. `docs/` in das Content-Verzeichnis übernehmen (oder per Sync einbinden)
2. Navigation anhand der Ordner `funktionen`, `wartung`, `versionen` aufbauen
3. `backend-api.md`, `frontend-web.md`, `mobile-app.md` als referenzierende Funktionskataloge einbinden
## Hinweis
Die Dateien `backend-api.md`, `frontend-web.md` und `mobile-app.md` werden automatisch generiert. Manuelle Änderungen in diesen Dateien werden bei der nächsten Synchronisation überschrieben.

13
docs/bedienung/README.md Normal file
View File

@@ -0,0 +1,13 @@
# Bedienung
Diese Anleitung erklärt die wichtigsten Arbeitsabläufe in FEDEO in verständlicher, praxisnaher Form.
## Bereiche
- [Ausgangsbelege erstellen und bearbeiten](./ausgangsbelege.md)
- [Serienrechnungen planen und ausführen](./serienrechnungen.md)
- [Bankportal nutzen](./bankportal.md)
## Für wen ist diese Anleitung?
Für Anwenderinnen und Anwender, die mit FEDEO im Tagesgeschäft arbeiten und klare Schritt-für-Schritt-Hinweise benötigen.

View File

@@ -0,0 +1,42 @@
# Ausgangsbelege erstellen und bearbeiten
Diese Anleitung hilft dir beim Erstellen von Rechnungen, Angeboten, Lieferscheinen und ähnlichen Belegen.
## Typischer Ablauf
1. Neuen Beleg anlegen.
2. Belegart wählen (z. B. Rechnung oder Angebot).
3. Kunden- und Adressdaten prüfen.
4. Positionen eintragen.
5. Daten wie Belegdatum und Zahlungsziel prüfen.
6. Beleg speichern und bei Bedarf buchen.
## Wichtige Eingaben einfach erklärt
- `Belegart`: Legt fest, welche Art von Dokument erstellt wird.
- `Kunde`: Empfänger des Belegs.
- `Ansprechpartner`: Person beim Kunden für Rückfragen.
- `Adresse`: Zieladresse auf dem Dokument.
- `Belegnummer`: Eindeutige Nummer zur Wiedererkennung.
- `Belegdatum`: Offizielles Ausstellungsdatum.
- `Liefer-/Leistungsdatum`: Zeitraum oder Datum der Leistung.
- `Zahlungsziel`: Frist für den Zahlungseingang.
- `Zahlungsart`: Überweisung oder Lastschrift.
- `Positionen`: Leistungen oder Artikel mit Menge, Preis und Steuersatz.
## Empfehlungen für fehlerfreie Belege
- Vor dem Buchen immer Kunde, Datum und Belegnummer prüfen.
- Bei Rechnungen Zahlungsziel und Zahlungsart kontrollieren.
- Bei Zeiträumen Start- und Enddatum vollständig setzen.
- Vorschau prüfen, bevor der Beleg verschickt wird.
## Häufige Fragen
### Warum kann ich nicht buchen?
Meist fehlt eine Pflichtangabe wie Kunde, Briefpapier, Datum oder eine gültige Position.
### Wann ist ein Beleg im Kundenportal sichtbar?
Nur wenn die Freigabe aktiv ist und der Beleg nicht mehr im Entwurfsstatus steht.

View File

@@ -0,0 +1,41 @@
# Bankportal nutzen
Im Bankportal verbindest du Konten, prüfst Umsätze und unterstützt die Zuordnung zu Belegen.
## Ziele im Bankportal
- Kontobewegungen aktuell halten
- Offene Zahlungsein- und -ausgänge schneller zuordnen
- Buchhaltungsprozesse vorbereiten
## Typischer Arbeitsablauf
1. Kontoverbindung prüfen oder aktualisieren.
2. Neue Umsätze abrufen.
3. Offene Bewegungen sichten.
4. Vorschläge zur Zuordnung prüfen.
5. Passende Belege oder Konten zuweisen.
6. Ergebnis kontrollieren.
## Wichtige Bereiche
- `Umsatzliste`: Zeigt alle importierten Bankbewegungen.
- `Filter/Suche`: Hilft beim schnellen Finden einzelner Vorgänge.
- `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien.
- `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist.
## Gute Praxis
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
- Unklare Buchungen zeitnah klären.
- Bei wiederkehrenden Zahlungen auf konsistente Bezeichnung achten.
## Häufige Probleme
### Ein Umsatz wird nicht automatisch zugeordnet
Prüfe Betrag, Datum, Verwendungszweck und ob ein passender Beleg im System vorhanden ist.
### Es erscheinen doppelte oder fehlende Umsätze
Kontoverbindung aktualisieren und den Zeitraum der Synchronisation prüfen.

View File

@@ -0,0 +1,31 @@
# Serienrechnungen planen und ausführen
Mit Serienrechnungen kannst du wiederkehrende Abrechnungen automatisieren.
## Wofür nutzt man Serienrechnungen?
- Regelmäßige Leistungen (z. B. monatliche Betreuung)
- Verträge mit festen Abständen
- Einheitliche Abrechnung ohne manuelles Neuerstellen jedes Belegs
## So legst du eine Serienrechnung an
1. Neue Serienrechnung öffnen.
2. Kunde, Leistungen und Preise eintragen.
3. Intervall festlegen (z. B. monatlich, quartalsweise).
4. Start- und Endzeitraum definieren.
5. Vorlage aktivieren und speichern.
## Manuelle Ausführung eines Laufs
1. Serienrechnungsübersicht öffnen.
2. Ausführungsdatum setzen.
3. Gewünschte Vorlagen auswählen.
4. Lauf starten.
5. Ergebnis prüfen und ggf. abschließen.
## Wichtige Hinweise
- Das Ausführungsdatum wirkt sich auf den Leistungszeitraum aus.
- Vor dem Lauf prüfen, ob alle Vorlagen aktiv und vollständig sind.
- Bei ungewöhnlichen Ergebnissen zuerst Beträge und Intervalle der Vorlage prüfen.

View File

@@ -1,238 +0,0 @@
# Backend API Funktionskatalog
Automatisch generiert (deterministisch, ohne Zeitstempel).
Hinweis: Diese Datei wird durch `docs/scripts/sync-funktionsdoku.mjs` erzeugt.
## backend/src/routes/admin.ts
| Methode | Pfad |
|---|---|
| POST | `/admin/add-user-to-tenant` |
| POST | `/admin/customers/:customerId/invite-portal-user` |
| GET | `/admin/overview` |
| POST | `/admin/tenants` |
| PUT | `/admin/tenants/:tenant_id` |
| GET | `/admin/user-tenants/:user_id` |
| POST | `/admin/users` |
| PUT | `/admin/users/:user_id` |
| PUT | `/admin/users/:user_id/access` |
## backend/src/routes/auth/auth-authenticated.ts
| Methode | Pfad |
|---|---|
| POST | `/auth/password/change` |
| POST | `/auth/refresh` |
## backend/src/routes/auth/auth.ts
| Methode | Pfad |
|---|---|
| POST | `/auth/login` |
| POST | `/auth/logout` |
| POST | `/auth/password/reset` |
| POST | `/auth/register` |
## backend/src/routes/auth/me.ts
| Methode | Pfad |
|---|---|
| GET | `/me` |
## backend/src/routes/auth/user.ts
| Methode | Pfad |
|---|---|
| GET | `/user/:id` |
| PUT | `/user/:id/profile` |
## backend/src/routes/banking.ts
| Methode | Pfad |
|---|---|
| GET | `/banking/iban/:iban` |
| GET | `/banking/institutions/:bic` |
| GET | `/banking/link/:institutionid` |
| GET | `/banking/requisitions/:reqId` |
| POST | `/banking/statements` |
| DELETE | `/banking/statements/:id` |
| GET | `/banking/statements/:id/suggestions` |
## backend/src/routes/devices/rfid.ts
| Methode | Pfad |
|---|---|
| POST | `/rfid/createevent/:terminal_id` |
## backend/src/routes/emailAsUser.ts
| Methode | Pfad |
|---|---|
| GET | `/email/accounts/:id?` |
| POST | `/email/accounts/:id?` |
| POST | `/email/send` |
## backend/src/routes/exports.ts
| Methode | Pfad |
|---|---|
| GET | `/exports` |
| POST | `/exports/datev` |
| POST | `/exports/sepa` |
## backend/src/routes/files.ts
| Methode | Pfad |
|---|---|
| GET | `/files/:id?` |
| POST | `/files/download/:id?` |
| POST | `/files/presigned/:id?` |
| POST | `/files/upload` |
## backend/src/routes/functions.ts
| Methode | Pfad |
|---|---|
| GET | `/functions/changelog` |
| GET | `/functions/check-zip/:zip` |
| POST | `/functions/pdf/:type` |
| POST | `/functions/serial/finish/:execution_id` |
| POST | `/functions/serial/start` |
| POST | `/functions/services/backfillfiletext` |
| POST | `/functions/services/bankstatementsync` |
| POST | `/functions/services/prepareincominginvoices` |
| POST | `/functions/services/syncdokubox` |
| GET | `/functions/timeevaluation/:user_id` |
| GET | `/functions/usenextnumber/:numberrange` |
| POST | `/print/label` |
## backend/src/routes/health.ts
| Methode | Pfad |
|---|---|
| GET | `/ping` |
## backend/src/routes/helpdesk.inbound.email.ts
| Methode | Pfad |
|---|---|
| POST | `/helpdesk/inbound-email` |
## backend/src/routes/helpdesk.inbound.ts
| Methode | Pfad |
|---|---|
| POST | `/helpdesk/inbound/:public_token` |
## backend/src/routes/helpdesk.ts
| Methode | Pfad |
|---|---|
| POST | `/helpdesk/channels` |
| POST | `/helpdesk/contacts` |
| GET | `/helpdesk/conversations` |
| POST | `/helpdesk/conversations` |
| GET | `/helpdesk/conversations/:id` |
| GET | `/helpdesk/conversations/:id/messages` |
| POST | `/helpdesk/conversations/:id/messages` |
| POST | `/helpdesk/conversations/:id/reply` |
| PATCH | `/helpdesk/conversations/:id/status` |
## backend/src/routes/history.ts
| Methode | Pfad |
|---|---|
| GET | `/history` |
## backend/src/routes/internal/auth.m2m.ts
| Methode | Pfad |
|---|---|
| POST | `/auth/m2m/token` |
## backend/src/routes/internal/tenant.ts
| Methode | Pfad |
|---|---|
| GET | `/tenant/:id` |
| GET | `/tenant/:id/profiles` |
| GET | `/tenant/users` |
## backend/src/routes/internal/time.ts
| Methode | Pfad |
|---|---|
| POST | `/staff/time/event` |
| GET | `/staff/time/spans` |
## backend/src/routes/notifications.ts
| Methode | Pfad |
|---|---|
| POST | `/notifications/trigger` |
## backend/src/routes/profiles.ts
| Methode | Pfad |
|---|---|
| GET | `/profiles/:id` |
| PUT | `/profiles/:id` |
## backend/src/routes/publiclinks/publiclinks-authenticated.ts
| Methode | Pfad |
|---|---|
| POST | `/publiclinks` |
## backend/src/routes/publiclinks/publiclinks-non-authenticated.ts
| Methode | Pfad |
|---|---|
| GET | `/workflows/context/:token` |
| POST | `/workflows/submit/:token` |
## backend/src/routes/resources/main.ts
| Methode | Pfad |
|---|---|
| GET | `/resource/:resource` |
| POST | `/resource/:resource` |
| PUT | `/resource/:resource/:id` |
| GET | `/resource/:resource/:id/:no_relations?` |
| GET | `/resource/:resource/paginated` |
## backend/src/routes/resourcesSpecial.ts
| Methode | Pfad |
|---|---|
| GET | `/resource-special/:resource` |
## backend/src/routes/staff/time.ts
| Methode | Pfad |
|---|---|
| POST | `/staff/time/approve` |
| POST | `/staff/time/edit` |
| GET | `/staff/time/evaluation` |
| POST | `/staff/time/event` |
| POST | `/staff/time/reject` |
| GET | `/staff/time/spans` |
| POST | `/staff/time/submit` |
## backend/src/routes/tenant.ts
| Methode | Pfad |
|---|---|
| GET | `/tenant` |
| GET | `/tenant/api-keys` |
| POST | `/tenant/api-keys` |
| DELETE | `/tenant/api-keys/:id` |
| PATCH | `/tenant/api-keys/:id` |
| PUT | `/tenant/numberrange/:numberrange` |
| PUT | `/tenant/other/:id` |
| GET | `/tenant/profiles` |
| POST | `/tenant/switch` |
| GET | `/tenant/users` |
Gesamtzahl erkannter Endpunkte: **93**

View File

@@ -1,70 +0,0 @@
# Frontend Web Funktionskatalog
Automatisch generiert (deterministisch, ohne Zeitstempel).
Hinweis: Diese Datei wird durch `docs/scripts/sync-funktionsdoku.mjs` erzeugt.
| Route (Nuxt) | Datei |
|---|---|
| `/` | `frontend/pages/index.client.vue` |
| `/accounting/bwa` | `frontend/pages/accounting/bwa.vue` |
| `/accounting/depreciation` | `frontend/pages/accounting/depreciation.vue` |
| `/accounting/tax` | `frontend/pages/accounting/tax.vue` |
| `/accounts` | `frontend/pages/accounts/index.vue` |
| `/accounts/show/:id` | `frontend/pages/accounts/show/[id].vue` |
| `/administration/tenants` | `frontend/pages/administration/tenants/index.vue` |
| `/administration/tenants/:id` | `frontend/pages/administration/tenants/[id].vue` |
| `/administration/users` | `frontend/pages/administration/users/index.vue` |
| `/administration/users/:id` | `frontend/pages/administration/users/[id].vue` |
| `/banking` | `frontend/pages/banking/index.vue` |
| `/banking/statements/:mode/:id?` | `frontend/pages/banking/statements/[mode]/[[id]].vue` |
| `/calendar/:mode` | `frontend/pages/calendar/[mode].vue` |
| `/createdletters/:mode/:id?` | `frontend/pages/createdletters/[mode]/[[id]].vue` |
| `/createDocument` | `frontend/pages/createDocument/index.vue` |
| `/createDocument/edit/:id?` | `frontend/pages/createDocument/edit/[[id]].vue` |
| `/createDocument/serialInvoice` | `frontend/pages/createDocument/serialInvoice.vue` |
| `/createDocument/show/:id` | `frontend/pages/createDocument/show/[id].vue` |
| `/customer-portal` | `frontend/pages/customer-portal.vue` |
| `/email/new` | `frontend/pages/email/new.vue` |
| `/export` | `frontend/pages/export/index.vue` |
| `/export/create/sepa` | `frontend/pages/export/create/sepa.vue` |
| `/files` | `frontend/pages/files/index.vue` |
| `/forms` | `frontend/pages/forms.vue` |
| `/helpdesk/:id?` | `frontend/pages/helpdesk/[[id]].vue` |
| `/historyitems` | `frontend/pages/historyitems/index.vue` |
| `/incomingInvoices` | `frontend/pages/incomingInvoices/index.vue` |
| `/incomingInvoices/:mode/:id` | `frontend/pages/incomingInvoices/[mode]/[id].vue` |
| `/login` | `frontend/pages/login.vue` |
| `/organisation/plantafel` | `frontend/pages/organisation/plantafel.vue` |
| `/password-change` | `frontend/pages/password-change.vue` |
| `/password-reset` | `frontend/pages/password-reset.vue` |
| `/projecttypes` | `frontend/pages/projecttypes/index.vue` |
| `/projecttypes/:mode/:id?` | `frontend/pages/projecttypes/[mode]/[[id]].vue` |
| `/roles` | `frontend/pages/roles/index.vue` |
| `/roles/:mode/:id?` | `frontend/pages/roles/[mode]/[[id]].vue` |
| `/settings` | `frontend/pages/settings/index.vue` |
| `/settings/admin` | `frontend/pages/settings/admin.vue` |
| `/settings/banking` | `frontend/pages/settings/banking/index.vue` |
| `/settings/emailaccounts` | `frontend/pages/settings/emailaccounts/index.vue` |
| `/settings/emailaccounts/:mode/:id?` | `frontend/pages/settings/emailaccounts/[mode]/[[id]].vue` |
| `/settings/externalDevices` | `frontend/pages/settings/externalDevices.vue` |
| `/settings/numberRanges` | `frontend/pages/settings/numberRanges.vue` |
| `/settings/ownfields` | `frontend/pages/settings/ownfields.vue` |
| `/settings/tenant` | `frontend/pages/settings/tenant.vue` |
| `/settings/texttemplates` | `frontend/pages/settings/texttemplates.vue` |
| `/staff/profiles` | `frontend/pages/staff/profiles/index.vue` |
| `/staff/profiles/:id` | `frontend/pages/staff/profiles/[id].vue` |
| `/staff/time` | `frontend/pages/staff/time/index.vue` |
| `/staff/time/:id/evaluate` | `frontend/pages/staff/time/[id]/evaluate.vue` |
| `/standardEntity/:type` | `frontend/pages/standardEntity/[type]/index.vue` |
| `/standardEntity/:type/:mode/:id?` | `frontend/pages/standardEntity/[type]/[mode]/[[id]].vue` |
| `/support` | `frontend/pages/support/index.vue` |
| `/support/:id` | `frontend/pages/support/[id].vue` |
| `/support/create` | `frontend/pages/support/create.vue` |
| `/tasks` | `frontend/pages/tasks/index.vue` |
| `/tasks/:mode/:id?` | `frontend/pages/tasks/[mode]/[[id]].vue` |
| `/test` | `frontend/pages/test.vue` |
| `/wiki/:id?` | `frontend/pages/wiki/[[id]].vue` |
| `/workflows/:token` | `frontend/pages/workflows/[token].vue` |
Gesamtzahl erkannter Web-Routen: **60**

View File

@@ -1,23 +0,0 @@
# Funktionen
Diese Sektion dokumentiert alle Funktionen der FEDEO-Software in drei Ebenen:
- Fachliche Übersicht: `uebersicht.md`
- Technischer API-Katalog: `backend-api.md`
- Technischer Web-Katalog: `frontend-web.md`
- Technischer Mobile-Katalog: `mobile-app.md`
## Empfohlene Lesereihenfolge
1. `uebersicht.md`
2. `backend-api.md`
3. `frontend-web.md`
4. `mobile-app.md`
## Aktualisierung
Die technischen Kataloge werden mit folgendem Befehl aktualisiert:
```bash
node docs/scripts/sync-funktionsdoku.mjs
```

View File

@@ -1,29 +0,0 @@
# Mobile App Funktionskatalog
Automatisch generiert (deterministisch, ohne Zeitstempel).
Hinweis: Diese Datei wird durch `docs/scripts/sync-funktionsdoku.mjs` erzeugt.
| Route (Expo Router) | Datei |
|---|---|
| `/` | `mobile/app/index.tsx` |
| `/(tabs)` | `mobile/app/(tabs)/index.tsx` |
| `/(tabs)/explore` | `mobile/app/(tabs)/explore.tsx` |
| `/(tabs)/projects` | `mobile/app/(tabs)/projects.tsx` |
| `/(tabs)/tasks` | `mobile/app/(tabs)/tasks.tsx` |
| `/(tabs)/time` | `mobile/app/(tabs)/time.tsx` |
| `/login` | `mobile/app/login.tsx` |
| `/modal` | `mobile/app/modal.tsx` |
| `/more/account` | `mobile/app/more/account.tsx` |
| `/more/customer/:id` | `mobile/app/more/customer/[id].tsx` |
| `/more/customers` | `mobile/app/more/customers.tsx` |
| `/more/inventory` | `mobile/app/more/inventory.tsx` |
| `/more/nimbot` | `mobile/app/more/nimbot.tsx` |
| `/more/plant/:id` | `mobile/app/more/plant/[id].tsx` |
| `/more/plants` | `mobile/app/more/plants.tsx` |
| `/more/settings` | `mobile/app/more/settings.tsx` |
| `/more/wiki` | `mobile/app/more/wiki.tsx` |
| `/project/:id` | `mobile/app/project/[id].tsx` |
| `/tenant-select` | `mobile/app/tenant-select.tsx` |
Gesamtzahl erkannter Mobile-Screens: **19**

View File

@@ -1,70 +0,0 @@
# Funktionsübersicht
## Zielbild
FEDEO besteht funktional aus drei zentralen Schichten:
- Backend-API (Geschäftslogik, Daten, Integrationen)
- Web-Frontend (administrative und operative Arbeitsoberfläche)
- Mobile-App (mobile Nutzung für operative Prozesse)
Die technische Detailauflistung wird automatisiert erzeugt:
- [Backend-API](./backend-api.md)
- [Frontend Web](./frontend-web.md)
- [Mobile-App](./mobile-app.md)
## Funktionsbereiche
### 1) Authentifizierung und Mandantenfähigkeit
- Login, Session, Nutzerkontext
- Rollen, Rechte, Profile
- Mandantenbezogene Datenabgrenzung
### 2) Stammdaten und Ressourcen
- Kunden, Kontakte, Projekte, Teams
- Materialien, Leistungen, Fahrzeuge, Standorte
- Erweiterbare Standard-Entitäten
### 3) Operative Prozesse
- Aufgabenmanagement
- Zeiterfassung und Zeitauswertung
- Dokumentenerstellung und Ablage
- Verlauf/Historie
### 4) Finanz- und Abrechnungsfunktionen
- Buchhaltungssichten
- Bankdaten und Zuordnungen
- Exporte (z. B. DATEV/SEPA)
- Rechnungskontexte
### 5) Kommunikation und Service
- Helpdesk und Nachrichten
- Benachrichtigungen
- E-Mail-bezogene Prozesse
### 6) Wissensmanagement
- Wiki-Seiten
- Strukturierte Inhalte für internes Wissen
### 7) Geräteschnittstellen und Integrationen
- RFID-/Geräteendpunkte
- S3-Dateispeicher
- Mail- und externe API-Integrationen
## Dokumentationsprinzip
- Fachliche Beschreibung in dieser Datei
- Technische Vollständigkeit in den automatisch erzeugten Katalogen
- Änderungsnachweis über die Doku-Versionierung
## Pflegehinweis
Wenn Funktionen hinzugefügt, umbenannt oder entfernt werden, ist die technische Dokumentation immer per Skript zu aktualisieren und zu committen.

View File

@@ -1,25 +0,0 @@
# Doku-Versionen
## Version 0.1.0 (2026-04-21)
- Grundstruktur der vollständigen Funktionsdokumentation erstellt
- Automatische Erzeugung für Backend-API, Frontend-Web und Mobile-App eingeführt
- Wartungsprozess für laufende Aktualisierung definiert
## Versionsschema
Empfohlenes Schema: `MAJOR.MINOR.PATCH`
- `MAJOR`: Grundlegende Umstrukturierung der Doku
- `MINOR`: Neue Funktionsbereiche oder größere Ergänzungen
- `PATCH`: Korrekturen, Präzisierungen, kleinere Ergänzungen
## Eintragsvorlage
```md
## Version X.Y.Z (YYYY-MM-DD)
- Änderung 1
- Änderung 2
- Änderung 3
```

View File

@@ -1,35 +0,0 @@
# Dokumentationsprozess
## Zweck
Dieser Prozess stellt sicher, dass die Funktionsdokumentation bei jeder Änderung aktuell bleibt.
## Verbindlicher Ablauf bei Funktionsänderungen
1. Funktion implementieren oder ändern
2. Technische Doku synchronisieren:
```bash
node docs/scripts/sync-funktionsdoku.mjs
```
3. Fachliche Beschreibung in `docs/funktionen/uebersicht.md` ergänzen, falls ein neuer Bereich entsteht
4. Neue Doku-Version in `docs/versionen/docs-versionen.md` eintragen
5. Code und Doku gemeinsam committen
## Was als Funktionsänderung gilt
- Neue API-Route oder geänderte API-Route
- Neue Web-Seite oder geänderte Seitenstruktur
- Neuer Mobile-Screen oder geänderte Navigationsstruktur
- Größere fachliche Änderung in bestehenden Modulen
## Qualitätsregeln
- Automatisch erzeugte Dateien nicht manuell pflegen
- Fachliche Begriffe konsistent halten
- Jede Doku-Version erhält Datum, Änderungszusammenfassung und Bezug zu Commits
## CI-Empfehlung
Optional kann in CI geprüft werden, ob die generierten Doku-Dateien aktuell sind (z. B. per Diff nach Skriptlauf), damit keine Funktionsänderung ohne Doku-Update gemerged wird.

View File

@@ -267,13 +267,8 @@ const selectItem = (item) => {
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }" :ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
:on-select="(row) => selectItem(row.original)" :on-select="(row) => selectItem(row.original)"
style="height: 70vh" style="height: 70vh"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
> >
<template #empty>
<div class="flex flex-col items-center justify-center py-8 text-center text-sm text-muted">
<UIcon name="i-heroicons-circle-stack-20-solid" class="mb-2 size-5" />
<span>Keine Belege anzuzeigen</span>
</div>
</template>
<template #type-cell="{ row }"> <template #type-cell="{ row }">
{{ dataStore.documentTypesForCreation[row.original.type].labelSingle }} {{ dataStore.documentTypesForCreation[row.original.type].labelSingle }}
</template> </template>

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)))
@@ -244,14 +254,8 @@ const selectAllocation = (allocationLike) => {
:columns="normalizeTableColumns(columns)" :columns="normalizeTableColumns(columns)"
:on-select="selectAllocation" :on-select="selectAllocation"
class="w-full" class="w-full"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen im ausgewählten Zeitraum' }"
> >
<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 Buchungen im ausgewaehlten Zeitraum</p>
</div>
</template>
<template #amount-cell="{ row }"> <template #amount-cell="{ row }">
<span class="text-right text-error" v-if="row.original.amount < 0 || row.original.color === 'red'">{{ useCurrency(row.original.amount) }}</span> <span class="text-right text-error" 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 class="text-right text-primary-500" v-else-if="row.original.amount > 0 || row.original.color === 'green'">{{ useCurrency(row.original.amount) }}</span>
@@ -259,7 +263,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

@@ -69,13 +69,8 @@ const columns = [
class="mt-3" class="mt-3"
:columns="normalizeTableColumns(columns)" :columns="normalizeTableColumns(columns)"
:data="props.item.times" :data="props.item.times"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Noch keine Einträge' }"
> >
<template #empty>
<div class="flex flex-col items-center justify-center py-8 text-center text-sm text-muted">
<UIcon name="i-heroicons-circle-stack-20-solid" class="mb-2 size-5" />
<span>Noch keine Einträge</span>
</div>
</template>
<template #state-cell="{ row }"> <template #state-cell="{ row }">
<span <span
v-if="row.original.state === 'Entwurf'" v-if="row.original.state === 'Entwurf'"

View File

@@ -141,7 +141,7 @@ const links = computed(() => {
to: "/accounting/depreciation", to: "/accounting/depreciation",
icon: "i-heroicons-calendar-days", icon: "i-heroicons-calendar-days",
} : null, } : null,
((featureEnabled("createDocument") || featureEnabled("incomingInvoices")) || featureEnabled("accounts") || featureEnabled("ownaccounts") || featureEnabled("costcentres")) ? { ((featureEnabled("createDocument") || featureEnabled("incomingInvoices")) || featureEnabled("accounts") || featureEnabled("ownaccounts") || featureEnabled("costcentres") || featureEnabled("banking")) ? {
label: "Auswertungen", label: "Auswertungen",
icon: "i-heroicons-chart-pie", icon: "i-heroicons-chart-pie",
defaultOpen: false, defaultOpen: false,
@@ -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("banking") ? {
label: "Liquidität",
to: "/accounting/liquidity",
icon: "i-heroicons-banknotes",
} : null,
featureEnabled("costcentres") ? { featureEnabled("costcentres") ? {
label: "Kostenstellen", label: "Kostenstellen",
to: "/standardEntity/costcentres", to: "/standardEntity/costcentres",
@@ -178,6 +183,11 @@ const links = computed(() => {
to: "/banking", to: "/banking",
icon: "i-heroicons-document-text", icon: "i-heroicons-document-text",
} : null, } : null,
(featureEnabled("accounts") || featureEnabled("ownaccounts")) ? {
label: "Manuelle Buchungen",
to: "/accounting/manual-bookings",
icon: "i-heroicons-arrows-right-left",
} : null,
] ]
const inventoryChildren = [ const inventoryChildren = [

View File

@@ -12,7 +12,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'saved']) const emit = defineEmits(['update:modelValue', 'saved'])
// 💡 createEntry importieren // 💡 createEntry importieren
const { update, createEntry } = useStaffTime() const { list, update, createEntry } = useStaffTime()
const { $dayjs } = useNuxtApp() const { $dayjs } = useNuxtApp()
const toast = useToast() const toast = useToast()
@@ -85,7 +85,7 @@ async function onSubmit(event: FormSubmitEvent<any>) {
if (state.end_date && state.end_time) { if (state.end_date && state.end_time) {
endIso = $dayjs(`${state.end_date} ${state.end_time}`).toISOString() endIso = $dayjs(`${state.end_date} ${state.end_time}`).toISOString()
if ($dayjs(endIso).isBefore($dayjs(startIso))) { if (!$dayjs(endIso).isAfter($dayjs(startIso))) {
throw new Error("Endzeitpunkt muss nach dem Startzeitpunkt liegen.") throw new Error("Endzeitpunkt muss nach dem Startzeitpunkt liegen.")
} }
} }
@@ -100,6 +100,33 @@ async function onSubmit(event: FormSubmitEvent<any>) {
}) })
toast.add({ title: 'Eintrag aktualisiert', color: 'green' }) toast.add({ title: 'Eintrag aktualisiert', color: 'green' })
} else { } else {
if (endIso) {
const existingEntries = await list({
user_id: props.defaultUserId
})
const newStart = $dayjs(startIso).valueOf()
const newEnd = $dayjs(endIso).valueOf()
const blockingStates = new Set(['draft', 'factual', 'submitted', 'approved'])
const conflictingEntry = existingEntries.find(existingEntry => {
if (!blockingStates.has(existingEntry.state) || !existingEntry.stopped_at) return false
const existingStart = $dayjs(existingEntry.started_at).valueOf()
const existingEnd = $dayjs(existingEntry.stopped_at).valueOf()
if (!Number.isFinite(existingStart) || !Number.isFinite(existingEnd)) return false
return newStart < existingEnd && existingStart < newEnd
})
if (conflictingEntry) {
const conflictStart = $dayjs(conflictingEntry.started_at).format('DD.MM.YYYY HH:mm')
const conflictEnd = $dayjs(conflictingEntry.stopped_at).format('DD.MM.YYYY HH:mm')
throw new Error(`Überschneidung mit ${conflictStart} bis ${conflictEnd} (${conflictingEntry.state}).`)
}
}
// 🟢 CREATE (Neu Erstellen) // 🟢 CREATE (Neu Erstellen)
// 💡 HIER WAR DER FEHLER: Wir nutzen jetzt createEntry mit den Daten aus dem Formular // 💡 HIER WAR DER FEHLER: Wir nutzen jetzt createEntry mit den Daten aus dem Formular
await createEntry({ await createEntry({

View File

@@ -0,0 +1,32 @@
<script setup>
const props = defineProps({
row: {
type: Object,
required: true,
default: {}
},
inShow: {
type: Boolean,
default: false
}
})
const value = computed(() => {
const costcentre = props.row.parentCostcentre || props.row.costcentre || props.row.costCentre || null
if (!costcentre) {
return ""
}
return [costcentre.number, costcentre.name].filter(Boolean).join(" - ")
})
</script>
<template>
<div v-if="props.row.parentCostcentre">
<nuxt-link v-if="props.inShow" :to="`/standardEntity/costcentres/show/${props.row.parentCostcentre.id}`">
{{ value }}
</nuxt-link>
<span v-else>{{ value }}</span>
</div>
</template>

View File

@@ -10,11 +10,67 @@ const props = defineProps({
const loading = ref(true) const loading = ref(true)
const incomingInvoices = ref([]) const incomingInvoices = ref([])
const costcentres = ref([])
const selectedYear = ref(String(dayjs().year())) const selectedYear = ref(String(dayjs().year()))
const selectedMonth = ref("all") const selectedMonth = ref("all")
const currency = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} EUR` const currency = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} EUR`
const getCostCentreId = (value) => {
if (!value) {
return null
}
return typeof value === "object" ? value.id : value
}
const costCentreMap = computed(() => {
return new Map(costcentres.value.map((costcentre) => [costcentre.id, costcentre]))
})
const relevantCostCentreIds = computed(() => {
const rootId = props.item?.id
if (!rootId) {
return new Set()
}
const childrenByParent = new Map()
costcentres.value.forEach((costcentre) => {
const parentId = getCostCentreId(costcentre.parentCostcentre)
if (!parentId) {
return
}
if (!childrenByParent.has(parentId)) {
childrenByParent.set(parentId, [])
}
childrenByParent.get(parentId).push(costcentre.id)
})
const collectedIds = new Set([rootId])
const queue = [rootId]
while (queue.length > 0) {
const currentId = queue.shift()
const childIds = childrenByParent.get(currentId) || []
childIds.forEach((childId) => {
if (collectedIds.has(childId)) {
return
}
collectedIds.add(childId)
queue.push(childId)
})
}
return collectedIds
})
const yearItems = computed(() => { const yearItems = computed(() => {
const years = [...new Set( const years = [...new Set(
incomingInvoices.value incomingInvoices.value
@@ -29,7 +85,7 @@ 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" },
@@ -53,12 +109,15 @@ const reportRows = computed(() => {
return [] return []
} }
const matchingAccounts = (invoice.accounts || []).filter((account) => account.costCentre === props.item.id) const matchingAccounts = (invoice.accounts || []).filter((account) =>
relevantCostCentreIds.value.has(account.costCentre)
)
return matchingAccounts.map((account, index) => { return matchingAccounts.map((account, index) => {
const amountNet = Number(account.amountNet || 0) const amountNet = Number(account.amountNet || 0)
const amountTax = Number(account.amountTax || 0) const amountTax = Number(account.amountTax || 0)
const amountGross = Number(account.amountGross || amountNet + amountTax || 0) const amountGross = Number(account.amountGross || amountNet + amountTax || 0)
const accountCostCentre = costCentreMap.value.get(getCostCentreId(account.costCentre))
return { return {
id: `${invoice.id}-${index}`, id: `${invoice.id}-${index}`,
@@ -68,6 +127,7 @@ const reportRows = computed(() => {
state: invoice.state || "-", state: invoice.state || "-",
vendorName: invoice.vendor?.name || "-", vendorName: invoice.vendor?.name || "-",
accountLabel: account.account?.label || account.accountLabel || "-", accountLabel: account.account?.label || account.accountLabel || "-",
costCentreName: accountCostCentre ? `${accountCostCentre.number} - ${accountCostCentre.name}` : "-",
description: account.description || invoice.description || "-", description: account.description || invoice.description || "-",
amountNet, amountNet,
amountTax, amountTax,
@@ -91,6 +151,7 @@ const columns = [
{ accessorKey: "date", header: "Datum" }, { accessorKey: "date", header: "Datum" },
{ accessorKey: "vendorName", header: "Lieferant" }, { accessorKey: "vendorName", header: "Lieferant" },
{ accessorKey: "accountLabel", header: "Konto" }, { accessorKey: "accountLabel", header: "Konto" },
{ accessorKey: "costCentreName", header: "Kostenstelle" },
{ accessorKey: "description", header: "Beschreibung" }, { accessorKey: "description", header: "Beschreibung" },
{ accessorKey: "amountNet", header: "Netto" }, { accessorKey: "amountNet", header: "Netto" },
{ accessorKey: "amountTax", header: "Steuer" }, { accessorKey: "amountTax", header: "Steuer" },
@@ -100,10 +161,11 @@ const columns = [
const setupPage = async () => { const setupPage = async () => {
loading.value = true loading.value = true
costcentres.value = await useEntities("costcentres").select("*", null, false, true)
const invoices = await useEntities("incominginvoices").select("*, vendor(id,name)") const invoices = await useEntities("incominginvoices").select("*, vendor(id,name)")
incomingInvoices.value = invoices.filter((invoice) => incomingInvoices.value = invoices.filter((invoice) =>
(invoice.accounts || []).some((account) => account.costCentre === props.item.id) (invoice.accounts || []).some((account) => relevantCostCentreIds.value.has(account.costCentre))
) )
const firstYear = yearItems.value[0]?.value const firstYear = yearItems.value[0]?.value
@@ -162,7 +224,7 @@ setupPage()
v-if="!loading" v-if="!loading"
:data="reportRows" :data="reportRows"
:columns="columns" :columns="columns"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Eingangsbelege mit dieser Kostenstelle gefunden' }" :empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Eingangsbelege mit dieser Kostenstelle oder ihren Unterkostenstellen gefunden' }"
class="w-full" class="w-full"
> >
<template #reference-cell="{ row }"> <template #reference-cell="{ row }">
@@ -181,6 +243,10 @@ setupPage()
<div class="truncate">{{ row.original.accountLabel }}</div> <div class="truncate">{{ row.original.accountLabel }}</div>
</template> </template>
<template #costCentreName-cell="{ row }">
<div class="truncate">{{ row.original.costCentreName }}</div>
</template>
<template #description-cell="{ row }"> <template #description-cell="{ row }">
<UTooltip :text="row.original.description"> <UTooltip :text="row.original.description">
<div class="max-w-[18rem] truncate">{{ row.original.description }}</div> <div class="max-w-[18rem] truncate">{{ row.original.description }}</div>

View File

@@ -22,14 +22,11 @@ setupPage()
<template> <template>
<UTable <UTable
v-if="openTasks.length > 0"
:data="openTasks" :data="openTasks"
:columns="normalizeTableColumns([{key:'name',label:'Name'},{key:'categorie',label:'Kategorie'}])" :columns="normalizeTableColumns([{key:'name',label:'Name'},{key:'categorie',label:'Kategorie'}])"
:on-select="(i) => router.push(`/tasks/show/${i.id}`)" :on-select="(i) => router.push(`/tasks/show/${i.id}`)"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine offenen Aufgaben' }"
/> />
<div v-else>
<p class="text-center font-bold">Keine offenen Aufgaben</p>
</div>
</template> </template>
<style scoped> <style scoped>

View File

@@ -97,5 +97,15 @@ export const useFunctions = () => {
return await useNuxtApp().$api(`/api/banking/statements/${statementId}/suggestions`) return await useNuxtApp().$api(`/api/banking/statements/${statementId}/suggestions`)
} }
return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useBankingResolveIban, useBankingStatementSuggestions, useCreatePDF} const useLiquidityForecast = async (ignoredRecurringKeys = []) => {
const query = new URLSearchParams()
if (ignoredRecurringKeys.length) {
query.set("ignoredRecurringKeys", ignoredRecurringKeys.join(","))
}
const suffix = query.toString() ? `?${query.toString()}` : ""
return await useNuxtApp().$api(`/api/functions/liquidity-forecast${suffix}`)
}
return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useBankingResolveIban, useBankingStatementSuggestions, useLiquidityForecast, useCreatePDF}
} }

View File

@@ -26,7 +26,7 @@ export const useStaffTime = () => {
duration_minutes: end.diff(start, 'minute'), duration_minutes: end.diff(start, 'minute'),
user_id: targetUserId, user_id: targetUserId,
type: span.type, type: span.type,
description: span.payload?.description || '' description: span.description || span.payload?.description || ''
} }
}).sort((a: any, b: any) => $dayjs(b.started_at).valueOf() - $dayjs(a.started_at).valueOf()) }).sort((a: any, b: any) => $dayjs(b.started_at).valueOf() - $dayjs(a.started_at).valueOf())
} catch (error) { } catch (error) {

View File

@@ -585,14 +585,8 @@ onMounted(setupPage)
:columns="normalizeTableColumns(accountColumns)" :columns="normalizeTableColumns(accountColumns)"
:loading="loading" :loading="loading"
:on-select="openAccount" :on-select="openAccount"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungskonten im ausgewählten Zeitraum' }"
> >
<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 }"> <template #label-cell="{ row }">
<div class="truncate font-medium">{{ row.original.label }}</div> <div class="truncate font-medium">{{ row.original.label }}</div>
</template> </template>
@@ -630,14 +624,8 @@ onMounted(setupPage)
:columns="normalizeTableColumns(ownAccountColumns)" :columns="normalizeTableColumns(ownAccountColumns)"
:loading="loading" :loading="loading"
:on-select="openOwnAccount" :on-select="openOwnAccount"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine eigenen Buchungen im ausgewählten Zeitraum' }"
> >
<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 }"> <template #label-cell="{ row }">
<div class="truncate font-medium">{{ row.original.label }}</div> <div class="truncate font-medium">{{ row.original.label }}</div>
</template> </template>
@@ -676,6 +664,7 @@ onMounted(setupPage)
:data="depreciationRows" :data="depreciationRows"
:columns="normalizeTableColumns(depreciationColumns)" :columns="normalizeTableColumns(depreciationColumns)"
:loading="loading" :loading="loading"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Abschreibungen im ausgewählten Zeitraum' }"
> >
<template #amount-cell="{ row }"> <template #amount-cell="{ row }">
<div class="text-right text-amber-600 dark:text-amber-400 tabular-nums">{{ useCurrency(row.original.amount) }}</div> <div class="text-right text-amber-600 dark:text-amber-400 tabular-nums">{{ useCurrency(row.original.amount) }}</div>

View File

@@ -0,0 +1,903 @@
<script setup>
import dayjs from "dayjs"
import { Line } from "vue-chartjs"
const toast = useToast()
const tempStore = useTempStore()
const forecast = ref(null)
const rawForecast = ref(null)
const loading = ref(false)
const error = ref("")
const cacheLoaded = ref(false)
const CACHE_KEY = "fedeo:liquidityForecast:v3"
const dismissedRecurringKeys = computed(() => {
return tempStore.settings?.liquidityForecast?.dismissedRecurringKeys || []
})
const includeDraftsAsInvoices = computed({
get: () => Boolean(tempStore.settings?.liquidityForecast?.includeDraftsAsInvoices),
set: (value) => {
tempStore.modifySettings("liquidityForecast", {
...(tempStore.settings?.liquidityForecast || {}),
includeDraftsAsInvoices: Boolean(value)
})
}
})
const storeDismissedRecurringKeys = (keys) => {
tempStore.modifySettings("liquidityForecast", {
...(tempStore.settings?.liquidityForecast || {}),
dismissedRecurringKeys: [...new Set(keys)].filter(Boolean)
})
}
const sourceLabels = {
open_createddocument: "Offene Ausgangsrechnung",
open_incominginvoice: "Offener Eingangsbeleg",
recurring_bankstatement: "Regelmäßige Bankbewegung",
draft_createddocument: "Rechnungsentwurf",
tax_settlement: "USt-Zahlung",
serial_template: "Serienvorlage"
}
const intervalLabels = {
weekly: "wöchentlich",
monthly: "monatlich",
quarterly: "quartalsweise",
yearly: "jährlich"
}
const roundMoney = (value) => Number(Number(value || 0).toFixed(2))
const saveForecastCache = (value) => {
if (!import.meta.client || !value) return
localStorage.setItem(CACHE_KEY, JSON.stringify({
cachedAt: new Date().toISOString(),
forecast: value
}))
}
const readForecastCache = () => {
if (!import.meta.client) return null
try {
const raw = localStorage.getItem(CACHE_KEY)
return raw ? JSON.parse(raw) : null
} catch (err) {
return null
}
}
const applyForecastAdjustments = (value) => {
if (!value) return null
const dismissed = new Set(dismissedRecurringKeys.value)
const baseEvents = (value.events || []).filter((event) => {
return event.source !== "recurring_bankstatement" || !event.recurringKey || !dismissed.has(event.recurringKey)
})
const events = includeDraftsAsInvoices.value
? [...baseEvents, ...(value.draftEvents || [])]
: baseEvents
const recurring = (value.recurring || []).filter((item) => !item.key || !dismissed.has(item.key))
const eventsByDate = new Map()
events.forEach((event) => {
if (!eventsByDate.has(event.date)) eventsByDate.set(event.date, [])
eventsByDate.get(event.date).push(event)
})
let runningBalance = Number(value.startingBalance || 0)
const points = (value.points || []).map((point) => {
const dayEvents = [...(eventsByDate.get(point.date) || [])].sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount))
const income = roundMoney(dayEvents.filter((event) => event.amount > 0).reduce((sum, event) => sum + Number(event.amount || 0), 0))
const expense = roundMoney(dayEvents.filter((event) => event.amount < 0).reduce((sum, event) => sum + Number(event.amount || 0), 0))
runningBalance = roundMoney(runningBalance + income + expense)
return {
...point,
balance: runningBalance,
income,
expense,
events: dayEvents
}
})
const lowestPoint = points.reduce((lowest, point) => point.balance < lowest.balance ? point : lowest, points[0] || { balance: value.startingBalance, date: value.generatedAt })
return {
...value,
recurring,
events,
points,
endingBalance: points[points.length - 1]?.balance || value.startingBalance,
lowestBalance: lowestPoint?.balance || value.startingBalance,
lowestBalanceDate: lowestPoint?.date || value.generatedAt,
totalIncome: roundMoney(events.filter((event) => event.amount > 0).reduce((sum, event) => sum + Number(event.amount || 0), 0)),
totalExpense: roundMoney(events.filter((event) => event.amount < 0).reduce((sum, event) => sum + Number(event.amount || 0), 0))
}
}
const setRawForecast = (value) => {
rawForecast.value = value
forecast.value = applyForecastAdjustments(value)
}
const loadForecastFromCache = () => {
const cached = readForecastCache()
if (cached?.forecast) {
setRawForecast({
...cached.forecast,
cachedAt: cached.cachedAt
})
}
cacheLoaded.value = true
}
const refreshForecast = async () => {
loading.value = true
error.value = ""
try {
const nextForecast = await useFunctions().useLiquidityForecast()
saveForecastCache(nextForecast)
setRawForecast({
...nextForecast,
cachedAt: new Date().toISOString()
})
toast.add({
title: "Prognose aktualisiert",
description: "Das neue Ergebnis wurde zwischengespeichert.",
color: "success"
})
} catch (err) {
error.value = "Die Liquiditätsprognose konnte nicht neu erstellt werden."
toast.add({
title: "Fehler",
description: error.value,
color: "error"
})
} finally {
loading.value = false
}
}
const formatDate = (value) => dayjs(value).format("DD.MM.YYYY")
const getEventRoute = (event) => {
if (event.source === "open_createddocument" && event.sourceId) return `/createDocument/show/${event.sourceId}`
if (event.source === "open_incominginvoice" && event.sourceId) return `/incomingInvoices/show/${event.sourceId}`
if (event.source === "tax_settlement") return "/accounting/tax"
if (event.source === "serial_template" && event.sourceId) return `/createDocument/edit/${event.sourceId}`
return null
}
const openEvent = (event) => {
const route = getEventRoute(event)
if (route) navigateTo(route)
}
const dismissRecurringKey = async (key) => {
if (!key) return
storeDismissedRecurringKeys([...dismissedRecurringKeys.value, key])
forecast.value = applyForecastAdjustments(rawForecast.value)
toast.add({
title: "Bankbewegung abgeschlossen",
description: "Das erkannte Muster wird aus der Liquiditätsprognose entfernt.",
color: "success"
})
}
const restoreDismissedRecurring = () => {
storeDismissedRecurringKeys([])
forecast.value = applyForecastAdjustments(rawForecast.value)
toast.add({
title: "Bankbewegungen wiederhergestellt",
description: "Ausgeblendete Muster werden wieder in der Prognose berücksichtigt.",
color: "success"
})
}
const cachedAtLabel = computed(() => {
const cachedAt = forecast.value?.cachedAt || forecast.value?.generatedAt
return cachedAt ? formatDate(cachedAt) + " " + dayjs(cachedAt).format("HH:mm") : null
})
const chartData = computed(() => ({
labels: (forecast.value?.points || []).map((point) => dayjs(point.date).format("DD.MM.")),
datasets: [
{
label: "Prognostizierter Kontostand",
borderColor: "#0f766e",
backgroundColor: "rgba(15, 118, 110, 0.12)",
pointRadius: 0,
pointHitRadius: 8,
tension: 0.28,
fill: true,
data: (forecast.value?.points || []).map((point) => point.balance)
},
{
label: "Warnschwelle 0 €",
borderColor: "#ef4444",
borderDash: [6, 6],
pointRadius: 0,
data: (forecast.value?.points || []).map(() => 0)
}
]
}))
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index",
intersect: false
},
plugins: {
legend: {
display: true
}
},
scales: {
y: {
ticks: {
callback: (value) => useCurrency(Number(value))
}
}
}
}
const upcomingEvents = computed(() => {
return [...(forecast.value?.events || [])]
.sort((a, b) => a.date.localeCompare(b.date) || Math.abs(b.amount) - Math.abs(a.amount))
.slice(0, 18)
})
const groupedEventSummary = computed(() => {
const summary = {
open_createddocument: { label: sourceLabels.open_createddocument, amount: 0, count: 0 },
open_incominginvoice: { label: sourceLabels.open_incominginvoice, amount: 0, count: 0 },
recurring_bankstatement: { label: sourceLabels.recurring_bankstatement, amount: 0, count: 0 },
draft_createddocument: { label: "Rechnungsentwürfe als Rechnungen", amount: 0, count: 0 },
serial_template: { label: sourceLabels.serial_template, amount: 0, count: 0 },
tax_settlement: { label: sourceLabels.tax_settlement, amount: 0, count: 0 }
}
;(forecast.value?.events || []).forEach((event) => {
if (!summary[event.source]) return
summary[event.source].amount = roundMoney(summary[event.source].amount + Number(event.amount || 0))
summary[event.source].count += 1
})
return Object.values(summary)
})
const topIncomeDrivers = computed(() => {
return [...(forecast.value?.events || [])]
.filter((event) => Number(event.amount || 0) > 0)
.sort((a, b) => Number(b.amount || 0) - Number(a.amount || 0))
.slice(0, 12)
})
const topExpenseDrivers = computed(() => {
return [...(forecast.value?.events || [])]
.filter((event) => Number(event.amount || 0) < 0)
.sort((a, b) => Math.abs(Number(b.amount || 0)) - Math.abs(Number(a.amount || 0)))
.slice(0, 12)
})
const draftIncomeDrivers = computed(() => {
if (includeDraftsAsInvoices.value) return []
return [...(forecast.value?.draftEvents || [])]
.sort((a, b) => Number(b.amount || 0) - Number(a.amount || 0))
.slice(0, 12)
})
const taxPeriodTypeLabel = computed(() => {
if (forecast.value?.tax?.periodType === "quarterly") return "quartalsweise"
if (forecast.value?.tax?.periodType === "yearly") return "jährlich"
return "monatlich"
})
const taxForecastPeriods = computed(() => {
return [...(forecast.value?.tax?.periods || [])]
.filter((period) => Math.abs(Number(period.balance || 0)) > 0.01)
.slice(0, 6)
})
const taxEventDrivers = computed(() => {
return [...(forecast.value?.events || [])]
.filter((event) => event.source === "tax_settlement")
.sort((a, b) => a.date.localeCompare(b.date))
})
const detailedDays = computed(() => {
let previousBalance = Number(forecast.value?.startingBalance || 0)
return (forecast.value?.points || [])
.filter((point) => (point.events || []).length > 0)
.map((point) => {
const startBalance = previousBalance
previousBalance = Number(point.balance || 0)
return {
...point,
startBalance: roundMoney(startBalance)
}
})
.slice(0, 24)
})
const riskTone = computed(() => {
if (!forecast.value) return "neutral"
if (forecast.value.lowestBalance < 0) return "danger"
if (forecast.value.lowestBalance < forecast.value.startingBalance * 0.15) return "warning"
return "positive"
})
watch(dismissedRecurringKeys, () => {
forecast.value = applyForecastAdjustments(rawForecast.value)
})
watch(includeDraftsAsInvoices, () => {
forecast.value = applyForecastAdjustments(rawForecast.value)
})
onMounted(() => {
loadForecastFromCache()
})
</script>
<template>
<UDashboardNavbar title="Liquiditätsprognose" />
<UDashboardToolbar>
<div class="flex w-full flex-wrap items-center justify-between gap-3">
<div class="text-sm text-gray-500">
<span v-if="cachedAtLabel">Zwischengespeichert am {{ cachedAtLabel }}</span>
<span v-else>Noch keine gespeicherte Prognose vorhanden</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm dark:border-gray-800">
<USwitch v-model="includeDraftsAsInvoices" />
<span class="text-gray-600 dark:text-gray-300">Entwürfe wie Rechnungen verwenden</span>
</div>
<UButton
v-if="dismissedRecurringKeys.length"
icon="i-heroicons-eye"
variant="ghost"
@click="restoreDismissedRecurring"
>
Ausgeblendete wiederherstellen
</UButton>
<UButton
icon="i-heroicons-arrow-path"
:loading="loading"
variant="soft"
@click="refreshForecast"
>
Prognose neu erstellen
</UButton>
</div>
</div>
</UDashboardToolbar>
<UDashboardPanelContent class="min-w-0 space-y-6 overflow-x-hidden overflow-y-auto p-4 md:p-6">
<UAlert
v-if="error"
color="error"
icon="i-heroicons-exclamation-triangle"
:title="error"
/>
<div v-if="loading" class="flex min-h-[360px] items-center justify-center text-sm text-gray-500">
Liquiditätsprognose wird erstellt...
</div>
<UCard v-else-if="cacheLoaded && !forecast">
<div class="flex min-h-[280px] flex-col items-center justify-center gap-4 text-center">
<UIcon name="i-heroicons-chart-bar-square" class="size-12 text-gray-300" />
<div>
<h2 class="text-lg font-semibold">Noch keine Prognose gespeichert</h2>
<p class="mt-1 max-w-xl text-sm text-gray-500">
Erstelle die Liquiditätsprognose bewusst über den Refresh-Button. Das Ergebnis bleibt danach zwischengespeichert und wird beim nächsten Öffnen sofort angezeigt.
</p>
</div>
<UButton icon="i-heroicons-arrow-path" :loading="loading" @click="refreshForecast">
Prognose neu erstellen
</UButton>
</div>
</UCard>
<template v-else-if="forecast">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<UCard>
<p class="text-sm text-gray-500">Aktuelle Liquidität</p>
<p class="mt-2 text-2xl font-semibold">{{ useCurrency(forecast.startingBalance) }}</p>
</UCard>
<UCard>
<p class="text-sm text-gray-500">Prognose in {{ forecast.horizonDays }} Tagen</p>
<p
class="mt-2 text-2xl font-semibold"
:class="forecast.endingBalance < 0 ? 'text-rose-600' : 'text-primary-600'"
>
{{ useCurrency(forecast.endingBalance) }}
</p>
</UCard>
<UCard>
<p class="text-sm text-gray-500">Niedrigster Stand</p>
<p
class="mt-2 text-2xl font-semibold"
:class="forecast.lowestBalance < 0 ? 'text-rose-600' : 'text-orange-500'"
>
{{ useCurrency(forecast.lowestBalance) }}
</p>
<p class="mt-1 text-xs text-gray-500">{{ formatDate(forecast.lowestBalanceDate) }}</p>
</UCard>
<UCard>
<p class="text-sm text-gray-500">KI-Erkennung</p>
<p class="mt-2 text-2xl font-semibold">{{ forecast.recurring.length }}</p>
<p class="mt-1 text-xs text-gray-500">
{{ forecast.ai?.enabled ? `${forecast.ai.candidates} KI-Kandidaten berücksichtigt` : "Fallback ohne KI-Key genutzt" }}
</p>
</UCard>
</div>
<UAlert
:color="riskTone === 'danger' ? 'error' : riskTone === 'warning' ? 'warning' : 'success'"
:icon="riskTone === 'danger' ? 'i-heroicons-exclamation-triangle' : 'i-heroicons-chart-bar-square'"
:title="riskTone === 'danger' ? 'Liquiditätsengpass möglich' : riskTone === 'warning' ? 'Liquidität wird knapp' : 'Liquidität bleibt positiv'"
:description="`Die gespeicherte Prognose kombiniert aktuelle Kontostände, offene Belege und erkannte regelmäßige Bankbewegungen bis ${formatDate(dayjs().add(forecast.horizonDays, 'day'))}. Neu berechnet wird sie nur über den Refresh-Button.`"
/>
<div class="grid gap-4 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<UCard>
<template #header>
<div>
<h2 class="font-semibold">Herleitung</h2>
<p class="text-sm text-gray-500">So entsteht der prognostizierte Endstand</p>
</div>
</template>
<div class="space-y-3">
<div class="flex items-center justify-between gap-3">
<span class="text-sm text-gray-500">Startbestand</span>
<span class="font-semibold">{{ useCurrency(forecast.startingBalance) }}</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-sm text-gray-500">Geplante Einzahlungen</span>
<span class="font-semibold text-primary-600">+ {{ useCurrency(forecast.totalIncome) }}</span>
</div>
<div v-if="includeDraftsAsInvoices" class="flex items-center justify-between gap-3">
<span class="text-sm text-gray-500">Davon aus Entwürfen</span>
<span class="font-semibold text-primary-600">+ {{ useCurrency(rawForecast?.draftIncome || 0) }}</span>
</div>
<div v-else-if="forecast.draftIncome" class="flex items-center justify-between gap-3">
<span class="text-sm text-gray-500">Rechnungsentwürfe optional</span>
<span class="font-semibold text-gray-500">(+ {{ useCurrency(forecast.draftIncome) }})</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-sm text-gray-500">Geplante Auszahlungen</span>
<span class="font-semibold text-rose-600">{{ useCurrency(forecast.totalExpense) }}</span>
</div>
<div class="border-t border-dashed border-gray-200 pt-3 dark:border-gray-800">
<div class="flex items-center justify-between gap-3">
<span class="font-medium">Erwarteter Endstand</span>
<span class="text-lg font-semibold">{{ useCurrency(forecast.endingBalance) }}</span>
</div>
</div>
</div>
</UCard>
<UCard>
<template #header>
<div>
<h2 class="font-semibold">Einflussfaktoren</h2>
<p class="text-sm text-gray-500">Welche Quellen in die Prognose einfließen</p>
</div>
</template>
<div class="divide-y divide-gray-200 dark:divide-gray-800">
<div
v-for="row in groupedEventSummary"
:key="row.label"
class="grid gap-3 py-3 sm:grid-cols-[minmax(0,1fr)_110px_140px]"
>
<div>
<p class="font-medium">{{ row.label }}</p>
<p class="text-xs text-gray-500">{{ row.count }} Positionen</p>
</div>
<span class="text-sm text-gray-500">{{ row.count }}x</span>
<span
class="text-right font-semibold"
:class="row.amount < 0 ? 'text-rose-600' : row.amount > 0 ? 'text-primary-600' : 'text-gray-500'"
>
{{ useCurrency(row.amount) }}
</span>
</div>
</div>
</UCard>
</div>
<UCard v-if="taxForecastPeriods.length">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="font-semibold">USt in der Prognose</h2>
<p class="text-sm text-gray-500">Aus der USt-Auswertung mit {{ taxPeriodTypeLabel }} Fälligkeit abgeleitet</p>
</div>
<UButton
size="sm"
variant="ghost"
icon="i-heroicons-arrow-top-right-on-square"
@click="navigateTo('/accounting/tax')"
>
USt-Auswertung öffnen
</UButton>
</div>
</template>
<div class="mb-4 rounded-lg border border-dashed border-gray-300 p-4 dark:border-gray-700">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-sm text-gray-500">Im Prognosezeitraum berücksichtigte USt-Salden</p>
<p class="text-xs text-gray-500">Positive Salden werden als Auszahlung an das Finanzamt eingeplant, negative als Erstattung.</p>
</div>
<span
class="text-lg font-semibold"
:class="Number(forecast.tax?.totalBalance || 0) > 0 ? 'text-rose-600' : 'text-primary-600'"
>
{{ useCurrency(Number(forecast.tax?.totalBalance || 0) * -1) }}
</span>
</div>
</div>
<div class="space-y-3">
<div
v-for="period in taxForecastPeriods"
:key="period.key"
class="grid gap-3 rounded-lg border border-gray-200 p-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)_auto] dark:border-gray-800"
>
<div>
<p class="font-medium">{{ period.label }}</p>
<p class="text-xs text-gray-500">{{ period.range }} · Fällig am {{ formatDate(period.dueDate) }}</p>
</div>
<div class="grid gap-1 text-sm text-gray-500">
<div class="flex items-center justify-between gap-3">
<span>USt Rechnungen</span>
<span>{{ useCurrency(period.outputTax) }}</span>
</div>
<div class="flex items-center justify-between gap-3">
<span>Vorsteuer</span>
<span>{{ useCurrency(period.inputTax) }}</span>
</div>
<div class="flex items-center justify-between gap-3 font-medium">
<span>Saldo</span>
<span :class="period.balance > 0 ? 'text-rose-600' : 'text-primary-600'">
{{ useCurrency(period.balance) }}
</span>
</div>
</div>
<div class="text-right">
<p class="text-xs text-gray-500">{{ period.outputCount }} Ausgangsbelege · {{ period.inputCount }} Eingangsbelege</p>
<p
class="mt-2 text-lg font-semibold"
:class="period.balance > 0 ? 'text-rose-600' : 'text-primary-600'"
>
{{ useCurrency(period.balance * -1) }}
</p>
<p class="text-xs text-gray-500">Liquiditätseffekt</p>
</div>
</div>
</div>
</UCard>
<UCard v-if="draftIncomeDrivers.length">
<template #header>
<div>
<h2 class="font-semibold">Rechnungsentwürfe in Klammern</h2>
<p class="text-sm text-gray-500">Optionaler Einfluss, noch nicht im Endstand enthalten</p>
</div>
</template>
<div class="mb-4 rounded-lg border border-dashed border-gray-300 p-4 dark:border-gray-700">
<div class="flex items-center justify-between gap-3">
<span class="text-sm text-gray-500">Optionale Zusatzliquidität aus Entwürfen</span>
<span class="text-lg font-semibold text-gray-600">(+ {{ useCurrency(forecast.draftIncome) }})</span>
</div>
</div>
<div class="space-y-2">
<div
v-for="event in draftIncomeDrivers"
:key="`draft-${event.sourceId || event.label}-${event.date}`"
class="grid gap-3 rounded-lg border border-gray-200 p-3 sm:grid-cols-[120px_minmax(0,1fr)_auto_auto] dark:border-gray-800"
>
<span class="text-sm text-gray-500">{{ formatDate(event.date) }}</span>
<div class="min-w-0">
<p class="truncate font-medium">{{ event.label }}</p>
<p class="text-xs text-gray-500">Noch nicht gebucht · {{ sourceLabels[event.source] }}</p>
</div>
<span class="text-right font-semibold text-gray-600">(+ {{ useCurrency(event.amount) }})</span>
<UButton
v-if="getEventRoute(event)"
size="xs"
variant="ghost"
icon="i-heroicons-arrow-top-right-on-square"
@click="openEvent(event)"
>
Öffnen
</UButton>
</div>
</div>
</UCard>
<UCard>
<template #header>
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 class="font-semibold">Liquiditätsverlauf</h2>
<p class="text-sm text-gray-500">Täglicher prognostizierter Kontostand</p>
</div>
<div class="flex gap-3 text-sm">
<span class="text-primary-600">+ {{ useCurrency(forecast.totalIncome) }}</span>
<span class="text-rose-600">{{ useCurrency(forecast.totalExpense) }}</span>
</div>
</div>
</template>
<div class="h-[360px]">
<Line :data="chartData" :options="chartOptions" />
</div>
</UCard>
<div class="grid gap-4 xl:grid-cols-2">
<UCard>
<template #header>
<div>
<h2 class="font-semibold">Größte Einflüsse</h2>
<p class="text-sm text-gray-500">Die wichtigsten Treiber der Prognose</p>
</div>
</template>
<div class="space-y-4">
<div>
<p class="mb-2 text-sm font-medium text-primary-700">Größte Einzahlungen</p>
<div v-if="topIncomeDrivers.length" class="space-y-2">
<div
v-for="event in topIncomeDrivers"
:key="`income-${event.source}-${event.sourceId || event.label}-${event.date}`"
class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-800"
>
<div class="min-w-0">
<p class="truncate font-medium">{{ event.label }}</p>
<p class="text-xs text-gray-500">{{ formatDate(event.date) }} · {{ sourceLabels[event.source] || event.source }}</p>
</div>
<span class="font-semibold text-primary-600">{{ useCurrency(event.amount) }}</span>
</div>
</div>
<p v-else class="text-sm text-gray-500">Keine Einzahlungen im Prognosezeitraum.</p>
</div>
<div>
<p class="mb-2 text-sm font-medium text-rose-700">Größte Auszahlungen</p>
<div v-if="topExpenseDrivers.length" class="space-y-2">
<div
v-for="event in topExpenseDrivers"
:key="`expense-${event.source}-${event.sourceId || event.label}-${event.date}`"
class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-800"
>
<div class="min-w-0">
<p class="truncate font-medium">{{ event.label }}</p>
<p class="text-xs text-gray-500">{{ formatDate(event.date) }} · {{ sourceLabels[event.source] || event.source }}</p>
</div>
<span class="font-semibold text-rose-600">{{ useCurrency(event.amount) }}</span>
</div>
</div>
</div>
</div>
</UCard>
<UCard>
<template #header>
<div>
<h2 class="font-semibold">Tagesaufschlüsselung</h2>
<p class="text-sm text-gray-500">Stand vor dem Tag, Bewegungen und neuer Stand</p>
</div>
</template>
<div v-if="detailedDays.length" class="space-y-3">
<div
v-for="day in detailedDays"
:key="day.date"
class="rounded-xl border border-gray-200 p-4 dark:border-gray-800"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="font-medium">{{ formatDate(day.date) }}</p>
<p class="text-xs text-gray-500">Start {{ useCurrency(day.startBalance) }} · Ende {{ useCurrency(day.balance) }}</p>
</div>
<div class="flex gap-3 text-sm">
<span class="text-primary-600">+ {{ useCurrency(day.income) }}</span>
<span class="text-rose-600">{{ useCurrency(day.expense) }}</span>
</div>
</div>
<div class="mt-3 space-y-2">
<div
v-for="event in day.events"
:key="`${day.date}-${event.source}-${event.sourceId || event.label}-${event.amount}`"
class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto]"
>
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{ event.label }}</p>
<p class="text-xs text-gray-500">{{ sourceLabels[event.source] || event.source }}</p>
</div>
<span
class="text-right text-sm font-semibold"
:class="event.amount < 0 ? 'text-rose-600' : 'text-primary-600'"
>
{{ useCurrency(event.amount) }}
</span>
</div>
</div>
</div>
</div>
<p v-else class="py-8 text-center text-sm text-gray-500">Keine einzelnen Tagesbewegungen im Prognosezeitraum vorhanden.</p>
</UCard>
</div>
<UCard v-if="taxEventDrivers.length">
<template #header>
<div>
<h2 class="font-semibold">Geplante USt-Zahlungen und Erstattungen</h2>
<p class="text-sm text-gray-500">Direkt aus den berücksichtigten USt-Zeiträumen abgeleitet</p>
</div>
</template>
<div class="space-y-2">
<div
v-for="event in taxEventDrivers"
:key="`tax-${event.date}-${event.sourceId}`"
class="grid gap-3 rounded-lg border border-gray-200 p-3 sm:grid-cols-[110px_minmax(0,1fr)_auto_auto] dark:border-gray-800"
>
<span class="text-sm text-gray-500">{{ formatDate(event.date) }}</span>
<div class="min-w-0">
<p class="truncate font-medium">{{ event.label }}</p>
<p class="text-xs text-gray-500">{{ sourceLabels[event.source] }}</p>
</div>
<span
class="text-right font-semibold"
:class="event.amount < 0 ? 'text-rose-600' : 'text-primary-600'"
>
{{ useCurrency(event.amount) }}
</span>
<UButton
size="xs"
variant="ghost"
icon="i-heroicons-arrow-top-right-on-square"
@click="openEvent(event)"
>
Öffnen
</UButton>
</div>
</div>
</UCard>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
<UCard>
<template #header>
<div>
<h2 class="font-semibold">Nächste Zahlungsereignisse</h2>
<p class="text-sm text-gray-500">Offene Belege und prognostizierte regelmäßige Bewegungen</p>
</div>
</template>
<div v-if="upcomingEvents.length" class="divide-y divide-gray-200 dark:divide-gray-800">
<div
v-for="event in upcomingEvents"
:key="`${event.source}-${event.sourceId || event.label}-${event.date}-${event.amount}`"
class="grid gap-3 py-3 sm:grid-cols-[110px_minmax(0,1fr)_auto_auto]"
>
<span class="text-sm text-gray-500">{{ formatDate(event.date) }}</span>
<div class="min-w-0">
<p class="truncate font-medium">{{ event.label }}</p>
<p class="text-xs text-gray-500">{{ sourceLabels[event.source] || event.source }}</p>
</div>
<span
class="text-right font-semibold"
:class="event.amount < 0 ? 'text-rose-600' : 'text-primary-600'"
>
{{ useCurrency(event.amount) }}
</span>
<div class="flex justify-end gap-1">
<UButton
v-if="getEventRoute(event)"
size="xs"
variant="ghost"
icon="i-heroicons-arrow-top-right-on-square"
@click="openEvent(event)"
>
Öffnen
</UButton>
<UButton
v-if="event.source === 'recurring_bankstatement' && event.recurringKey"
size="xs"
color="gray"
variant="ghost"
icon="i-heroicons-check-circle"
@click="dismissRecurringKey(event.recurringKey)"
>
Abschließen
</UButton>
</div>
</div>
</div>
<p v-else class="py-8 text-center text-sm text-gray-500">Keine Zahlungsereignisse im Prognosezeitraum erkannt.</p>
</UCard>
<UCard>
<template #header>
<div>
<h2 class="font-semibold">Regelmäßige Bewegungen</h2>
<p class="text-sm text-gray-500">Aus Bankumsätzen erkannt, inklusive KI-Analyse</p>
</div>
</template>
<div v-if="forecast.recurring.length" class="space-y-3">
<div
v-for="item in forecast.recurring"
:key="`${item.label}-${item.amount}-${item.nextDate}`"
class="rounded-lg border border-gray-200 p-3 dark:border-gray-800"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate font-medium">{{ item.label }}</p>
<p class="text-xs text-gray-500">
{{ intervalLabels[item.interval] || item.interval }} ab {{ formatDate(item.nextDate) }}
</p>
</div>
<span
class="text-right font-semibold"
:class="item.amount < 0 ? 'text-rose-600' : 'text-primary-600'"
>
{{ useCurrency(item.amount) }}
</span>
</div>
<div class="mt-2 flex flex-wrap items-center justify-between gap-2">
<p class="text-xs text-gray-500">
Sicherheit {{ Math.round(item.confidence * 100) }}% · {{ item.evidence }}
</p>
<UButton
size="xs"
color="gray"
variant="soft"
icon="i-heroicons-check-circle"
@click="dismissRecurringKey(item.key)"
>
Als abgeschlossen entfernen
</UButton>
</div>
</div>
</div>
<p v-else class="py-8 text-center text-sm text-gray-500">Noch keine regelmäßigen Bewegungen erkannt.</p>
</UCard>
</div>
</template>
</UDashboardPanelContent>
</template>

View File

@@ -0,0 +1,491 @@
<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 incomingInvoices = ref([])
const bookings = ref([])
const debitSearch = ref("")
const creditSearch = ref("")
const expandedDebitGroups = ref([])
const expandedCreditGroups = ref([])
const DATEV_TAX_KEY_ITEMS = [
{ value: "__none__", label: "Ohne Steuerschlüssel" },
{ value: "9", label: "9 - Vorsteuer 19 %" },
{ value: "8", label: "8 - Vorsteuer 7 %" },
{ value: "19", label: "19 - EU Vorsteuer 19 %" },
{ value: "18", label: "18 - EU Vorsteuer 7 %" }
]
const form = reactive({
manualBookingDate: dayjs().format("YYYY-MM-DD"),
amount: null,
debit: "",
credit: "",
datevTaxKey: "__none__",
description: ""
})
const displayCurrency = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")}`
const normalizeSearch = (value) => String(value || "").toLowerCase().trim()
const matchesSearch = (entry, query) => {
const search = normalizeSearch(query)
if (!search) return true
return [
entry.number,
entry.name,
entry.label,
entry.typeLabel
].some((value) => normalizeSearch(value).includes(search))
}
const buildEntries = (rows, type, labelBuilder) =>
(rows || []).map((item) => ({
key: `${type}:${item.id}`,
id: item.id,
type,
number: item.number || item.vendorNumber || item.customerNumber || item.reference || "",
name: item.label || item.name || item.vendor?.name || "",
label: labelBuilder(item),
typeLabel:
type === "account"
? "Sachkonten"
: type === "vendor"
? "Kreditoren"
: type === "customer"
? "Debitoren"
: type === "incominginvoice"
? "Eingangsbelege"
: "Zusätzliche Konten"
}))
const getIncomingInvoiceGross = (invoice) => {
return Number((invoice.accounts || []).reduce((sum, account) => {
return sum + Number(account.amountNet || 0) + Number(account.amountTax || 0)
}, 0))
}
const getIncomingInvoiceOpenAmount = (invoice) => {
const gross = getIncomingInvoiceGross(invoice)
const allocated = Number((invoice.statementallocations || []).reduce((sum, allocation) => sum + Number(allocation.amount || 0), 0))
return Math.abs(gross) - Math.abs(allocated)
}
const entryGroups = computed(() => ([
{
key: "account",
label: "Sachkonten",
entries: buildEntries(accounts.value, "account", (item) => `${item.number} - ${item.label}`)
},
{
key: "vendor",
label: "Kreditoren",
entries: buildEntries(vendors.value, "vendor", (item) => `${item.vendorNumber || "ohne Nr."} - ${item.name}`)
},
{
key: "customer",
label: "Debitoren",
entries: buildEntries(customers.value, "customer", (item) => `${item.customerNumber || "ohne Nr."} - ${item.name}`)
},
{
key: "ownaccount",
label: "Zusätzliche Konten",
entries: buildEntries(ownaccounts.value, "ownaccount", (item) => `${item.number} - ${item.name}`)
},
{
key: "incominginvoice",
label: "Eingangsbelege",
entries: buildEntries(incomingInvoices.value, "incominginvoice", (item) => `${item.reference || "Ohne Referenz"} - ${item.vendor?.name || "Ohne Lieferant"} - Offen ${displayCurrency(getIncomingInvoiceOpenAmount(item))}`)
}
]))
const groupedDebitEntries = computed(() =>
entryGroups.value.map((group) => ({
...group,
entries: group.entries.filter((entry) => matchesSearch(entry, debitSearch.value))
})).filter((group) => group.entries.length > 0)
)
const groupedCreditEntries = computed(() =>
entryGroups.value.map((group) => ({
...group,
entries: group.entries.filter((entry) => matchesSearch(entry, creditSearch.value))
})).filter((group) => group.entries.length > 0)
)
const isGroupExpanded = (side, groupKey) => {
const source = side === "debit" ? expandedDebitGroups.value : expandedCreditGroups.value
return source.includes(groupKey)
}
const toggleGroupExpanded = (side, groupKey) => {
const source = side === "debit" ? expandedDebitGroups.value : expandedCreditGroups.value
if (source.includes(groupKey)) {
if (side === "debit") expandedDebitGroups.value = source.filter((item) => item !== groupKey)
else expandedCreditGroups.value = source.filter((item) => item !== groupKey)
return
}
if (side === "debit") expandedDebitGroups.value = [...source, groupKey]
else expandedCreditGroups.value = [...source, groupKey]
}
const visibleEntries = (side, group) => {
if (group.entries.length <= 5 || isGroupExpanded(side, group.key)) return group.entries
return group.entries.slice(0, 5)
}
const allEntries = computed(() => entryGroups.value.flatMap((group) => group.entries))
const selectedDebit = computed(() => allEntries.value.find((item) => item.key === form.debit))
const selectedCredit = computed(() => allEntries.value.find((item) => item.key === form.credit))
const selectedTaxKey = computed(() => DATEV_TAX_KEY_ITEMS.find((item) => item.value === form.datevTaxKey))
const getBookingSide = (booking, side) => {
if (booking.incominginvoice && booking.manualInvoiceSide === side) {
return {
type: "Eingangsbeleg",
number: booking.incominginvoice?.reference || "",
name: booking.incominginvoice?.vendor?.name || booking.incominginvoice?.description || ""
}
}
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 (type === "incominginvoice") {
return {
incominginvoice: numericId,
manualInvoiceSide: target
}
}
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, incomingInvoiceRows, bookingRows] = await Promise.all([
useEntities("accounts").selectSpecial("*", "number", true),
useEntities("customers").select(),
useEntities("vendors").select(),
useEntities("ownaccounts").select(),
useEntities("incominginvoices").select("*, vendor(*), statementallocations(id,amount)"),
useNuxtApp().$api("/api/banking/manual-bookings")
])
accounts.value = accountRows || []
customers.value = customerRows || []
vendors.value = vendorRows || []
ownaccounts.value = ownaccountRows || []
incomingInvoices.value = (incomingInvoiceRows || [])
.filter((invoice) => invoice.state === "Gebucht" && !invoice.archived)
.filter((invoice) => getIncomingInvoiceOpenAmount(invoice) > 0.004)
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.datevTaxKey = "__none__"
form.description = ""
debitSearch.value = ""
creditSearch.value = ""
expandedDebitGroups.value = []
expandedCreditGroups.value = []
}
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
try {
const payload = {
manualBookingDate: form.manualBookingDate,
amount: Number(form.amount),
datevTaxKey: form.datevTaxKey === "__none__" ? null : form.datevTaxKey,
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()
} finally {
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">
<UCard>
<template #header>
<div class="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 class="font-semibold text-gray-900 dark:text-white">Neue Soll/Haben-Buchung</h2>
<p class="text-sm text-gray-500">Kontenarten sind jetzt getrennt nach Sachkonten, Kreditoren, Debitoren und zusätzlichen Konten auswählbar.</p>
</div>
<div class="grid gap-3 sm:grid-cols-3">
<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="DATEV-Steuerschlüssel">
<USelect
v-model="form.datevTaxKey"
:items="DATEV_TAX_KEY_ITEMS"
value-key="value"
label-key="label"
class="w-full"
/>
</UFormField>
</div>
</div>
</template>
<div class="grid gap-6 xl:grid-cols-2">
<div class="rounded-xl border border-emerald-200 bg-emerald-50/60 p-4 dark:border-emerald-900 dark:bg-emerald-950/20">
<div class="mb-4 flex items-center justify-between gap-3">
<div>
<h3 class="font-semibold text-emerald-900 dark:text-emerald-200">Soll</h3>
<p class="text-sm text-emerald-700/80 dark:text-emerald-300/80">Linke Buchungsseite</p>
</div>
<UInput v-model="debitSearch" icon="i-heroicons-magnifying-glass" placeholder="Soll durchsuchen..." class="max-w-xs" />
</div>
<div class="space-y-4">
<div v-for="group in groupedDebitEntries" :key="`debit-${group.key}`" class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-300">{{ group.label }}</div>
<div class="grid gap-2">
<button
v-for="entry in visibleEntries('debit', group)"
:key="entry.key"
type="button"
class="rounded-lg border px-3 py-2 text-left transition"
:class="form.debit === entry.key
? 'border-emerald-500 bg-white dark:bg-emerald-900/30 shadow-sm'
: 'border-emerald-200/80 bg-white/80 hover:border-emerald-300 dark:border-emerald-900 dark:bg-gray-900'"
@click="form.debit = entry.key"
>
<div class="flex items-center gap-2">
<span class="font-mono text-sm">{{ entry.number }}</span>
<span class="text-sm">{{ entry.name }}</span>
</div>
</button>
</div>
<UButton
v-if="group.entries.length > 5"
size="xs"
color="neutral"
variant="ghost"
:label="isGroupExpanded('debit', group.key) ? 'Weniger anzeigen' : `${group.entries.length - 5} weitere anzeigen`"
@click="toggleGroupExpanded('debit', group.key)"
/>
</div>
</div>
</div>
<div class="rounded-xl border border-sky-200 bg-sky-50/60 p-4 dark:border-sky-900 dark:bg-sky-950/20">
<div class="mb-4 flex items-center justify-between gap-3">
<div>
<h3 class="font-semibold text-sky-900 dark:text-sky-200">Haben</h3>
<p class="text-sm text-sky-700/80 dark:text-sky-300/80">Rechte Buchungsseite</p>
</div>
<UInput v-model="creditSearch" icon="i-heroicons-magnifying-glass" placeholder="Haben durchsuchen..." class="max-w-xs" />
</div>
<div class="space-y-4">
<div v-for="group in groupedCreditEntries" :key="`credit-${group.key}`" class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-300">{{ group.label }}</div>
<div class="grid gap-2">
<button
v-for="entry in visibleEntries('credit', group)"
:key="entry.key"
type="button"
class="rounded-lg border px-3 py-2 text-left transition"
:class="form.credit === entry.key
? 'border-sky-500 bg-white dark:bg-sky-900/30 shadow-sm'
: 'border-sky-200/80 bg-white/80 hover:border-sky-300 dark:border-sky-900 dark:bg-gray-900'"
@click="form.credit = entry.key"
>
<div class="flex items-center gap-2">
<span class="font-mono text-sm">{{ entry.number }}</span>
<span class="text-sm">{{ entry.name }}</span>
</div>
</button>
</div>
<UButton
v-if="group.entries.length > 5"
size="xs"
color="neutral"
variant="ghost"
:label="isGroupExpanded('credit', group.key) ? 'Weniger anzeigen' : `${group.entries.length - 5} weitere anzeigen`"
@click="toggleGroupExpanded('credit', group.key)"
/>
</div>
</div>
</div>
</div>
<div class="mt-6 grid gap-4">
<div class="w-full space-y-4">
<UAlert
v-if="selectedDebit && selectedCredit"
color="primary"
variant="soft"
icon="i-heroicons-arrows-right-left"
:title="`${selectedDebit.number} an ${selectedCredit.number}`"
:description="`${selectedDebit.typeLabel.slice(0, -1)} ${selectedDebit.name} wird im Soll, ${selectedCredit.typeLabel.slice(0, -1)} ${selectedCredit.name} im Haben gebucht.`"
/>
<UFormField label="Beschreibung" class="w-full">
<UTextarea
v-model="form.description"
placeholder="z. B. Versicherungsentschädigung"
autoresize
class="w-full"
/>
</UFormField>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<UBadge v-if="selectedTaxKey?.value" color="warning" variant="subtle">
Steuerschlüssel: {{ selectedTaxKey.label }}
</UBadge>
<UButton color="primary" :loading="saving" @click="saveBooking" class="sm:ml-auto">
Manuelle Buchung erstellen
</UButton>
</div>
</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">Übersicht mit getrennter Soll- und Haben-Seite inklusive DATEV-Steuerschlüssel.</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">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<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>
<UBadge v-if="booking.datevTaxKey" size="xs" color="warning" variant="subtle">
St.-Schlüssel {{ booking.datevTaxKey }}
</UBadge>
</div>
<UButton
icon="i-heroicons-trash"
color="error"
variant="ghost"
size="sm"
@click="deleteBooking(booking)"
/>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="rounded-lg border border-emerald-200 bg-emerald-50/60 p-3 dark:border-emerald-900 dark:bg-emerald-950/20">
<div class="text-xs font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-300">Soll</div>
<div class="mt-1 flex items-center gap-2">
<span class="font-mono">{{ getBookingSide(booking, 'debit').number }}</span>
<span>{{ getBookingSide(booking, "debit").name }}</span>
</div>
<div class="mt-1 text-xs text-gray-500">{{ getBookingSide(booking, "debit").type }}</div>
</div>
<div class="rounded-lg border border-sky-200 bg-sky-50/60 p-3 dark:border-sky-900 dark:bg-sky-950/20">
<div class="text-xs font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-300">Haben</div>
<div class="mt-1 flex items-center gap-2">
<span class="font-mono">{{ getBookingSide(booking, 'credit').number }}</span>
<span>{{ getBookingSide(booking, "credit").name }}</span>
</div>
<div class="mt-1 text-xs text-gray-500">{{ getBookingSide(booking, "credit").type }}</div>
</div>
</div>
<div v-if="booking.description" class="mt-3 text-sm text-gray-600 dark:text-gray-300">
{{ booking.description }}
</div>
</div>
</div>
</UCard>
</div>
</UDashboardPanelContent>
</template>

View File

@@ -197,6 +197,7 @@ onMounted(async () => {
])" ])"
:on-select="(row) => router.push(`/administration/users/${row.original?.id || row.id}`)" :on-select="(row) => router.push(`/administration/users/${row.original?.id || row.id}`)"
class="mt-4" class="mt-4"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine zugeordneten Benutzer gefunden' }"
/> />
</UCard> </UCard>

View File

@@ -211,6 +211,7 @@
:get-row-id="(row) => row.id" :get-row-id="(row) => row.id"
:ui="{ th: { base: 'whitespace-nowrap' } }" :ui="{ th: { base: 'whitespace-nowrap' } }"
:on-select="toggleExecutionRow" :on-select="toggleExecutionRow"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
> >
<template #select-header="{ table }"> <template #select-header="{ table }">
<div class="flex justify-center" @click.stop> <div class="flex justify-center" @click.stop>

View File

@@ -35,11 +35,12 @@ const createExport = async () => {
</template> </template>
</UDashboardNavbar> </UDashboardNavbar>
<UTable <UTable
v-if="createddocuments.length > 0"
:loading="true" :loading="true"
v-model="selected" v-model="selected"
:loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }" :loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }"
:data="createddocuments" /> :data="createddocuments"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine SEPA-Belege anzuzeigen' }"
/>
</template> </template>
<style scoped> <style scoped>

View File

@@ -154,6 +154,7 @@ const createExport = async () => {
{ key: 'type', label: 'Typ' }, { key: 'type', label: 'Typ' },
{ key: 'download', label: 'Download' }, { key: 'download', label: 'Download' },
])" ])"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Exporte anzuzeigen' }"
> >
<template #created_at-cell="{row}"> <template #created_at-cell="{row}">
{{dayjs(row.original.created_at).format("DD.MM.YYYY HH:mm")}} {{dayjs(row.original.created_at).format("DD.MM.YYYY HH:mm")}}

View File

@@ -58,6 +58,7 @@ const isPreparing = ref(false)
const type = "incominginvoices" const type = "incominginvoices"
const dataType = dataStore.dataTypes[type] const dataType = dataStore.dataTypes[type]
const openAmountColumnKey = "openAmount"
const setupPage = async () => { const setupPage = async () => {
items.value = await useEntities(type).select("*, vendor(id,name), statementallocations(id,amount)",sort.value.column,sort.value.direction === "asc") items.value = await useEntities(type).select("*, vendor(id,name), statementallocations(id,amount)",sort.value.column,sort.value.direction === "asc")
@@ -93,7 +94,13 @@ const prepareInvoices = async () => {
setupPage() setupPage()
const selectedColumns = ref(tempStore.columns[type] ? tempStore.columns[type] : dataType.templateColumns.filter(i => !i.disabledInTable)) const selectedColumns = ref(tempStore.columns[type] ? [...tempStore.columns[type]] : dataType.templateColumns.filter(i => !i.disabledInTable))
if (!selectedColumns.value.find((column) => column.key === openAmountColumnKey)) {
const openAmountColumn = dataType.templateColumns.find((column) => column.key === openAmountColumnKey)
if (openAmountColumn) {
selectedColumns.value.splice(5, 0, openAmountColumn)
}
}
const columns = computed(() => dataType.templateColumns.filter((column) => !column.disabledInTable && selectedColumns.value.find(i => i.key === column.key))) const columns = computed(() => dataType.templateColumns.filter((column) => !column.disabledInTable && selectedColumns.value.find(i => i.key === column.key)))
const selectableFilters = ref(dataType.filters.map(i => i.name)) const selectableFilters = ref(dataType.filters.map(i => i.name))
@@ -142,10 +149,18 @@ const getInvoiceSum = (invoice) => {
return sum.toFixed(2) return sum.toFixed(2)
} }
const isPaid = (item) => { const getPaidAmount = (item) => {
let amountPaid = 0 let amountPaid = 0
item.statementallocations.forEach(allocation => amountPaid += allocation.amount) item.statementallocations.forEach(allocation => amountPaid += allocation.amount)
return Math.abs(amountPaid) === Math.abs(Number(getInvoiceSum(item))) return Number(Math.abs(amountPaid).toFixed(2))
}
const isPaid = (item) => {
return getPaidAmount(item) >= Number(Math.abs(Number(getInvoiceSum(item))).toFixed(2))
}
const getOpenAmount = (item) => {
return Number(Math.max(0, Number(getInvoiceSum(item)) - getPaidAmount(item)).toFixed(2))
} }
const unwrapInvoiceRow = (invoiceLike) => invoiceLike?.original || invoiceLike const unwrapInvoiceRow = (invoiceLike) => invoiceLike?.original || invoiceLike
@@ -283,6 +298,11 @@ const selectIncomingInvoice = (invoiceLike) => {
<template #amount-cell="{row}"> <template #amount-cell="{row}">
{{displayCurrency(sum.getIncomingInvoiceSum(row.original))}} {{displayCurrency(sum.getIncomingInvoiceSum(row.original))}}
</template> </template>
<template #openAmount-cell="{row}">
<span v-if="row.original.state === 'Gebucht' && !isPaid(row.original)">
{{ displayCurrency(getOpenAmount(row.original)) }}
</span>
</template>
<template #dueDate-cell="{row}"> <template #dueDate-cell="{row}">
<span v-if="row.original.dueDate">{{dayjs(row.original.dueDate).format("DD.MM.YYYY")}}</span> <span v-if="row.original.dueDate">{{dayjs(row.original.dueDate).format("DD.MM.YYYY")}}</span>
</template> </template>

View File

@@ -203,6 +203,7 @@ setupPage()
label: 'Saldo' label: 'Saldo'
}, },
])" ])"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Bankkonten anzuzeigen' }"
> >
<template #expired-cell="{ row }"> <template #expired-cell="{ row }">
<span v-if="row.original.expired" class="text-error-600">Ausgelaufen</span> <span v-if="row.original.expired" class="text-error-600">Ausgelaufen</span>

View File

@@ -79,13 +79,8 @@
:columns="normalizeTableColumns(columns)" :columns="normalizeTableColumns(columns)"
:loading="pending" :loading="pending"
:on-select="(row) => navigateTo(`/staff/profiles/${row.original?.id || row.id}`)" :on-select="(row) => navigateTo(`/staff/profiles/${row.original?.id || row.id}`)"
> :empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Mitarbeiterprofile gefunden' }"
<template #empty> />
<div class="py-10 text-center text-sm text-gray-500">
Keine Mitarbeiterprofile gefunden.
</div>
</template>
</UTable>
</template> </template>
<style scoped> <style scoped>

View File

@@ -272,7 +272,7 @@ onMounted(async () => {
<UTooltip text="Genehmigen" v-if="row.original.state === 'submitted' && canViewAll"> <UTooltip text="Genehmigen" v-if="row.original.state === 'submitted' && canViewAll">
<UButton size="xs" color="green" variant="ghost" icon="i-heroicons-check" @click="handleApprove(row.original)" :loading="loading" /> <UButton size="xs" color="green" variant="ghost" icon="i-heroicons-check" @click="handleApprove(row.original)" :loading="loading" />
</UTooltip> </UTooltip>
<UTooltip text="Ablehnen" v-if="(row.original.state === 'submitted' || row.original.state === 'approved') && canViewAll"> <UTooltip text="Ablehnen" v-if="['draft', 'factual', 'submitted', 'approved'].includes(row.original.state) && canViewAll">
<UButton size="xs" color="error" variant="ghost" icon="i-heroicons-x-mark" @click="openRejectModal(row.original)" :loading="loading" /> <UButton size="xs" color="error" variant="ghost" icon="i-heroicons-x-mark" @click="openRejectModal(row.original)" :loading="loading" />
</UTooltip> </UTooltip>
<UTooltip text="Bearbeiten" v-if="['draft', 'factual', 'submitted'].includes(row.original.state)"> <UTooltip text="Bearbeiten" v-if="['draft', 'factual', 'submitted'].includes(row.original.state)">
@@ -366,7 +366,7 @@ onMounted(async () => {
/> />
<UButton <UButton
v-if="(entry.state === 'submitted' || entry.state === 'approved') && canViewAll" v-if="['draft', 'factual', 'submitted', 'approved'].includes(entry.state) && canViewAll"
size="xs" color="error" variant="ghost" icon="i-heroicons-x-mark" label="Ablehnen" size="xs" color="error" variant="ghost" icon="i-heroicons-x-mark" label="Ablehnen"
@click="openRejectModal(entry)" :loading="loading" @click="openRejectModal(entry)" :loading="loading"
/> />
@@ -434,6 +434,7 @@ onMounted(async () => {
<div class="flex gap-2 mt-3 justify-end"> <div class="flex gap-2 mt-3 justify-end">
<UButton v-if="(row.state === 'draft' || row.state === 'factual') && row.stopped_at" color="cyan" size="sm" icon="i-heroicons-paper-airplane" label="Einreichen" variant="soft" @click.stop="handleSubmit(row)" :loading="loading" /> <UButton v-if="(row.state === 'draft' || row.state === 'factual') && row.stopped_at" color="cyan" size="sm" icon="i-heroicons-paper-airplane" label="Einreichen" variant="soft" @click.stop="handleSubmit(row)" :loading="loading" />
<UButton v-if="row.state === 'submitted' && canViewAll" color="green" size="sm" icon="i-heroicons-check" label="Genehmigen" variant="soft" @click.stop="handleApprove(row)" :loading="loading" /> <UButton v-if="row.state === 'submitted' && canViewAll" color="green" size="sm" icon="i-heroicons-check" label="Genehmigen" variant="soft" @click.stop="handleApprove(row)" :loading="loading" />
<UButton v-if="['draft', 'factual', 'submitted', 'approved'].includes(row.state) && canViewAll" color="error" size="sm" icon="i-heroicons-x-mark" label="Ablehnen" variant="soft" @click.stop="openRejectModal(row)" :loading="loading" />
</div> </div>
</UCard> </UCard>
</UDashboardPanelContent> </UDashboardPanelContent>

View File

@@ -461,6 +461,7 @@ onMounted(async () => {
:data="filteredTasks" :data="filteredTasks"
:columns="normalizedListColumns" :columns="normalizedListColumns"
:on-select="(task) => openTaskViaRoute(task)" :on-select="(task) => openTaskViaRoute(task)"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Aufgaben anzuzeigen' }"
> >
<template #actions-cell="{ row }"> <template #actions-cell="{ row }">
<UButton <UButton
@@ -490,11 +491,11 @@ onMounted(async () => {
{{ getEntityLabel(plantOptions, row.original.plant?.id || row.original.plant) || "-" }} {{ getEntityLabel(plantOptions, row.original.plant?.id || row.original.plant) || "-" }}
</template> </template>
</UTable> </UTable>
<UAlert <UTable
v-else v-else
icon="i-heroicons-circle-stack-20-solid" :data="[]"
title="Keine Aufgaben anzuzeigen" :columns="normalizedListColumns"
variant="subtle" :empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Aufgaben anzuzeigen' }"
/> />
</UDashboardPanelContent> </UDashboardPanelContent>

View File

@@ -1,4 +1,4 @@
import { Chart, Title, Tooltip, Legend, LineElement, CategoryScale, LinearScale, PointElement } from 'chart.js' import { Chart, Title, Tooltip, Legend, LineElement, CategoryScale, LinearScale, PointElement, Filler } from 'chart.js'
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
Chart.register(CategoryScale, LinearScale, LineElement, Title, Tooltip, Legend, PointElement) Chart.register(CategoryScale, LinearScale, LineElement, Title, Tooltip, Legend, PointElement, Filler)
}) })

View File

@@ -22,6 +22,7 @@ import description from "~/components/columnRenderings/description.vue"
import purchasePrice from "~/components/columnRenderings/purchasePrice.vue"; import purchasePrice from "~/components/columnRenderings/purchasePrice.vue";
import project from "~/components/columnRenderings/project.vue"; import project from "~/components/columnRenderings/project.vue";
import branch from "~/components/columnRenderings/branch.vue"; import branch from "~/components/columnRenderings/branch.vue";
import costcentre from "~/components/columnRenderings/costcentre.vue";
import created_at from "~/components/columnRenderings/created_at.vue"; import created_at from "~/components/columnRenderings/created_at.vue";
import profile from "~/components/columnRenderings/profile.vue"; import profile from "~/components/columnRenderings/profile.vue";
import profiles from "~/components/columnRenderings/profiles.vue"; import profiles from "~/components/columnRenderings/profiles.vue";
@@ -2200,6 +2201,10 @@ export const useDataStore = defineStore('data', () => {
key: "amount", key: "amount",
label: "Betrag", label: "Betrag",
}, },
{
key: "openAmount",
label: "Offener Betrag",
},
{ {
key: "dueDate", key: "dueDate",
label: "Fälligkeitsdatum", label: "Fälligkeitsdatum",
@@ -3308,7 +3313,7 @@ export const useDataStore = defineStore('data', () => {
numberRangeHolder: "number", numberRangeHolder: "number",
historyItemHolder: "costcentre", historyItemHolder: "costcentre",
sortColumn: "number", sortColumn: "number",
selectWithInformation: "*, project(*), vehicle(*), inventoryitem(*), branch(*)", selectWithInformation: "*, parentCostcentre(*), project(*), vehicle(*), inventoryitem(*), branch(*)",
filters: [{ filters: [{
name: "Archivierte ausblenden", name: "Archivierte ausblenden",
default: true, default: true,
@@ -3340,6 +3345,18 @@ export const useDataStore = defineStore('data', () => {
label: "Beschreibung", label: "Beschreibung",
inputType: "textarea" inputType: "textarea"
}, },
{
key: "parentCostcentre",
label: "Übergeordnete Kostenstelle",
component: costcentre,
inputType: "select",
selectDataType: "costcentres",
selectOptionAttribute: "name",
selectSearchAttributes: ["name", "number"],
selectDataTypeFilter: function (option, item) {
return option.id !== item.value?.id
}
},
{ {
key: "vehicle", key: "vehicle",
label: "Fahrzeug", label: "Fahrzeug",