Compare commits
30 Commits
8c458f4953
...
a021d3d15c
| Author | SHA1 | Date | |
|---|---|---|---|
| a021d3d15c | |||
| bb61caed6d | |||
| d3ab03da7e | |||
| 5869f88c1a | |||
| 50c76b67c7 | |||
| f4edcc2d44 | |||
| 35ef3a7cf8 | |||
| 4783971000 | |||
| c085b1e4d5 | |||
| 46b08b29b9 | |||
| 5cc41f9a2d | |||
| edec670ee0 | |||
| 41e5a4021b | |||
| 9c608cbf71 | |||
| 543952dbf8 | |||
| 2f7819e309 | |||
| 7799cbce80 | |||
| 0284ea8726 | |||
| 743bf0660c | |||
| df4b591be4 | |||
| 86e0743cbb | |||
| aaf91ea15e | |||
| cb71e9d294 | |||
| 75148b2718 | |||
| 81b4eee1e8 | |||
| 0fbda27609 | |||
| 3562d55a12 | |||
| 6224a25c38 | |||
| 63b1c563c1 | |||
| 76f86e87c1 |
10
backend/db/migrations/0030_manual_statementallocations.sql
Normal file
10
backend/db/migrations/0030_manual_statementallocations.sql
Normal 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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "statementallocations" ADD COLUMN "datev_tax_key" text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "statementallocations" ADD COLUMN "manual_invoice_side" text;
|
||||
2
backend/db/migrations/0033_costcentres_parent.sql
Normal file
2
backend/db/migrations/0033_costcentres_parent.sql
Normal 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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
839
backend/src/utils/liquidityForecast.ts
Normal file
839
backend/src/utils/liquidityForecast.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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
3
docs-site/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
56
docs-site/app/app.config.ts
Normal file
56
docs-site/app/app.config.ts
Normal 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
44
docs-site/app/app.vue
Normal 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>
|
||||
25
docs-site/app/assets/css/main.css
Normal file
25
docs-site/app/assets/css/main.css
Normal 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);
|
||||
}
|
||||
23
docs-site/app/components/AppFooter.vue
Normal file
23
docs-site/app/components/AppFooter.vue
Normal 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>
|
||||
49
docs-site/app/components/AppHeader.vue
Normal file
49
docs-site/app/components/AppHeader.vue
Normal 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>
|
||||
3
docs-site/app/components/AppLogo.vue
Normal file
3
docs-site/app/components/AppLogo.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<span class="font-semibold text-primary">FEDEO Docs</span>
|
||||
</template>
|
||||
23
docs-site/app/components/PageHeaderLinks.vue
Normal file
23
docs-site/app/components/PageHeaderLinks.vue
Normal 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
31
docs-site/app/error.vue
Normal 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>
|
||||
22
docs-site/app/layouts/docs.vue
Normal file
22
docs-site/app/layouts/docs.vue
Normal 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>
|
||||
97
docs-site/app/pages/[...slug].vue
Normal file
97
docs-site/app/pages/[...slug].vue
Normal 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>
|
||||
7
docs-site/app/pages/index.vue
Normal file
7
docs-site/app/pages/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
await navigateTo('/bedienung', { redirectCode: 302 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
18
docs-site/content.config.ts
Normal file
18
docs-site/content.config.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
42
docs-site/content/bedienung/ausgangsbelege.md
Normal file
42
docs-site/content/bedienung/ausgangsbelege.md
Normal 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.
|
||||
41
docs-site/content/bedienung/bankportal.md
Normal file
41
docs-site/content/bedienung/bankportal.md
Normal 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.
|
||||
13
docs-site/content/bedienung/index.md
Normal file
13
docs-site/content/bedienung/index.md
Normal 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.
|
||||
31
docs-site/content/bedienung/serienrechnungen.md
Normal file
31
docs-site/content/bedienung/serienrechnungen.md
Normal 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.
|
||||
@@ -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**
|
||||
@@ -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**
|
||||
@@ -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
|
||||
```
|
||||
@@ -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**
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
16365
docs-site/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,5 +1,3 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"types": ["@types/node"]
|
||||
}
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
|
||||
@@ -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
13
docs/bedienung/README.md
Normal 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.
|
||||
42
docs/bedienung/ausgangsbelege.md
Normal file
42
docs/bedienung/ausgangsbelege.md
Normal 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.
|
||||
41
docs/bedienung/bankportal.md
Normal file
41
docs/bedienung/bankportal.md
Normal 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.
|
||||
31
docs/bedienung/serienrechnungen.md
Normal file
31
docs/bedienung/serienrechnungen.md
Normal 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.
|
||||
@@ -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**
|
||||
@@ -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**
|
||||
@@ -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
|
||||
```
|
||||
@@ -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**
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }">
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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({
|
||||
|
||||
32
frontend/components/columnRenderings/costcentre.vue
Normal file
32
frontend/components/columnRenderings/costcentre.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
903
frontend/pages/accounting/liquidity.vue
Normal file
903
frontend/pages/accounting/liquidity.vue
Normal 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>
|
||||
491
frontend/pages/accounting/manual-bookings.vue
Normal file
491
frontend/pages/accounting/manual-bookings.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user