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,
"tag": "0029_events_quick",
"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(),
name: text("name").notNull(),
parentCostcentre: uuid("parent_costcentre").references(() => costcentres.id),
vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.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(),
// foreign keys
bankstatement: integer("bs_id")
.notNull()
.references(() => bankstatements.id),
bankstatement: integer("bs_id").references(() => bankstatements.id),
createddocument: integer("cd_id").references(() => createddocuments.id),
@@ -34,6 +32,7 @@ export const statementallocations = pgTable("statementallocations", {
incominginvoice: bigint("ii_id", { mode: "number" }).references(
() => incominginvoices.id
),
manualInvoiceSide: text("manual_invoice_side"),
tenant: bigint("tenant", { mode: "number" })
.notNull()
@@ -43,14 +42,23 @@ export const statementallocations = pgTable("statementallocations", {
() => accounts.id
),
contraAccount: bigint("contra_account", { mode: "number" }).references(
() => accounts.id
),
created_at: timestamp("created_at", {
withTimezone: false,
}).defaultNow(),
ownaccount: uuid("ownaccount").references(() => ownaccounts.id),
contraOwnaccount: uuid("contra_ownaccount").references(() => ownaccounts.id),
description: text("description"),
manualBookingDate: text("manual_booking_date"),
datevTaxKey: text("datev_tax_key"),
bookingMode: text("booking_mode").notNull().default("expense"),
depreciationMonths: integer("depreciation_months"),
depreciationStartDate: text("depreciation_start_date"),
@@ -65,6 +73,12 @@ export const statementallocations = pgTable("statementallocations", {
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_by: uuid("updated_by").references(() => authUsers.id),

View File

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

View File

@@ -1,7 +1,7 @@
// src/services/loadValidEvents.ts
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";
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;
eventtype: string;
eventtime: Date;
actoruser_id: string;
actoruser_id?: string;
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(
server: FastifyInstance,
tenantId: number,
@@ -62,10 +94,9 @@ export async function loadValidEvents(
)
)
.orderBy(
// Wichtig für die deriveTimeSpans Logik: Event-Zeit muss primär sein
baseEvents.eventtime,
baseEvents.created_at, // Wenn Eventtime identisch ist, das später erstellte zuerst
baseEvents.id
asc(baseEvents.eventtime),
asc(baseEvents.created_at),
asc(baseEvents.id)
);
// Mapping auf den sauberen TimeEvent Typ
@@ -73,8 +104,10 @@ export async function loadValidEvents(
id: e.id,
eventtype: e.eventtype,
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[];
}
@@ -99,7 +132,11 @@ export async function loadRelatedAdminEvents(server, eventIds) {
)
)
// 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 {
bankrequisitions,
bankstatements,
accounts,
createddocuments,
customers,
entitybankaccounts,
incominginvoices,
ownaccounts,
statementallocations,
vendors,
} from "../../db/schema"
@@ -22,10 +24,71 @@ import {
import {
eq,
and,
isNull,
aliasedTable,
} from "drizzle-orm"
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) =>
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
@@ -686,9 +807,11 @@ export default async function bankingRoutes(server: FastifyInstance) {
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
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({
...payload,
...prepared.data,
tenant: req.user.tenant_id
}).returning()
@@ -720,16 +843,18 @@ export default async function bankingRoutes(server: FastifyInstance) {
}
}
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: Number(createdRecord.bankstatement),
action: "created",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: null,
newVal: createdRecord,
text: "Buchung erstellt",
})
if (createdRecord.bankstatement) {
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: Number(createdRecord.bankstatement),
action: "created",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: null,
newVal: createdRecord,
text: "Buchung erstellt",
})
}
return reply.send(createdRecord)
@@ -763,16 +888,18 @@ export default async function bankingRoutes(server: FastifyInstance) {
.delete(statementallocations)
.where(eq(statementallocations.id, id))
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: Number(old.bankstatement),
action: "deleted",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: old,
newVal: null,
text: "Buchung gelöscht",
})
if (old.bankstatement) {
await insertHistoryItem(server, {
entity: "bankstatements",
entityId: Number(old.bankstatement),
action: "deleted",
created_by: req.user.user_id,
tenant_id: req.user.tenant_id,
oldVal: old,
newVal: null,
text: "Buchung gelöscht",
})
}
return reply.send({ success: true })

