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,
|
"when": 1776211200000,
|
||||||
"tag": "0029_events_quick",
|
"tag": "0029_events_quick",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 30,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776297600000,
|
||||||
|
"tag": "0030_manual_statementallocations",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 31,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776298200000,
|
||||||
|
"tag": "0031_manual_statementallocations_tax_key",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 32,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776298800000,
|
||||||
|
"tag": "0032_manual_statementallocations_invoice_side",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 33,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777003200000,
|
||||||
|
"tag": "0033_costcentres_parent",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export const costcentres = pgTable("costcentres", {
|
|||||||
number: text("number").notNull(),
|
number: text("number").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
|
|
||||||
|
parentCostcentre: uuid("parent_costcentre").references(() => costcentres.id),
|
||||||
|
|
||||||
vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.id),
|
vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.id),
|
||||||
|
|
||||||
project: bigint("project", { mode: "number" }).references(() => projects.id),
|
project: bigint("project", { mode: "number" }).references(() => projects.id),
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ export const statementallocations = pgTable("statementallocations", {
|
|||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
|
||||||
// foreign keys
|
// foreign keys
|
||||||
bankstatement: integer("bs_id")
|
bankstatement: integer("bs_id").references(() => bankstatements.id),
|
||||||
.notNull()
|
|
||||||
.references(() => bankstatements.id),
|
|
||||||
|
|
||||||
createddocument: integer("cd_id").references(() => createddocuments.id),
|
createddocument: integer("cd_id").references(() => createddocuments.id),
|
||||||
|
|
||||||
@@ -34,6 +32,7 @@ export const statementallocations = pgTable("statementallocations", {
|
|||||||
incominginvoice: bigint("ii_id", { mode: "number" }).references(
|
incominginvoice: bigint("ii_id", { mode: "number" }).references(
|
||||||
() => incominginvoices.id
|
() => incominginvoices.id
|
||||||
),
|
),
|
||||||
|
manualInvoiceSide: text("manual_invoice_side"),
|
||||||
|
|
||||||
tenant: bigint("tenant", { mode: "number" })
|
tenant: bigint("tenant", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -43,14 +42,23 @@ export const statementallocations = pgTable("statementallocations", {
|
|||||||
() => accounts.id
|
() => accounts.id
|
||||||
),
|
),
|
||||||
|
|
||||||
|
contraAccount: bigint("contra_account", { mode: "number" }).references(
|
||||||
|
() => accounts.id
|
||||||
|
),
|
||||||
|
|
||||||
created_at: timestamp("created_at", {
|
created_at: timestamp("created_at", {
|
||||||
withTimezone: false,
|
withTimezone: false,
|
||||||
}).defaultNow(),
|
}).defaultNow(),
|
||||||
|
|
||||||
ownaccount: uuid("ownaccount").references(() => ownaccounts.id),
|
ownaccount: uuid("ownaccount").references(() => ownaccounts.id),
|
||||||
|
|
||||||
|
contraOwnaccount: uuid("contra_ownaccount").references(() => ownaccounts.id),
|
||||||
|
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
|
|
||||||
|
manualBookingDate: text("manual_booking_date"),
|
||||||
|
datevTaxKey: text("datev_tax_key"),
|
||||||
|
|
||||||
bookingMode: text("booking_mode").notNull().default("expense"),
|
bookingMode: text("booking_mode").notNull().default("expense"),
|
||||||
depreciationMonths: integer("depreciation_months"),
|
depreciationMonths: integer("depreciation_months"),
|
||||||
depreciationStartDate: text("depreciation_start_date"),
|
depreciationStartDate: text("depreciation_start_date"),
|
||||||
@@ -65,6 +73,12 @@ export const statementallocations = pgTable("statementallocations", {
|
|||||||
|
|
||||||
vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id),
|
vendor: bigint("vendor", { mode: "number" }).references(() => vendors.id),
|
||||||
|
|
||||||
|
contraCustomer: bigint("contra_customer", { mode: "number" }).references(
|
||||||
|
() => customers.id
|
||||||
|
),
|
||||||
|
|
||||||
|
contraVendor: bigint("contra_vendor", { mode: "number" }).references(() => vendors.id),
|
||||||
|
|
||||||
updated_at: timestamp("updated_at", { withTimezone: true }),
|
updated_at: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
|
||||||
updated_by: uuid("updated_by").references(() => authUsers.id),
|
updated_by: uuid("updated_by").references(() => authUsers.id),
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ export type DerivedSpan = {
|
|||||||
sourceEventIds: string[];
|
sourceEventIds: string[];
|
||||||
status: SpanStatus;
|
status: SpanStatus;
|
||||||
statusActorId?: string;
|
statusActorId?: string;
|
||||||
|
payload?: Record<string, any> | null;
|
||||||
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TimeEvent = {
|
type TimeEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
eventtype: string;
|
eventtype: string;
|
||||||
eventtime: Date;
|
eventtime: Date;
|
||||||
|
payload?: Record<string, any> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Liste aller faktischen Event-Typen, die die Zustandsmaschine beeinflussen
|
// Liste aller faktischen Event-Typen, die die Zustandsmaschine beeinflussen
|
||||||
@@ -45,9 +48,17 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
|||||||
let currentStart: Date | null = null;
|
let currentStart: Date | null = null;
|
||||||
let currentType: DerivedSpan["type"] | null = null;
|
let currentType: DerivedSpan["type"] | null = null;
|
||||||
let sourceEventIds: string[] = [];
|
let sourceEventIds: string[] = [];
|
||||||
|
let currentPayload: Record<string, any> | null = null;
|
||||||
|
|
||||||
const closeSpan = (end: Date) => {
|
const closeSpan = (end: Date) => {
|
||||||
if (!currentStart || !currentType) return;
|
if (!currentStart || !currentType) return;
|
||||||
|
if (end.getTime() <= currentStart.getTime()) {
|
||||||
|
currentStart = null;
|
||||||
|
currentType = null;
|
||||||
|
sourceEventIds = [];
|
||||||
|
currentPayload = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
spans.push({
|
spans.push({
|
||||||
type: currentType,
|
type: currentType,
|
||||||
@@ -55,12 +66,15 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
|||||||
endedAt: end,
|
endedAt: end,
|
||||||
sourceEventIds: [...sourceEventIds],
|
sourceEventIds: [...sourceEventIds],
|
||||||
// Standardstatus ist "factual", wird später angereichert
|
// Standardstatus ist "factual", wird später angereichert
|
||||||
status: "factual"
|
status: "factual",
|
||||||
|
payload: currentPayload,
|
||||||
|
description: currentPayload?.description || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
currentStart = null;
|
currentStart = null;
|
||||||
currentType = null;
|
currentType = null;
|
||||||
sourceEventIds = [];
|
sourceEventIds = [];
|
||||||
|
currentPayload = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeOpenSpanAsRunning = () => {
|
const closeOpenSpanAsRunning = () => {
|
||||||
@@ -72,12 +86,15 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
|||||||
endedAt: null,
|
endedAt: null,
|
||||||
sourceEventIds: [...sourceEventIds],
|
sourceEventIds: [...sourceEventIds],
|
||||||
// Standardstatus ist "factual", wird später angereichert
|
// Standardstatus ist "factual", wird später angereichert
|
||||||
status: "factual"
|
status: "factual",
|
||||||
|
payload: currentPayload,
|
||||||
|
description: currentPayload?.description || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
currentStart = null;
|
currentStart = null;
|
||||||
currentType = null;
|
currentType = null;
|
||||||
sourceEventIds = [];
|
sourceEventIds = [];
|
||||||
|
currentPayload = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
@@ -96,6 +113,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
|||||||
state = "WORKING";
|
state = "WORKING";
|
||||||
currentStart = event.eventtime;
|
currentStart = event.eventtime;
|
||||||
currentType = "work";
|
currentType = "work";
|
||||||
|
currentPayload = event.payload || null;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "pause_start":
|
case "pause_start":
|
||||||
@@ -104,6 +122,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
|||||||
state = "PAUSED";
|
state = "PAUSED";
|
||||||
currentStart = event.eventtime;
|
currentStart = event.eventtime;
|
||||||
currentType = "pause";
|
currentType = "pause";
|
||||||
|
currentPayload = event.payload || null;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -113,6 +132,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
|||||||
state = "WORKING";
|
state = "WORKING";
|
||||||
currentStart = event.eventtime;
|
currentStart = event.eventtime;
|
||||||
currentType = "work";
|
currentType = "work";
|
||||||
|
currentPayload = event.payload || null;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -140,6 +160,7 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
|||||||
state = "ABSENT";
|
state = "ABSENT";
|
||||||
currentStart = event.eventtime;
|
currentStart = event.eventtime;
|
||||||
currentType = newType;
|
currentType = newType;
|
||||||
|
currentPayload = event.payload || null;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "vacation_end":
|
case "vacation_end":
|
||||||
@@ -162,4 +183,4 @@ export function deriveTimeSpans(allValidEvents: TimeEvent[]): DerivedSpan[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return spans;
|
return spans;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/services/loadValidEvents.ts
|
// src/services/loadValidEvents.ts
|
||||||
|
|
||||||
import { stafftimeevents } from "../../../db/schema";
|
import { stafftimeevents } from "../../../db/schema";
|
||||||
import {sql, and, eq, gte, lte, inArray} from "drizzle-orm";
|
import {sql, and, eq, gte, lte, inArray, asc} from "drizzle-orm";
|
||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
export type TimeType = "work_start" | "work_end" | "pause_start" | "pause_end" | "sick_start" | "sick_end" | "vacation_start" | "vacation_end" | "ovetime_compensation_start" | "overtime_compensation_end";
|
export type TimeType = "work_start" | "work_end" | "pause_start" | "pause_end" | "sick_start" | "sick_end" | "vacation_start" | "vacation_end" | "ovetime_compensation_start" | "overtime_compensation_end";
|
||||||
@@ -12,11 +12,43 @@ export type TimeEvent = {
|
|||||||
id: string;
|
id: string;
|
||||||
eventtype: string;
|
eventtype: string;
|
||||||
eventtime: Date;
|
eventtime: Date;
|
||||||
actoruser_id: string;
|
actoruser_id?: string;
|
||||||
related_event_id: string | null;
|
related_event_id: string | null;
|
||||||
// Fügen Sie hier alle weiteren Felder hinzu, die Sie benötigen
|
payload?: Record<string, any> | null;
|
||||||
|
created_at?: Date | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EVENT_TYPE_ORDER: Record<string, number> = {
|
||||||
|
auto_stop: 10,
|
||||||
|
work_end: 10,
|
||||||
|
pause_end: 10,
|
||||||
|
vacation_end: 10,
|
||||||
|
sick_end: 10,
|
||||||
|
overtime_compensation_end: 10,
|
||||||
|
work_start: 20,
|
||||||
|
pause_start: 20,
|
||||||
|
vacation_start: 20,
|
||||||
|
sick_start: 20,
|
||||||
|
overtime_compensation_start: 20,
|
||||||
|
submitted: 30,
|
||||||
|
approved: 30,
|
||||||
|
rejected: 30,
|
||||||
|
invalidated: 40,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function compareTimeEvents(a: TimeEvent, b: TimeEvent) {
|
||||||
|
const eventTimeDiff = a.eventtime.getTime() - b.eventtime.getTime();
|
||||||
|
if (eventTimeDiff !== 0) return eventTimeDiff;
|
||||||
|
|
||||||
|
const typeOrderDiff = (EVENT_TYPE_ORDER[a.eventtype] ?? 999) - (EVENT_TYPE_ORDER[b.eventtype] ?? 999);
|
||||||
|
if (typeOrderDiff !== 0) return typeOrderDiff;
|
||||||
|
|
||||||
|
const createdAtDiff = (a.created_at?.getTime() ?? 0) - (b.created_at?.getTime() ?? 0);
|
||||||
|
if (createdAtDiff !== 0) return createdAtDiff;
|
||||||
|
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadValidEvents(
|
export async function loadValidEvents(
|
||||||
server: FastifyInstance,
|
server: FastifyInstance,
|
||||||
tenantId: number,
|
tenantId: number,
|
||||||
@@ -62,10 +94,9 @@ export async function loadValidEvents(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(
|
.orderBy(
|
||||||
// Wichtig für die deriveTimeSpans Logik: Event-Zeit muss primär sein
|
asc(baseEvents.eventtime),
|
||||||
baseEvents.eventtime,
|
asc(baseEvents.created_at),
|
||||||
baseEvents.created_at, // Wenn Eventtime identisch ist, das später erstellte zuerst
|
asc(baseEvents.id)
|
||||||
baseEvents.id
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mapping auf den sauberen TimeEvent Typ
|
// Mapping auf den sauberen TimeEvent Typ
|
||||||
@@ -73,8 +104,10 @@ export async function loadValidEvents(
|
|||||||
id: e.id,
|
id: e.id,
|
||||||
eventtype: e.eventtype,
|
eventtype: e.eventtype,
|
||||||
eventtime: e.eventtime,
|
eventtime: e.eventtime,
|
||||||
// Fügen Sie hier alle weiteren benötigten Felder hinzu (z.B. metadata, actoruser_id)
|
actoruser_id: e.actoruser_id,
|
||||||
// ...
|
related_event_id: e.related_event_id,
|
||||||
|
payload: e.payload,
|
||||||
|
created_at: e.created_at,
|
||||||
})) as TimeEvent[];
|
})) as TimeEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +132,11 @@ export async function loadRelatedAdminEvents(server, eventIds) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
// Muss nach Zeit sortiert sein, um den Status korrekt zu bestimmen!
|
// Muss nach Zeit sortiert sein, um den Status korrekt zu bestimmen!
|
||||||
.orderBy(stafftimeevents.eventtime);
|
.orderBy(
|
||||||
|
asc(stafftimeevents.eventtime),
|
||||||
|
asc(stafftimeevents.created_at),
|
||||||
|
asc(stafftimeevents.id)
|
||||||
|
);
|
||||||
|
|
||||||
return adminEvents;
|
return adminEvents as TimeEvent[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
|
|||||||
import {
|
import {
|
||||||
bankrequisitions,
|
bankrequisitions,
|
||||||
bankstatements,
|
bankstatements,
|
||||||
|
accounts,
|
||||||
createddocuments,
|
createddocuments,
|
||||||
customers,
|
customers,
|
||||||
entitybankaccounts,
|
entitybankaccounts,
|
||||||
incominginvoices,
|
incominginvoices,
|
||||||
|
ownaccounts,
|
||||||
statementallocations,
|
statementallocations,
|
||||||
vendors,
|
vendors,
|
||||||
} from "../../db/schema"
|
} from "../../db/schema"
|
||||||
@@ -22,10 +24,71 @@ import {
|
|||||||
import {
|
import {
|
||||||
eq,
|
eq,
|
||||||
and,
|
and,
|
||||||
|
isNull,
|
||||||
|
aliasedTable,
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
|
|
||||||
|
|
||||||
export default async function bankingRoutes(server: FastifyInstance) {
|
export default async function bankingRoutes(server: FastifyInstance) {
|
||||||
|
const ContraAccounts = aliasedTable(accounts, "contra_accounts")
|
||||||
|
const ContraCustomers = aliasedTable(customers, "contra_customers")
|
||||||
|
const ContraVendors = aliasedTable(vendors, "contra_vendors")
|
||||||
|
const ContraOwnaccounts = aliasedTable(ownaccounts, "contra_ownaccounts")
|
||||||
|
const ManualInvoices = aliasedTable(incominginvoices, "manual_invoices")
|
||||||
|
const ManualInvoiceVendors = aliasedTable(vendors, "manual_invoice_vendors")
|
||||||
|
|
||||||
|
const normalizeManualSide = (payload: any, keys: string[]) =>
|
||||||
|
keys.filter((key) => payload[key] !== null && payload[key] !== undefined && payload[key] !== "")
|
||||||
|
|
||||||
|
const prepareStatementAllocationPayload = (payload: any) => {
|
||||||
|
const next = { ...payload }
|
||||||
|
const isManualBooking = !next.bankstatement
|
||||||
|
|
||||||
|
if (!isManualBooking) {
|
||||||
|
next.manualBookingDate = null
|
||||||
|
next.contraAccount = null
|
||||||
|
next.contraCustomer = null
|
||||||
|
next.contraVendor = null
|
||||||
|
next.contraOwnaccount = null
|
||||||
|
next.datevTaxKey = next.datevTaxKey ? String(next.datevTaxKey).trim() : null
|
||||||
|
next.manualInvoiceSide = null
|
||||||
|
return { data: next }
|
||||||
|
}
|
||||||
|
|
||||||
|
const debitKeys = ["account", "customer", "vendor", "ownaccount"]
|
||||||
|
const creditKeys = ["contraAccount", "contraCustomer", "contraVendor", "contraOwnaccount"]
|
||||||
|
const hasManualInvoice = next.incominginvoice !== null && next.incominginvoice !== undefined && next.incominginvoice !== ""
|
||||||
|
const debitSide = normalizeManualSide(next, debitKeys)
|
||||||
|
const creditSide = normalizeManualSide(next, creditKeys)
|
||||||
|
|
||||||
|
if (hasManualInvoice) {
|
||||||
|
if (next.manualInvoiceSide === "debit") debitSide.push("incominginvoice")
|
||||||
|
else if (next.manualInvoiceSide === "credit") creditSide.push("incominginvoice")
|
||||||
|
else return { error: "Für zugewiesene Eingangsbelege muss Soll oder Haben ausgewählt sein." }
|
||||||
|
} else {
|
||||||
|
next.manualInvoiceSide = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!next.manualBookingDate || !dayjs(next.manualBookingDate).isValid()) {
|
||||||
|
return { error: "Für manuelle Buchungen ist ein gültiges Buchungsdatum erforderlich." }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(Number(next.amount)) || Number(next.amount) <= 0) {
|
||||||
|
return { error: "Für manuelle Buchungen muss der Betrag größer als 0 sein." }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debitSide.length !== 1 || creditSide.length !== 1) {
|
||||||
|
return { error: "Für manuelle Buchungen muss genau ein Soll- und ein Haben-Konto ausgewählt werden." }
|
||||||
|
}
|
||||||
|
|
||||||
|
next.amount = Math.abs(Number(next.amount))
|
||||||
|
next.bankstatement = null
|
||||||
|
next.manualBookingDate = dayjs(next.manualBookingDate).format("YYYY-MM-DD")
|
||||||
|
next.datevTaxKey = next.datevTaxKey ? String(next.datevTaxKey).trim() : null
|
||||||
|
|
||||||
|
return { data: next }
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeIban = (value?: string | null) =>
|
const normalizeIban = (value?: string | null) =>
|
||||||
String(value || "").replace(/\s+/g, "").toUpperCase()
|
String(value || "").replace(/\s+/g, "").toUpperCase()
|
||||||
|
|
||||||
@@ -677,6 +740,64 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 📒 List Manual Statement Allocations
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
server.get("/banking/manual-bookings", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
|
const rows = await server.db.select({
|
||||||
|
allocation: statementallocations,
|
||||||
|
account: accounts,
|
||||||
|
customer: customers,
|
||||||
|
vendor: vendors,
|
||||||
|
ownaccount: ownaccounts,
|
||||||
|
contraAccount: ContraAccounts,
|
||||||
|
contraCustomer: ContraCustomers,
|
||||||
|
contraVendor: ContraVendors,
|
||||||
|
contraOwnaccount: ContraOwnaccounts,
|
||||||
|
incominginvoice: ManualInvoices,
|
||||||
|
incominginvoiceVendor: ManualInvoiceVendors,
|
||||||
|
})
|
||||||
|
.from(statementallocations)
|
||||||
|
.leftJoin(accounts, eq(statementallocations.account, accounts.id))
|
||||||
|
.leftJoin(customers, eq(statementallocations.customer, customers.id))
|
||||||
|
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
|
||||||
|
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
|
||||||
|
.leftJoin(ContraAccounts, eq(statementallocations.contraAccount, ContraAccounts.id))
|
||||||
|
.leftJoin(ContraCustomers, eq(statementallocations.contraCustomer, ContraCustomers.id))
|
||||||
|
.leftJoin(ContraVendors, eq(statementallocations.contraVendor, ContraVendors.id))
|
||||||
|
.leftJoin(ContraOwnaccounts, eq(statementallocations.contraOwnaccount, ContraOwnaccounts.id))
|
||||||
|
.leftJoin(ManualInvoices, eq(statementallocations.incominginvoice, ManualInvoices.id))
|
||||||
|
.leftJoin(ManualInvoiceVendors, eq(ManualInvoices.vendor, ManualInvoiceVendors.id))
|
||||||
|
.where(and(
|
||||||
|
eq(statementallocations.tenant, req.user.tenant_id),
|
||||||
|
eq(statementallocations.archived, false),
|
||||||
|
isNull(statementallocations.bankstatement)
|
||||||
|
))
|
||||||
|
|
||||||
|
return reply.send(rows.map((row) => ({
|
||||||
|
...row.allocation,
|
||||||
|
account: row.account,
|
||||||
|
customer: row.customer,
|
||||||
|
vendor: row.vendor,
|
||||||
|
ownaccount: row.ownaccount,
|
||||||
|
contraAccount: row.contraAccount,
|
||||||
|
contraCustomer: row.contraCustomer,
|
||||||
|
contraVendor: row.contraVendor,
|
||||||
|
contraOwnaccount: row.contraOwnaccount,
|
||||||
|
incominginvoice: row.incominginvoice ? {
|
||||||
|
...row.incominginvoice,
|
||||||
|
vendor: row.incominginvoiceVendor,
|
||||||
|
} : null,
|
||||||
|
})))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
return reply.code(500).send({ error: "Failed to load manual bookings" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 💰 Create Statement Allocation
|
// 💰 Create Statement Allocation
|
||||||
@@ -686,9 +807,11 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
if (!req.user) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
const { data: payload } = req.body as { data: any }
|
const { data: payload } = req.body as { data: any }
|
||||||
|
const prepared = prepareStatementAllocationPayload(payload)
|
||||||
|
if (prepared.error) return reply.code(400).send({ error: prepared.error })
|
||||||
|
|
||||||
const inserted = await server.db.insert(statementallocations).values({
|
const inserted = await server.db.insert(statementallocations).values({
|
||||||
...payload,
|
...prepared.data,
|
||||||
tenant: req.user.tenant_id
|
tenant: req.user.tenant_id
|
||||||
}).returning()
|
}).returning()
|
||||||
|
|
||||||
@@ -720,16 +843,18 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await insertHistoryItem(server, {
|
if (createdRecord.bankstatement) {
|
||||||
entity: "bankstatements",
|
await insertHistoryItem(server, {
|
||||||
entityId: Number(createdRecord.bankstatement),
|
entity: "bankstatements",
|
||||||
action: "created",
|
entityId: Number(createdRecord.bankstatement),
|
||||||
created_by: req.user.user_id,
|
action: "created",
|
||||||
tenant_id: req.user.tenant_id,
|
created_by: req.user.user_id,
|
||||||
oldVal: null,
|
tenant_id: req.user.tenant_id,
|
||||||
newVal: createdRecord,
|
oldVal: null,
|
||||||
text: "Buchung erstellt",
|
newVal: createdRecord,
|
||||||
})
|
text: "Buchung erstellt",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return reply.send(createdRecord)
|
return reply.send(createdRecord)
|
||||||
|
|
||||||
@@ -763,16 +888,18 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
.delete(statementallocations)
|
.delete(statementallocations)
|
||||||
.where(eq(statementallocations.id, id))
|
.where(eq(statementallocations.id, id))
|
||||||
|
|
||||||
await insertHistoryItem(server, {
|
if (old.bankstatement) {
|
||||||
entity: "bankstatements",
|
await insertHistoryItem(server, {
|
||||||
entityId: Number(old.bankstatement),
|
entity: "bankstatements",
|
||||||
action: "deleted",
|
entityId: Number(old.bankstatement),
|
||||||
created_by: req.user.user_id,
|
action: "deleted",
|
||||||
tenant_id: req.user.tenant_id,
|
created_by: req.user.user_id,
|
||||||
oldVal: old,
|
tenant_id: req.user.tenant_id,
|
||||||
newVal: null,
|
oldVal: old,
|
||||||
text: "Buchung gelöscht",
|
newVal: null,
|
||||||
})
|
text: "Buchung gelöscht",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return reply.send({ success: true })
|
return reply.send({ success: true })
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {executeManualGeneration, finishManualGeneration} from "../modules/serial
|
|||||||
import { s3 } from "../utils/s3";
|
import { s3 } from "../utils/s3";
|
||||||
import { secrets } from "../utils/secrets";
|
import { secrets } from "../utils/secrets";
|
||||||
import { storeExtractedTextForFile } from "../utils/documentText";
|
import { storeExtractedTextForFile } from "../utils/documentText";
|
||||||
|
import { generateLiquidityForecast } from "../utils/liquidityForecast";
|
||||||
dayjs.extend(customParseFormat)
|
dayjs.extend(customParseFormat)
|
||||||
dayjs.extend(isoWeek)
|
dayjs.extend(isoWeek)
|
||||||
dayjs.extend(isBetween)
|
dayjs.extend(isBetween)
|
||||||
@@ -306,6 +307,21 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
|
await server.services.prepareIncomingInvoices.run(req.user.tenant_id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
server.get('/functions/liquidity-forecast', async (req, reply) => {
|
||||||
|
const { ignoredRecurringKeys } = req.query as { ignoredRecurringKeys?: string }
|
||||||
|
const ignoredKeys = String(ignoredRecurringKeys || "")
|
||||||
|
.split(",")
|
||||||
|
.map((key) => key.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await generateLiquidityForecast(server, req.user.tenant_id, ignoredKeys)
|
||||||
|
} catch (err) {
|
||||||
|
req.log.error(err)
|
||||||
|
return reply.code(500).send({ error: "Liquiditätsprognose konnte nicht erstellt werden." })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
server.post('/functions/services/backfillfiletext', async (req, reply) => {
|
server.post('/functions/services/backfillfiletext', async (req, reply) => {
|
||||||
const tenantId = req.user.tenant_id
|
const tenantId = req.user.tenant_id
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
desc
|
desc
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
import {stafftimeevents} from "../../../db/schema/staff_time_events";
|
import {stafftimeevents} from "../../../db/schema/staff_time_events";
|
||||||
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
import {compareTimeEvents, loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
||||||
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
|
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
|
||||||
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
|
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
@@ -102,7 +102,7 @@ export default async function staffTimeRoutesInternal(server: FastifyInstance) {
|
|||||||
const combinedEvents = [
|
const combinedEvents = [
|
||||||
...factualEvents,
|
...factualEvents,
|
||||||
...relatedAdminEvents,
|
...relatedAdminEvents,
|
||||||
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
].sort(compareTimeEvents);
|
||||||
|
|
||||||
// SCHRITT 5: Spans ableiten
|
// SCHRITT 5: Spans ableiten
|
||||||
const derivedSpans = deriveTimeSpans(combinedEvents);
|
const derivedSpans = deriveTimeSpans(combinedEvents);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
sql,
|
sql,
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
|
|
||||||
import { authProfiles } from "../../../db/schema";
|
import { authProfiles, costcentres } from "../../../db/schema";
|
||||||
import { resourceConfig } from "../../utils/resource.config";
|
import { resourceConfig } from "../../utils/resource.config";
|
||||||
import { useNextNumberRangeNumber } from "../../utils/functions";
|
import { useNextNumberRangeNumber } from "../../utils/functions";
|
||||||
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
|
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
|
||||||
@@ -260,6 +260,59 @@ function validateMemberPayload(payload: Record<string, any>) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function validateCostCentreParent(
|
||||||
|
server: FastifyInstance,
|
||||||
|
tenantId: number,
|
||||||
|
costCentreId: string | null,
|
||||||
|
parentCostcentreId: string | null
|
||||||
|
) {
|
||||||
|
if (!parentCostcentreId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const hierarchyRows = await server.db
|
||||||
|
.select({
|
||||||
|
id: costcentres.id,
|
||||||
|
parentCostcentre: costcentres.parentCostcentre,
|
||||||
|
})
|
||||||
|
.from(costcentres)
|
||||||
|
.where(eq(costcentres.tenant, tenantId))
|
||||||
|
|
||||||
|
const hierarchyMap = new Map(
|
||||||
|
hierarchyRows.map((row) => [row.id, row.parentCostcentre || null])
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hierarchyMap.has(parentCostcentreId)) {
|
||||||
|
return "Die übergeordnete Kostenstelle wurde nicht gefunden."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (costCentreId && parentCostcentreId === costCentreId) {
|
||||||
|
return "Eine Kostenstelle kann nicht sich selbst als übergeordnete Kostenstelle haben."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!costCentreId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentParentId: string | null = parentCostcentreId
|
||||||
|
const visited = new Set<string>()
|
||||||
|
|
||||||
|
while (currentParentId) {
|
||||||
|
if (currentParentId === costCentreId) {
|
||||||
|
return "Die ausgewählte übergeordnete Kostenstelle würde einen Zyklus erzeugen."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visited.has(currentParentId)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(currentParentId)
|
||||||
|
currentParentId = hierarchyMap.get(currentParentId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function maskIban(iban: string) {
|
function maskIban(iban: string) {
|
||||||
if (!iban) return ""
|
if (!iban) return ""
|
||||||
const cleaned = iban.replace(/\s+/g, "")
|
const cleaned = iban.replace(/\s+/g, "")
|
||||||
@@ -730,6 +783,19 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
createData = prepared.data!
|
createData = prepared.data!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resource === "costcentres") {
|
||||||
|
const validationError = await validateCostCentreParent(
|
||||||
|
server,
|
||||||
|
req.user.tenant_id,
|
||||||
|
null,
|
||||||
|
createData.parentCostcentre || null
|
||||||
|
)
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
return reply.code(400).send({ error: validationError })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
|
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
|
||||||
const numberRangeResource = resource === "members" ? "customers" : resource
|
const numberRangeResource = resource === "members" ? "customers" : resource
|
||||||
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
|
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
|
||||||
@@ -836,6 +902,21 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resource === "costcentres") {
|
||||||
|
const validationError = await validateCostCentreParent(
|
||||||
|
server,
|
||||||
|
tenantId,
|
||||||
|
oldRecord.id,
|
||||||
|
Object.prototype.hasOwnProperty.call(data, "parentCostcentre")
|
||||||
|
? data.parentCostcentre || null
|
||||||
|
: oldRecord.parentCostcentre || null
|
||||||
|
)
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
return reply.code(400).send({ error: validationError })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Object.keys(data).forEach((key) => {
|
Object.keys(data).forEach((key) => {
|
||||||
const value = data[key]
|
const value = data[key]
|
||||||
const shouldNormalize =
|
const shouldNormalize =
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
desc
|
desc
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
import {stafftimeevents} from "../../../db/schema/staff_time_events";
|
import {stafftimeevents} from "../../../db/schema/staff_time_events";
|
||||||
import {loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
import {compareTimeEvents, loadRelatedAdminEvents, loadValidEvents} from "../../modules/time/loadvalidevents.service";
|
||||||
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
|
import {deriveTimeSpans} from "../../modules/time/derivetimespans.service";
|
||||||
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
|
import {buildTimeEvaluationFromSpans} from "../../modules/time/buildtimeevaluation.service";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
@@ -354,7 +354,7 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
|
|||||||
const combinedEvents = [
|
const combinedEvents = [
|
||||||
...factualEvents,
|
...factualEvents,
|
||||||
...relatedAdminEvents,
|
...relatedAdminEvents,
|
||||||
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
].sort(compareTimeEvents);
|
||||||
|
|
||||||
// SCHRITT 5: Spans ableiten
|
// SCHRITT 5: Spans ableiten
|
||||||
const derivedSpans = deriveTimeSpans(combinedEvents);
|
const derivedSpans = deriveTimeSpans(combinedEvents);
|
||||||
@@ -424,7 +424,7 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
|
|||||||
const combinedEvents = [
|
const combinedEvents = [
|
||||||
...factualEvents,
|
...factualEvents,
|
||||||
...relatedAdminEvents,
|
...relatedAdminEvents,
|
||||||
].sort((a, b) => a.eventtime.getTime() - b.eventtime.getTime());
|
].sort(compareTimeEvents);
|
||||||
|
|
||||||
// SCHRITT 4: Ableiten und Anreichern
|
// SCHRITT 4: Ableiten und Anreichern
|
||||||
const derivedSpans = deriveTimeSpans(combinedEvents);
|
const derivedSpans = deriveTimeSpans(combinedEvents);
|
||||||
@@ -453,4 +453,4 @@ export default async function staffTimeRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { s3 } from "../s3";
|
|||||||
import { secrets } from "../secrets";
|
import { secrets } from "../secrets";
|
||||||
|
|
||||||
// Drizzle Core Imports
|
// Drizzle Core Imports
|
||||||
import { eq, and, inArray, gte, lte, asc, aliasedTable } from "drizzle-orm";
|
import { eq, and, or, isNull, inArray, gte, lte, asc, aliasedTable } from "drizzle-orm";
|
||||||
|
|
||||||
// Tabellen Imports (keine Relations nötig!)
|
// Tabellen Imports (keine Relations nötig!)
|
||||||
import {
|
import {
|
||||||
@@ -136,6 +136,10 @@ export async function buildExportZip(
|
|||||||
|
|
||||||
const CdCustomer = aliasedTable(customers, "cd_customer");
|
const CdCustomer = aliasedTable(customers, "cd_customer");
|
||||||
const IiVendor = aliasedTable(vendors, "ii_vendor");
|
const IiVendor = aliasedTable(vendors, "ii_vendor");
|
||||||
|
const ContraAccount = aliasedTable(accounts, "contra_account");
|
||||||
|
const ContraVendor = aliasedTable(vendors, "contra_vendor");
|
||||||
|
const ContraCustomer = aliasedTable(customers, "contra_customer");
|
||||||
|
const ContraOwnaccount = aliasedTable(ownaccounts, "contra_ownaccount");
|
||||||
|
|
||||||
const allocRaw = await server.db.select({
|
const allocRaw = await server.db.select({
|
||||||
allocation: statementallocations,
|
allocation: statementallocations,
|
||||||
@@ -148,11 +152,15 @@ export async function buildExportZip(
|
|||||||
acc: accounts,
|
acc: accounts,
|
||||||
direct_vend: vendors, // Direkte Zuordnung an Kreditor
|
direct_vend: vendors, // Direkte Zuordnung an Kreditor
|
||||||
direct_cust: customers, // Direkte Zuordnung an Debitor
|
direct_cust: customers, // Direkte Zuordnung an Debitor
|
||||||
own: ownaccounts
|
own: ownaccounts,
|
||||||
|
contra_acc: ContraAccount,
|
||||||
|
contra_vend: ContraVendor,
|
||||||
|
contra_cust: ContraCustomer,
|
||||||
|
contra_own: ContraOwnaccount
|
||||||
})
|
})
|
||||||
.from(statementallocations)
|
.from(statementallocations)
|
||||||
// JOIN 1: Bankstatement (Pflicht, für Datum Filter)
|
// JOIN 1: Bankstatement (optional, manuelle Buchungen haben keinen Bankumsatz)
|
||||||
.innerJoin(bankstatements, eq(statementallocations.bankstatement, bankstatements.id))
|
.leftJoin(bankstatements, eq(statementallocations.bankstatement, bankstatements.id))
|
||||||
// JOIN 2: Bankaccount (für DATEV Nummer)
|
// JOIN 2: Bankaccount (für DATEV Nummer)
|
||||||
.leftJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id))
|
.leftJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id))
|
||||||
|
|
||||||
@@ -169,13 +177,25 @@ export async function buildExportZip(
|
|||||||
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
|
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
|
||||||
.leftJoin(customers, eq(statementallocations.customer, customers.id))
|
.leftJoin(customers, eq(statementallocations.customer, customers.id))
|
||||||
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
|
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
|
||||||
|
.leftJoin(ContraAccount, eq(statementallocations.contraAccount, ContraAccount.id))
|
||||||
|
.leftJoin(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.id))
|
||||||
|
.leftJoin(ContraCustomer, eq(statementallocations.contraCustomer, ContraCustomer.id))
|
||||||
|
.leftJoin(ContraOwnaccount, eq(statementallocations.contraOwnaccount, ContraOwnaccount.id))
|
||||||
|
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(statementallocations.tenant, tenantId),
|
eq(statementallocations.tenant, tenantId),
|
||||||
eq(statementallocations.archived, false),
|
eq(statementallocations.archived, false),
|
||||||
// Datum Filter direkt auf dem Bankstatement
|
or(
|
||||||
gte(bankstatements.date, startDate),
|
and(
|
||||||
lte(bankstatements.date, endDate)
|
gte(bankstatements.date, startDate),
|
||||||
|
lte(bankstatements.date, endDate)
|
||||||
|
),
|
||||||
|
and(
|
||||||
|
isNull(statementallocations.bankstatement),
|
||||||
|
gte(statementallocations.manualBookingDate, startDate),
|
||||||
|
lte(statementallocations.manualBookingDate, endDate)
|
||||||
|
)
|
||||||
|
)
|
||||||
));
|
));
|
||||||
|
|
||||||
// Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet
|
// Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet
|
||||||
@@ -196,7 +216,11 @@ export async function buildExportZip(
|
|||||||
account: r.acc,
|
account: r.acc,
|
||||||
vendor: r.direct_vend,
|
vendor: r.direct_vend,
|
||||||
customer: r.direct_cust,
|
customer: r.direct_cust,
|
||||||
ownaccount: r.own
|
ownaccount: r.own,
|
||||||
|
contraAccount: r.contra_acc,
|
||||||
|
contraVendor: r.contra_vend,
|
||||||
|
contraCustomer: r.contra_cust,
|
||||||
|
contraOwnaccount: r.contra_own
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// --- D) Stammdaten Accounts ---
|
// --- D) Stammdaten Accounts ---
|
||||||
@@ -311,8 +335,42 @@ export async function buildExportZip(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Bank
|
// Bank
|
||||||
|
const getManualBookingSide = (alloc: any, side: "debit" | "credit") => {
|
||||||
|
const prefix = side === "credit" ? "contra" : "";
|
||||||
|
const account = side === "credit" ? alloc.contraAccount : alloc.account;
|
||||||
|
const vendor = side === "credit" ? alloc.contraVendor : alloc.vendor;
|
||||||
|
const customer = side === "credit" ? alloc.contraCustomer : alloc.customer;
|
||||||
|
const ownaccount = side === "credit" ? alloc.contraOwnaccount : alloc.ownaccount;
|
||||||
|
const incominginvoice = alloc.manualInvoiceSide === side ? alloc.incominginvoice : null;
|
||||||
|
|
||||||
|
if (account) return { number: account.number, name: account.label, type: "Sachkonto" };
|
||||||
|
if (vendor) return { number: vendor.vendorNumber, name: vendor.name, type: "Kreditor" };
|
||||||
|
if (customer) return { number: customer.customerNumber, name: customer.name, type: "Debitor" };
|
||||||
|
if (ownaccount) return { number: ownaccount.number, name: ownaccount.name, type: "Eigenes Konto" };
|
||||||
|
if (incominginvoice) {
|
||||||
|
return {
|
||||||
|
number: incominginvoice.vendor?.vendorNumber || "",
|
||||||
|
name: `${incominginvoice.reference || "Eingangsbeleg"} ${incominginvoice.vendor?.name || ""}`.trim(),
|
||||||
|
type: "Eingangsbeleg",
|
||||||
|
reference: incominginvoice.reference || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { number: "", name: "", type: prefix };
|
||||||
|
};
|
||||||
|
|
||||||
statementallocationsList.forEach(alloc => {
|
statementallocationsList.forEach(alloc => {
|
||||||
const bs = alloc.bankstatement; // durch Mapping verfügbar
|
const bs = alloc.bankstatement; // durch Mapping verfügbar
|
||||||
|
|
||||||
|
if(!bs && alloc.manualBookingDate) {
|
||||||
|
const debit = getManualBookingSide(alloc, "debit");
|
||||||
|
const credit = getManualBookingSide(alloc, "credit");
|
||||||
|
const dateManual = dayjs(alloc.manualBookingDate).format("DDMM");
|
||||||
|
const dateManualFull = dayjs(alloc.manualBookingDate).format("DD.MM.YYYY");
|
||||||
|
const belegnummer = debit.reference || credit.reference || "";
|
||||||
|
bookingLines.push(`${displayCurrency(alloc.amount,true)};"S";;;;;${debit.number};${credit.number};"${alloc.datevTaxKey || ""}";${dateManual};"${belegnummer}";;;"${`MB ${debit.number} an ${credit.number} ${escapeString(alloc.description)}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${escapeString(debit.name || credit.name)}";"Kundennummer";"${debit.number}";"Belegnummer";"${belegnummer}";"Leistungsdatum";"${dateManualFull}";"Belegdatum";"${dateManualFull}";;;;;;;;;;"";;;;;;;;Manuelle-Buchung;${alloc.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if(!bs) return;
|
if(!bs) return;
|
||||||
|
|
||||||
let shSelector = Math.sign(alloc.amount) === -1 ? "H" : "S";
|
let shSelector = Math.sign(alloc.amount) === -1 ? "H" : "S";
|
||||||
@@ -425,4 +483,4 @@ export async function buildExportZip(
|
|||||||
console.error("DATEV Export Error:", error);
|
console.error("DATEV Export Error:", error);
|
||||||
throw 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: {
|
costcentres: {
|
||||||
table: costcentres,
|
table: costcentres,
|
||||||
searchColumns: ["name","number","description"],
|
searchColumns: ["name","number","description"],
|
||||||
mtoLoad: ["vehicle","project","inventoryitem","branch"],
|
mtoLoad: ["parentCostcentre","vehicle","project","inventoryitem","branch"],
|
||||||
numberRangeHolder: "number",
|
numberRangeHolder: "number",
|
||||||
},
|
},
|
||||||
|
parentCostcentre: {
|
||||||
|
table: costcentres,
|
||||||
|
searchColumns: ["name", "number", "description"],
|
||||||
|
},
|
||||||
branches: {
|
branches: {
|
||||||
table: branches,
|
table: branches,
|
||||||
searchColumns: ["name","number","description"],
|
searchColumns: ["name","number","description"],
|
||||||
|
|||||||
3
docs-site/.gitignore
vendored
Normal file
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
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app/docs-site
|
WORKDIR /app/docs-site
|
||||||
|
|
||||||
COPY docs-site/package.json ./
|
COPY docs-site/package.json docs-site/package-lock.json ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
COPY docs-site ./
|
COPY docs-site ./
|
||||||
COPY docs /app/docs
|
COPY docs /app/docs
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# FEDEO Docs Site (Nuxt Content)
|
# FEDEO Docs Site (Nuxt UI + Nuxt Content)
|
||||||
|
|
||||||
Diese Docs-App basiert auf Nuxt Content und rendert die Inhalte aus dem Repository-Ordner `docs/`.
|
Diese Docs-App nutzt den Standardstil des offiziellen Nuxt-UI-Docs-Templates und rendert Inhalte aus `docs/`.
|
||||||
|
|
||||||
## Lokale Entwicklung
|
## Lokale Entwicklung
|
||||||
|
|
||||||
@@ -11,20 +11,21 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Die App ist danach unter `http://localhost:3005` erreichbar.
|
Danach ist die App unter `http://localhost:3005` erreichbar.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
|
npm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
## Production-Deploy
|
## Production-Deploy
|
||||||
|
|
||||||
Das Docker-Image startet einen Node-Server auf Port `3000`.
|
Das Docker-Image startet einen Nuxt Node-Server auf Port `3000`.
|
||||||
In der Haupt-`docker-compose.yml` wird die App hinter Traefik unter `/docs` veröffentlicht.
|
In der Haupt-`docker-compose.yml` ist der Service hinter Traefik unter `/docs` veröffentlicht.
|
||||||
|
|
||||||
## Content-Quelle
|
## Content-Synchronisierung
|
||||||
|
|
||||||
Vor `dev` und `build` wird automatisch synchronisiert:
|
Vor `dev` und `build` wird automatisch synchronisiert:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- [Bedienung](./bedienung/README.md)
|
||||||
- Nachvollziehbare Versionierung der Doku
|
|
||||||
- Einfache Aktualisierung bei Funktionsänderungen
|
|
||||||
|
|
||||||
## Struktur
|
|
||||||
|
|
||||||
- `docs/funktionen/uebersicht.md`: Fachliche Gesamtübersicht der Bereiche
|
|
||||||
- `docs/funktionen/backend-api.md`: Automatisch erzeugte API-Funktionsliste
|
|
||||||
- `docs/funktionen/frontend-web.md`: Automatisch erzeugte Seiten-/Funktionsliste des Web-Frontends
|
|
||||||
- `docs/funktionen/mobile-app.md`: Automatisch erzeugte Screens-/Funktionsliste der Mobile-App
|
|
||||||
- `docs/versionen/docs-versionen.md`: Versionierung der Dokumentation
|
|
||||||
- `docs/wartung/dokumentationsprozess.md`: Prozess, damit die Doku dauerhaft aktuell bleibt
|
|
||||||
- `docs/scripts/sync-funktionsdoku.mjs`: Skript zur automatischen Aktualisierung
|
|
||||||
|
|
||||||
## Aktualisierung bei Funktionsänderungen
|
|
||||||
|
|
||||||
Bei jeder Funktionsänderung bitte ausführen:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node docs/scripts/sync-funktionsdoku.mjs
|
|
||||||
```
|
|
||||||
|
|
||||||
Danach:
|
|
||||||
|
|
||||||
1. Änderungen in `docs/funktionen/*.md` prüfen
|
|
||||||
2. Falls nötig fachliche Texte in `docs/funktionen/uebersicht.md` ergänzen
|
|
||||||
3. Eintrag in `docs/versionen/docs-versionen.md` ergänzen
|
|
||||||
4. Alles gemeinsam committen
|
|
||||||
|
|
||||||
## Verwendung mit Nuxt Content
|
|
||||||
|
|
||||||
Empfohlene Vorgehensweise:
|
|
||||||
|
|
||||||
1. `docs/` in das Content-Verzeichnis übernehmen (oder per Sync einbinden)
|
|
||||||
2. Navigation anhand der Ordner `funktionen`, `wartung`, `versionen` aufbauen
|
|
||||||
3. `backend-api.md`, `frontend-web.md`, `mobile-app.md` als referenzierende Funktionskataloge einbinden
|
|
||||||
|
|
||||||
## Hinweis
|
|
||||||
|
|
||||||
Die Dateien `backend-api.md`, `frontend-web.md` und `mobile-app.md` werden automatisch generiert. Manuelle Änderungen in diesen Dateien werden bei der nächsten Synchronisation überschrieben.
|
|
||||||
|
|||||||
@@ -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({
|
export default defineNuxtConfig({
|
||||||
modules: ['@nuxt/content'],
|
modules: [
|
||||||
|
'@nuxt/image',
|
||||||
|
'@nuxt/ui',
|
||||||
|
'@nuxt/content'
|
||||||
|
],
|
||||||
css: ['~/assets/css/main.css'],
|
css: ['~/assets/css/main.css'],
|
||||||
app: {
|
|
||||||
head: {
|
|
||||||
title: 'FEDEO Docs',
|
|
||||||
meta: [
|
|
||||||
{ name: 'description', content: 'Versionierte FEDEO-Dokumentation auf Nuxt Content.' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
content: {
|
content: {
|
||||||
documentDriven: false,
|
build: {
|
||||||
highlight: {
|
markdown: {
|
||||||
theme: 'github-light'
|
toc: {
|
||||||
|
searchDepth: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
experimental: {
|
||||||
|
asyncContext: true
|
||||||
|
},
|
||||||
|
compatibilityDate: '2024-07-11',
|
||||||
nitro: {
|
nitro: {
|
||||||
preset: 'node-server'
|
preset: 'node-server'
|
||||||
},
|
},
|
||||||
compatibilityDate: '2025-01-01'
|
icon: {
|
||||||
|
provider: 'iconify'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
16365
docs-site/package-lock.json
generated
Normal file
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",
|
"name": "fedeo-docs-site",
|
||||||
"version": "2.0.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node ./scripts/sync-content.mjs && nuxi dev --host 0.0.0.0 --port 3005",
|
"build": "node ./scripts/sync-content.mjs && nuxt build",
|
||||||
"build": "node ./scripts/sync-content.mjs && nuxi build",
|
"dev": "node ./scripts/sync-content.mjs && nuxt dev --host 0.0.0.0 --port 3005",
|
||||||
"preview": "nuxi preview --host 0.0.0.0 --port 3005"
|
"preview": "nuxt preview --host 0.0.0.0 --port 3005",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nuxt": "^3.17.7",
|
"@iconify-json/lucide": "^1.2.102",
|
||||||
"@nuxt/content": "^2.13.4"
|
"@iconify-json/simple-icons": "^1.2.78",
|
||||||
|
"@nuxt/content": "^3.12.0",
|
||||||
|
"@nuxt/image": "^2.0.0",
|
||||||
|
"@nuxt/ui": "^4.6.1",
|
||||||
|
"better-sqlite3": "^12.9.0",
|
||||||
|
"nuxt": "^4.4.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^6.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0"
|
"node": ">=20.0"
|
||||||
|
|||||||
@@ -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": {
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
"types": ["@types/node"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,7 @@
|
|||||||
# FEDEO Funktionsdokumentation
|
# Bedienungsanleitung
|
||||||
|
|
||||||
Diese Dokumentation bildet alle Funktionen der Software zentral ab und ist für die Nutzung in der Nuxt-Content-Docs-Homepage vorbereitet.
|
Diese Dokumentation unterstützt dich bei der täglichen Nutzung von FEDEO.
|
||||||
|
|
||||||
## Ziel
|
## Einstieg
|
||||||
|
|
||||||
- Vollständige Übersicht über Funktionen in Backend, Web-Frontend und Mobile-App
|
- [Bedienung](./bedienung/README.md)
|
||||||
- Nachvollziehbare Versionierung der Doku
|
|
||||||
- Einfache Aktualisierung bei Funktionsänderungen
|
|
||||||
|
|
||||||
## Struktur
|
|
||||||
|
|
||||||
- `docs/funktionen/uebersicht.md`: Fachliche Gesamtübersicht der Bereiche
|
|
||||||
- `docs/funktionen/backend-api.md`: Automatisch erzeugte API-Funktionsliste
|
|
||||||
- `docs/funktionen/frontend-web.md`: Automatisch erzeugte Seiten-/Funktionsliste des Web-Frontends
|
|
||||||
- `docs/funktionen/mobile-app.md`: Automatisch erzeugte Screens-/Funktionsliste der Mobile-App
|
|
||||||
- `docs/versionen/docs-versionen.md`: Versionierung der Dokumentation
|
|
||||||
- `docs/wartung/dokumentationsprozess.md`: Prozess, damit die Doku dauerhaft aktuell bleibt
|
|
||||||
- `docs/scripts/sync-funktionsdoku.mjs`: Skript zur automatischen Aktualisierung
|
|
||||||
|
|
||||||
## Aktualisierung bei Funktionsänderungen
|
|
||||||
|
|
||||||
Bei jeder Funktionsänderung bitte ausführen:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node docs/scripts/sync-funktionsdoku.mjs
|
|
||||||
```
|
|
||||||
|
|
||||||
Danach:
|
|
||||||
|
|
||||||
1. Änderungen in `docs/funktionen/*.md` prüfen
|
|
||||||
2. Falls nötig fachliche Texte in `docs/funktionen/uebersicht.md` ergänzen
|
|
||||||
3. Eintrag in `docs/versionen/docs-versionen.md` ergänzen
|
|
||||||
4. Alles gemeinsam committen
|
|
||||||
|
|
||||||
## Verwendung mit Nuxt Content
|
|
||||||
|
|
||||||
Empfohlene Vorgehensweise:
|
|
||||||
|
|
||||||
1. `docs/` in das Content-Verzeichnis übernehmen (oder per Sync einbinden)
|
|
||||||
2. Navigation anhand der Ordner `funktionen`, `wartung`, `versionen` aufbauen
|
|
||||||
3. `backend-api.md`, `frontend-web.md`, `mobile-app.md` als referenzierende Funktionskataloge einbinden
|
|
||||||
|
|
||||||
## Hinweis
|
|
||||||
|
|
||||||
Die Dateien `backend-api.md`, `frontend-web.md` und `mobile-app.md` werden automatisch generiert. Manuelle Änderungen in diesen Dateien werden bei der nächsten Synchronisation überschrieben.
|
|
||||||
|
|||||||
13
docs/bedienung/README.md
Normal file
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' }"
|
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
|
||||||
:on-select="(row) => selectItem(row.original)"
|
:on-select="(row) => selectItem(row.original)"
|
||||||
style="height: 70vh"
|
style="height: 70vh"
|
||||||
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
|
||||||
>
|
>
|
||||||
<template #empty>
|
|
||||||
<div class="flex flex-col items-center justify-center py-8 text-center text-sm text-muted">
|
|
||||||
<UIcon name="i-heroicons-circle-stack-20-solid" class="mb-2 size-5" />
|
|
||||||
<span>Keine Belege anzuzeigen</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #type-cell="{ row }">
|
<template #type-cell="{ row }">
|
||||||
{{ dataStore.documentTypesForCreation[row.original.type].labelSingle }}
|
{{ dataStore.documentTypesForCreation[row.original.type].labelSingle }}
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -35,11 +35,13 @@ const getStatementLike = (allocation) => allocation?.bankstatement || (typeof al
|
|||||||
const getAllocationDate = (allocation) => {
|
const getAllocationDate = (allocation) => {
|
||||||
const statement = getStatementLike(allocation)
|
const statement = getStatementLike(allocation)
|
||||||
|
|
||||||
return statement?.date || statement?.valueDate || allocation?.bs_id?.date || allocation?.bs_id?.valueDate || null
|
return statement?.date || statement?.valueDate || allocation?.bs_id?.date || allocation?.bs_id?.valueDate || allocation?.manualBookingDate || null
|
||||||
}
|
}
|
||||||
const getAllocationPartner = (allocation) => {
|
const getAllocationPartner = (allocation) => {
|
||||||
const statement = getStatementLike(allocation)
|
const statement = getStatementLike(allocation)
|
||||||
|
|
||||||
|
if (!statement && allocation?.manualBookingDate) return "Manuelle Buchung"
|
||||||
|
|
||||||
return statement?.debName || statement?.credName || statement?.partner || allocation?.bs_id?.debName || allocation?.bs_id?.credName || ""
|
return statement?.debName || statement?.credName || statement?.partner || allocation?.bs_id?.debName || allocation?.bs_id?.credName || ""
|
||||||
}
|
}
|
||||||
const getAllocationDescription = (allocation) => {
|
const getAllocationDescription = (allocation) => {
|
||||||
@@ -48,12 +50,20 @@ const getAllocationDescription = (allocation) => {
|
|||||||
return allocation?.description || statement?.purpose || statement?.text || statement?.description || allocation?.bs_id?.purpose || allocation?.bs_id?.text || ""
|
return allocation?.description || statement?.purpose || statement?.text || statement?.description || allocation?.bs_id?.purpose || allocation?.bs_id?.text || ""
|
||||||
}
|
}
|
||||||
const hasContent = (value) => value !== null && value !== undefined && String(value).trim() !== ""
|
const hasContent = (value) => value !== null && value !== undefined && String(value).trim() !== ""
|
||||||
|
const isContraAllocation = (allocation) =>
|
||||||
|
sameAccount(allocation.contraAccount?.id || allocation.contraAccount)
|
||||||
|
|| sameAccount(allocation.contraOwnaccount?.id || allocation.contraOwnaccount)
|
||||||
|
|
||||||
|
const touchesCurrentAccount = (allocation) =>
|
||||||
|
sameAccount(allocation.account?.id || allocation.account)
|
||||||
|
|| sameAccount(allocation.ownaccount?.id || allocation.ownaccount)
|
||||||
|
|| isContraAllocation(allocation)
|
||||||
|
|
||||||
const monthItems = [
|
const monthItems = [
|
||||||
{ label: "Ganzes Jahr", value: "all" },
|
{ label: "Ganzes Jahr", value: "all" },
|
||||||
{ label: "Januar", value: "1" },
|
{ label: "Januar", value: "1" },
|
||||||
{ label: "Februar", value: "2" },
|
{ label: "Februar", value: "2" },
|
||||||
{ label: "Maerz", value: "3" },
|
{ label: "März", value: "3" },
|
||||||
{ label: "April", value: "4" },
|
{ label: "April", value: "4" },
|
||||||
{ label: "Mai", value: "5" },
|
{ label: "Mai", value: "5" },
|
||||||
{ label: "Juni", value: "6" },
|
{ label: "Juni", value: "6" },
|
||||||
@@ -73,7 +83,7 @@ const allAllocations = computed(() => {
|
|||||||
date: getAllocationDate(allocation),
|
date: getAllocationDate(allocation),
|
||||||
partner: getAllocationPartner(allocation),
|
partner: getAllocationPartner(allocation),
|
||||||
description: getAllocationDescription(allocation),
|
description: getAllocationDescription(allocation),
|
||||||
amount: Number(allocation.amount || 0)
|
amount: Number(allocation.amount || 0) * (isContraAllocation(allocation) ? -1 : 1)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const incomingInvoiceRows = incomingInvoices.value.flatMap((invoice) => {
|
const incomingInvoiceRows = incomingInvoices.value.flatMap((invoice) => {
|
||||||
@@ -162,7 +172,7 @@ const setup = async () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
statementAllocations.value = (await useEntities("statementallocations").select("*, bankstatement(*), createddocument(*), incominginvoice(*)"))
|
statementAllocations.value = (await useEntities("statementallocations").select("*, bankstatement(*), createddocument(*), incominginvoice(*)"))
|
||||||
.filter((allocation) => sameAccount(allocation.account?.id || allocation.account) || sameAccount(allocation.ownaccount?.id || allocation.ownaccount))
|
.filter((allocation) => touchesCurrentAccount(allocation))
|
||||||
|
|
||||||
incomingInvoices.value = (await useEntities("incominginvoices").select("*, vendor(*)"))
|
incomingInvoices.value = (await useEntities("incominginvoices").select("*, vendor(*)"))
|
||||||
.filter((invoice) => (invoice.accounts || []).some((account) => sameAccount(account.account?.id || account.account)))
|
.filter((invoice) => (invoice.accounts || []).some((account) => sameAccount(account.account?.id || account.account)))
|
||||||
@@ -244,14 +254,8 @@ const selectAllocation = (allocationLike) => {
|
|||||||
:columns="normalizeTableColumns(columns)"
|
:columns="normalizeTableColumns(columns)"
|
||||||
:on-select="selectAllocation"
|
:on-select="selectAllocation"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen im ausgewählten Zeitraum' }"
|
||||||
>
|
>
|
||||||
<template #empty>
|
|
||||||
<div class="flex flex-col items-center justify-center py-10 text-center">
|
|
||||||
<UIcon name="i-heroicons-circle-stack-20-solid" class="mb-2 h-10 w-10 text-gray-400" />
|
|
||||||
<p class="font-medium">Keine Buchungen im ausgewaehlten Zeitraum</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #amount-cell="{ row }">
|
<template #amount-cell="{ row }">
|
||||||
<span class="text-right text-error" v-if="row.original.amount < 0 || row.original.color === 'red'">{{ useCurrency(row.original.amount) }}</span>
|
<span class="text-right text-error" v-if="row.original.amount < 0 || row.original.color === 'red'">{{ useCurrency(row.original.amount) }}</span>
|
||||||
<span class="text-right text-primary-500" v-else-if="row.original.amount > 0 || row.original.color === 'green'">{{ useCurrency(row.original.amount) }}</span>
|
<span class="text-right text-primary-500" v-else-if="row.original.amount > 0 || row.original.color === 'green'">{{ useCurrency(row.original.amount) }}</span>
|
||||||
@@ -259,7 +263,7 @@ const selectAllocation = (allocationLike) => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #date-cell="{ row }">
|
<template #date-cell="{ row }">
|
||||||
{{ row.original.bankstatement.date && dayjs(row.original.bankstatement.date).isValid() ? dayjs(row.original.bankstatement.date).format('DD.MM.YYYY') : '-' }}
|
{{ row.original.date && dayjs(row.original.date).isValid() ? dayjs(row.original.date).format('DD.MM.YYYY') : '-' }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #partner-cell="{ row }">
|
<template #partner-cell="{ row }">
|
||||||
|
|||||||
@@ -69,13 +69,8 @@ const columns = [
|
|||||||
class="mt-3"
|
class="mt-3"
|
||||||
:columns="normalizeTableColumns(columns)"
|
:columns="normalizeTableColumns(columns)"
|
||||||
:data="props.item.times"
|
:data="props.item.times"
|
||||||
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Noch keine Einträge' }"
|
||||||
>
|
>
|
||||||
<template #empty>
|
|
||||||
<div class="flex flex-col items-center justify-center py-8 text-center text-sm text-muted">
|
|
||||||
<UIcon name="i-heroicons-circle-stack-20-solid" class="mb-2 size-5" />
|
|
||||||
<span>Noch keine Einträge</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #state-cell="{ row }">
|
<template #state-cell="{ row }">
|
||||||
<span
|
<span
|
||||||
v-if="row.original.state === 'Entwurf'"
|
v-if="row.original.state === 'Entwurf'"
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ const links = computed(() => {
|
|||||||
to: "/accounting/depreciation",
|
to: "/accounting/depreciation",
|
||||||
icon: "i-heroicons-calendar-days",
|
icon: "i-heroicons-calendar-days",
|
||||||
} : null,
|
} : null,
|
||||||
((featureEnabled("createDocument") || featureEnabled("incomingInvoices")) || featureEnabled("accounts") || featureEnabled("ownaccounts") || featureEnabled("costcentres")) ? {
|
((featureEnabled("createDocument") || featureEnabled("incomingInvoices")) || featureEnabled("accounts") || featureEnabled("ownaccounts") || featureEnabled("costcentres") || featureEnabled("banking")) ? {
|
||||||
label: "Auswertungen",
|
label: "Auswertungen",
|
||||||
icon: "i-heroicons-chart-pie",
|
icon: "i-heroicons-chart-pie",
|
||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
@@ -156,6 +156,11 @@ const links = computed(() => {
|
|||||||
to: "/accounting/bwa",
|
to: "/accounting/bwa",
|
||||||
icon: "i-heroicons-chart-bar-square",
|
icon: "i-heroicons-chart-bar-square",
|
||||||
} : null,
|
} : null,
|
||||||
|
featureEnabled("banking") ? {
|
||||||
|
label: "Liquidität",
|
||||||
|
to: "/accounting/liquidity",
|
||||||
|
icon: "i-heroicons-banknotes",
|
||||||
|
} : null,
|
||||||
featureEnabled("costcentres") ? {
|
featureEnabled("costcentres") ? {
|
||||||
label: "Kostenstellen",
|
label: "Kostenstellen",
|
||||||
to: "/standardEntity/costcentres",
|
to: "/standardEntity/costcentres",
|
||||||
@@ -178,6 +183,11 @@ const links = computed(() => {
|
|||||||
to: "/banking",
|
to: "/banking",
|
||||||
icon: "i-heroicons-document-text",
|
icon: "i-heroicons-document-text",
|
||||||
} : null,
|
} : null,
|
||||||
|
(featureEnabled("accounts") || featureEnabled("ownaccounts")) ? {
|
||||||
|
label: "Manuelle Buchungen",
|
||||||
|
to: "/accounting/manual-bookings",
|
||||||
|
icon: "i-heroicons-arrows-right-left",
|
||||||
|
} : null,
|
||||||
]
|
]
|
||||||
|
|
||||||
const inventoryChildren = [
|
const inventoryChildren = [
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['update:modelValue', 'saved'])
|
const emit = defineEmits(['update:modelValue', 'saved'])
|
||||||
|
|
||||||
// 💡 createEntry importieren
|
// 💡 createEntry importieren
|
||||||
const { update, createEntry } = useStaffTime()
|
const { list, update, createEntry } = useStaffTime()
|
||||||
const { $dayjs } = useNuxtApp()
|
const { $dayjs } = useNuxtApp()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ async function onSubmit(event: FormSubmitEvent<any>) {
|
|||||||
if (state.end_date && state.end_time) {
|
if (state.end_date && state.end_time) {
|
||||||
endIso = $dayjs(`${state.end_date} ${state.end_time}`).toISOString()
|
endIso = $dayjs(`${state.end_date} ${state.end_time}`).toISOString()
|
||||||
|
|
||||||
if ($dayjs(endIso).isBefore($dayjs(startIso))) {
|
if (!$dayjs(endIso).isAfter($dayjs(startIso))) {
|
||||||
throw new Error("Endzeitpunkt muss nach dem Startzeitpunkt liegen.")
|
throw new Error("Endzeitpunkt muss nach dem Startzeitpunkt liegen.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,6 +100,33 @@ async function onSubmit(event: FormSubmitEvent<any>) {
|
|||||||
})
|
})
|
||||||
toast.add({ title: 'Eintrag aktualisiert', color: 'green' })
|
toast.add({ title: 'Eintrag aktualisiert', color: 'green' })
|
||||||
} else {
|
} else {
|
||||||
|
if (endIso) {
|
||||||
|
const existingEntries = await list({
|
||||||
|
user_id: props.defaultUserId
|
||||||
|
})
|
||||||
|
|
||||||
|
const newStart = $dayjs(startIso).valueOf()
|
||||||
|
const newEnd = $dayjs(endIso).valueOf()
|
||||||
|
const blockingStates = new Set(['draft', 'factual', 'submitted', 'approved'])
|
||||||
|
|
||||||
|
const conflictingEntry = existingEntries.find(existingEntry => {
|
||||||
|
if (!blockingStates.has(existingEntry.state) || !existingEntry.stopped_at) return false
|
||||||
|
|
||||||
|
const existingStart = $dayjs(existingEntry.started_at).valueOf()
|
||||||
|
const existingEnd = $dayjs(existingEntry.stopped_at).valueOf()
|
||||||
|
|
||||||
|
if (!Number.isFinite(existingStart) || !Number.isFinite(existingEnd)) return false
|
||||||
|
|
||||||
|
return newStart < existingEnd && existingStart < newEnd
|
||||||
|
})
|
||||||
|
|
||||||
|
if (conflictingEntry) {
|
||||||
|
const conflictStart = $dayjs(conflictingEntry.started_at).format('DD.MM.YYYY HH:mm')
|
||||||
|
const conflictEnd = $dayjs(conflictingEntry.stopped_at).format('DD.MM.YYYY HH:mm')
|
||||||
|
throw new Error(`Überschneidung mit ${conflictStart} bis ${conflictEnd} (${conflictingEntry.state}).`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🟢 CREATE (Neu Erstellen)
|
// 🟢 CREATE (Neu Erstellen)
|
||||||
// 💡 HIER WAR DER FEHLER: Wir nutzen jetzt createEntry mit den Daten aus dem Formular
|
// 💡 HIER WAR DER FEHLER: Wir nutzen jetzt createEntry mit den Daten aus dem Formular
|
||||||
await createEntry({
|
await createEntry({
|
||||||
|
|||||||
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 loading = ref(true)
|
||||||
const incomingInvoices = ref([])
|
const incomingInvoices = ref([])
|
||||||
|
const costcentres = ref([])
|
||||||
const selectedYear = ref(String(dayjs().year()))
|
const selectedYear = ref(String(dayjs().year()))
|
||||||
const selectedMonth = ref("all")
|
const selectedMonth = ref("all")
|
||||||
|
|
||||||
const currency = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} EUR`
|
const currency = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} EUR`
|
||||||
|
|
||||||
|
const getCostCentreId = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof value === "object" ? value.id : value
|
||||||
|
}
|
||||||
|
|
||||||
|
const costCentreMap = computed(() => {
|
||||||
|
return new Map(costcentres.value.map((costcentre) => [costcentre.id, costcentre]))
|
||||||
|
})
|
||||||
|
|
||||||
|
const relevantCostCentreIds = computed(() => {
|
||||||
|
const rootId = props.item?.id
|
||||||
|
|
||||||
|
if (!rootId) {
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
const childrenByParent = new Map()
|
||||||
|
|
||||||
|
costcentres.value.forEach((costcentre) => {
|
||||||
|
const parentId = getCostCentreId(costcentre.parentCostcentre)
|
||||||
|
|
||||||
|
if (!parentId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!childrenByParent.has(parentId)) {
|
||||||
|
childrenByParent.set(parentId, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
childrenByParent.get(parentId).push(costcentre.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const collectedIds = new Set([rootId])
|
||||||
|
const queue = [rootId]
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const currentId = queue.shift()
|
||||||
|
const childIds = childrenByParent.get(currentId) || []
|
||||||
|
|
||||||
|
childIds.forEach((childId) => {
|
||||||
|
if (collectedIds.has(childId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
collectedIds.add(childId)
|
||||||
|
queue.push(childId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return collectedIds
|
||||||
|
})
|
||||||
|
|
||||||
const yearItems = computed(() => {
|
const yearItems = computed(() => {
|
||||||
const years = [...new Set(
|
const years = [...new Set(
|
||||||
incomingInvoices.value
|
incomingInvoices.value
|
||||||
@@ -29,7 +85,7 @@ const monthItems = [
|
|||||||
{ label: "Ganzes Jahr", value: "all" },
|
{ label: "Ganzes Jahr", value: "all" },
|
||||||
{ label: "Januar", value: "1" },
|
{ label: "Januar", value: "1" },
|
||||||
{ label: "Februar", value: "2" },
|
{ label: "Februar", value: "2" },
|
||||||
{ label: "Maerz", value: "3" },
|
{ label: "März", value: "3" },
|
||||||
{ label: "April", value: "4" },
|
{ label: "April", value: "4" },
|
||||||
{ label: "Mai", value: "5" },
|
{ label: "Mai", value: "5" },
|
||||||
{ label: "Juni", value: "6" },
|
{ label: "Juni", value: "6" },
|
||||||
@@ -53,12 +109,15 @@ const reportRows = computed(() => {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchingAccounts = (invoice.accounts || []).filter((account) => account.costCentre === props.item.id)
|
const matchingAccounts = (invoice.accounts || []).filter((account) =>
|
||||||
|
relevantCostCentreIds.value.has(account.costCentre)
|
||||||
|
)
|
||||||
|
|
||||||
return matchingAccounts.map((account, index) => {
|
return matchingAccounts.map((account, index) => {
|
||||||
const amountNet = Number(account.amountNet || 0)
|
const amountNet = Number(account.amountNet || 0)
|
||||||
const amountTax = Number(account.amountTax || 0)
|
const amountTax = Number(account.amountTax || 0)
|
||||||
const amountGross = Number(account.amountGross || amountNet + amountTax || 0)
|
const amountGross = Number(account.amountGross || amountNet + amountTax || 0)
|
||||||
|
const accountCostCentre = costCentreMap.value.get(getCostCentreId(account.costCentre))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${invoice.id}-${index}`,
|
id: `${invoice.id}-${index}`,
|
||||||
@@ -68,6 +127,7 @@ const reportRows = computed(() => {
|
|||||||
state: invoice.state || "-",
|
state: invoice.state || "-",
|
||||||
vendorName: invoice.vendor?.name || "-",
|
vendorName: invoice.vendor?.name || "-",
|
||||||
accountLabel: account.account?.label || account.accountLabel || "-",
|
accountLabel: account.account?.label || account.accountLabel || "-",
|
||||||
|
costCentreName: accountCostCentre ? `${accountCostCentre.number} - ${accountCostCentre.name}` : "-",
|
||||||
description: account.description || invoice.description || "-",
|
description: account.description || invoice.description || "-",
|
||||||
amountNet,
|
amountNet,
|
||||||
amountTax,
|
amountTax,
|
||||||
@@ -91,6 +151,7 @@ const columns = [
|
|||||||
{ accessorKey: "date", header: "Datum" },
|
{ accessorKey: "date", header: "Datum" },
|
||||||
{ accessorKey: "vendorName", header: "Lieferant" },
|
{ accessorKey: "vendorName", header: "Lieferant" },
|
||||||
{ accessorKey: "accountLabel", header: "Konto" },
|
{ accessorKey: "accountLabel", header: "Konto" },
|
||||||
|
{ accessorKey: "costCentreName", header: "Kostenstelle" },
|
||||||
{ accessorKey: "description", header: "Beschreibung" },
|
{ accessorKey: "description", header: "Beschreibung" },
|
||||||
{ accessorKey: "amountNet", header: "Netto" },
|
{ accessorKey: "amountNet", header: "Netto" },
|
||||||
{ accessorKey: "amountTax", header: "Steuer" },
|
{ accessorKey: "amountTax", header: "Steuer" },
|
||||||
@@ -100,10 +161,11 @@ const columns = [
|
|||||||
const setupPage = async () => {
|
const setupPage = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
|
costcentres.value = await useEntities("costcentres").select("*", null, false, true)
|
||||||
const invoices = await useEntities("incominginvoices").select("*, vendor(id,name)")
|
const invoices = await useEntities("incominginvoices").select("*, vendor(id,name)")
|
||||||
|
|
||||||
incomingInvoices.value = invoices.filter((invoice) =>
|
incomingInvoices.value = invoices.filter((invoice) =>
|
||||||
(invoice.accounts || []).some((account) => account.costCentre === props.item.id)
|
(invoice.accounts || []).some((account) => relevantCostCentreIds.value.has(account.costCentre))
|
||||||
)
|
)
|
||||||
|
|
||||||
const firstYear = yearItems.value[0]?.value
|
const firstYear = yearItems.value[0]?.value
|
||||||
@@ -162,7 +224,7 @@ setupPage()
|
|||||||
v-if="!loading"
|
v-if="!loading"
|
||||||
:data="reportRows"
|
:data="reportRows"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Eingangsbelege mit dieser Kostenstelle gefunden' }"
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Eingangsbelege mit dieser Kostenstelle oder ihren Unterkostenstellen gefunden' }"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<template #reference-cell="{ row }">
|
<template #reference-cell="{ row }">
|
||||||
@@ -181,6 +243,10 @@ setupPage()
|
|||||||
<div class="truncate">{{ row.original.accountLabel }}</div>
|
<div class="truncate">{{ row.original.accountLabel }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #costCentreName-cell="{ row }">
|
||||||
|
<div class="truncate">{{ row.original.costCentreName }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #description-cell="{ row }">
|
<template #description-cell="{ row }">
|
||||||
<UTooltip :text="row.original.description">
|
<UTooltip :text="row.original.description">
|
||||||
<div class="max-w-[18rem] truncate">{{ row.original.description }}</div>
|
<div class="max-w-[18rem] truncate">{{ row.original.description }}</div>
|
||||||
|
|||||||
@@ -22,14 +22,11 @@ setupPage()
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UTable
|
<UTable
|
||||||
v-if="openTasks.length > 0"
|
|
||||||
:data="openTasks"
|
:data="openTasks"
|
||||||
:columns="normalizeTableColumns([{key:'name',label:'Name'},{key:'categorie',label:'Kategorie'}])"
|
:columns="normalizeTableColumns([{key:'name',label:'Name'},{key:'categorie',label:'Kategorie'}])"
|
||||||
:on-select="(i) => router.push(`/tasks/show/${i.id}`)"
|
:on-select="(i) => router.push(`/tasks/show/${i.id}`)"
|
||||||
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine offenen Aufgaben' }"
|
||||||
/>
|
/>
|
||||||
<div v-else>
|
|
||||||
<p class="text-center font-bold">Keine offenen Aufgaben</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -97,5 +97,15 @@ export const useFunctions = () => {
|
|||||||
return await useNuxtApp().$api(`/api/banking/statements/${statementId}/suggestions`)
|
return await useNuxtApp().$api(`/api/banking/statements/${statementId}/suggestions`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useBankingResolveIban, useBankingStatementSuggestions, useCreatePDF}
|
const useLiquidityForecast = async (ignoredRecurringKeys = []) => {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (ignoredRecurringKeys.length) {
|
||||||
|
query.set("ignoredRecurringKeys", ignoredRecurringKeys.join(","))
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = query.toString() ? `?${query.toString()}` : ""
|
||||||
|
return await useNuxtApp().$api(`/api/functions/liquidity-forecast${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useBankingResolveIban, useBankingStatementSuggestions, useLiquidityForecast, useCreatePDF}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const useStaffTime = () => {
|
|||||||
duration_minutes: end.diff(start, 'minute'),
|
duration_minutes: end.diff(start, 'minute'),
|
||||||
user_id: targetUserId,
|
user_id: targetUserId,
|
||||||
type: span.type,
|
type: span.type,
|
||||||
description: span.payload?.description || ''
|
description: span.description || span.payload?.description || ''
|
||||||
}
|
}
|
||||||
}).sort((a: any, b: any) => $dayjs(b.started_at).valueOf() - $dayjs(a.started_at).valueOf())
|
}).sort((a: any, b: any) => $dayjs(b.started_at).valueOf() - $dayjs(a.started_at).valueOf())
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -126,4 +126,4 @@ export const useStaffTime = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { list, start, stop, submit, approve, reject, update, createEntry }
|
return { list, start, stop, submit, approve, reject, update, createEntry }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -585,14 +585,8 @@ onMounted(setupPage)
|
|||||||
:columns="normalizeTableColumns(accountColumns)"
|
:columns="normalizeTableColumns(accountColumns)"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:on-select="openAccount"
|
:on-select="openAccount"
|
||||||
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungskonten im ausgewählten Zeitraum' }"
|
||||||
>
|
>
|
||||||
<template #empty>
|
|
||||||
<div class="flex flex-col items-center justify-center py-10 text-center">
|
|
||||||
<UIcon name="i-heroicons-circle-stack-20-solid" class="mb-2 h-10 w-10 text-gray-400" />
|
|
||||||
<p class="font-medium">Keine Buchungskonten im ausgewaehlten Zeitraum</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #label-cell="{ row }">
|
<template #label-cell="{ row }">
|
||||||
<div class="truncate font-medium">{{ row.original.label }}</div>
|
<div class="truncate font-medium">{{ row.original.label }}</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -630,14 +624,8 @@ onMounted(setupPage)
|
|||||||
:columns="normalizeTableColumns(ownAccountColumns)"
|
:columns="normalizeTableColumns(ownAccountColumns)"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:on-select="openOwnAccount"
|
:on-select="openOwnAccount"
|
||||||
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine eigenen Buchungen im ausgewählten Zeitraum' }"
|
||||||
>
|
>
|
||||||
<template #empty>
|
|
||||||
<div class="flex flex-col items-center justify-center py-10 text-center">
|
|
||||||
<UIcon name="i-heroicons-circle-stack-20-solid" class="mb-2 h-10 w-10 text-gray-400" />
|
|
||||||
<p class="font-medium">Keine eigenen Buchungen im ausgewaehlten Zeitraum</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #label-cell="{ row }">
|
<template #label-cell="{ row }">
|
||||||
<div class="truncate font-medium">{{ row.original.label }}</div>
|
<div class="truncate font-medium">{{ row.original.label }}</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -676,6 +664,7 @@ onMounted(setupPage)
|
|||||||
:data="depreciationRows"
|
:data="depreciationRows"
|
||||||
:columns="normalizeTableColumns(depreciationColumns)"
|
:columns="normalizeTableColumns(depreciationColumns)"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Abschreibungen im ausgewählten Zeitraum' }"
|
||||||
>
|
>
|
||||||
<template #amount-cell="{ row }">
|
<template #amount-cell="{ row }">
|
||||||
<div class="text-right text-amber-600 dark:text-amber-400 tabular-nums">{{ useCurrency(row.original.amount) }}</div>
|
<div class="text-right text-amber-600 dark:text-amber-400 tabular-nums">{{ useCurrency(row.original.amount) }}</div>
|
||||||
|
|||||||
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}`)"
|
:on-select="(row) => router.push(`/administration/users/${row.original?.id || row.id}`)"
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine zugeordneten Benutzer gefunden' }"
|
||||||
/>
|
/>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,7 @@
|
|||||||
:get-row-id="(row) => row.id"
|
:get-row-id="(row) => row.id"
|
||||||
:ui="{ th: { base: 'whitespace-nowrap' } }"
|
:ui="{ th: { base: 'whitespace-nowrap' } }"
|
||||||
:on-select="toggleExecutionRow"
|
:on-select="toggleExecutionRow"
|
||||||
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
|
||||||
>
|
>
|
||||||
<template #select-header="{ table }">
|
<template #select-header="{ table }">
|
||||||
<div class="flex justify-center" @click.stop>
|
<div class="flex justify-center" @click.stop>
|
||||||
|
|||||||
@@ -35,13 +35,14 @@ const createExport = async () => {
|
|||||||
</template>
|
</template>
|
||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
<UTable
|
<UTable
|
||||||
v-if="createddocuments.length > 0"
|
|
||||||
:loading="true"
|
:loading="true"
|
||||||
v-model="selected"
|
v-model="selected"
|
||||||
:loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }"
|
:loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }"
|
||||||
:data="createddocuments" />
|
:data="createddocuments"
|
||||||
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine SEPA-Belege anzuzeigen' }"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ const createExport = async () => {
|
|||||||
{ key: 'type', label: 'Typ' },
|
{ key: 'type', label: 'Typ' },
|
||||||
{ key: 'download', label: 'Download' },
|
{ key: 'download', label: 'Download' },
|
||||||
])"
|
])"
|
||||||
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Exporte anzuzeigen' }"
|
||||||
>
|
>
|
||||||
<template #created_at-cell="{row}">
|
<template #created_at-cell="{row}">
|
||||||
{{dayjs(row.original.created_at).format("DD.MM.YYYY HH:mm")}}
|
{{dayjs(row.original.created_at).format("DD.MM.YYYY HH:mm")}}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const isPreparing = ref(false)
|
|||||||
|
|
||||||
const type = "incominginvoices"
|
const type = "incominginvoices"
|
||||||
const dataType = dataStore.dataTypes[type]
|
const dataType = dataStore.dataTypes[type]
|
||||||
|
const openAmountColumnKey = "openAmount"
|
||||||
|
|
||||||
const setupPage = async () => {
|
const setupPage = async () => {
|
||||||
items.value = await useEntities(type).select("*, vendor(id,name), statementallocations(id,amount)",sort.value.column,sort.value.direction === "asc")
|
items.value = await useEntities(type).select("*, vendor(id,name), statementallocations(id,amount)",sort.value.column,sort.value.direction === "asc")
|
||||||
@@ -93,7 +94,13 @@ const prepareInvoices = async () => {
|
|||||||
|
|
||||||
setupPage()
|
setupPage()
|
||||||
|
|
||||||
const selectedColumns = ref(tempStore.columns[type] ? tempStore.columns[type] : dataType.templateColumns.filter(i => !i.disabledInTable))
|
const selectedColumns = ref(tempStore.columns[type] ? [...tempStore.columns[type]] : dataType.templateColumns.filter(i => !i.disabledInTable))
|
||||||
|
if (!selectedColumns.value.find((column) => column.key === openAmountColumnKey)) {
|
||||||
|
const openAmountColumn = dataType.templateColumns.find((column) => column.key === openAmountColumnKey)
|
||||||
|
if (openAmountColumn) {
|
||||||
|
selectedColumns.value.splice(5, 0, openAmountColumn)
|
||||||
|
}
|
||||||
|
}
|
||||||
const columns = computed(() => dataType.templateColumns.filter((column) => !column.disabledInTable && selectedColumns.value.find(i => i.key === column.key)))
|
const columns = computed(() => dataType.templateColumns.filter((column) => !column.disabledInTable && selectedColumns.value.find(i => i.key === column.key)))
|
||||||
|
|
||||||
const selectableFilters = ref(dataType.filters.map(i => i.name))
|
const selectableFilters = ref(dataType.filters.map(i => i.name))
|
||||||
@@ -142,10 +149,18 @@ const getInvoiceSum = (invoice) => {
|
|||||||
return sum.toFixed(2)
|
return sum.toFixed(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPaid = (item) => {
|
const getPaidAmount = (item) => {
|
||||||
let amountPaid = 0
|
let amountPaid = 0
|
||||||
item.statementallocations.forEach(allocation => amountPaid += allocation.amount)
|
item.statementallocations.forEach(allocation => amountPaid += allocation.amount)
|
||||||
return Math.abs(amountPaid) === Math.abs(Number(getInvoiceSum(item)))
|
return Number(Math.abs(amountPaid).toFixed(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPaid = (item) => {
|
||||||
|
return getPaidAmount(item) >= Number(Math.abs(Number(getInvoiceSum(item))).toFixed(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOpenAmount = (item) => {
|
||||||
|
return Number(Math.max(0, Number(getInvoiceSum(item)) - getPaidAmount(item)).toFixed(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
const unwrapInvoiceRow = (invoiceLike) => invoiceLike?.original || invoiceLike
|
const unwrapInvoiceRow = (invoiceLike) => invoiceLike?.original || invoiceLike
|
||||||
@@ -283,6 +298,11 @@ const selectIncomingInvoice = (invoiceLike) => {
|
|||||||
<template #amount-cell="{row}">
|
<template #amount-cell="{row}">
|
||||||
{{displayCurrency(sum.getIncomingInvoiceSum(row.original))}}
|
{{displayCurrency(sum.getIncomingInvoiceSum(row.original))}}
|
||||||
</template>
|
</template>
|
||||||
|
<template #openAmount-cell="{row}">
|
||||||
|
<span v-if="row.original.state === 'Gebucht' && !isPaid(row.original)">
|
||||||
|
{{ displayCurrency(getOpenAmount(row.original)) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
<template #dueDate-cell="{row}">
|
<template #dueDate-cell="{row}">
|
||||||
<span v-if="row.original.dueDate">{{dayjs(row.original.dueDate).format("DD.MM.YYYY")}}</span>
|
<span v-if="row.original.dueDate">{{dayjs(row.original.dueDate).format("DD.MM.YYYY")}}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ setupPage()
|
|||||||
label: 'Saldo'
|
label: 'Saldo'
|
||||||
},
|
},
|
||||||
])"
|
])"
|
||||||
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Bankkonten anzuzeigen' }"
|
||||||
>
|
>
|
||||||
<template #expired-cell="{ row }">
|
<template #expired-cell="{ row }">
|
||||||
<span v-if="row.original.expired" class="text-error-600">Ausgelaufen</span>
|
<span v-if="row.original.expired" class="text-error-600">Ausgelaufen</span>
|
||||||
|
|||||||
@@ -79,13 +79,8 @@
|
|||||||
:columns="normalizeTableColumns(columns)"
|
:columns="normalizeTableColumns(columns)"
|
||||||
:loading="pending"
|
:loading="pending"
|
||||||
:on-select="(row) => navigateTo(`/staff/profiles/${row.original?.id || row.id}`)"
|
:on-select="(row) => navigateTo(`/staff/profiles/${row.original?.id || row.id}`)"
|
||||||
>
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Mitarbeiterprofile gefunden' }"
|
||||||
<template #empty>
|
/>
|
||||||
<div class="py-10 text-center text-sm text-gray-500">
|
|
||||||
Keine Mitarbeiterprofile gefunden.
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UTable>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ onMounted(async () => {
|
|||||||
<UTooltip text="Genehmigen" v-if="row.original.state === 'submitted' && canViewAll">
|
<UTooltip text="Genehmigen" v-if="row.original.state === 'submitted' && canViewAll">
|
||||||
<UButton size="xs" color="green" variant="ghost" icon="i-heroicons-check" @click="handleApprove(row.original)" :loading="loading" />
|
<UButton size="xs" color="green" variant="ghost" icon="i-heroicons-check" @click="handleApprove(row.original)" :loading="loading" />
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
<UTooltip text="Ablehnen" v-if="(row.original.state === 'submitted' || row.original.state === 'approved') && canViewAll">
|
<UTooltip text="Ablehnen" v-if="['draft', 'factual', 'submitted', 'approved'].includes(row.original.state) && canViewAll">
|
||||||
<UButton size="xs" color="error" variant="ghost" icon="i-heroicons-x-mark" @click="openRejectModal(row.original)" :loading="loading" />
|
<UButton size="xs" color="error" variant="ghost" icon="i-heroicons-x-mark" @click="openRejectModal(row.original)" :loading="loading" />
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
<UTooltip text="Bearbeiten" v-if="['draft', 'factual', 'submitted'].includes(row.original.state)">
|
<UTooltip text="Bearbeiten" v-if="['draft', 'factual', 'submitted'].includes(row.original.state)">
|
||||||
@@ -366,7 +366,7 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<UButton
|
<UButton
|
||||||
v-if="(entry.state === 'submitted' || entry.state === 'approved') && canViewAll"
|
v-if="['draft', 'factual', 'submitted', 'approved'].includes(entry.state) && canViewAll"
|
||||||
size="xs" color="error" variant="ghost" icon="i-heroicons-x-mark" label="Ablehnen"
|
size="xs" color="error" variant="ghost" icon="i-heroicons-x-mark" label="Ablehnen"
|
||||||
@click="openRejectModal(entry)" :loading="loading"
|
@click="openRejectModal(entry)" :loading="loading"
|
||||||
/>
|
/>
|
||||||
@@ -434,6 +434,7 @@ onMounted(async () => {
|
|||||||
<div class="flex gap-2 mt-3 justify-end">
|
<div class="flex gap-2 mt-3 justify-end">
|
||||||
<UButton v-if="(row.state === 'draft' || row.state === 'factual') && row.stopped_at" color="cyan" size="sm" icon="i-heroicons-paper-airplane" label="Einreichen" variant="soft" @click.stop="handleSubmit(row)" :loading="loading" />
|
<UButton v-if="(row.state === 'draft' || row.state === 'factual') && row.stopped_at" color="cyan" size="sm" icon="i-heroicons-paper-airplane" label="Einreichen" variant="soft" @click.stop="handleSubmit(row)" :loading="loading" />
|
||||||
<UButton v-if="row.state === 'submitted' && canViewAll" color="green" size="sm" icon="i-heroicons-check" label="Genehmigen" variant="soft" @click.stop="handleApprove(row)" :loading="loading" />
|
<UButton v-if="row.state === 'submitted' && canViewAll" color="green" size="sm" icon="i-heroicons-check" label="Genehmigen" variant="soft" @click.stop="handleApprove(row)" :loading="loading" />
|
||||||
|
<UButton v-if="['draft', 'factual', 'submitted', 'approved'].includes(row.state) && canViewAll" color="error" size="sm" icon="i-heroicons-x-mark" label="Ablehnen" variant="soft" @click.stop="openRejectModal(row)" :loading="loading" />
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
</UDashboardPanelContent>
|
</UDashboardPanelContent>
|
||||||
|
|||||||
@@ -461,6 +461,7 @@ onMounted(async () => {
|
|||||||
:data="filteredTasks"
|
:data="filteredTasks"
|
||||||
:columns="normalizedListColumns"
|
:columns="normalizedListColumns"
|
||||||
:on-select="(task) => openTaskViaRoute(task)"
|
:on-select="(task) => openTaskViaRoute(task)"
|
||||||
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Aufgaben anzuzeigen' }"
|
||||||
>
|
>
|
||||||
<template #actions-cell="{ row }">
|
<template #actions-cell="{ row }">
|
||||||
<UButton
|
<UButton
|
||||||
@@ -490,11 +491,11 @@ onMounted(async () => {
|
|||||||
{{ getEntityLabel(plantOptions, row.original.plant?.id || row.original.plant) || "-" }}
|
{{ getEntityLabel(plantOptions, row.original.plant?.id || row.original.plant) || "-" }}
|
||||||
</template>
|
</template>
|
||||||
</UTable>
|
</UTable>
|
||||||
<UAlert
|
<UTable
|
||||||
v-else
|
v-else
|
||||||
icon="i-heroicons-circle-stack-20-solid"
|
:data="[]"
|
||||||
title="Keine Aufgaben anzuzeigen"
|
:columns="normalizedListColumns"
|
||||||
variant="subtle"
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Aufgaben anzuzeigen' }"
|
||||||
/>
|
/>
|
||||||
</UDashboardPanelContent>
|
</UDashboardPanelContent>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Chart, Title, Tooltip, Legend, LineElement, CategoryScale, LinearScale, PointElement } from 'chart.js'
|
import { Chart, Title, Tooltip, Legend, LineElement, CategoryScale, LinearScale, PointElement, Filler } from 'chart.js'
|
||||||
export default defineNuxtPlugin(() => {
|
export default defineNuxtPlugin(() => {
|
||||||
Chart.register(CategoryScale, LinearScale, LineElement, Title, Tooltip, Legend, PointElement)
|
Chart.register(CategoryScale, LinearScale, LineElement, Title, Tooltip, Legend, PointElement, Filler)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import description from "~/components/columnRenderings/description.vue"
|
|||||||
import purchasePrice from "~/components/columnRenderings/purchasePrice.vue";
|
import purchasePrice from "~/components/columnRenderings/purchasePrice.vue";
|
||||||
import project from "~/components/columnRenderings/project.vue";
|
import project from "~/components/columnRenderings/project.vue";
|
||||||
import branch from "~/components/columnRenderings/branch.vue";
|
import branch from "~/components/columnRenderings/branch.vue";
|
||||||
|
import costcentre from "~/components/columnRenderings/costcentre.vue";
|
||||||
import created_at from "~/components/columnRenderings/created_at.vue";
|
import created_at from "~/components/columnRenderings/created_at.vue";
|
||||||
import profile from "~/components/columnRenderings/profile.vue";
|
import profile from "~/components/columnRenderings/profile.vue";
|
||||||
import profiles from "~/components/columnRenderings/profiles.vue";
|
import profiles from "~/components/columnRenderings/profiles.vue";
|
||||||
@@ -2200,6 +2201,10 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
key: "amount",
|
key: "amount",
|
||||||
label: "Betrag",
|
label: "Betrag",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "openAmount",
|
||||||
|
label: "Offener Betrag",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "dueDate",
|
key: "dueDate",
|
||||||
label: "Fälligkeitsdatum",
|
label: "Fälligkeitsdatum",
|
||||||
@@ -3308,7 +3313,7 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
numberRangeHolder: "number",
|
numberRangeHolder: "number",
|
||||||
historyItemHolder: "costcentre",
|
historyItemHolder: "costcentre",
|
||||||
sortColumn: "number",
|
sortColumn: "number",
|
||||||
selectWithInformation: "*, project(*), vehicle(*), inventoryitem(*), branch(*)",
|
selectWithInformation: "*, parentCostcentre(*), project(*), vehicle(*), inventoryitem(*), branch(*)",
|
||||||
filters: [{
|
filters: [{
|
||||||
name: "Archivierte ausblenden",
|
name: "Archivierte ausblenden",
|
||||||
default: true,
|
default: true,
|
||||||
@@ -3340,6 +3345,18 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
label: "Beschreibung",
|
label: "Beschreibung",
|
||||||
inputType: "textarea"
|
inputType: "textarea"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "parentCostcentre",
|
||||||
|
label: "Übergeordnete Kostenstelle",
|
||||||
|
component: costcentre,
|
||||||
|
inputType: "select",
|
||||||
|
selectDataType: "costcentres",
|
||||||
|
selectOptionAttribute: "name",
|
||||||
|
selectSearchAttributes: ["name", "number"],
|
||||||
|
selectDataTypeFilter: function (option, item) {
|
||||||
|
return option.id !== item.value?.id
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "vehicle",
|
key: "vehicle",
|
||||||
label: "Fahrzeug",
|
label: "Fahrzeug",
|
||||||
|
|||||||
Reference in New Issue
Block a user