View File

@@ -24,6 +24,7 @@ import {executeManualGeneration, finishManualGeneration} from "../modules/serial
import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets";
import { storeExtractedTextForFile } from "../utils/documentText";
import { generateLiquidityForecast } from "../utils/liquidityForecast";
dayjs.extend(customParseFormat)
dayjs.extend(isoWeek)
dayjs.extend(isBetween)
@@ -306,6 +307,21 @@ export default async function functionRoutes(server: FastifyInstance) {
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) => {
const tenantId = req.user.tenant_id

View File

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

View File

@@ -11,7 +11,7 @@ import {
sql,
} from "drizzle-orm"
import { authProfiles } from "../../../db/schema";
import { authProfiles, costcentres } from "../../../db/schema";
import { resourceConfig } from "../../utils/resource.config";
import { useNextNumberRangeNumber } from "../../utils/functions";
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
@@ -260,6 +260,59 @@ function validateMemberPayload(payload: Record<string, any>) {
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) {
if (!iban) return ""
const cleaned = iban.replace(/\s+/g, "")
@@ -730,6 +783,19 @@ export default async function resourceRoutes(server: FastifyInstance) {
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]) {
const numberRangeResource = resource === "members" ? "customers" : resource
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) => {
const value = data[key]
const shouldNormalize =

View File

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

View File

@@ -8,7 +8,7 @@ import { s3 } from "../s3";
import { secrets } from "../secrets";
// 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!)
import {
@@ -136,6 +136,10 @@ export async function buildExportZip(
const CdCustomer = aliasedTable(customers, "cd_customer");
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({
allocation: statementallocations,
@@ -148,11 +152,15 @@ export async function buildExportZip(
acc: accounts,
direct_vend: vendors, // Direkte Zuordnung an Kreditor
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)
// JOIN 1: Bankstatement (Pflicht, für Datum Filter)
.innerJoin(bankstatements, eq(statementallocations.bankstatement, bankstatements.id))
// JOIN 1: Bankstatement (optional, manuelle Buchungen haben keinen Bankumsatz)
.leftJoin(bankstatements, eq(statementallocations.bankstatement, bankstatements.id))
// JOIN 2: Bankaccount (für DATEV Nummer)
.leftJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id))
@@ -169,13 +177,25 @@ export async function buildExportZip(
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
.leftJoin(customers, eq(statementallocations.customer, customers.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(
eq(statementallocations.tenant, tenantId),
eq(statementallocations.archived, false),
// Datum Filter direkt auf dem Bankstatement
gte(bankstatements.date, startDate),
lte(bankstatements.date, endDate)
or(
and(
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
@@ -196,7 +216,11 @@ export async function buildExportZip(
account: r.acc,
vendor: r.direct_vend,
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 ---
@@ -311,8 +335,42 @@ export async function buildExportZip(
});
// 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 => {
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;
let shSelector = Math.sign(alloc.amount) === -1 ? "H" : "S";
@@ -425,4 +483,4 @@ export async function buildExportZip(
console.error("DATEV Export Error:", error);
throw error;
}
}
}

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: {
table: costcentres,
searchColumns: ["name","number","description"],
mtoLoad: ["vehicle","project","inventoryitem","branch"],
mtoLoad: ["parentCostcentre","vehicle","project","inventoryitem","branch"],
numberRangeHolder: "number",
},
parentCostcentre: {
table: costcentres,
searchColumns: ["name", "number", "description"],
},
branches: {
table: branches,
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
WORKDIR /app/docs-site
COPY docs-site/package.json ./
RUN npm install
COPY docs-site/package.json docs-site/package-lock.json ./
RUN npm ci
COPY docs-site ./
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
@@ -11,20 +11,21 @@ npm install
npm run dev
```
Die App ist danach unter `http://localhost:3005` erreichbar.
Danach ist die App unter `http://localhost:3005` erreichbar.
## Build
```bash
npm run build
npm run preview
```
## Production-Deploy
Das Docker-Image startet einen Node-Server auf Port `3000`.
In der Haupt-`docker-compose.yml` wird die App hinter Traefik unter `/docs` veröffentlicht.
Das Docker-Image startet einen Nuxt Node-Server auf Port `3000`.
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:

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
- 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.
- [Bedienung](./bedienung/README.md)

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({
modules: ['@nuxt/content'],
modules: [
'@nuxt/image',
'@nuxt/ui',
'@nuxt/content'
],
css: ['~/assets/css/main.css'],
app: {
head: {
title: 'FEDEO Docs',
meta: [
{ name: 'description', content: 'Versionierte FEDEO-Dokumentation auf Nuxt Content.' }
]
}
},
content: {
documentDriven: false,
highlight: {
theme: 'github-light'
build: {
markdown: {
toc: {
searchDepth: 1
}
}
}
},
experimental: {
asyncContext: true
},
compatibilityDate: '2024-07-11',
nitro: {
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",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node ./scripts/sync-content.mjs && nuxi dev --host 0.0.0.0 --port 3005",
"build": "node ./scripts/sync-content.mjs && nuxi build",
"preview": "nuxi preview --host 0.0.0.0 --port 3005"
"build": "node ./scripts/sync-content.mjs && nuxt build",
"dev": "node ./scripts/sync-content.mjs && nuxt dev --host 0.0.0.0 --port 3005",
"preview": "nuxt preview --host 0.0.0.0 --port 3005",
"postinstall": "nuxt prepare"
},
"dependencies": {
"nuxt": "^3.17.7",
"@nuxt/content": "^2.13.4"
"@iconify-json/lucide": "^1.2.102",
"@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": {
"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": {
"types": ["@types/node"]
}
"extends": "./.nuxt/tsconfig.json"
}

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
- 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.
- [Bedienung](./bedienung/README.md)

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' }"
:on-select="(row) => selectItem(row.original)"
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 }">
{{ dataStore.documentTypesForCreation[row.original.type].labelSingle }}
</template>

View File

@@ -35,11 +35,13 @@ const getStatementLike = (allocation) => allocation?.bankstatement || (typeof al
const getAllocationDate = (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 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 || ""
}
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 || ""
}
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 = [
{ label: "Ganzes Jahr", value: "all" },
{ label: "Januar", value: "1" },
{ label: "Februar", value: "2" },
{ label: "Maerz", value: "3" },
{ label: "März", value: "3" },
{ label: "April", value: "4" },
{ label: "Mai", value: "5" },
{ label: "Juni", value: "6" },
@@ -73,7 +83,7 @@ const allAllocations = computed(() => {
date: getAllocationDate(allocation),
partner: getAllocationPartner(allocation),
description: getAllocationDescription(allocation),
amount: Number(allocation.amount || 0)
amount: Number(allocation.amount || 0) * (isContraAllocation(allocation) ? -1 : 1)
}))
const incomingInvoiceRows = incomingInvoices.value.flatMap((invoice) => {
@@ -162,7 +172,7 @@ const setup = async () => {
loading.value = true
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(*)"))
.filter((invoice) => (invoice.accounts || []).some((account) => sameAccount(account.account?.id || account.account)))
@@ -244,14 +254,8 @@ const selectAllocation = (allocationLike) => {
:columns="normalizeTableColumns(columns)"
:on-select="selectAllocation"
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 }">
<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>
@@ -259,7 +263,7 @@ const selectAllocation = (allocationLike) => {
</template>
<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 #partner-cell="{ row }">

View File

@@ -69,13 +69,8 @@ const columns = [
class="mt-3"
:columns="normalizeTableColumns(columns)"
: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 }">
<span
v-if="row.original.state === 'Entwurf'"

View File

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

View File

@@ -12,7 +12,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'saved'])
// 💡 createEntry importieren
const { update, createEntry } = useStaffTime()
const { list, update, createEntry } = useStaffTime()
const { $dayjs } = useNuxtApp()
const toast = useToast()
@@ -85,7 +85,7 @@ async function onSubmit(event: FormSubmitEvent<any>) {
if (state.end_date && state.end_time) {
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.")
}
}
@@ -100,6 +100,33 @@ async function onSubmit(event: FormSubmitEvent<any>) {
})
toast.add({ title: 'Eintrag aktualisiert', color: 'green' })
} 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)
// 💡 HIER WAR DER FEHLER: Wir nutzen jetzt createEntry mit den Daten aus dem Formular
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 incomingInvoices = ref([])
const costcentres = ref([])
const selectedYear = ref(String(dayjs().year()))
const selectedMonth = ref("all")
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 years = [...new Set(
incomingInvoices.value
@@ -29,7 +85,7 @@ const monthItems = [
{ label: "Ganzes Jahr", value: "all" },
{ label: "Januar", value: "1" },
{ label: "Februar", value: "2" },
{ label: "Maerz", value: "3" },
{ label: "März", value: "3" },
{ label: "April", value: "4" },
{ label: "Mai", value: "5" },
{ label: "Juni", value: "6" },
@@ -53,12 +109,15 @@ const reportRows = computed(() => {
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) => {
const amountNet = Number(account.amountNet || 0)
const amountTax = Number(account.amountTax || 0)
const amountGross = Number(account.amountGross || amountNet + amountTax || 0)
const accountCostCentre = costCentreMap.value.get(getCostCentreId(account.costCentre))
return {
id: `${invoice.id}-${index}`,
@@ -68,6 +127,7 @@ const reportRows = computed(() => {
state: invoice.state || "-",
vendorName: invoice.vendor?.name || "-",
accountLabel: account.account?.label || account.accountLabel || "-",
costCentreName: accountCostCentre ? `${accountCostCentre.number} - ${accountCostCentre.name}` : "-",
description: account.description || invoice.description || "-",
amountNet,
amountTax,
@@ -91,6 +151,7 @@ const columns = [
{ accessorKey: "date", header: "Datum" },
{ accessorKey: "vendorName", header: "Lieferant" },
{ accessorKey: "accountLabel", header: "Konto" },
{ accessorKey: "costCentreName", header: "Kostenstelle" },
{ accessorKey: "description", header: "Beschreibung" },
{ accessorKey: "amountNet", header: "Netto" },
{ accessorKey: "amountTax", header: "Steuer" },
@@ -100,10 +161,11 @@ const columns = [
const setupPage = async () => {
loading.value = true
costcentres.value = await useEntities("costcentres").select("*", null, false, true)
const invoices = await useEntities("incominginvoices").select("*, vendor(id,name)")
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
@@ -162,7 +224,7 @@ setupPage()
v-if="!loading"
:data="reportRows"
: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"
>
<template #reference-cell="{ row }">
@@ -181,6 +243,10 @@ setupPage()
<div class="truncate">{{ row.original.accountLabel }}</div>
</template>
<template #costCentreName-cell="{ row }">
<div class="truncate">{{ row.original.costCentreName }}</div>
</template>
<template #description-cell="{ row }">
<UTooltip :text="row.original.description">
<div class="max-w-[18rem] truncate">{{ row.original.description }}</div>

View File

@@ -22,14 +22,11 @@ setupPage()
<template>
<UTable
v-if="openTasks.length > 0"
:data="openTasks"
:columns="normalizeTableColumns([{key:'name',label:'Name'},{key:'categorie',label:'Kategorie'}])"
:on-select="(i) => router.push(`/tasks/show/${i.id}`)"
:columns="normalizeTableColumns([{key:'name',label:'Name'},{key:'categorie',label:'Kategorie'}])"
: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>
<style scoped>

View File

@@ -97,5 +97,15 @@ export const useFunctions = () => {
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'),
user_id: targetUserId,
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())
} catch (error) {
@@ -126,4 +126,4 @@ export const useStaffTime = () => {
}
return { list, start, stop, submit, approve, reject, update, createEntry }
}
}

View File

@@ -585,14 +585,8 @@ onMounted(setupPage)
:columns="normalizeTableColumns(accountColumns)"
:loading="loading"
: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 }">
<div class="truncate font-medium">{{ row.original.label }}</div>
</template>
@@ -630,14 +624,8 @@ onMounted(setupPage)
:columns="normalizeTableColumns(ownAccountColumns)"
:loading="loading"
: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 }">
<div class="truncate font-medium">{{ row.original.label }}</div>
</template>
@@ -676,6 +664,7 @@ onMounted(setupPage)
:data="depreciationRows"
:columns="normalizeTableColumns(depreciationColumns)"
:loading="loading"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Abschreibungen im ausgewählten Zeitraum' }"
>
<template #amount-cell="{ row }">
<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}`)"
class="mt-4"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine zugeordneten Benutzer gefunden' }"
/>
</UCard>

View File

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

View File

@@ -35,13 +35,14 @@ const createExport = async () => {
</template>
</UDashboardNavbar>
<UTable
v-if="createddocuments.length > 0"
:loading="true"
v-model="selected"
: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>
<style scoped>
</style>
</style>

View File

@@ -154,6 +154,7 @@ const createExport = async () => {
{ key: 'type', label: 'Typ' },
{ key: 'download', label: 'Download' },
])"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Exporte anzuzeigen' }"
>
<template #created_at-cell="{row}">
{{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 dataType = dataStore.dataTypes[type]
const openAmountColumnKey = "openAmount"
const setupPage = async () => {
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()
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 selectableFilters = ref(dataType.filters.map(i => i.name))
@@ -142,10 +149,18 @@ const getInvoiceSum = (invoice) => {
return sum.toFixed(2)
}
const isPaid = (item) => {
const getPaidAmount = (item) => {
let amountPaid = 0
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
@@ -283,6 +298,11 @@ const selectIncomingInvoice = (invoiceLike) => {
<template #amount-cell="{row}">
{{displayCurrency(sum.getIncomingInvoiceSum(row.original))}}
</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}">
<span v-if="row.original.dueDate">{{dayjs(row.original.dueDate).format("DD.MM.YYYY")}}</span>
</template>

View File

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

View File

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

View File

@@ -272,7 +272,7 @@ onMounted(async () => {
<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" />
</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" />
</UTooltip>
<UTooltip text="Bearbeiten" v-if="['draft', 'factual', 'submitted'].includes(row.original.state)">
@@ -366,7 +366,7 @@ onMounted(async () => {
/>
<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"
@click="openRejectModal(entry)" :loading="loading"
/>
@@ -434,6 +434,7 @@ onMounted(async () => {
<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 === '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>
</UCard>
</UDashboardPanelContent>

View File

@@ -461,6 +461,7 @@ onMounted(async () => {
:data="filteredTasks"
:columns="normalizedListColumns"
:on-select="(task) => openTaskViaRoute(task)"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Aufgaben anzuzeigen' }"
>
<template #actions-cell="{ row }">
<UButton
@@ -490,11 +491,11 @@ onMounted(async () => {
{{ getEntityLabel(plantOptions, row.original.plant?.id || row.original.plant) || "-" }}
</template>
</UTable>
<UAlert
<UTable
v-else
icon="i-heroicons-circle-stack-20-solid"
title="Keine Aufgaben anzuzeigen"
variant="subtle"
:data="[]"
:columns="normalizedListColumns"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Aufgaben anzuzeigen' }"
/>
</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(() => {
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 project from "~/components/columnRenderings/project.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 profile from "~/components/columnRenderings/profile.vue";
import profiles from "~/components/columnRenderings/profiles.vue";
@@ -2200,6 +2201,10 @@ export const useDataStore = defineStore('data', () => {
key: "amount",
label: "Betrag",
},
{
key: "openAmount",
label: "Offener Betrag",
},
{
key: "dueDate",
label: "Fälligkeitsdatum",
@@ -3308,7 +3313,7 @@ export const useDataStore = defineStore('data', () => {
numberRangeHolder: "number",
historyItemHolder: "costcentre",
sortColumn: "number",
selectWithInformation: "*, project(*), vehicle(*), inventoryitem(*), branch(*)",
selectWithInformation: "*, parentCostcentre(*), project(*), vehicle(*), inventoryitem(*), branch(*)",
filters: [{
name: "Archivierte ausblenden",
default: true,
@@ -3340,6 +3345,18 @@ export const useDataStore = defineStore('data', () => {
label: "Beschreibung",
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",
label: "Fahrzeug",