Compare commits
37 Commits
uichange
...
4fb3d3c8a0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fb3d3c8a0 | |||
| 30dc99e4e0 | |||
| 9fea18b215 | |||
| 75c15c14c4 | |||
| b27b00f59c | |||
| 1637d4bd91 | |||
| 8114a8c645 | |||
| 0b7d20d946 | |||
| 849e24092e | |||
| 6fcaf3f65c | |||
| dce0046e63 | |||
| 02b5769049 | |||
| f125617af0 | |||
| d9e5df07bf | |||
| 7996c746c3 | |||
| f679eb3624 | |||
| 7ad44544cf | |||
| 669bcd93ab | |||
| aee45e29fd | |||
| 42e0d7b35e | |||
| f6c9875320 | |||
| 05f3b678c4 | |||
| eb718021fd | |||
| 01b4d0f973 | |||
| c29494dc0d | |||
| 809a37a410 | |||
| 232e3f3260 | |||
| b2657f5d52 | |||
| cee0e1fa7d | |||
| 7dea2de7f3 | |||
| 4db753d34a | |||
| e0e99ba6f5 | |||
| ace2213cc4 | |||
| 7e6c5cc189 | |||
| 7c644c941a | |||
| 11a242d70d | |||
| 9f665fc3b8 |
@@ -1,5 +1,5 @@
|
||||
name: Build and Push Docker Images
|
||||
run-name: Build Backend & Frontend by @${{ github.actor }}
|
||||
run-name: Build Backend, Frontend & Docs by @${{ github.actor }}
|
||||
|
||||
on: [push]
|
||||
|
||||
@@ -8,12 +8,38 @@ env:
|
||||
# Wenn du die Gitea-interne Registry nutzt, ist es meist einfach der Hostname deiner Gitea-Instanz.
|
||||
# Beispiel: gitea.deine-domain.de
|
||||
REGISTRY_HOST: git.federspiel.tech
|
||||
# Der Name des Repos (z.B. user/repo)
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
# Der Name des Repos (z.B. user/repo).
|
||||
# Explizit in lowercase gesetzt, damit es exakt zu den Compose-Imagepfaden passt.
|
||||
IMAGE_NAME: flfeders/fedeo
|
||||
ACTOR: flfeders
|
||||
|
||||
jobs:
|
||||
verify-docs-sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Prüfe Node-Version
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Synchronisiere Funktionsdokumentation
|
||||
run: node docs/scripts/sync-funktionsdoku.mjs
|
||||
|
||||
- name: Breche ab, wenn Doku nicht aktuell committed ist
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain docs/)" ]; then
|
||||
echo "Die generierte Dokumentation ist nicht aktuell."
|
||||
echo "Bitte lokal ausführen: node docs/scripts/sync-funktionsdoku.mjs"
|
||||
echo "Danach die Änderungen committen."
|
||||
git status --short docs/
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-backend:
|
||||
needs: verify-docs-sync
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
@@ -46,6 +72,7 @@ jobs:
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
|
||||
build-frontend:
|
||||
needs: verify-docs-sync
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
@@ -74,4 +101,37 @@ jobs:
|
||||
context: ./frontend # Hier wird der Ordner gewechselt (wie 'cd frontend')
|
||||
push: true
|
||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
|
||||
build-docs:
|
||||
needs: verify-docs-sync
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to Docker Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_HOST }}
|
||||
username: ${{ env.ACTOR }}
|
||||
password: ${{ vars.CI_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docs
|
||||
id: meta-docs
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}/docs
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push Docs
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./docs-site/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta-docs.outputs.tags }}
|
||||
labels: ${{ steps.meta-docs.outputs.labels }}
|
||||
|
||||
@@ -6,10 +6,16 @@ import {secrets} from "../src/utils/secrets";
|
||||
|
||||
console.log("[DB INIT] 1. Suche Connection String...");
|
||||
|
||||
const fallbackConnectionString = "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo"
|
||||
|
||||
// Checken woher die URL kommt
|
||||
let connectionString = process.env.DATABASE_URL || secrets.DATABASE_URL;
|
||||
if (connectionString) {
|
||||
let connectionString = process.env.DATABASE_URL || secrets.DATABASE_URL || fallbackConnectionString;
|
||||
if (process.env.DATABASE_URL) {
|
||||
console.log("[DB INIT] -> Gefunden in process.env.DATABASE_URL");
|
||||
} else if (secrets.DATABASE_URL) {
|
||||
console.log("[DB INIT] -> Gefunden in secrets.DATABASE_URL");
|
||||
} else if (connectionString) {
|
||||
console.log("[DB INIT] -> Nutze Fallback aus dem Projekt");
|
||||
} else {
|
||||
console.error("[DB INIT] ❌ KEIN CONNECTION STRING GEFUNDEN! .env nicht geladen?");
|
||||
}
|
||||
@@ -24,4 +30,4 @@ pool.query('SELECT NOW()')
|
||||
.then(res => console.log(`[DB INIT] ✅ VERBINDUNG ERFOLGREICH! Zeit auf DB: ${res.rows[0].now}`))
|
||||
.catch(err => console.error(`[DB INIT] ❌ VERBINDUNGSFEHLER:`, err.message));
|
||||
|
||||
export const db = drizzle(pool, { schema });
|
||||
export const db = drizzle(pool, { schema });
|
||||
|
||||
37
backend/db/migrations/0024_tenant_branches.sql
Normal file
37
backend/db/migrations/0024_tenant_branches.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
CREATE TABLE "branches" (
|
||||
"id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"tenant" bigint NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"number" text,
|
||||
"description" text,
|
||||
"archived" boolean DEFAULT false NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
"updated_by" uuid
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "branches" ADD CONSTRAINT "branches_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "branches" ADD CONSTRAINT "branches_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "costcentres" ADD COLUMN "branch" bigint;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "costcentres" ADD CONSTRAINT "costcentres_branch_branches_id_fk" FOREIGN KEY ("branch") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth_profiles" ADD COLUMN "branch_id" bigint;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth_profiles" ADD CONSTRAINT "auth_profiles_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "auth_profile_branches" (
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"profile_id" uuid NOT NULL,
|
||||
"branch_id" bigint NOT NULL,
|
||||
"created_by" uuid,
|
||||
CONSTRAINT "auth_profile_branches_profile_id_branch_id_pk" PRIMARY KEY("profile_id","branch_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_profile_id_auth_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."auth_profiles"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "statementallocations"
|
||||
ADD COLUMN "booking_mode" text DEFAULT 'expense' NOT NULL,
|
||||
ADD COLUMN "depreciation_months" integer,
|
||||
ADD COLUMN "depreciation_start_date" text,
|
||||
ADD COLUMN "depreciation_label" text,
|
||||
ADD COLUMN "depreciation_group" text;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "statementallocations"
|
||||
ADD COLUMN "depreciation_method" text,
|
||||
ADD COLUMN "residual_value" double precision;
|
||||
2
backend/db/migrations/0027_product_supplier_link.sql
Normal file
2
backend/db/migrations/0027_product_supplier_link.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "products"
|
||||
ADD COLUMN "supplierLink" text;
|
||||
31
backend/db/migrations/0028_teams.sql
Normal file
31
backend/db/migrations/0028_teams.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
CREATE TABLE "teams" (
|
||||
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "teams_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"tenant" bigint NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"branch" bigint,
|
||||
"archived" boolean DEFAULT false NOT NULL,
|
||||
"updated_at" timestamp with time zone,
|
||||
"updated_by" uuid
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "auth_profile_teams" (
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"profile_id" uuid NOT NULL,
|
||||
"team_id" bigint NOT NULL,
|
||||
"created_by" uuid,
|
||||
CONSTRAINT "auth_profile_teams_profile_id_team_id_pk" PRIMARY KEY("profile_id","team_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_branch_branches_id_fk" FOREIGN KEY ("branch") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_profile_id_auth_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."auth_profiles"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;
|
||||
1
backend/db/migrations/0029_events_quick.sql
Normal file
1
backend/db/migrations/0029_events_quick.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "events" ADD COLUMN "quick" boolean DEFAULT false NOT NULL;
|
||||
11813
backend/db/migrations/meta/0025_snapshot.json
Normal file
11813
backend/db/migrations/meta/0025_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -138,30 +138,79 @@
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1773489600000,
|
||||
"tag": "0019_custom_surcharge_percentage_decimal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1773572400000,
|
||||
"tag": "0020_file_extracted_text",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1773835200000,
|
||||
"tag": "0021_admin_user_flag",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1773925200000,
|
||||
"tag": "0022_task_dependencies",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1774080000000,
|
||||
"tag": "0023_tax_evaluation_period",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "7",
|
||||
"when": 1774393200000,
|
||||
"tag": "0024_tenant_branches",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1774393201000,
|
||||
"tag": "0025_statementallocation_depreciation",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1774393202000,
|
||||
"tag": "0026_statementallocation_depreciation_method",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "7",
|
||||
"when": 1774602000000,
|
||||
"tag": "0027_product_supplier_link",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "7",
|
||||
"when": 1776124800000,
|
||||
"tag": "0028_teams",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1776211200000,
|
||||
"tag": "0029_events_quick",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
30
backend/db/schema/auth_profile_branches.ts
Normal file
30
backend/db/schema/auth_profile_branches.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { pgTable, uuid, bigint, timestamp } from "drizzle-orm/pg-core"
|
||||
|
||||
import { authProfiles } from "./auth_profiles"
|
||||
import { branches } from "./branches"
|
||||
import { authUsers } from "./auth_users"
|
||||
|
||||
export const authProfileBranches = pgTable(
|
||||
"auth_profile_branches",
|
||||
{
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
profile_id: uuid("profile_id")
|
||||
.notNull()
|
||||
.references(() => authProfiles.id, { onDelete: "cascade" }),
|
||||
|
||||
branch_id: bigint("branch_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => branches.id, { onDelete: "cascade" }),
|
||||
|
||||
created_by: uuid("created_by").references(() => authUsers.id),
|
||||
},
|
||||
(table) => ({
|
||||
primaryKey: [table.profile_id, table.branch_id],
|
||||
})
|
||||
)
|
||||
|
||||
export type AuthProfileBranch = typeof authProfileBranches.$inferSelect
|
||||
export type NewAuthProfileBranch = typeof authProfileBranches.$inferInsert
|
||||
30
backend/db/schema/auth_profile_teams.ts
Normal file
30
backend/db/schema/auth_profile_teams.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { pgTable, uuid, bigint, timestamp } from "drizzle-orm/pg-core"
|
||||
|
||||
import { authProfiles } from "./auth_profiles"
|
||||
import { teams } from "./teams"
|
||||
import { authUsers } from "./auth_users"
|
||||
|
||||
export const authProfileTeams = pgTable(
|
||||
"auth_profile_teams",
|
||||
{
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
profile_id: uuid("profile_id")
|
||||
.notNull()
|
||||
.references(() => authProfiles.id, { onDelete: "cascade" }),
|
||||
|
||||
team_id: bigint("team_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => teams.id, { onDelete: "cascade" }),
|
||||
|
||||
created_by: uuid("created_by").references(() => authUsers.id),
|
||||
},
|
||||
(table) => ({
|
||||
primaryKey: [table.profile_id, table.team_id],
|
||||
})
|
||||
)
|
||||
|
||||
export type AuthProfileTeam = typeof authProfileTeams.$inferSelect
|
||||
export type NewAuthProfileTeam = typeof authProfileTeams.$inferInsert
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
jsonb,
|
||||
} from "drizzle-orm/pg-core"
|
||||
import { authUsers } from "./auth_users"
|
||||
import { branches } from "./branches"
|
||||
|
||||
export const authProfiles = pgTable("auth_profiles", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
@@ -18,6 +19,8 @@ export const authProfiles = pgTable("auth_profiles", {
|
||||
|
||||
tenant_id: bigint("tenant_id", { mode: "number" }).notNull(),
|
||||
|
||||
branch_id: bigint("branch_id", { mode: "number" }).references(() => branches.id),
|
||||
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
37
backend/db/schema/branches.ts
Normal file
37
backend/db/schema/branches.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
timestamp,
|
||||
text,
|
||||
boolean,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
import { tenants } from "./tenants"
|
||||
import { authUsers } from "./auth_users"
|
||||
|
||||
export const branches = pgTable("branches", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
.primaryKey()
|
||||
.generatedByDefaultAsIdentity(),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
tenant: bigint("tenant", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id),
|
||||
|
||||
name: text("name").notNull(),
|
||||
number: text("number"),
|
||||
description: text("description"),
|
||||
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||
})
|
||||
|
||||
export type Branch = typeof branches.$inferSelect
|
||||
export type NewBranch = typeof branches.$inferInsert
|
||||
@@ -13,6 +13,7 @@ import { inventoryitems } from "./inventoryitems"
|
||||
import { projects } from "./projects"
|
||||
import { vehicles } from "./vehicles"
|
||||
import { authUsers } from "./auth_users"
|
||||
import { branches } from "./branches"
|
||||
|
||||
export const costcentres = pgTable("costcentres", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
@@ -32,6 +33,8 @@ export const costcentres = pgTable("costcentres", {
|
||||
|
||||
project: bigint("project", { mode: "number" }).references(() => projects.id),
|
||||
|
||||
branch: bigint("branch", { mode: "number" }).references(() => branches.id),
|
||||
|
||||
inventoryitem: bigint("inventoryitem", { mode: "number" }).references(
|
||||
() => inventoryitems.id
|
||||
),
|
||||
|
||||
@@ -31,6 +31,7 @@ export const events = pgTable(
|
||||
endDate: timestamp("endDate", { withTimezone: true }),
|
||||
|
||||
eventtype: text("eventtype").default("Umsetzung"),
|
||||
quick: boolean("quick").notNull().default(false),
|
||||
|
||||
project: bigint("project", { mode: "number" }), // FK follows when projects.ts exists
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export * from "./accounts"
|
||||
export * from "./auth_profiles"
|
||||
export * from "./auth_profile_branches"
|
||||
export * from "./auth_profile_teams"
|
||||
export * from "./auth_role_permisssions"
|
||||
export * from "./auth_roles"
|
||||
export * from "./auth_tenant_users"
|
||||
@@ -8,6 +10,7 @@ export * from "./auth_users"
|
||||
export * from "./bankaccounts"
|
||||
export * from "./bankrequisitions"
|
||||
export * from "./bankstatements"
|
||||
export * from "./branches"
|
||||
export * from "./checkexecutions"
|
||||
export * from "./checks"
|
||||
export * from "./citys"
|
||||
@@ -67,6 +70,7 @@ export * from "./staff_time_entry_connects"
|
||||
export * from "./staff_zeitstromtimestamps"
|
||||
export * from "./statementallocations"
|
||||
export * from "./tasks"
|
||||
export * from "./teams"
|
||||
export * from "./taxtypes"
|
||||
export * from "./tenants"
|
||||
export * from "./texttemplates"
|
||||
|
||||
@@ -50,6 +50,7 @@ export const products = pgTable("products", {
|
||||
vendor_allocation: jsonb("vendorAllocation").default([]),
|
||||
|
||||
article_number: text("articleNumber"),
|
||||
supplier_link: text("supplierLink"),
|
||||
|
||||
barcodes: text("barcodes").array().notNull().default([]),
|
||||
|
||||
|
||||
@@ -51,6 +51,14 @@ export const statementallocations = pgTable("statementallocations", {
|
||||
|
||||
description: text("description"),
|
||||
|
||||
bookingMode: text("booking_mode").notNull().default("expense"),
|
||||
depreciationMonths: integer("depreciation_months"),
|
||||
depreciationStartDate: text("depreciation_start_date"),
|
||||
depreciationMethod: text("depreciation_method"),
|
||||
depreciationLabel: text("depreciation_label"),
|
||||
depreciationGroup: text("depreciation_group"),
|
||||
residualValue: doublePrecision("residual_value"),
|
||||
|
||||
customer: bigint("customer", { mode: "number" }).references(
|
||||
() => customers.id
|
||||
),
|
||||
|
||||
40
backend/db/schema/teams.ts
Normal file
40
backend/db/schema/teams.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
pgTable,
|
||||
bigint,
|
||||
timestamp,
|
||||
text,
|
||||
boolean,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
import { tenants } from "./tenants"
|
||||
import { authUsers } from "./auth_users"
|
||||
import { branches } from "./branches"
|
||||
|
||||
export const teams = pgTable("teams", {
|
||||
id: bigint("id", { mode: "number" })
|
||||
.primaryKey()
|
||||
.generatedByDefaultAsIdentity(),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
tenant: bigint("tenant", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => tenants.id),
|
||||
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
|
||||
branch: bigint("branch", { mode: "number" })
|
||||
.references(() => branches.id),
|
||||
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||
})
|
||||
|
||||
export type Team = typeof teams.$inferSelect
|
||||
export type NewTeam = typeof teams.$inferInsert
|
||||
@@ -92,6 +92,8 @@ export const tenants = pgTable(
|
||||
serialInvoice: true,
|
||||
incomingInvoices: true,
|
||||
costcentres: true,
|
||||
branches: true,
|
||||
teams: true,
|
||||
accounts: true,
|
||||
ownaccounts: true,
|
||||
banking: true,
|
||||
@@ -127,8 +129,11 @@ export const tenants = pgTable(
|
||||
customers: { prefix: "", suffix: "", nextNumber: 10000 },
|
||||
products: { prefix: "AT-", suffix: "", nextNumber: 1000 },
|
||||
quotes: { prefix: "AN-", suffix: "", nextNumber: 1000 },
|
||||
costEstimates: { prefix: "KS-", suffix: "", nextNumber: 1000 },
|
||||
confirmationOrders: { prefix: "AB-", suffix: "", nextNumber: 1000 },
|
||||
invoices: { prefix: "RE-", suffix: "", nextNumber: 1000 },
|
||||
deliveryNotes: { prefix: "LS-", suffix: "", nextNumber: 1000 },
|
||||
packingSlips: { prefix: "PS-", suffix: "", nextNumber: 1000 },
|
||||
spaces: { prefix: "LP-", suffix: "", nextNumber: 1000 },
|
||||
customerspaces: { prefix: "KLP-", suffix: "", nextNumber: 1000 },
|
||||
inventoryitems: { prefix: "IA-", suffix: "", nextNumber: 1000 },
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import "dotenv/config"
|
||||
import { defineConfig } from "drizzle-kit"
|
||||
import {secrets} from "./src/utils/secrets";
|
||||
|
||||
const fallbackDatabaseUrl = "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo"
|
||||
const databaseUrl = process.env.DATABASE_URL || fallbackDatabaseUrl
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "postgresql",
|
||||
schema: "./db/schema",
|
||||
out: "./db/migrations",
|
||||
dbCredentials: {
|
||||
url: secrets.DATABASE_URL || "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo",
|
||||
url: databaseUrl,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"migrate": "tsx scripts/migrate.ts",
|
||||
"fill": "ts-node src/webdav/fill-file-sizes.ts",
|
||||
"dev:dav": "tsx watch src/webdav/server.ts",
|
||||
"build": "tsc",
|
||||
|
||||
47
backend/scripts/migrate.ts
Normal file
47
backend/scripts/migrate.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import "dotenv/config"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { drizzle } from "drizzle-orm/node-postgres"
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator"
|
||||
import { Pool } from "pg"
|
||||
|
||||
import { loadSecrets, secrets } from "../src/utils/secrets"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
async function run() {
|
||||
let connectionString = process.env.DATABASE_URL
|
||||
|
||||
if (!connectionString) {
|
||||
await loadSecrets()
|
||||
connectionString = secrets.DATABASE_URL
|
||||
}
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL not configured")
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString,
|
||||
max: 1,
|
||||
})
|
||||
|
||||
try {
|
||||
const db = drizzle(pool)
|
||||
|
||||
await migrate(db, {
|
||||
migrationsFolder: path.resolve(__dirname, "../db/migrations"),
|
||||
})
|
||||
|
||||
console.log("✅ Drizzle-Migrationen erfolgreich angewendet")
|
||||
} finally {
|
||||
await pool.end()
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error("❌ Migration fehlgeschlagen")
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import {
|
||||
authTenantUsers,
|
||||
authProfiles,
|
||||
customers,
|
||||
authRoles,
|
||||
authUserRoles,
|
||||
authUsers,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
tenants,
|
||||
} from "../../db/schema";
|
||||
import { generateRandomPassword, hashPassword } from "../utils/password";
|
||||
import { sendMail } from "../utils/mailer";
|
||||
|
||||
export default async function adminRoutes(server: FastifyInstance) {
|
||||
const deriveNameFromEmail = (email: string) => {
|
||||
@@ -255,6 +257,33 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
return currentUser;
|
||||
};
|
||||
|
||||
const ensurePortalRoleForTenant = async (tenantId: number, createdBy: string) => {
|
||||
const existingRoles = await server.db
|
||||
.select({
|
||||
id: authRoles.id,
|
||||
name: authRoles.name,
|
||||
})
|
||||
.from(authRoles)
|
||||
.where(eq(authRoles.tenant_id, tenantId));
|
||||
|
||||
const portalRole = existingRoles.find((role) => role.name === "Kundenportal");
|
||||
if (portalRole) return portalRole.id;
|
||||
|
||||
const [createdRole] = await server.db
|
||||
.insert(authRoles)
|
||||
.values({
|
||||
name: "Kundenportal",
|
||||
description: "Automatisch angelegte Rolle für eingeladene Kundenportal-Benutzer",
|
||||
tenant_id: tenantId,
|
||||
created_by: createdBy,
|
||||
})
|
||||
.returning({
|
||||
id: authRoles.id,
|
||||
});
|
||||
|
||||
return createdRole.id;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GET /admin/overview
|
||||
// -------------------------------------------------------------
|
||||
@@ -422,6 +451,233 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
server.post("/admin/customers/:customerId/invite-portal-user", async (req, reply) => {
|
||||
try {
|
||||
const currentUser = await requireAdmin(req, reply);
|
||||
if (!currentUser) return;
|
||||
|
||||
const tenantId = Number(req.user?.tenant_id);
|
||||
const { customerId } = req.params as { customerId: string };
|
||||
|
||||
if (!tenantId) {
|
||||
return reply.code(400).send({ error: "No tenant selected" });
|
||||
}
|
||||
|
||||
const [tenantRecord] = await server.db
|
||||
.select({
|
||||
id: tenants.id,
|
||||
name: tenants.name,
|
||||
portalDomain: tenants.portalDomain,
|
||||
})
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, tenantId))
|
||||
.limit(1);
|
||||
|
||||
const [customerRecord] = await server.db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(and(eq(customers.id, Number(customerId)), eq(customers.tenant, tenantId)))
|
||||
.limit(1);
|
||||
|
||||
if (!customerRecord) {
|
||||
return reply.code(404).send({ error: "Customer not found" });
|
||||
}
|
||||
|
||||
const customerInfo = customerRecord.infoData && typeof customerRecord.infoData === "object" ? customerRecord.infoData as Record<string, any> : {};
|
||||
const email = String(customerInfo.email || customerInfo.invoiceEmail || "").trim().toLowerCase();
|
||||
|
||||
if (!email) {
|
||||
return reply.code(400).send({ error: "Customer has no email address" });
|
||||
}
|
||||
|
||||
const generatedPassword = generateRandomPassword(14);
|
||||
const passwordHash = await hashPassword(generatedPassword);
|
||||
|
||||
const [existingUser] = await server.db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
is_admin: authUsers.is_admin,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.email, email))
|
||||
.limit(1);
|
||||
|
||||
const derivedName = deriveNameFromEmail(email);
|
||||
const firstName = customerRecord.firstname?.trim() || derivedName.first_name;
|
||||
const lastName = customerRecord.lastname?.trim() || derivedName.last_name;
|
||||
|
||||
let userId = existingUser?.id || null;
|
||||
let createdNewUser = false;
|
||||
|
||||
if (existingUser) {
|
||||
const [existingProfile] = await server.db
|
||||
.select({
|
||||
id: authProfiles.id,
|
||||
customer_for_portal: authProfiles.customer_for_portal,
|
||||
})
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.user_id, existingUser.id),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.is_admin) {
|
||||
return reply.code(409).send({ error: "Email address is already used by an admin user" });
|
||||
}
|
||||
|
||||
if (!existingProfile) {
|
||||
return reply.code(409).send({ error: "Email address is already used by another user" });
|
||||
}
|
||||
|
||||
if (existingProfile.customer_for_portal && existingProfile.customer_for_portal !== customerRecord.id) {
|
||||
return reply.code(409).send({ error: "Email address is already assigned to another portal customer" });
|
||||
}
|
||||
|
||||
await server.db
|
||||
.update(authUsers)
|
||||
.set({
|
||||
passwordHash,
|
||||
must_change_password: true,
|
||||
multiTenant: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(authUsers.id, existingUser.id));
|
||||
|
||||
userId = existingUser.id;
|
||||
} else {
|
||||
const [createdUser] = await server.db
|
||||
.insert(authUsers)
|
||||
.values({
|
||||
email,
|
||||
passwordHash,
|
||||
is_admin: false,
|
||||
multiTenant: false,
|
||||
must_change_password: true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning({
|
||||
id: authUsers.id,
|
||||
});
|
||||
|
||||
userId = createdUser.id;
|
||||
createdNewUser = true;
|
||||
}
|
||||
|
||||
const portalRoleId = await ensurePortalRoleForTenant(tenantId, currentUser.id);
|
||||
|
||||
const existingMemberships = await server.db
|
||||
.select()
|
||||
.from(authTenantUsers)
|
||||
.where(and(
|
||||
eq(authTenantUsers.user_id, userId!),
|
||||
eq(authTenantUsers.tenant_id, tenantId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!existingMemberships.length) {
|
||||
await server.db
|
||||
.insert(authTenantUsers)
|
||||
.values({
|
||||
tenant_id: tenantId,
|
||||
user_id: userId!,
|
||||
created_by: currentUser.id,
|
||||
});
|
||||
}
|
||||
|
||||
const existingPortalRoleAssignment = await server.db
|
||||
.select()
|
||||
.from(authUserRoles)
|
||||
.where(and(
|
||||
eq(authUserRoles.user_id, userId!),
|
||||
eq(authUserRoles.tenant_id, tenantId),
|
||||
eq(authUserRoles.role_id, portalRoleId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!existingPortalRoleAssignment.length) {
|
||||
await server.db
|
||||
.insert(authUserRoles)
|
||||
.values({
|
||||
user_id: userId!,
|
||||
tenant_id: tenantId,
|
||||
role_id: portalRoleId,
|
||||
created_by: currentUser.id,
|
||||
});
|
||||
}
|
||||
|
||||
const [existingTenantProfile] = await server.db
|
||||
.select({
|
||||
id: authProfiles.id,
|
||||
user_id: authProfiles.user_id,
|
||||
customer_for_portal: authProfiles.customer_for_portal,
|
||||
})
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.user_id, userId!),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (existingTenantProfile) {
|
||||
await server.db
|
||||
.update(authProfiles)
|
||||
.set({
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
customer_for_portal: customerRecord.id,
|
||||
active: true,
|
||||
})
|
||||
.where(eq(authProfiles.id, existingTenantProfile.id));
|
||||
} else {
|
||||
await server.db
|
||||
.insert(authProfiles)
|
||||
.values({
|
||||
user_id: userId!,
|
||||
tenant_id: tenantId,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
customer_for_portal: customerRecord.id,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
|
||||
const portalUrl = tenantRecord?.portalDomain ? `https://${tenantRecord.portalDomain}/login` : null;
|
||||
|
||||
const mailResult = await sendMail(
|
||||
email,
|
||||
`FEDEO | Einladung ins Kundenportal`,
|
||||
`
|
||||
<p>Hallo${customerRecord.name ? ` ${customerRecord.name}` : ""},</p>
|
||||
<p>für Sie wurde ein Zugang zum FEDEO Kundenportal eingerichtet.</p>
|
||||
<p><strong>E-Mail:</strong> ${email}</p>
|
||||
<p><strong>Initialpasswort:</strong> ${generatedPassword}</p>
|
||||
<p>Bitte ändern Sie dieses Passwort direkt nach dem ersten Login.</p>
|
||||
${portalUrl ? `<p><strong>Login:</strong> <a href="${portalUrl}">${portalUrl}</a></p>` : ""}
|
||||
<p>Viele Grüße<br>${tenantRecord?.name || "FEDEO"}</p>
|
||||
`
|
||||
);
|
||||
|
||||
if (!mailResult.success) {
|
||||
return reply.code(500).send({ error: "Invitation email could not be sent" });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
createdNewUser,
|
||||
email,
|
||||
initialPassword: generatedPassword,
|
||||
portalUrl,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("ERROR /admin/customers/:customerId/invite-portal-user:", err);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// POST /admin/tenants
|
||||
// -------------------------------------------------------------
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
authRolePermissions,
|
||||
} from "../../../db/schema"
|
||||
import { eq, and, or, isNull } from "drizzle-orm"
|
||||
import { enrichProfilesWithBranches } from "../../utils/profileBranches"
|
||||
|
||||
export default async function meRoutes(server: FastifyInstance) {
|
||||
server.get("/me", async (req, reply) => {
|
||||
@@ -51,6 +52,7 @@ export default async function meRoutes(server: FastifyInstance) {
|
||||
id: tenants.id,
|
||||
name: tenants.name,
|
||||
short: tenants.short,
|
||||
hasActiveLicense: tenants.hasActiveLicense,
|
||||
locked: tenants.locked,
|
||||
features: tenants.features,
|
||||
extraModules: tenants.extraModules,
|
||||
@@ -89,7 +91,8 @@ export default async function meRoutes(server: FastifyInstance) {
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
profile = profileResult?.[0] ?? null
|
||||
const enrichedProfiles = await enrichProfilesWithBranches(server, profileResult)
|
||||
profile = enrichedProfiles?.[0] ?? null
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
|
||||
@@ -10,7 +10,9 @@ import { secrets } from "../utils/secrets"
|
||||
import { saveFile } from "../utils/files"
|
||||
|
||||
import { eq, inArray } from "drizzle-orm"
|
||||
import { and } from "drizzle-orm"
|
||||
import {
|
||||
authProfiles,
|
||||
files,
|
||||
createddocuments,
|
||||
customers
|
||||
@@ -18,6 +20,55 @@ import {
|
||||
|
||||
|
||||
export default async function fileRoutes(server: FastifyInstance) {
|
||||
const getPortalCustomerId = async (req: any) => {
|
||||
const tenantId = req.user?.tenant_id
|
||||
const userId = req.user?.user_id
|
||||
|
||||
if (!tenantId || !userId) return null
|
||||
|
||||
const [profile] = await server.db
|
||||
.select({ customer_for_portal: authProfiles.customer_for_portal })
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.tenant_id, tenantId),
|
||||
eq(authProfiles.user_id, userId)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
return profile?.customer_for_portal || null
|
||||
}
|
||||
|
||||
const loadSingleFileForRequest = async (req: any, id: string) => {
|
||||
const tenantId = req.user?.tenant_id
|
||||
if (!tenantId) return null
|
||||
|
||||
const portalCustomerId = await getPortalCustomerId(req)
|
||||
|
||||
if (!portalCustomerId) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.id, id), eq(files.tenant, tenantId)))
|
||||
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const rows = await server.db
|
||||
.select({
|
||||
file: files,
|
||||
})
|
||||
.from(files)
|
||||
.leftJoin(createddocuments, eq(files.createddocument, createddocuments.id))
|
||||
.where(and(
|
||||
eq(files.id, id),
|
||||
eq(files.tenant, tenantId),
|
||||
eq(createddocuments.customer, portalCustomerId),
|
||||
eq(createddocuments.availableInPortal, true)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
return rows[0]?.file || null
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// MULTIPART INIT
|
||||
@@ -80,12 +131,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
|
||||
// 🔹 EINZELNE DATEI
|
||||
if (id) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.id, id))
|
||||
|
||||
const file = rows[0]
|
||||
const file = await loadSingleFileForRequest(req, id)
|
||||
if (!file) return reply.code(404).send({ error: "Not found" })
|
||||
|
||||
return file
|
||||
@@ -135,12 +181,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
// 1️⃣ SINGLE DOWNLOAD
|
||||
// -------------------------------------------------
|
||||
if (id) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.id, id))
|
||||
|
||||
const file = rows[0]
|
||||
const file = await loadSingleFileForRequest(req, id)
|
||||
if (!file) return reply.code(404).send({ error: "File not found" })
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
@@ -217,12 +258,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
// SINGLE FILE PRESIGNED URL
|
||||
// -------------------------------------------------
|
||||
if (id) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.id, id))
|
||||
|
||||
const file = rows[0]
|
||||
const file = await loadSingleFileForRequest(req, id)
|
||||
if (!file) return reply.code(404).send({ error: "Not found" })
|
||||
|
||||
const url = await getSignedUrl(
|
||||
|
||||
@@ -3,7 +3,7 @@ import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
|
||||
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import dayjs from "dayjs";
|
||||
@@ -57,6 +57,42 @@ function resolveGitRoot() {
|
||||
return null
|
||||
}
|
||||
|
||||
function getDeploymentChangelogFallback() {
|
||||
const backendPackagePath = path.resolve(process.cwd(), "package.json")
|
||||
let version = "unbekannt"
|
||||
|
||||
if (existsSync(backendPackagePath)) {
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(backendPackagePath, "utf-8"))
|
||||
version = packageJson?.version || version
|
||||
} catch (err) {
|
||||
console.error("Could not read backend package.json for changelog fallback", err)
|
||||
}
|
||||
}
|
||||
|
||||
const commitHash =
|
||||
process.env.RAILWAY_GIT_COMMIT_SHA ||
|
||||
process.env.VERCEL_GIT_COMMIT_SHA ||
|
||||
process.env.GITHUB_SHA ||
|
||||
process.env.COMMIT_SHA ||
|
||||
process.env.SOURCE_COMMIT ||
|
||||
null
|
||||
|
||||
const committedAt =
|
||||
process.env.BUILD_DATE ||
|
||||
process.env.RENDER_GIT_COMMIT_DATE ||
|
||||
process.env.VERCEL_GIT_COMMIT_DATE ||
|
||||
new Date().toISOString()
|
||||
|
||||
return [{
|
||||
hash: commitHash || `version-${version}`,
|
||||
shortHash: commitHash ? commitHash.slice(0, 7) : `v${version}`,
|
||||
subject: `Bereitgestellte Version ${version}`,
|
||||
authorName: "Deployment",
|
||||
committedAt
|
||||
}]
|
||||
}
|
||||
|
||||
export default async function functionRoutes(server: FastifyInstance) {
|
||||
const streamToBuffer = async (stream: any): Promise<Buffer> =>
|
||||
new Promise((resolve, reject) => {
|
||||
@@ -201,7 +237,11 @@ export default async function functionRoutes(server: FastifyInstance) {
|
||||
const gitRoot = resolveGitRoot()
|
||||
|
||||
if (!gitRoot) {
|
||||
return reply.code(500).send({ error: 'Git repository not found' })
|
||||
return reply.send({
|
||||
repositoryRoot: null,
|
||||
source: 'deployment',
|
||||
entries: getDeploymentChangelogFallback().slice(0, safeLimit)
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -232,11 +272,16 @@ export default async function functionRoutes(server: FastifyInstance) {
|
||||
|
||||
return reply.send({
|
||||
repositoryRoot: gitRoot,
|
||||
source: 'git',
|
||||
entries
|
||||
})
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
return reply.code(500).send({ error: 'Failed to load changelog' })
|
||||
return reply.send({
|
||||
repositoryRoot: gitRoot,
|
||||
source: 'deployment',
|
||||
entries: getDeploymentChangelogFallback().slice(0, safeLimit)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from "../../../db/schema"
|
||||
|
||||
import {and, eq, inArray} from "drizzle-orm"
|
||||
import { enrichProfilesWithBranches } from "../../utils/profileBranches"
|
||||
import { enrichProfilesWithTeams } from "../../utils/profileTeams"
|
||||
|
||||
|
||||
export default async function tenantRoutesInternal(server: FastifyInstance) {
|
||||
@@ -53,7 +55,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
|
||||
.where(inArray(authUsers.id, userIds))
|
||||
|
||||
// 3) auth_profiles pro Tenant laden
|
||||
const profiles = await server.db
|
||||
const profileRows = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
@@ -61,6 +63,8 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
|
||||
eq(authProfiles.tenant_id, tenantId),
|
||||
inArray(authProfiles.user_id, userIds)
|
||||
))
|
||||
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
||||
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
|
||||
|
||||
const combined = users.map(u => {
|
||||
const profile = profiles.find(p => p.user_id === u.id)
|
||||
@@ -91,12 +95,13 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
|
||||
const tenantId = req.params.id
|
||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const data = await server.db
|
||||
const profileRows = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(eq(authProfiles.tenant_id, tenantId))
|
||||
|
||||
return data
|
||||
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
||||
return await enrichProfilesWithTeams(server, profilesWithBranches)
|
||||
|
||||
} catch (err) {
|
||||
console.error("/tenant/profiles ERROR:", err)
|
||||
|
||||
@@ -4,6 +4,16 @@ import { eq, and } from "drizzle-orm";
|
||||
import {
|
||||
authProfiles,
|
||||
} from "../../db/schema";
|
||||
import {
|
||||
loadProfileWithBranches,
|
||||
resolveTenantBranchIds,
|
||||
syncProfileBranches,
|
||||
} from "../utils/profileBranches";
|
||||
import {
|
||||
enrichProfilesWithTeams,
|
||||
resolveTenantTeamIds,
|
||||
syncProfileTeams,
|
||||
} from "../utils/profileTeams";
|
||||
|
||||
export default async function authProfilesRoutes(server: FastifyInstance) {
|
||||
|
||||
@@ -19,22 +29,16 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
||||
return reply.code(400).send({ error: "No tenant selected" });
|
||||
}
|
||||
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.id, id),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const profileWithBranches = await loadProfileWithBranches(server, id, tenantId)
|
||||
const [profile] = profileWithBranches
|
||||
? await enrichProfilesWithTeams(server, [profileWithBranches])
|
||||
: [null]
|
||||
|
||||
if (!rows.length) {
|
||||
if (!profile) {
|
||||
return reply.code(404).send({ error: "User not found or not in tenant" });
|
||||
}
|
||||
|
||||
return rows[0];
|
||||
return profile;
|
||||
|
||||
} catch (error) {
|
||||
console.error("GET /profiles/:id ERROR:", error);
|
||||
@@ -48,7 +52,8 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
||||
// ❌ Systemfelder entfernen
|
||||
const forbidden = [
|
||||
"id", "user_id", "tenant_id", "created_at", "updated_at",
|
||||
"updatedAt", "updatedBy", "old_profile_id", "full_name"
|
||||
"updatedAt", "updatedBy", "old_profile_id", "full_name",
|
||||
"branch"
|
||||
]
|
||||
forbidden.forEach(f => delete cleaned[f])
|
||||
|
||||
@@ -89,8 +94,32 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
||||
// Clean + Normalize
|
||||
body = sanitizeProfileUpdate(body)
|
||||
|
||||
const { primaryBranchId, branchIds } = await resolveTenantBranchIds(
|
||||
server,
|
||||
tenantId,
|
||||
[
|
||||
...(Array.isArray(body.branch_ids) ? body.branch_ids : []),
|
||||
...(Array.isArray(body.branches) ? body.branches : []),
|
||||
],
|
||||
body.branch_id ?? body.branch?.id ?? null
|
||||
)
|
||||
const teamIds = await resolveTenantTeamIds(
|
||||
server,
|
||||
tenantId,
|
||||
[
|
||||
...(Array.isArray(body.team_ids) ? body.team_ids : []),
|
||||
...(Array.isArray(body.teams) ? body.teams : []),
|
||||
],
|
||||
)
|
||||
|
||||
delete body.branch_ids
|
||||
delete body.branches
|
||||
delete body.team_ids
|
||||
delete body.teams
|
||||
|
||||
const updateData = {
|
||||
...body,
|
||||
branch_id: primaryBranchId,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
}
|
||||
@@ -110,10 +139,23 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
||||
return reply.code(404).send({ error: "User not found or not in tenant" })
|
||||
}
|
||||
|
||||
return updated[0]
|
||||
await syncProfileBranches(server, id, branchIds, userId)
|
||||
await syncProfileTeams(server, id, teamIds, userId)
|
||||
|
||||
const profileWithBranches = await loadProfileWithBranches(server, id, tenantId)
|
||||
const [profile] = profileWithBranches
|
||||
? await enrichProfilesWithTeams(server, [profileWithBranches])
|
||||
: [null]
|
||||
return profile || updated[0]
|
||||
|
||||
} catch (err) {
|
||||
console.error("PUT /profiles/:id ERROR:", err)
|
||||
if (err instanceof Error && ["INVALID_BRANCH_SELECTION", "INVALID_PRIMARY_BRANCH"].includes(err.message)) {
|
||||
return reply.code(400).send({ error: "Ungültige Niederlassungsauswahl" })
|
||||
}
|
||||
if (err instanceof Error && err.message === "INVALID_TEAM_SELECTION") {
|
||||
return reply.code(400).send({ error: "Ungültige Teamauswahl" })
|
||||
}
|
||||
return reply.code(500).send({ error: "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
sql,
|
||||
} from "drizzle-orm"
|
||||
|
||||
import { authProfiles } from "../../../db/schema";
|
||||
import { resourceConfig } from "../../utils/resource.config";
|
||||
import { useNextNumberRangeNumber } from "../../utils/functions";
|
||||
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
|
||||
@@ -18,6 +19,9 @@ import { diffObjects } from "../../utils/diff";
|
||||
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service";
|
||||
import { decrypt, encrypt } from "../../utils/crypt";
|
||||
|
||||
const PORTAL_ALLOWED_RESOURCES = new Set(["customers", "contracts", "createddocuments"])
|
||||
const PORTAL_VISIBLE_DOCUMENT_TYPES = ["invoices", "advanceInvoices", "cancellationInvoices"]
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// SQL Suche auf mehreren Feldern (Haupttabelle + Relationen)
|
||||
// -------------------------------------------------------------
|
||||
@@ -130,12 +134,92 @@ function applyResourceWhereFilters(resource: string, table: any, whereCond: any)
|
||||
return whereCond
|
||||
}
|
||||
|
||||
async function getPortalCustomerId(server: FastifyInstance, req: any) {
|
||||
const tenantId = req.user?.tenant_id
|
||||
const userId = req.user?.user_id
|
||||
|
||||
if (!tenantId || !userId) return null
|
||||
|
||||
const [profile] = await server.db
|
||||
.select({ customer_for_portal: authProfiles.customer_for_portal })
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.tenant_id, tenantId),
|
||||
eq(authProfiles.user_id, userId)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
return profile?.customer_for_portal || null
|
||||
}
|
||||
|
||||
function applyPortalScope(resource: string, table: any, whereCond: any, portalCustomerId: number | null) {
|
||||
if (!portalCustomerId) return whereCond
|
||||
|
||||
if (!PORTAL_ALLOWED_RESOURCES.has(resource)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (resource === "customers") {
|
||||
return and(whereCond, eq(table.id, portalCustomerId))
|
||||
}
|
||||
|
||||
if (resource === "contracts") {
|
||||
return and(whereCond, eq(table.customer, portalCustomerId))
|
||||
}
|
||||
|
||||
if (resource === "createddocuments") {
|
||||
return and(
|
||||
whereCond,
|
||||
eq(table.customer, portalCustomerId),
|
||||
eq(table.availableInPortal, true),
|
||||
inArray(table.type, PORTAL_VISIBLE_DOCUMENT_TYPES)
|
||||
)
|
||||
}
|
||||
|
||||
return whereCond
|
||||
}
|
||||
|
||||
function sanitizePortalCustomerUpdate(payload: Record<string, any>) {
|
||||
const nextInfoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
|
||||
|
||||
return {
|
||||
name: payload.name,
|
||||
firstname: payload.firstname,
|
||||
lastname: payload.lastname,
|
||||
salutation: payload.salutation,
|
||||
title: payload.title,
|
||||
nameAddition: payload.nameAddition,
|
||||
infoData: nextInfoData,
|
||||
}
|
||||
}
|
||||
|
||||
function getTenantColumn(resource: string, table: any) {
|
||||
const config = resourceConfig[resource]
|
||||
const tenantKey = config?.tenantKey || "tenant"
|
||||
return table[tenantKey]
|
||||
}
|
||||
|
||||
function getRelationConfig(relation: string) {
|
||||
const candidateKeys = [
|
||||
relation,
|
||||
`${relation}s`,
|
||||
]
|
||||
|
||||
if (relation.endsWith("y")) {
|
||||
candidateKeys.push(`${relation.slice(0, -1)}ies`)
|
||||
}
|
||||
|
||||
if (/(s|x|z|ch|sh)$/.test(relation)) {
|
||||
candidateKeys.push(`${relation}es`)
|
||||
}
|
||||
|
||||
for (const key of candidateKeys) {
|
||||
if (resourceConfig[key]) return resourceConfig[key]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isDateLikeField(key: string) {
|
||||
if (key === "deliveryDateType") return false
|
||||
if (key.includes("_at") || key.endsWith("At")) return true
|
||||
@@ -250,18 +334,23 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
if (!config) {
|
||||
return reply.code(404).send({ error: "Unknown resource" })
|
||||
}
|
||||
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
|
||||
return reply.code(403).send({ error: "Forbidden" })
|
||||
}
|
||||
const table = config.table
|
||||
|
||||
const tenantColumn = getTenantColumn(resource, table)
|
||||
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
|
||||
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
||||
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
|
||||
let q = server.db.select().from(table).$dynamic()
|
||||
|
||||
const searchCols: any[] = (config.searchColumns || []).map(c => table[c])
|
||||
|
||||
if (config.mtoLoad) {
|
||||
config.mtoLoad.forEach(rel => {
|
||||
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel]
|
||||
const relConfig = getRelationConfig(rel)
|
||||
if (relConfig) {
|
||||
const relTable = relConfig.table
|
||||
|
||||
@@ -307,7 +396,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
|
||||
})
|
||||
for await (const rel of config.mtoLoad) {
|
||||
const relConf = resourceConfig[rel + "s"] || resourceConfig[rel];
|
||||
const relConf = getRelationConfig(rel)
|
||||
if (!relConf) continue
|
||||
const relTab = relConf.table
|
||||
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : []
|
||||
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
|
||||
@@ -358,6 +448,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
if (!config) {
|
||||
return reply.code(404).send({ error: "Unknown resource" });
|
||||
}
|
||||
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
|
||||
return reply.code(403).send({ error: "Forbidden" })
|
||||
}
|
||||
const table = config.table;
|
||||
|
||||
const { queryConfig } = req;
|
||||
@@ -367,6 +461,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
const tenantColumn = getTenantColumn(resource, table);
|
||||
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined;
|
||||
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
||||
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
|
||||
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
|
||||
const debugSearchColumnNames: string[] = [...(config.searchColumns || [])];
|
||||
const parsedFilters: Array<{ key: string; value: any }> = []
|
||||
@@ -376,7 +471,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
|
||||
if (config.mtoLoad) {
|
||||
config.mtoLoad.forEach(rel => {
|
||||
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel];
|
||||
const relConfig = getRelationConfig(rel)
|
||||
if (relConfig) {
|
||||
const relTable = relConfig.table;
|
||||
|
||||
@@ -457,7 +552,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
let distinctQuery = server.db.select({ v: col }).from(table).$dynamic();
|
||||
if (config.mtoLoad) {
|
||||
config.mtoLoad.forEach(rel => {
|
||||
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel];
|
||||
const relConfig = getRelationConfig(rel)
|
||||
if (!relConfig) return;
|
||||
const relTable = relConfig.table;
|
||||
if (relTable !== table) {
|
||||
@@ -467,6 +562,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
}
|
||||
let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
|
||||
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond)
|
||||
distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId)
|
||||
|
||||
if (search) {
|
||||
const searchCond = buildSearchCondition(searchCols, search.trim())
|
||||
@@ -496,7 +592,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
|
||||
});
|
||||
for await (const rel of config.mtoLoad) {
|
||||
const relConf = resourceConfig[rel + "s"] || resourceConfig[rel];
|
||||
const relConf = getRelationConfig(rel)
|
||||
if (!relConf) continue
|
||||
const relTab = relConf.table;
|
||||
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : [];
|
||||
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
|
||||
@@ -547,10 +644,15 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
|
||||
|
||||
const { resource, no_relations } = req.params as { resource: string, no_relations?: boolean }
|
||||
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
|
||||
return reply.code(403).send({ error: "Forbidden" })
|
||||
}
|
||||
const table = resourceConfig[resource].table
|
||||
|
||||
let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId))
|
||||
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
||||
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
|
||||
|
||||
const projRows = await server.db
|
||||
.select()
|
||||
@@ -567,7 +669,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
if (resourceConfig[resource].mtoLoad) {
|
||||
for await (const relation of resourceConfig[resource].mtoLoad) {
|
||||
if (data[relation]) {
|
||||
const relConf = resourceConfig[relation + "s"] || resourceConfig[relation];
|
||||
const relConf = getRelationConfig(relation)
|
||||
if (!relConf) continue
|
||||
const relTable = relConf.table
|
||||
const relData = await server.db.select().from(relTable).where(eq(relTable.id, data[relation]))
|
||||
data[relation] = relData[0] || null
|
||||
@@ -600,6 +703,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
try {
|
||||
if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" });
|
||||
const { resource } = req.params as { resource: string };
|
||||
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||
if (portalCustomerId) {
|
||||
return reply.code(403).send({ error: "Forbidden" })
|
||||
}
|
||||
if (resource === "accounts") {
|
||||
return reply.code(403).send({ error: "Accounts are read-only" })
|
||||
}
|
||||
@@ -679,8 +786,12 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
const body = req.body as Record<string, any>
|
||||
const tenantId = req.user?.tenant_id
|
||||
const userId = req.user?.user_id
|
||||
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||
|
||||
if (!tenantId || !userId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
if (portalCustomerId && resource !== "customers") {
|
||||
return reply.code(403).send({ error: "Forbidden" })
|
||||
}
|
||||
|
||||
const table = resourceConfig[resource].table
|
||||
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
|
||||
@@ -688,13 +799,25 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
const [oldRecord] = await server.db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(eq(table.id, id), eq(table.tenant, tenantId)))
|
||||
.where(applyPortalScope(resource, table, and(eq(table.id, id), eq(table.tenant, tenantId)), portalCustomerId))
|
||||
.limit(1)
|
||||
|
||||
if (!oldRecord) {
|
||||
return reply.code(404).send({ error: "Resource not found" })
|
||||
}
|
||||
|
||||
let data: Record<string, any> = { ...body, updated_at: new Date().toISOString(), updated_by: userId }
|
||||
//@ts-ignore
|
||||
delete data.updatedBy; delete data.updatedAt;
|
||||
|
||||
if (portalCustomerId) {
|
||||
data = {
|
||||
...sanitizePortalCustomerUpdate(data),
|
||||
updated_at: data.updated_at,
|
||||
updated_by: data.updated_by,
|
||||
}
|
||||
}
|
||||
|
||||
if (resource === "members") {
|
||||
data = normalizeMemberPayload(data)
|
||||
const validationError = validateMemberPayload(data)
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from "../../db/schema"
|
||||
|
||||
import {and, desc, eq, inArray} from "drizzle-orm"
|
||||
import { enrichProfilesWithBranches } from "../utils/profileBranches"
|
||||
import { enrichProfilesWithTeams } from "../utils/profileTeams"
|
||||
|
||||
|
||||
export default async function tenantRoutes(server: FastifyInstance) {
|
||||
@@ -123,7 +125,7 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
||||
.where(inArray(authUsers.id, userIds))
|
||||
|
||||
// 3) auth_profiles pro Tenant laden
|
||||
const profiles = await server.db
|
||||
const profileRows = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
@@ -131,6 +133,8 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
||||
eq(authProfiles.tenant_id, tenantId),
|
||||
inArray(authProfiles.user_id, userIds)
|
||||
))
|
||||
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
||||
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
|
||||
|
||||
const combined = users.map(u => {
|
||||
const profile = profiles.find(p => p.user_id === u.id)
|
||||
@@ -160,11 +164,13 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
||||
const tenantId = req.user?.tenant_id
|
||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
|
||||
const data = await server.db
|
||||
const profileRows = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(eq(authProfiles.tenant_id, tenantId))
|
||||
|
||||
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
|
||||
const data = await enrichProfilesWithTeams(server, profilesWithBranches)
|
||||
return { data }
|
||||
|
||||
} catch (err) {
|
||||
|
||||
@@ -12,6 +12,13 @@ export const useNextNumberRangeNumber = async (
|
||||
tenantId: number,
|
||||
numberRange: string
|
||||
) => {
|
||||
const numberRangeFallbacks: Record<string, string> = {
|
||||
costEstimates: "quotes",
|
||||
packingSlips: "deliveryNotes",
|
||||
advanceInvoices: "invoices",
|
||||
cancellationInvoices: "invoices",
|
||||
}
|
||||
|
||||
const [tenant] = await server.db
|
||||
.select()
|
||||
.from(tenants)
|
||||
@@ -23,11 +30,15 @@ export const useNextNumberRangeNumber = async (
|
||||
|
||||
const numberRanges = tenant.numberRanges || {}
|
||||
|
||||
if (!numberRanges[numberRange]) {
|
||||
const resolvedNumberRange = numberRanges[numberRange]
|
||||
? numberRange
|
||||
: numberRangeFallbacks[numberRange]
|
||||
|
||||
if (!resolvedNumberRange || !numberRanges[resolvedNumberRange]) {
|
||||
throw new Error(`Number range '${numberRange}' not found`)
|
||||
}
|
||||
|
||||
const current = numberRanges[numberRange]
|
||||
const current = numberRanges[resolvedNumberRange]
|
||||
|
||||
const usedNumber =
|
||||
(current.prefix || "") +
|
||||
@@ -37,7 +48,7 @@ export const useNextNumberRangeNumber = async (
|
||||
const updatedRanges = {
|
||||
// @ts-ignore
|
||||
...numberRanges,
|
||||
[numberRange]: {
|
||||
[resolvedNumberRange]: {
|
||||
...current,
|
||||
nextNumber: current.nextNumber + 1,
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
||||
incominginvoices: "Eingangsrechnungen",
|
||||
files: "Dateien",
|
||||
memberrelations: "Mitgliedsverhältnisse",
|
||||
teams: "Teams",
|
||||
}
|
||||
|
||||
export function getHistoryEntityLabel(entity: string) {
|
||||
|
||||
@@ -58,6 +58,8 @@ const getDuration = (time) => {
|
||||
|
||||
|
||||
export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string) => {
|
||||
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
|
||||
const isPackingSlip = invoiceData?.type === "packingSlips"
|
||||
|
||||
const genPDF = async (invoiceData, backgroundSourceBuffer) => {
|
||||
const pdfDoc = await PDFDocument.create()
|
||||
@@ -347,7 +349,19 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
font: fontBold
|
||||
})
|
||||
|
||||
if (invoiceData.type !== "deliveryNotes") {
|
||||
if (isPackingSlip) {
|
||||
pages[pageCounter - 1].drawText("Check", {
|
||||
...getCoordinatesForPDFLib(180, 137, page1),
|
||||
size: 12,
|
||||
color: rgb(0, 0, 0),
|
||||
lineHeight: 12,
|
||||
opacity: 1,
|
||||
maxWidth: 240,
|
||||
font: fontBold
|
||||
})
|
||||
}
|
||||
|
||||
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||
pages[pageCounter - 1].drawText("Steuer", {
|
||||
...getCoordinatesForPDFLib(135, 137, page1),
|
||||
size: 12,
|
||||
@@ -414,9 +428,21 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
maxWidth: 240
|
||||
})
|
||||
|
||||
if (isPackingSlip) {
|
||||
pages[pageCounter - 1].drawRectangle({
|
||||
...getCoordinatesForPDFLib(182, rowHeight + 1, page1),
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderColor: rgb(0, 0, 0),
|
||||
borderWidth: 0.8,
|
||||
opacity: 1,
|
||||
borderOpacity: 1,
|
||||
})
|
||||
}
|
||||
|
||||
let rowTextLines = 0
|
||||
|
||||
if (invoiceData.type !== "deliveryNotes") {
|
||||
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, 35).join("\n"), {
|
||||
...getCoordinatesForPDFLib(52, rowHeight, page1),
|
||||
size: 10,
|
||||
@@ -428,7 +454,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
rowTextLines = splitStringBySpace(row.text, 35).length
|
||||
|
||||
} else {
|
||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, 80).join("\n"), {
|
||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, isPackingSlip ? 68 : 80).join("\n"), {
|
||||
...getCoordinatesForPDFLib(52, rowHeight, page1),
|
||||
size: 10,
|
||||
color: rgb(0, 0, 0),
|
||||
@@ -437,13 +463,13 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
font: fontBold
|
||||
})
|
||||
|
||||
rowTextLines = splitStringBySpace(row.text, 80).length
|
||||
rowTextLines = splitStringBySpace(row.text, isPackingSlip ? 68 : 80).length
|
||||
}
|
||||
|
||||
let rowDescriptionLines = 0
|
||||
|
||||
if (row.descriptionText) {
|
||||
if (invoiceData.type !== "deliveryNotes") {
|
||||
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||
rowDescriptionLines = splitStringBySpace(row.descriptionText, 60).length
|
||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, 60).join("\n"), {
|
||||
...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1),
|
||||
@@ -454,8 +480,8 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
})
|
||||
|
||||
} else {
|
||||
rowDescriptionLines = splitStringBySpace(row.descriptionText, 80).length
|
||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, 80).join("\n"), {
|
||||
rowDescriptionLines = splitStringBySpace(row.descriptionText, isPackingSlip ? 68 : 80).length
|
||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, isPackingSlip ? 68 : 80).join("\n"), {
|
||||
...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1),
|
||||
size: 10,
|
||||
color: rgb(0, 0, 0),
|
||||
@@ -466,7 +492,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
}
|
||||
|
||||
|
||||
if (invoiceData.type !== "deliveryNotes") {
|
||||
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||
pages[pageCounter - 1].drawText(`${row.taxPercent} %`, {
|
||||
...getCoordinatesForPDFLib(135, rowHeight, page1),
|
||||
size: 10,
|
||||
@@ -632,7 +658,19 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
font: fontBold
|
||||
})
|
||||
|
||||
if (invoiceData.type !== "deliveryNotes") {
|
||||
if (isPackingSlip) {
|
||||
page.drawText("Check", {
|
||||
...getCoordinatesForPDFLib(180, 22, page1),
|
||||
size: 12,
|
||||
color: rgb(0, 0, 0),
|
||||
lineHeight: 12,
|
||||
opacity: 1,
|
||||
maxWidth: 240,
|
||||
font: fontBold
|
||||
})
|
||||
}
|
||||
|
||||
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||
page.drawText("Steuer", {
|
||||
...getCoordinatesForPDFLib(135, 22, page1),
|
||||
size: 12,
|
||||
@@ -742,7 +780,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
|
||||
let endTextDiff = 35
|
||||
|
||||
if (invoiceData.type !== "deliveryNotes") {
|
||||
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||
pages[pageCounter - 1].drawLine({
|
||||
start: getCoordinatesForPDFLib(20, rowHeight, page1),
|
||||
end: getCoordinatesForPDFLib(198, rowHeight, page1),
|
||||
@@ -864,10 +902,10 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
||||
opacity: 1,
|
||||
maxWidth: 500
|
||||
})
|
||||
|
||||
return await pdfDoc.saveAsBase64()
|
||||
}
|
||||
|
||||
return await pdfDoc.saveAsBase64()
|
||||
|
||||
}
|
||||
|
||||
const pdfBytes = await genPDF(invoiceData, await getBackgroundSourceBuffer(server,backgroundPath))
|
||||
@@ -1138,4 +1176,4 @@ export const createTimeSheetPDF = async (server: FastifyInstance, returnMode, da
|
||||
console.log(error)
|
||||
throw error; // Fehler weiterwerfen, damit er oben ankommt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
142
backend/src/utils/profileBranches.ts
Normal file
142
backend/src/utils/profileBranches.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { and, eq, inArray } from "drizzle-orm"
|
||||
import { FastifyInstance } from "fastify"
|
||||
|
||||
import { authProfileBranches, authProfiles, branches } from "../../db/schema"
|
||||
|
||||
function normalizeBranchIds(values: any[]): number[] {
|
||||
return [...new Set(
|
||||
values
|
||||
.map((value) => {
|
||||
if (typeof value === "number") return value
|
||||
if (typeof value === "string" && value.trim()) return Number(value)
|
||||
if (value && typeof value === "object" && "id" in value) return Number(value.id)
|
||||
return NaN
|
||||
})
|
||||
.filter((value) => Number.isFinite(value))
|
||||
)]
|
||||
}
|
||||
|
||||
export async function enrichProfilesWithBranches(server: FastifyInstance, profiles: any[]) {
|
||||
if (!profiles.length) return profiles
|
||||
|
||||
const profileIds = profiles.map((profile) => profile.id).filter(Boolean)
|
||||
if (!profileIds.length) return profiles
|
||||
|
||||
const profileBranchRows = await server.db
|
||||
.select()
|
||||
.from(authProfileBranches)
|
||||
.where(inArray(authProfileBranches.profile_id, profileIds))
|
||||
|
||||
const branchIds = [...new Set(profileBranchRows.map((row) => row.branch_id).filter(Boolean))]
|
||||
const branchRows = branchIds.length
|
||||
? await server.db.select().from(branches).where(inArray(branches.id, branchIds))
|
||||
: []
|
||||
|
||||
const branchMap = new Map(branchRows.map((branch) => [branch.id, branch]))
|
||||
const branchIdsByProfile = new Map<string, number[]>()
|
||||
|
||||
for (const row of profileBranchRows) {
|
||||
const current = branchIdsByProfile.get(row.profile_id) || []
|
||||
current.push(row.branch_id)
|
||||
branchIdsByProfile.set(row.profile_id, current)
|
||||
}
|
||||
|
||||
return profiles.map((profile) => {
|
||||
const assignedBranchIds = [...new Set(branchIdsByProfile.get(profile.id) || [])]
|
||||
return {
|
||||
...profile,
|
||||
branch: profile.branch_id ? branchMap.get(profile.branch_id) || null : null,
|
||||
branches: assignedBranchIds
|
||||
.map((branchId) => branchMap.get(branchId))
|
||||
.filter(Boolean),
|
||||
branch_ids: assignedBranchIds,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadProfileWithBranches(server: FastifyInstance, profileId: string, tenantId: number) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(authProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(authProfiles.id, profileId),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!rows.length) return null
|
||||
|
||||
const [profile] = await enrichProfilesWithBranches(server, rows)
|
||||
return profile
|
||||
}
|
||||
|
||||
export async function resolveTenantBranchIds(
|
||||
server: FastifyInstance,
|
||||
tenantId: number,
|
||||
values: any[],
|
||||
primaryBranchId?: any
|
||||
) {
|
||||
const normalizedPrimaryBranchId = primaryBranchId == null || primaryBranchId === ""
|
||||
? null
|
||||
: Number(primaryBranchId)
|
||||
|
||||
const requestedBranchIds = normalizeBranchIds([
|
||||
...values,
|
||||
normalizedPrimaryBranchId,
|
||||
])
|
||||
|
||||
if (!requestedBranchIds.length) {
|
||||
return {
|
||||
primaryBranchId: normalizedPrimaryBranchId,
|
||||
branchIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
const validBranches = await server.db
|
||||
.select({ id: branches.id })
|
||||
.from(branches)
|
||||
.where(
|
||||
and(
|
||||
eq(branches.tenant, tenantId),
|
||||
inArray(branches.id, requestedBranchIds)
|
||||
)
|
||||
)
|
||||
|
||||
const validBranchIds = validBranches.map((branch) => branch.id)
|
||||
|
||||
if (validBranchIds.length !== requestedBranchIds.length) {
|
||||
throw new Error("INVALID_BRANCH_SELECTION")
|
||||
}
|
||||
|
||||
if (normalizedPrimaryBranchId != null && !validBranchIds.includes(normalizedPrimaryBranchId)) {
|
||||
throw new Error("INVALID_PRIMARY_BRANCH")
|
||||
}
|
||||
|
||||
return {
|
||||
primaryBranchId: normalizedPrimaryBranchId,
|
||||
branchIds: validBranchIds,
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncProfileBranches(
|
||||
server: FastifyInstance,
|
||||
profileId: string,
|
||||
branchIds: number[],
|
||||
userId?: string | null
|
||||
) {
|
||||
await server.db
|
||||
.delete(authProfileBranches)
|
||||
.where(eq(authProfileBranches.profile_id, profileId))
|
||||
|
||||
if (!branchIds.length) return
|
||||
|
||||
await server.db
|
||||
.insert(authProfileBranches)
|
||||
.values(branchIds.map((branchId) => ({
|
||||
profile_id: profileId,
|
||||
branch_id: branchId,
|
||||
created_by: userId || null,
|
||||
})))
|
||||
}
|
||||
117
backend/src/utils/profileTeams.ts
Normal file
117
backend/src/utils/profileTeams.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { and, eq, inArray } from "drizzle-orm"
|
||||
import { FastifyInstance } from "fastify"
|
||||
|
||||
import { authProfileTeams, branches, teams } from "../../db/schema"
|
||||
|
||||
function normalizeTeamIds(values: any[]): number[] {
|
||||
return [...new Set(
|
||||
values
|
||||
.map((value) => {
|
||||
if (typeof value === "number") return value
|
||||
if (typeof value === "string" && value.trim()) return Number(value)
|
||||
if (value && typeof value === "object" && "id" in value) return Number(value.id)
|
||||
return NaN
|
||||
})
|
||||
.filter((value) => Number.isFinite(value))
|
||||
)]
|
||||
}
|
||||
|
||||
export async function enrichProfilesWithTeams(server: FastifyInstance, profiles: any[]) {
|
||||
if (!profiles.length) return profiles
|
||||
|
||||
const profileIds = profiles.map((profile) => profile.id).filter(Boolean)
|
||||
if (!profileIds.length) return profiles
|
||||
|
||||
const profileTeamRows = await server.db
|
||||
.select()
|
||||
.from(authProfileTeams)
|
||||
.where(inArray(authProfileTeams.profile_id, profileIds))
|
||||
|
||||
const teamIds = [...new Set(profileTeamRows.map((row) => row.team_id).filter(Boolean))]
|
||||
const teamRows = teamIds.length
|
||||
? await server.db.select().from(teams).where(inArray(teams.id, teamIds))
|
||||
: []
|
||||
|
||||
const branchIds = [...new Set(teamRows.map((team) => team.branch).filter(Boolean))]
|
||||
const branchRows = branchIds.length
|
||||
? await server.db.select().from(branches).where(inArray(branches.id, branchIds))
|
||||
: []
|
||||
|
||||
const branchMap = new Map(branchRows.map((branch) => [branch.id, branch]))
|
||||
const teamMap = new Map(teamRows.map((team) => [
|
||||
team.id,
|
||||
{
|
||||
...team,
|
||||
branch: team.branch ? branchMap.get(team.branch) || null : null,
|
||||
},
|
||||
]))
|
||||
const teamIdsByProfile = new Map<string, number[]>()
|
||||
|
||||
for (const row of profileTeamRows) {
|
||||
const current = teamIdsByProfile.get(row.profile_id) || []
|
||||
current.push(row.team_id)
|
||||
teamIdsByProfile.set(row.profile_id, current)
|
||||
}
|
||||
|
||||
return profiles.map((profile) => {
|
||||
const assignedTeamIds = [...new Set(teamIdsByProfile.get(profile.id) || [])]
|
||||
return {
|
||||
...profile,
|
||||
teams: assignedTeamIds
|
||||
.map((teamId) => teamMap.get(teamId))
|
||||
.filter(Boolean),
|
||||
team_ids: assignedTeamIds,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function resolveTenantTeamIds(
|
||||
server: FastifyInstance,
|
||||
tenantId: number,
|
||||
values: any[],
|
||||
) {
|
||||
const requestedTeamIds = normalizeTeamIds(values)
|
||||
|
||||
if (!requestedTeamIds.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const validTeams = await server.db
|
||||
.select({ id: teams.id })
|
||||
.from(teams)
|
||||
.where(
|
||||
and(
|
||||
eq(teams.tenant, tenantId),
|
||||
inArray(teams.id, requestedTeamIds)
|
||||
)
|
||||
)
|
||||
|
||||
const validTeamIds = validTeams.map((team) => team.id)
|
||||
|
||||
if (validTeamIds.length !== requestedTeamIds.length) {
|
||||
throw new Error("INVALID_TEAM_SELECTION")
|
||||
}
|
||||
|
||||
return validTeamIds
|
||||
}
|
||||
|
||||
export async function syncProfileTeams(
|
||||
server: FastifyInstance,
|
||||
profileId: string,
|
||||
teamIds: number[],
|
||||
userId?: string | null
|
||||
) {
|
||||
await server.db
|
||||
.delete(authProfileTeams)
|
||||
.where(eq(authProfileTeams.profile_id, profileId))
|
||||
|
||||
if (!teamIds.length) return
|
||||
|
||||
await server.db
|
||||
.insert(authProfileTeams)
|
||||
.values(teamIds.map((teamId) => ({
|
||||
profile_id: profileId,
|
||||
team_id: teamId,
|
||||
created_by: userId || null,
|
||||
})))
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
bankaccounts,
|
||||
bankrequisitions,
|
||||
bankstatements,
|
||||
branches,
|
||||
entitybankaccounts,
|
||||
events,
|
||||
contacts,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
spaces,
|
||||
statementallocations,
|
||||
tasks,
|
||||
teams,
|
||||
texttemplates,
|
||||
units,
|
||||
vehicles,
|
||||
@@ -162,9 +164,19 @@ export const resourceConfig = {
|
||||
costcentres: {
|
||||
table: costcentres,
|
||||
searchColumns: ["name","number","description"],
|
||||
mtoLoad: ["vehicle","project","inventoryitem"],
|
||||
mtoLoad: ["vehicle","project","inventoryitem","branch"],
|
||||
numberRangeHolder: "number",
|
||||
},
|
||||
branches: {
|
||||
table: branches,
|
||||
searchColumns: ["name","number","description"],
|
||||
numberRangeHolder: "number",
|
||||
},
|
||||
teams: {
|
||||
table: teams,
|
||||
searchColumns: ["name", "description"],
|
||||
mtoLoad: ["branch"],
|
||||
},
|
||||
tasks: {
|
||||
table: tasks,
|
||||
},
|
||||
@@ -186,7 +198,7 @@ export const resourceConfig = {
|
||||
table: createddocuments,
|
||||
mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead","createddocument"],
|
||||
mtmLoad: ["statementallocations","files","createddocuments"],
|
||||
mtmListLoad: ["statementallocations"],
|
||||
mtmListLoad: ["statementallocations", "files"],
|
||||
},
|
||||
texttemplates: {
|
||||
table: texttemplates
|
||||
|
||||
9
docker-compose.docs.yml
Normal file
9
docker-compose.docs.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
docs:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docs-site/Dockerfile
|
||||
container_name: fedeo-docs
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3205:80"
|
||||
@@ -17,10 +17,33 @@ services:
|
||||
- "traefik.http.routers.fedeo-frontend.middlewares=fedeo-frontend-redirect-web-secure"
|
||||
- "traefik.http.routers.fedeo-frontend.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
|
||||
- "traefik.http.routers.fedeo-frontend.entrypoints=web"
|
||||
- "traefik.http.routers.fedeo-frontend.priority=1"
|
||||
# Web Secure Entrypoint
|
||||
- "traefik.http.routers.fedeo-frontend-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
|
||||
- "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured" #
|
||||
- "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.fedeo-frontend-secure.priority=1"
|
||||
docs:
|
||||
image: git.federspiel.tech/flfeders/fedeo/docs:dev
|
||||
restart: always
|
||||
networks:
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.port=80"
|
||||
# Middlewares
|
||||
- "traefik.http.middlewares.fedeo-docs-redirect-web-secure.redirectscheme.scheme=https"
|
||||
# Web Entrypoint
|
||||
- "traefik.http.routers.fedeo-docs.middlewares=fedeo-docs-redirect-web-secure"
|
||||
- "traefik.http.routers.fedeo-docs.rule=Host(`app.fedeo.de`) && PathPrefix(`/docs`)"
|
||||
- "traefik.http.routers.fedeo-docs.entrypoints=web"
|
||||
- "traefik.http.routers.fedeo-docs.priority=120"
|
||||
# Web Secure Entrypoint
|
||||
- "traefik.http.routers.fedeo-docs-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/docs`)"
|
||||
- "traefik.http.routers.fedeo-docs-secure.entrypoints=web-secured"
|
||||
- "traefik.http.routers.fedeo-docs-secure.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.fedeo-docs-secure.priority=120"
|
||||
backend:
|
||||
image: git.federspiel.tech/flfeders/fedeo/backend:dev
|
||||
restart: always
|
||||
@@ -90,4 +113,4 @@ services:
|
||||
- traefik
|
||||
networks:
|
||||
traefik:
|
||||
external: false
|
||||
external: false
|
||||
|
||||
4
docs-site/.dockerignore
Normal file
4
docs-site/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
build
|
||||
.docusaurus
|
||||
.git
|
||||
17
docs-site/Dockerfile
Normal file
17
docs-site/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app/docs-site
|
||||
|
||||
COPY docs-site/package.json docs-site/package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
COPY docs-site ./
|
||||
COPY docs /app/docs
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine AS runner
|
||||
COPY docs-site/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/docs-site/build /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
59
docs-site/README.md
Normal file
59
docs-site/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# FEDEO Docs Site (Docusaurus)
|
||||
|
||||
Diese Docusaurus-App rendert die versionierte FEDEO-Dokumentation aus dem Ordner `../docs`.
|
||||
|
||||
## Zielpfad in Produktion
|
||||
|
||||
Die Seite ist für den Betrieb hinter Traefik unter folgendem Pfad konfiguriert:
|
||||
|
||||
- `https://app.fedeo.de/docs`
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
Im Ordner `docs-site` ausführen:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run start
|
||||
```
|
||||
|
||||
Danach ist die Seite unter `http://localhost:3005` erreichbar.
|
||||
|
||||
## Statischer Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run serve
|
||||
```
|
||||
|
||||
## Deploy über Haupt-Compose
|
||||
|
||||
Die Docs sind in der zentralen `docker-compose.yml` als eigener Service `docs` eingebunden.
|
||||
|
||||
Deploy aus dem Projekt-Root:
|
||||
|
||||
```bash
|
||||
docker compose pull docs
|
||||
docker compose up -d docs
|
||||
```
|
||||
|
||||
Für ein komplettes Update des gesamten Stacks:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Workflow bei Funktionsänderungen
|
||||
|
||||
Vor jedem Docs-Deploy:
|
||||
|
||||
1. Technische Kataloge aktualisieren
|
||||
|
||||
```bash
|
||||
node docs/scripts/sync-funktionsdoku.mjs
|
||||
```
|
||||
|
||||
2. Änderungen committen
|
||||
3. CI baut und pusht das `docs`-Image
|
||||
4. Server zieht neues Image und startet den Service neu
|
||||
85
docs-site/docusaurus.config.ts
Normal file
85
docs-site/docusaurus.config.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Config } from '@docusaurus/types';
|
||||
import { themes as prismThemes } from 'prism-react-renderer';
|
||||
|
||||
const config: Config = {
|
||||
title: 'FEDEO Docs',
|
||||
tagline: 'Versionierte Funktionsdokumentation für FEDEO',
|
||||
|
||||
url: 'https://app.fedeo.de',
|
||||
baseUrl: '/docs/',
|
||||
|
||||
onBrokenLinks: 'throw',
|
||||
onBrokenMarkdownLinks: 'warn',
|
||||
|
||||
i18n: {
|
||||
defaultLocale: 'de',
|
||||
locales: ['de'],
|
||||
},
|
||||
|
||||
presets: [
|
||||
[
|
||||
'classic',
|
||||
{
|
||||
docs: {
|
||||
path: '../docs',
|
||||
routeBasePath: '/',
|
||||
sidebarPath: './sidebars.ts',
|
||||
editUrl: 'https://github.com/DEIN-ORG/DEIN-REPO/tree/main/',
|
||||
},
|
||||
blog: false,
|
||||
theme: {
|
||||
customCss: './src/css/custom.css',
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
themeConfig: {
|
||||
navbar: {
|
||||
title: 'FEDEO Docs',
|
||||
items: [
|
||||
{
|
||||
type: 'docSidebar',
|
||||
sidebarId: 'docsSidebar',
|
||||
position: 'left',
|
||||
label: 'Dokumentation',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/DEIN-ORG/DEIN-REPO',
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
style: 'dark',
|
||||
links: [
|
||||
{
|
||||
title: 'Dokumentation',
|
||||
items: [
|
||||
{
|
||||
label: 'Funktionsübersicht',
|
||||
to: '/funktionen/uebersicht',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Projekt',
|
||||
items: [
|
||||
{
|
||||
label: 'Repository',
|
||||
href: 'https://github.com/DEIN-ORG/DEIN-REPO',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
copyright: `Copyright © ${new Date().getFullYear()} FEDEO`,
|
||||
},
|
||||
prism: {
|
||||
theme: prismThemes.github,
|
||||
darkTheme: prismThemes.dracula,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
16
docs-site/nginx.conf
Normal file
16
docs-site/nginx.conf
Normal file
@@ -0,0 +1,16 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /docs/index.html;
|
||||
}
|
||||
|
||||
location ~* \.(?:css|js|map|jpg|jpeg|gif|png|svg|ico|webp|woff2?)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
30
docs-site/package.json
Normal file
30
docs-site/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "fedeo-docs-site",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "docusaurus start --host 0.0.0.0 --port 3005",
|
||||
"build": "docusaurus build",
|
||||
"serve": "docusaurus serve --host 0.0.0.0 --port 3005",
|
||||
"clear": "docusaurus clear",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.9.2",
|
||||
"@docusaurus/preset-classic": "3.9.2",
|
||||
"clsx": "^2.1.1",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3",
|
||||
"@docusaurus/module-type-aliases": "3.9.2",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
}
|
||||
7
docs-site/sidebars.ts
Normal file
7
docs-site/sidebars.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { SidebarsConfig } from '@docusaurus/plugin-content-docs';
|
||||
|
||||
const sidebars: SidebarsConfig = {
|
||||
docsSidebar: [{ type: 'autogenerated', dirName: '.' }],
|
||||
};
|
||||
|
||||
export default sidebars;
|
||||
36
docs-site/src/css/custom.css
Normal file
36
docs-site/src/css/custom.css
Normal file
@@ -0,0 +1,36 @@
|
||||
:root {
|
||||
--ifm-color-primary: #0b6e4f;
|
||||
--ifm-color-primary-dark: #0a6348;
|
||||
--ifm-color-primary-darker: #095d44;
|
||||
--ifm-color-primary-darkest: #074c37;
|
||||
--ifm-color-primary-light: #0c7956;
|
||||
--ifm-color-primary-lighter: #0d7f5a;
|
||||
--ifm-color-primary-lightest: #0f8f66;
|
||||
--ifm-background-color: #f6f8f7;
|
||||
--ifm-font-family-base: 'Atkinson Hyperlegible', 'Source Sans 3', system-ui, sans-serif;
|
||||
--ifm-heading-font-family: 'IBM Plex Sans', 'Source Sans 3', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.heroSection {
|
||||
min-height: calc(100vh - 60px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background:
|
||||
radial-gradient(circle at 80% 20%, rgba(11, 110, 79, 0.2), transparent 40%),
|
||||
radial-gradient(circle at 20% 80%, rgba(25, 130, 196, 0.2), transparent 35%),
|
||||
linear-gradient(160deg, #eef6f3 0%, #f8faf9 100%);
|
||||
}
|
||||
|
||||
.heroSection h1 {
|
||||
font-size: clamp(2rem, 6vw, 3.6rem);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.heroSection p {
|
||||
font-size: clamp(1.05rem, 2.2vw, 1.35rem);
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.heroButtons {
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
0
docs-site/static/img/.gitkeep
Normal file
0
docs-site/static/img/.gitkeep
Normal file
7
docs-site/tsconfig.json
Normal file
7
docs-site/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@docusaurus/tsconfig",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "."
|
||||
},
|
||||
"exclude": ["build", "node_modules"]
|
||||
}
|
||||
54
docs/README.md
Normal file
54
docs/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# FEDEO Funktionsdokumentation
|
||||
|
||||
Diese Dokumentation bildet alle Funktionen der Software zentral ab und ist für die spätere Nutzung auf einer eigenen Docs-Homepage vorbereitet, z. B. mit Nuxt Content oder Docusaurus.
|
||||
|
||||
## Ziel
|
||||
|
||||
- Vollständige Übersicht über Funktionen in Backend, Web-Frontend und Mobile-App
|
||||
- Nachvollziehbare Versionierung der Doku
|
||||
- Einfache Aktualisierung bei Funktionsänderungen
|
||||
|
||||
## Struktur
|
||||
|
||||
- `docs/funktionen/uebersicht.md`: Fachliche Gesamtübersicht der Bereiche
|
||||
- `docs/funktionen/backend-api.md`: Automatisch erzeugte API-Funktionsliste
|
||||
- `docs/funktionen/frontend-web.md`: Automatisch erzeugte Seiten-/Funktionsliste des Web-Frontends
|
||||
- `docs/funktionen/mobile-app.md`: Automatisch erzeugte Screens-/Funktionsliste der Mobile-App
|
||||
- `docs/versionen/docs-versionen.md`: Versionierung der Dokumentation
|
||||
- `docs/wartung/dokumentationsprozess.md`: Prozess, damit die Doku dauerhaft aktuell bleibt
|
||||
- `docs/scripts/sync-funktionsdoku.mjs`: Skript zur automatischen Aktualisierung
|
||||
|
||||
## Aktualisierung bei Funktionsänderungen
|
||||
|
||||
Bei jeder Funktionsänderung bitte ausführen:
|
||||
|
||||
```bash
|
||||
node docs/scripts/sync-funktionsdoku.mjs
|
||||
```
|
||||
|
||||
Danach:
|
||||
|
||||
1. Änderungen in `docs/funktionen/*.md` prüfen
|
||||
2. Falls nötig fachliche Texte in `docs/funktionen/uebersicht.md` ergänzen
|
||||
3. Eintrag in `docs/versionen/docs-versionen.md` ergänzen
|
||||
4. Alles gemeinsam committen
|
||||
|
||||
## Verwendung mit Nuxt Content
|
||||
|
||||
Empfohlene Vorgehensweise:
|
||||
|
||||
1. `docs/` in das Content-Verzeichnis übernehmen (oder per Sync einbinden)
|
||||
2. Navigation anhand der Ordner `funktionen`, `wartung`, `versionen` aufbauen
|
||||
3. `backend-api.md`, `frontend-web.md`, `mobile-app.md` als referenzierende Funktionskataloge einbinden
|
||||
|
||||
## Verwendung mit Docusaurus
|
||||
|
||||
Empfohlene Vorgehensweise:
|
||||
|
||||
1. Inhalte aus `docs/` in den Docusaurus-`docs`-Ordner übernehmen
|
||||
2. Sidebar nach den Bereichen `Funktionen`, `Wartung`, `Versionen` strukturieren
|
||||
3. Die automatisch erzeugten Dateien als technische Referenzseiten markieren
|
||||
|
||||
## 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.
|
||||
238
docs/funktionen/backend-api.md
Normal file
238
docs/funktionen/backend-api.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# 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**
|
||||
70
docs/funktionen/frontend-web.md
Normal file
70
docs/funktionen/frontend-web.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# 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**
|
||||
23
docs/funktionen/index.md
Normal file
23
docs/funktionen/index.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# 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
|
||||
```
|
||||
29
docs/funktionen/mobile-app.md
Normal file
29
docs/funktionen/mobile-app.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 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**
|
||||
70
docs/funktionen/uebersicht.md
Normal file
70
docs/funktionen/uebersicht.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# 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.
|
||||
231
docs/scripts/sync-funktionsdoku.mjs
Executable file
231
docs/scripts/sync-funktionsdoku.mjs
Executable file
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const DOCS_DIR = path.join(ROOT, "docs");
|
||||
|
||||
const BACKEND_ROUTES_DIR = path.join(ROOT, "backend", "src", "routes");
|
||||
const FRONTEND_PAGES_DIR = path.join(ROOT, "frontend", "pages");
|
||||
const MOBILE_APP_DIR = path.join(ROOT, "mobile", "app");
|
||||
|
||||
const OUT_BACKEND = path.join(DOCS_DIR, "funktionen", "backend-api.md");
|
||||
const OUT_FRONTEND = path.join(DOCS_DIR, "funktionen", "frontend-web.md");
|
||||
const OUT_MOBILE = path.join(DOCS_DIR, "funktionen", "mobile-app.md");
|
||||
|
||||
const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "options", "head"];
|
||||
|
||||
async function walkFiles(dir, extension) {
|
||||
const result = [];
|
||||
|
||||
async function walk(current) {
|
||||
const entries = await fs.readdir(current, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(full);
|
||||
} else if (entry.isFile() && full.endsWith(extension)) {
|
||||
result.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(dir);
|
||||
return result.sort();
|
||||
}
|
||||
|
||||
function normalizePosix(p) {
|
||||
return p.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function toRelative(filePath) {
|
||||
return normalizePosix(path.relative(ROOT, filePath));
|
||||
}
|
||||
|
||||
function extractBackendEndpoints(sourceText) {
|
||||
const endpoints = [];
|
||||
const regex = /\b(?:server|subApp|m2mApp|devicesApp)\.(get|post|put|patch|delete|options|head)\(\s*["'`](.+?)["'`]/g;
|
||||
|
||||
let match;
|
||||
while ((match = regex.exec(sourceText)) !== null) {
|
||||
const method = String(match[1] || "").toUpperCase();
|
||||
const routePath = String(match[2] || "");
|
||||
if (!HTTP_METHODS.includes(method.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
endpoints.push({ method, path: routePath });
|
||||
}
|
||||
|
||||
return endpoints.sort((a, b) => {
|
||||
if (a.path === b.path) {
|
||||
return a.method.localeCompare(b.method);
|
||||
}
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
}
|
||||
|
||||
function sortAndUnique(items) {
|
||||
return [...new Set(items)].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function filePathToNuxtRoute(filePath, baseDir) {
|
||||
const relative = normalizePosix(path.relative(baseDir, filePath));
|
||||
let route = relative.replace(/\.vue$/, "");
|
||||
route = route.replace(/\.client$/, "").replace(/\.server$/, "");
|
||||
|
||||
route = route
|
||||
.replace(/\[\[\.\.\.(.+?)\]\]/g, ":$1*?")
|
||||
.replace(/\[\.\.\.(.+?)\]/g, ":$1*")
|
||||
.replace(/\[\[(.+?)\]\]/g, ":$1?")
|
||||
.replace(/\[(.+?)\]/g, ":$1");
|
||||
|
||||
route = route.replace(/\/index$/g, "");
|
||||
if (route === "index") {
|
||||
route = "";
|
||||
}
|
||||
|
||||
if (!route.startsWith("/")) {
|
||||
route = `/${route}`;
|
||||
}
|
||||
|
||||
if (route === "") {
|
||||
return "/";
|
||||
}
|
||||
|
||||
return route || "/";
|
||||
}
|
||||
|
||||
function filePathToExpoRoute(filePath, baseDir) {
|
||||
const relative = normalizePosix(path.relative(baseDir, filePath));
|
||||
const baseName = path.basename(relative);
|
||||
|
||||
if (baseName.startsWith("_")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let route = relative.replace(/\.tsx$/, "");
|
||||
|
||||
route = route
|
||||
.replace(/\[\[(.+?)\]\]/g, ":$1?")
|
||||
.replace(/\[(.+?)\]/g, ":$1");
|
||||
|
||||
route = route.replace(/\/index$/g, "");
|
||||
if (route === "index") {
|
||||
route = "";
|
||||
}
|
||||
|
||||
if (!route.startsWith("/")) {
|
||||
route = `/${route}`;
|
||||
}
|
||||
|
||||
if (route === "") {
|
||||
return "/";
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
async function generateBackendDoc() {
|
||||
const files = await walkFiles(BACKEND_ROUTES_DIR, ".ts");
|
||||
|
||||
let output = "# Backend API Funktionskatalog\n\n";
|
||||
output += "Automatisch generiert (deterministisch, ohne Zeitstempel).\n\n";
|
||||
output += "Hinweis: Diese Datei wird durch `docs/scripts/sync-funktionsdoku.mjs` erzeugt.\n\n";
|
||||
|
||||
const allEndpoints = [];
|
||||
|
||||
for (const file of files) {
|
||||
const source = await fs.readFile(file, "utf-8");
|
||||
const endpoints = extractBackendEndpoints(source);
|
||||
const relativeFile = toRelative(file);
|
||||
|
||||
if (endpoints.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
output += `## ${relativeFile}\n\n`;
|
||||
output += "| Methode | Pfad |\n";
|
||||
output += "|---|---|\n";
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
output += `| ${endpoint.method} | \`${endpoint.path}\` |\n`;
|
||||
allEndpoints.push(`${endpoint.method} ${endpoint.path}`);
|
||||
}
|
||||
|
||||
output += "\n";
|
||||
}
|
||||
|
||||
const uniqueCount = sortAndUnique(allEndpoints).length;
|
||||
output += `Gesamtzahl erkannter Endpunkte: **${uniqueCount}**\n`;
|
||||
|
||||
await fs.writeFile(OUT_BACKEND, output, "utf-8");
|
||||
}
|
||||
|
||||
async function generateFrontendDoc() {
|
||||
const files = await walkFiles(FRONTEND_PAGES_DIR, ".vue");
|
||||
|
||||
const rows = files.map((file) => {
|
||||
const route = filePathToNuxtRoute(file, FRONTEND_PAGES_DIR);
|
||||
return { route, file: toRelative(file) };
|
||||
});
|
||||
|
||||
rows.sort((a, b) => a.route.localeCompare(b.route));
|
||||
|
||||
let output = "# Frontend Web Funktionskatalog\n\n";
|
||||
output += "Automatisch generiert (deterministisch, ohne Zeitstempel).\n\n";
|
||||
output += "Hinweis: Diese Datei wird durch `docs/scripts/sync-funktionsdoku.mjs` erzeugt.\n\n";
|
||||
output += "| Route (Nuxt) | Datei |\n";
|
||||
output += "|---|---|\n";
|
||||
|
||||
for (const row of rows) {
|
||||
output += `| \`${row.route}\` | \`${row.file}\` |\n`;
|
||||
}
|
||||
|
||||
output += `\nGesamtzahl erkannter Web-Routen: **${rows.length}**\n`;
|
||||
|
||||
await fs.writeFile(OUT_FRONTEND, output, "utf-8");
|
||||
}
|
||||
|
||||
async function generateMobileDoc() {
|
||||
const files = await walkFiles(MOBILE_APP_DIR, ".tsx");
|
||||
|
||||
const rows = files
|
||||
.map((file) => {
|
||||
const route = filePathToExpoRoute(file, MOBILE_APP_DIR);
|
||||
if (!route) {
|
||||
return null;
|
||||
}
|
||||
return { route, file: toRelative(file) };
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
rows.sort((a, b) => a.route.localeCompare(b.route));
|
||||
|
||||
let output = "# Mobile App Funktionskatalog\n\n";
|
||||
output += "Automatisch generiert (deterministisch, ohne Zeitstempel).\n\n";
|
||||
output += "Hinweis: Diese Datei wird durch `docs/scripts/sync-funktionsdoku.mjs` erzeugt.\n\n";
|
||||
output += "| Route (Expo Router) | Datei |\n";
|
||||
output += "|---|---|\n";
|
||||
|
||||
for (const row of rows) {
|
||||
output += `| \`${row.route}\` | \`${row.file}\` |\n`;
|
||||
}
|
||||
|
||||
output += `\nGesamtzahl erkannter Mobile-Screens: **${rows.length}**\n`;
|
||||
|
||||
await fs.writeFile(OUT_MOBILE, output, "utf-8");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await generateBackendDoc();
|
||||
await generateFrontendDoc();
|
||||
await generateMobileDoc();
|
||||
console.log("Funktionsdokumentation erfolgreich synchronisiert.");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Fehler bei der Doku-Synchronisierung:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
25
docs/versionen/docs-versionen.md
Normal file
25
docs/versionen/docs-versionen.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# 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
|
||||
```
|
||||
35
docs/wartung/dokumentationsprozess.md
Normal file
35
docs/wartung/dokumentationsprozess.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 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,5 +1,6 @@
|
||||
<script setup>
|
||||
import * as Sentry from "@sentry/browser"
|
||||
import { de as germanLocale } from "@nuxt/ui/locale"
|
||||
|
||||
|
||||
|
||||
@@ -47,7 +48,7 @@ useSeoMeta({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp>
|
||||
<UApp :locale="germanLocale">
|
||||
<div class="safearea">
|
||||
<NuxtLayout>
|
||||
<NuxtPage/>
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
<script setup>
|
||||
// Falls useDropZone nicht auto-importiert wird:
|
||||
// import { useDropZone } from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
fileData: {
|
||||
type: Object,
|
||||
default: {
|
||||
default: () => ({
|
||||
type: null
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(["uploadFinished"])
|
||||
|
||||
const modal = useModal()
|
||||
// const profileStore = useProfileStore() // Wird im Snippet nicht genutzt, aber ich lasse es drin
|
||||
|
||||
const uploadInProgress = ref(false)
|
||||
const availableFiletypes = ref([])
|
||||
const localFileData = reactive({
|
||||
...props.fileData
|
||||
})
|
||||
|
||||
// 1. State für die Dateien und die Dropzone Referenz
|
||||
const selectedFiles = ref([])
|
||||
@@ -58,10 +57,8 @@ const uploadFiles = async () => {
|
||||
|
||||
uploadInProgress.value = true;
|
||||
|
||||
let fileData = props.fileData
|
||||
delete fileData.typeEnabled
|
||||
const { typeEnabled, ...fileData } = localFileData
|
||||
|
||||
// 4. Hier nutzen wir nun selectedFiles.value statt document.getElementById
|
||||
await useFiles().uploadFiles(fileData, selectedFiles.value, [], true)
|
||||
|
||||
uploadInProgress.value = false;
|
||||
@@ -80,12 +77,11 @@ const fileNames = computed(() => {
|
||||
<UModal>
|
||||
<template #content>
|
||||
<div ref="dropZoneRef" class="relative h-full flex flex-col">
|
||||
|
||||
<div
|
||||
v-if="isOverDropZone"
|
||||
class="absolute inset-0 z-50 flex items-center justify-center bg-primary-500/10 border-2 border-primary-500 border-dashed rounded-lg backdrop-blur-sm transition-all"
|
||||
class="absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-dashed border-primary-500 bg-primary-500/10 backdrop-blur-sm transition-all"
|
||||
>
|
||||
<span class="text-xl font-bold text-primary-600 bg-white/80 px-4 py-2 rounded shadow-sm">
|
||||
<span class="rounded bg-white/80 px-4 py-2 text-xl font-bold text-primary-600 shadow-sm">
|
||||
Dateien hier ablegen
|
||||
</span>
|
||||
</div>
|
||||
@@ -130,16 +126,17 @@ const fileNames = computed(() => {
|
||||
class="mt-3"
|
||||
>
|
||||
<USelectMenu
|
||||
option-attribute="name"
|
||||
value-attribute="id"
|
||||
searchable
|
||||
searchable-placeholder="Suchen..."
|
||||
:options="availableFiletypes"
|
||||
v-model="props.fileData.type"
|
||||
:disabled="!props.fileData.typeEnabled"
|
||||
:items="availableFiletypes"
|
||||
v-model="localFileData.type"
|
||||
value-key="id"
|
||||
label-key="name"
|
||||
:search-input="{ placeholder: 'Suchen...' }"
|
||||
:filter-fields="['name']"
|
||||
:disabled="!localFileData.typeEnabled"
|
||||
class="w-full"
|
||||
>
|
||||
<template #label>
|
||||
<span v-if="availableFiletypes.find(x => x.id === props.fileData.type)">{{availableFiletypes.find(x => x.id === props.fileData.type).name}}</span>
|
||||
<template #default>
|
||||
<span v-if="availableFiletypes.find(x => x.id === localFileData.type)">{{ availableFiletypes.find(x => x.id === localFileData.type).name }}</span>
|
||||
<span v-else>Kein Typ ausgewählt</span>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
@@ -159,5 +156,4 @@ const fileNames = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Optional: Animationen für das Overlay */
|
||||
</style>
|
||||
|
||||
@@ -63,6 +63,20 @@ const generateOldItemData = () => {
|
||||
}
|
||||
generateOldItemData()
|
||||
|
||||
const inputColumnCount = computed(() => {
|
||||
return Array.isArray(dataType.inputColumns) && dataType.inputColumns.length > 0
|
||||
? dataType.inputColumns.length
|
||||
: 1
|
||||
})
|
||||
|
||||
const getInputColumnStyle = () => {
|
||||
if (props.platform === 'mobile') return undefined
|
||||
|
||||
return {
|
||||
width: `${100 / inputColumnCount.value}%`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- ÄNDERUNG START: Computed Property statt Watcher/Function ---
|
||||
// Dies berechnet den Status automatisch neu, egal woher die Daten kommen (Init oder User-Eingabe)
|
||||
@@ -174,6 +188,49 @@ const setupQuery = () => {
|
||||
setupQuery()
|
||||
|
||||
const loadedOptions = ref({})
|
||||
|
||||
const normalizeSelectFieldValue = (value, isMultiple = false) => {
|
||||
if (isMultiple) {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
return value.map((entry) => {
|
||||
if (entry && typeof entry === "object" && "id" in entry) {
|
||||
return entry.id
|
||||
}
|
||||
|
||||
return entry
|
||||
})
|
||||
}
|
||||
|
||||
if (value && typeof value === "object" && "id" in value) {
|
||||
return value.id
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
const normalizeLoadedSelectValues = () => {
|
||||
dataType.templateColumns.forEach((datapoint) => {
|
||||
if (datapoint.inputType !== "select") return
|
||||
|
||||
if (datapoint.key.includes(".")) {
|
||||
const [parentKey, childKey] = datapoint.key.split(".")
|
||||
if (!item.value[parentKey]) return
|
||||
|
||||
item.value[parentKey][childKey] = normalizeSelectFieldValue(
|
||||
item.value[parentKey][childKey],
|
||||
datapoint.selectMultiple
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
item.value[datapoint.key] = normalizeSelectFieldValue(
|
||||
item.value[datapoint.key],
|
||||
datapoint.selectMultiple
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const loadOptions = async () => {
|
||||
let optionsToLoad = dataType.templateColumns.filter(i => i.selectDataType).map(i => {
|
||||
return {
|
||||
@@ -184,9 +241,9 @@ const loadOptions = async () => {
|
||||
|
||||
for await(const option of optionsToLoad) {
|
||||
if (option.option === "countrys") {
|
||||
loadedOptions.value[option.option] = useEntities("countrys").selectSpecial()
|
||||
loadedOptions.value[option.option] = await useEntities("countrys").selectSpecial()
|
||||
} else if (option.option === "units") {
|
||||
loadedOptions.value[option.option] = useEntities("units").selectSpecial()
|
||||
loadedOptions.value[option.option] = await useEntities("units").selectSpecial()
|
||||
} else {
|
||||
loadedOptions.value[option.option] = (await useEntities(option.option).select())
|
||||
|
||||
@@ -197,7 +254,41 @@ const loadOptions = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
loadOptions()
|
||||
normalizeLoadedSelectValues()
|
||||
|
||||
const initialProjecttype = props.type === "projects" ? item.value.projecttype : null
|
||||
const lastAppliedProjecttype = ref(null)
|
||||
|
||||
const syncProjectPhasesForProjecttype = () => {
|
||||
if (props.type !== "projects") return
|
||||
if (!item.value?.projecttype) return
|
||||
if (!Array.isArray(loadedOptions.value.projecttypes) || !loadedOptions.value.projecttypes.length) return
|
||||
|
||||
const projecttypeColumn = dataType.templateColumns.find((column) => column.key === "projecttype")
|
||||
if (!projecttypeColumn?.inputChangeFunction) return
|
||||
|
||||
const shouldSyncOnCreate = props.mode === "create" && lastAppliedProjecttype.value !== item.value.projecttype
|
||||
const shouldSyncOnEdit = props.mode === "edit"
|
||||
&& item.value.projecttype !== initialProjecttype
|
||||
&& lastAppliedProjecttype.value !== item.value.projecttype
|
||||
|
||||
if (!shouldSyncOnCreate && !shouldSyncOnEdit) return
|
||||
|
||||
projecttypeColumn.inputChangeFunction(item.value, loadedOptions.value)
|
||||
lastAppliedProjecttype.value = item.value.projecttype
|
||||
}
|
||||
|
||||
loadOptions().then(() => {
|
||||
syncProjectPhasesForProjecttype()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [item.value?.projecttype, loadedOptions.value.projecttypes?.length || 0],
|
||||
() => {
|
||||
syncProjectPhasesForProjecttype()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const contentChanged = (content, datapoint) => {
|
||||
if (datapoint.key.includes(".")) {
|
||||
@@ -227,6 +318,15 @@ const getSelectSearchInput = (datapoint) => {
|
||||
return datapoint.selectSearchAttributes ? { placeholder: 'Suche...' } : false
|
||||
}
|
||||
|
||||
const triggerInputChange = (datapoint) => {
|
||||
if (datapoint.inputChangeFunction) {
|
||||
datapoint.inputChangeFunction(item.value, loadedOptions.value)
|
||||
if (datapoint.key === "projecttype") {
|
||||
lastAppliedProjecttype.value = item.value.projecttype
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const createItem = async () => {
|
||||
let ret = null
|
||||
@@ -350,7 +450,8 @@ const updateItem = async () => {
|
||||
<div :class="platform === 'mobile' ?['flex','flex-col'] : ['flex','flex-row']">
|
||||
<div
|
||||
v-for="(columnName,index) in dataType.inputColumns"
|
||||
:class="platform === 'mobile' ? ['w-full'] : [`w-1/${dataType.inputColumns.length}`, ... index < dataType.inputColumns.length -1 ? ['mr-5'] : []]"
|
||||
:class="platform === 'mobile' ? ['w-full'] : [... index < inputColumnCount - 1 ? ['mr-5'] : []]"
|
||||
:style="getInputColumnStyle()"
|
||||
>
|
||||
<USeparator :label="columnName"/>
|
||||
|
||||
@@ -393,7 +494,7 @@ const updateItem = async () => {
|
||||
<USelectMenu
|
||||
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
|
||||
class="flex-auto"
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
@update:model-value="triggerInputChange(datapoint)"
|
||||
v-else-if="datapoint.inputType === 'select'"
|
||||
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
@@ -498,7 +599,7 @@ const updateItem = async () => {
|
||||
<USelectMenu
|
||||
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
|
||||
class="flex-auto"
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
@update:model-value="triggerInputChange(datapoint)"
|
||||
v-else-if="datapoint.inputType === 'select'"
|
||||
v-model="item[datapoint.key]"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
@@ -626,7 +727,7 @@ const updateItem = async () => {
|
||||
<USelectMenu
|
||||
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
|
||||
class="flex-auto"
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
@update:model-value="triggerInputChange(datapoint)"
|
||||
v-else-if="datapoint.inputType === 'select'"
|
||||
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
@@ -731,7 +832,7 @@ const updateItem = async () => {
|
||||
<USelectMenu
|
||||
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
|
||||
class="flex-auto"
|
||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
||||
@update:model-value="triggerInputChange(datapoint)"
|
||||
v-else-if="datapoint.inputType === 'select'"
|
||||
v-model="item[datapoint.key]"
|
||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||
|
||||
@@ -39,7 +39,9 @@ const modal = useModal()
|
||||
<template>
|
||||
<UButton
|
||||
variant="outline"
|
||||
class="w-25 ml-2"
|
||||
size="sm"
|
||||
square
|
||||
class="ml-2 shrink-0"
|
||||
v-if="props.id && props.buttonShow"
|
||||
icon="i-heroicons-eye"
|
||||
@click="modal.open(StandardEntityModal, {
|
||||
@@ -50,7 +52,9 @@ const modal = useModal()
|
||||
/>
|
||||
<UButton
|
||||
variant="outline"
|
||||
class="w-25 ml-2"
|
||||
size="sm"
|
||||
square
|
||||
class="ml-2 shrink-0"
|
||||
v-if="props.id && props.buttonEdit"
|
||||
icon="i-heroicons-pencil-solid"
|
||||
@click="modal.open(StandardEntityModal, {
|
||||
@@ -64,7 +68,9 @@ const modal = useModal()
|
||||
/>
|
||||
<UButton
|
||||
variant="outline"
|
||||
class="w-25 ml-2"
|
||||
size="sm"
|
||||
square
|
||||
class="ml-2 shrink-0"
|
||||
v-if="!props.id && props.buttonCreate"
|
||||
icon="i-heroicons-plus"
|
||||
@click="modal.open(StandardEntityModal, {
|
||||
@@ -80,4 +86,4 @@ const modal = useModal()
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -50,10 +50,12 @@ const route = useRoute()
|
||||
const dataStore = useDataStore()
|
||||
const modal = useModal()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const dataType = dataStore.dataTypes[type]
|
||||
|
||||
const openTab = ref(String(route.query.tabIndex || 0))
|
||||
const portalInviteLoading = ref(false)
|
||||
|
||||
|
||||
|
||||
@@ -152,6 +154,31 @@ const openCustomerInventoryLabelPrint = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const invitePortalUser = async () => {
|
||||
if (type !== "customers" || !props.item?.id) return
|
||||
|
||||
portalInviteLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await useAdmin().invitePortalUser(Number(props.item.id))
|
||||
toast.add({
|
||||
title: "Portal-Einladung versendet",
|
||||
description: `E-Mail: ${response.email}${response.initialPassword ? ` | Initialpasswort: ${response.initialPassword}` : ""}`,
|
||||
timeout: 9000
|
||||
})
|
||||
emit("updateNeeded")
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
title: "Portal-Einladung fehlgeschlagen",
|
||||
description: err?.data?.error || "Die Einladung konnte nicht erstellt werden.",
|
||||
color: "error",
|
||||
timeout: 7000
|
||||
})
|
||||
} finally {
|
||||
portalInviteLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -217,6 +244,15 @@ const openCustomerInventoryLabelPrint = () => {
|
||||
>
|
||||
Label
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="type === 'customers' && auth.user?.is_admin"
|
||||
icon="i-heroicons-envelope"
|
||||
variant="outline"
|
||||
:loading="portalInviteLoading"
|
||||
@click="invitePortalUser"
|
||||
>
|
||||
Portal einladen
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="router.push(`/standardEntity/${type}/edit/${item.id}`)"
|
||||
>
|
||||
@@ -246,6 +282,15 @@ const openCustomerInventoryLabelPrint = () => {
|
||||
>
|
||||
Label
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="type === 'customers' && auth.user?.is_admin"
|
||||
icon="i-heroicons-envelope"
|
||||
variant="outline"
|
||||
:loading="portalInviteLoading"
|
||||
@click="invitePortalUser"
|
||||
>
|
||||
Portal einladen
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="router.push(`/standardEntity/${type}/edit/${item.id}`)"
|
||||
>
|
||||
|
||||
@@ -56,6 +56,7 @@ const dataStore = useDataStore()
|
||||
const tempStore = useTempStore()
|
||||
|
||||
const router = useRouter()
|
||||
const deliveryNoteLikeDocumentTypes = ['deliveryNotes', 'packingSlips']
|
||||
|
||||
const createddocuments = ref([])
|
||||
|
||||
@@ -117,7 +118,7 @@ const getAvailableQueryStringData = (keys) => {
|
||||
}
|
||||
|
||||
const invoiceDeliveryNotes = () => {
|
||||
router.push(`/createDocument/edit?type=invoices&loadMode=deliveryNotes&linkedDocuments=[${props.item.createddocuments.filter(i => i.type === "deliveryNotes").map(i => i.id)}]`)
|
||||
router.push(`/createDocument/edit?type=invoices&loadMode=deliveryNotes&linkedDocuments=[${props.item.createddocuments.filter(i => deliveryNoteLikeDocumentTypes.includes(i.type)).map(i => i.id)}]`)
|
||||
}
|
||||
|
||||
const showFinalInvoiceConfig = ref(false)
|
||||
@@ -150,13 +151,18 @@ const selectItem = (item) => {
|
||||
@click="invoiceDeliveryNotes"
|
||||
v-if="props.topLevelType === 'projects'"
|
||||
>
|
||||
Lieferscheine abrechnen
|
||||
Lieferscheine/Packscheine abrechnen
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'quotes'})}`)"
|
||||
>
|
||||
+ Angebot
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'costEstimates'})}`)"
|
||||
>
|
||||
+ Kostenschätzung
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'confirmationOrders'})}`)"
|
||||
>
|
||||
@@ -167,6 +173,11 @@ const selectItem = (item) => {
|
||||
>
|
||||
+ Lieferschein
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'packingSlips'})}`)"
|
||||
>
|
||||
+ Packschein
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'advanceInvoices'})}`)"
|
||||
>
|
||||
@@ -198,7 +209,7 @@ const selectItem = (item) => {
|
||||
label="Rechnungsvorlage"
|
||||
>
|
||||
<USelectMenu
|
||||
:items="props.item.createddocuments.filter(i => ['confirmationOrders','quotes'].includes(i.type))"
|
||||
:items="props.item.createddocuments.filter(i => ['confirmationOrders','quotes','costEstimates'].includes(i.type))"
|
||||
value-key="id"
|
||||
label-key="documentNumber"
|
||||
v-model="referenceDocument"
|
||||
@@ -255,9 +266,14 @@ const selectItem = (item) => {
|
||||
class="w-full"
|
||||
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
|
||||
:on-select="(row) => selectItem(row.original)"
|
||||
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
|
||||
style="height: 70vh"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="flex flex-col items-center justify-center py-8 text-center text-sm text-muted">
|
||||
<UIcon name="i-heroicons-circle-stack-20-solid" class="mb-2 size-5" />
|
||||
<span>Keine Belege anzuzeigen</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #type-cell="{ row }">
|
||||
{{ dataStore.documentTypesForCreation[row.original.type].labelSingle }}
|
||||
</template>
|
||||
@@ -299,7 +315,7 @@ const selectItem = (item) => {
|
||||
<span v-if="row.original.paymentDays && ['invoices','advanceInvoices'].includes(row.original.type)">{{ row.original.documentDate ? dayjs(row.original.documentDate).add(row.original.paymentDays,'day').format("DD.MM.YY") : '' }}</span>
|
||||
</template>
|
||||
<template #amount-cell="{ row }">
|
||||
<span v-if="row.original.type !== 'deliveryNotes'">{{ useCurrency(useSum().getCreatedDocumentSum(row.original, createddocuments)) }}</span>
|
||||
<span v-if="!deliveryNoteLikeDocumentTypes.includes(row.original.type)">{{ useCurrency(useSum().getCreatedDocumentSum(row.original, createddocuments)) }}</span>
|
||||
</template>
|
||||
</UTable>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import dayjs from "dayjs";
|
||||
const router = useRouter()
|
||||
import dayjs from "dayjs"
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
queryStringData: {
|
||||
@@ -21,83 +21,260 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const statementallocations = ref([])
|
||||
const incominginvoices = ref([])
|
||||
const loading = ref(true)
|
||||
const incomingInvoices = ref([])
|
||||
const statementAllocations = ref([])
|
||||
const selectedYear = ref(String(dayjs().year()))
|
||||
const selectedMonth = ref("all")
|
||||
|
||||
const currency = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} EUR`
|
||||
const currentAccountId = computed(() => String(props.item?.id ?? ""))
|
||||
const sameAccount = (value) => String(value ?? "") === currentAccountId.value
|
||||
const getStatementId = (allocation) => allocation?.bankstatement?.id || allocation?.bs_id?.id || allocation?.bs_id || null
|
||||
const getStatementLike = (allocation) => allocation?.bankstatement || (typeof allocation?.bs_id === "object" ? allocation.bs_id : null)
|
||||
const getAllocationDate = (allocation) => {
|
||||
const statement = getStatementLike(allocation)
|
||||
|
||||
return statement?.date || statement?.valueDate || allocation?.bs_id?.date || allocation?.bs_id?.valueDate || null
|
||||
}
|
||||
const getAllocationPartner = (allocation) => {
|
||||
const statement = getStatementLike(allocation)
|
||||
|
||||
return statement?.debName || statement?.credName || statement?.partner || allocation?.bs_id?.debName || allocation?.bs_id?.credName || ""
|
||||
}
|
||||
const getAllocationDescription = (allocation) => {
|
||||
const statement = getStatementLike(allocation)
|
||||
|
||||
return allocation?.description || statement?.purpose || statement?.text || statement?.description || allocation?.bs_id?.purpose || allocation?.bs_id?.text || ""
|
||||
}
|
||||
const hasContent = (value) => value !== null && value !== undefined && String(value).trim() !== ""
|
||||
|
||||
const monthItems = [
|
||||
{ label: "Ganzes Jahr", value: "all" },
|
||||
{ label: "Januar", value: "1" },
|
||||
{ label: "Februar", value: "2" },
|
||||
{ label: "Maerz", value: "3" },
|
||||
{ label: "April", value: "4" },
|
||||
{ label: "Mai", value: "5" },
|
||||
{ label: "Juni", value: "6" },
|
||||
{ label: "Juli", value: "7" },
|
||||
{ label: "August", value: "8" },
|
||||
{ label: "September", value: "9" },
|
||||
{ label: "Oktober", value: "10" },
|
||||
{ label: "November", value: "11" },
|
||||
{ label: "Dezember", value: "12" }
|
||||
]
|
||||
|
||||
const allAllocations = computed(() => {
|
||||
const statementRows = statementAllocations.value.map((allocation) => ({
|
||||
...allocation,
|
||||
type: "statementallocation",
|
||||
bankstatement: allocation.bankstatement || getStatementLike(allocation),
|
||||
date: getAllocationDate(allocation),
|
||||
partner: getAllocationPartner(allocation),
|
||||
description: getAllocationDescription(allocation),
|
||||
amount: Number(allocation.amount || 0)
|
||||
}))
|
||||
|
||||
const incomingInvoiceRows = incomingInvoices.value.flatMap((invoice) => {
|
||||
return (invoice.accounts || [])
|
||||
.filter((account) => sameAccount(account.account?.id || account.account))
|
||||
.map((account, index) => ({
|
||||
id: `${invoice.id}-${index}`,
|
||||
incominginvoiceid: invoice.id,
|
||||
type: "incominginvoice",
|
||||
amount: Number(account.amountGross || account.amountNet || 0),
|
||||
date: invoice.date,
|
||||
partner: invoice.vendor?.name || "",
|
||||
description: account.description || invoice.description || "",
|
||||
color: invoice.expense ? "red" : "green",
|
||||
expense: invoice.expense,
|
||||
reference: invoice.reference || "-"
|
||||
}))
|
||||
})
|
||||
|
||||
return [...statementRows, ...incomingInvoiceRows]
|
||||
})
|
||||
|
||||
const yearItems = computed(() => {
|
||||
const years = [...new Set(
|
||||
allAllocations.value
|
||||
.map((allocation) => allocation.bankstatement?.date || allocation.date)
|
||||
.filter(Boolean)
|
||||
.map((date) => String(dayjs(date).year()))
|
||||
)].sort((a, b) => Number(b) - Number(a))
|
||||
|
||||
return years.length > 0
|
||||
? years.map((year) => ({ label: year, value: year }))
|
||||
: [{ label: String(dayjs().year()), value: String(dayjs().year()) }]
|
||||
})
|
||||
|
||||
const renderedAllocations = computed(() => {
|
||||
return allAllocations.value.filter((allocation) => {
|
||||
const allocationDateValue = allocation.bankstatement?.date || allocation.date
|
||||
const allocationDate = allocationDateValue ? dayjs(allocationDateValue) : null
|
||||
|
||||
if (allocationDate && allocationDate.year().toString() !== selectedYear.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (allocationDate && selectedMonth.value !== "all" && allocationDate.month() + 1 !== Number(selectedMonth.value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const totals = computed(() => {
|
||||
return renderedAllocations.value.reduce((acc, allocation) => {
|
||||
const amount = Number(allocation.amount || 0)
|
||||
|
||||
if (allocation.incominginvoiceid) {
|
||||
if (allocation.expense) {
|
||||
acc.expenses += amount
|
||||
acc.balance -= amount
|
||||
} else {
|
||||
acc.income += amount
|
||||
acc.balance += amount
|
||||
}
|
||||
} else {
|
||||
if (amount < 0) {
|
||||
acc.expenses += Math.abs(amount)
|
||||
} else {
|
||||
acc.income += amount
|
||||
}
|
||||
acc.balance += amount
|
||||
}
|
||||
|
||||
return acc
|
||||
}, { income: 0, expenses: 0, balance: 0 })
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ accessorKey: "amount", header: "Betrag" },
|
||||
{ accessorKey: "date", header: "Datum" },
|
||||
{ accessorKey: "partner", header: "Partner" },
|
||||
{ accessorKey: "description", header: "Beschreibung" }
|
||||
]
|
||||
|
||||
const setup = async () => {
|
||||
loading.value = true
|
||||
|
||||
statementAllocations.value = (await useEntities("statementallocations").select("*, bankstatement(*), createddocument(*), incominginvoice(*)"))
|
||||
.filter((allocation) => sameAccount(allocation.account?.id || allocation.account) || sameAccount(allocation.ownaccount?.id || allocation.ownaccount))
|
||||
|
||||
incomingInvoices.value = (await useEntities("incominginvoices").select("*, vendor(*)"))
|
||||
.filter((invoice) => (invoice.accounts || []).some((account) => sameAccount(account.account?.id || account.account)))
|
||||
|
||||
const firstYear = yearItems.value[0]?.value
|
||||
if (firstYear && !yearItems.value.some((item) => item.value === selectedYear.value)) {
|
||||
selectedYear.value = firstYear
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
setup()
|
||||
|
||||
const selectAllocation = (allocation) => {
|
||||
if(allocation.type === "statementallocation") {
|
||||
router.push(`/banking/statements/edit/${allocation.bs_id.id}`)
|
||||
} else if(allocation.type === "incominginvoice") {
|
||||
router.push(`/incominginvoices/show/${allocation.incominginvoiceid}`)
|
||||
const unwrapAllocationRow = (allocationLike) => allocationLike?.original || allocationLike
|
||||
|
||||
const selectAllocation = (allocationLike) => {
|
||||
const allocation = unwrapAllocationRow(allocationLike)
|
||||
|
||||
if (!allocation) {
|
||||
return
|
||||
}
|
||||
|
||||
const statementId = getStatementId(allocation)
|
||||
|
||||
if (allocation.type === "statementallocation" && statementId) {
|
||||
router.push(`/banking/statements/edit/${statementId}`)
|
||||
} else if (allocation.type === "incominginvoice" && allocation.incominginvoiceid) {
|
||||
router.push(`/incomingInvoices/show/${allocation.incominginvoiceid}`)
|
||||
}
|
||||
}
|
||||
|
||||
const renderedAllocations = computed(() => {
|
||||
|
||||
let tempstatementallocations = props.item.statementallocations.map(i => {
|
||||
return {
|
||||
...i,
|
||||
type: "statementallocation",
|
||||
date: i.bs_id.date,
|
||||
partner: i.bs_id ? (i.bs_id.debName ? i.bs_id.debName : (i.bs_id.credName ? i.bs_id.credName : '')) : ''
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
/*let incominginvoicesallocations = []
|
||||
|
||||
incominginvoices.value.forEach(i => {
|
||||
|
||||
incominginvoicesallocations.push(...i.accounts.filter(x => x.account == route.params.id).map(x => {
|
||||
return {
|
||||
...x,
|
||||
incominginvoiceid: i.id,
|
||||
type: "incominginvoice",
|
||||
amount: x.amountGross ? x.amountGross : x.amountNet,
|
||||
date: i.date,
|
||||
partner: i.vendor.name,
|
||||
description: i.description,
|
||||
color: i.expense ? "red" : "green"
|
||||
}
|
||||
}))
|
||||
})*/
|
||||
|
||||
return [...tempstatementallocations/*, ... incominginvoicesallocations*/]
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard class="mt-5">
|
||||
<UTable
|
||||
v-if="props.item.statementallocations"
|
||||
:data="renderedAllocations"
|
||||
:columns="normalizeTableColumns([{key:'amount', label:'Betrag'},{key:'date', label:'Datum'},{key:'partner', label:'Partner'},{key:'description', label:'Beschreibung'}])"
|
||||
:on-select="(i) => selectAllocation(i)"
|
||||
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }"
|
||||
>
|
||||
<template #amount-cell="{row}">
|
||||
<span class="text-right text-rose-600" 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 v-else>{{useCurrency(row.original.amount)}}</span>
|
||||
</template>
|
||||
<template #date-cell="{row}">
|
||||
{{row.original.date ? dayjs(row.original.date).format('DD.MM.YYYY') : ''}}
|
||||
</template>
|
||||
<template #description-cell="{row}">
|
||||
{{row.original.description ? row.original.description : ''}}
|
||||
</template>
|
||||
</UTable>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<UFormField label="Jahr" class="w-full md:w-48">
|
||||
<USelectMenu
|
||||
v-model="selectedYear"
|
||||
:items="yearItems"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Monat" class="w-full md:w-56">
|
||||
<USelectMenu
|
||||
v-model="selectedMonth"
|
||||
:items="monthItems"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Einnahmen</div>
|
||||
<div class="mt-1 text-xl font-semibold">{{ currency(totals.income) }}</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Ausgaben</div>
|
||||
<div class="mt-1 text-xl font-semibold">{{ currency(totals.expenses) }}</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Saldo</div>
|
||||
<div class="mt-1 text-xl font-semibold">{{ currency(totals.balance) }}</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading" class="overflow-auto max-h-[60vh]">
|
||||
<UTable
|
||||
:data="renderedAllocations"
|
||||
:columns="normalizeTableColumns(columns)"
|
||||
:on-select="selectAllocation"
|
||||
class="w-full"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="flex flex-col items-center justify-center py-10 text-center">
|
||||
<UIcon name="i-heroicons-circle-stack-20-solid" class="mb-2 h-10 w-10 text-gray-400" />
|
||||
<p class="font-medium">Keine Buchungen im ausgewaehlten Zeitraum</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #amount-cell="{ row }">
|
||||
<span class="text-right text-error" v-if="row.original.amount < 0 || row.original.color === 'red'">{{ useCurrency(row.original.amount) }}</span>
|
||||
<span class="text-right text-primary-500" v-else-if="row.original.amount > 0 || row.original.color === 'green'">{{ useCurrency(row.original.amount) }}</span>
|
||||
<span v-else>{{ useCurrency(row.original.amount) }}</span>
|
||||
</template>
|
||||
|
||||
<template #date-cell="{ row }">
|
||||
{{ row.original.bankstatement.date && dayjs(row.original.bankstatement.date).isValid() ? dayjs(row.original.bankstatement.date).format('DD.MM.YYYY') : '-' }}
|
||||
</template>
|
||||
|
||||
<template #partner-cell="{ row }">
|
||||
<div class="truncate">{{ hasContent(row.original.partner) ? row.original.partner : '-' }}</div>
|
||||
</template>
|
||||
|
||||
<template #description-cell="{ row }">
|
||||
<UTooltip :text="hasContent(row.original.description) ? row.original.description : '-'">
|
||||
<div class="max-w-[22rem] truncate">{{ hasContent(row.original.description) ? row.original.description : '-' }}</div>
|
||||
</UTooltip>
|
||||
</template>
|
||||
</UTable>
|
||||
</div>
|
||||
|
||||
<UProgress v-else animation="carousel" class="w-3/4 mx-auto" />
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
||||
@@ -25,30 +25,42 @@ const emit = defineEmits(["updateNeeded"]);
|
||||
const router = useRouter()
|
||||
const profileStore = useProfileStore()
|
||||
const auth = useAuthStore()
|
||||
const openPhaseKey = ref(null)
|
||||
|
||||
const isPhaseAvailable = (phase, index, phases) => {
|
||||
if (phase.label === "Abgeschlossen" || phase.active) {
|
||||
return true
|
||||
}
|
||||
|
||||
const activeIndex = phases.findIndex((item) => item.active)
|
||||
|
||||
if (activeIndex > index) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (activeIndex === -1) {
|
||||
return index === 0
|
||||
}
|
||||
|
||||
if (index === activeIndex + 1) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (index <= activeIndex) {
|
||||
return true
|
||||
}
|
||||
|
||||
return phases.slice(activeIndex + 1, index).every((item) => item.optional)
|
||||
}
|
||||
|
||||
const renderedPhases = computed(() => {
|
||||
if(props.topLevelType === "projects" && props.item.phases) {
|
||||
return props.item.phases.map((phase,index,array) => {
|
||||
|
||||
let isAvailable = false
|
||||
|
||||
if(phase.active) {
|
||||
isAvailable = true
|
||||
} else if(index > 0 && array[index-1].active ){
|
||||
isAvailable = true
|
||||
} else if(index > 1 && array[index-1].optional && array[index-2].active){
|
||||
isAvailable = true
|
||||
} else if(array.findIndex(i => i.active) > index) {
|
||||
isAvailable = true
|
||||
} else if(phase.label === "Abgeschlossen") {
|
||||
isAvailable = true
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
...phase,
|
||||
label: phase.optional ? `${phase.label}(optional)`: phase.label,
|
||||
disabled: !isAvailable,
|
||||
disabled: !isPhaseAvailable(phase, index, array),
|
||||
defaultOpen: phase.active ? true : false
|
||||
}
|
||||
})
|
||||
@@ -57,6 +69,33 @@ const renderedPhases = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(renderedPhases, (phases) => {
|
||||
if (!phases.length) {
|
||||
openPhaseKey.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const activePhase = phases.find((phase) => phase.active)
|
||||
const currentPhaseStillExists = phases.some((phase) => phase.key === openPhaseKey.value)
|
||||
|
||||
if (activePhase) {
|
||||
openPhaseKey.value = activePhase.key
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentPhaseStillExists) {
|
||||
openPhaseKey.value = phases[0].key
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const togglePhasePanel = (phase) => {
|
||||
if (phase.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
openPhaseKey.value = openPhaseKey.value === phase.key ? null : phase.key
|
||||
}
|
||||
|
||||
const changeActivePhase = async (key) => {
|
||||
console.log(props.item)
|
||||
let item = await useEntities("projects").selectSingle(props.item.id,'*')
|
||||
@@ -92,41 +131,41 @@ const changeActivePhase = async (key) => {
|
||||
<template #header v-if="props.platform === 'mobile'">
|
||||
<span>Phasen</span>
|
||||
</template>
|
||||
<UAccordion
|
||||
:items="renderedPhases"
|
||||
>
|
||||
<template #default="slotProps">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="(item, index) in renderedPhases"
|
||||
:key="item.key"
|
||||
class="space-y-2"
|
||||
>
|
||||
<UButton
|
||||
variant="ghost"
|
||||
:color="slotProps.item.active ? 'primary' : 'white'"
|
||||
class="mb-1"
|
||||
:disabled="true"
|
||||
:color="item.active ? 'primary' : 'neutral'"
|
||||
class="w-full justify-start"
|
||||
:disabled="item.disabled"
|
||||
@click="togglePhasePanel(item)"
|
||||
>
|
||||
<template #leading>
|
||||
<div class="w-6 h-6 flex items-center justify-center -my-1">
|
||||
<UIcon :name="slotProps.item.icon" class="w-4 h-4 " />
|
||||
<div class="w-6 h-6 flex items-center justify-center -my-1">
|
||||
<UIcon :name="item.icon" class="w-4 h-4" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<span class="truncate"> {{ slotProps.item.label }}</span>
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
|
||||
<template #trailing>
|
||||
<UIcon
|
||||
name="i-heroicons-chevron-right-20-solid"
|
||||
class="w-5 h-5 ms-auto transform transition-transform duration-200"
|
||||
:class="[slotProps?.open && 'rotate-90']"
|
||||
:class="[openPhaseKey === item.key && 'rotate-90']"
|
||||
/>
|
||||
</template>
|
||||
|
||||
</UButton>
|
||||
</template>
|
||||
<template #item="{item, index}">
|
||||
<UCard class="mx-5">
|
||||
|
||||
<UCard v-if="openPhaseKey === item.key" class="mx-5">
|
||||
<template #header>
|
||||
<span class="dark:text-white text-black">{{item.label}}</span>
|
||||
<span class="dark:text-white text-black">{{ item.label }}</span>
|
||||
</template>
|
||||
<InputGroup>
|
||||
<!-- TODO: Reactive Change Phase -->
|
||||
<UButton
|
||||
v-if="!item.activated_at && index !== 0 "
|
||||
@click="changeActivePhase(item.key)"
|
||||
@@ -148,10 +187,8 @@ const changeActivePhase = async (key) => {
|
||||
<p v-if="item.description" class="dark:text-white text-black">Beschreibung: {{item.description}}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
|
||||
</template>
|
||||
</UAccordion>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
</template>
|
||||
|
||||
@@ -69,8 +69,13 @@ const columns = [
|
||||
class="mt-3"
|
||||
:columns="normalizeTableColumns(columns)"
|
||||
:data="props.item.times"
|
||||
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Noch keine Einträge' }"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="flex flex-col items-center justify-center py-8 text-center text-sm text-muted">
|
||||
<UIcon name="i-heroicons-circle-stack-20-solid" class="mb-2 size-5" />
|
||||
<span>Noch keine Einträge</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #state-cell="{ row }">
|
||||
<span
|
||||
v-if="row.original.state === 'Entwurf'"
|
||||
|
||||
@@ -41,7 +41,7 @@ const handleClick = async () => {
|
||||
:icon="labelPrinter.connected ? 'i-heroicons-printer' : 'i-heroicons-printer'"
|
||||
:color="labelPrinter.connected ? 'green' : ''"
|
||||
variant="soft"
|
||||
class="w-full justify-start"
|
||||
class="w-full justify-start rounded-lg px-2.5 py-2 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
:loading="labelPrinter.connectLoading"
|
||||
@click="handleClick"
|
||||
>
|
||||
|
||||
@@ -136,25 +136,42 @@ const links = computed(() => {
|
||||
to: "/incomingInvoices",
|
||||
icon: "i-heroicons-document-text",
|
||||
} : null,
|
||||
(featureEnabled("createDocument") || featureEnabled("incomingInvoices")) ? {
|
||||
label: "USt-Auswertung",
|
||||
to: "/accounting/tax",
|
||||
icon: "i-heroicons-calculator",
|
||||
(featureEnabled("incomingInvoices") || featureEnabled("banking")) ? {
|
||||
label: "Abschreibungen",
|
||||
to: "/accounting/depreciation",
|
||||
icon: "i-heroicons-calendar-days",
|
||||
} : null,
|
||||
featureEnabled("costcentres") ? {
|
||||
label: "Kostenstellen",
|
||||
to: "/standardEntity/costcentres",
|
||||
icon: "i-heroicons-document-currency-euro"
|
||||
} : null,
|
||||
featureEnabled("accounts") ? {
|
||||
label: "Buchungskonten",
|
||||
to: "/accounts",
|
||||
icon: "i-heroicons-document-text",
|
||||
} : null,
|
||||
featureEnabled("ownaccounts") ? {
|
||||
label: "zusätzliche Buchungskonten",
|
||||
to: "/standardEntity/ownaccounts",
|
||||
icon: "i-heroicons-document-text"
|
||||
((featureEnabled("createDocument") || featureEnabled("incomingInvoices")) || featureEnabled("accounts") || featureEnabled("ownaccounts") || featureEnabled("costcentres")) ? {
|
||||
label: "Auswertungen",
|
||||
icon: "i-heroicons-chart-pie",
|
||||
defaultOpen: false,
|
||||
children: visibleItems([
|
||||
(featureEnabled("createDocument") || featureEnabled("incomingInvoices")) ? {
|
||||
label: "USt",
|
||||
to: "/accounting/tax",
|
||||
icon: "i-heroicons-calculator",
|
||||
} : null,
|
||||
(featureEnabled("createDocument") || featureEnabled("incomingInvoices") || featureEnabled("accounts") || featureEnabled("ownaccounts")) ? {
|
||||
label: "BWA",
|
||||
to: "/accounting/bwa",
|
||||
icon: "i-heroicons-chart-bar-square",
|
||||
} : null,
|
||||
featureEnabled("costcentres") ? {
|
||||
label: "Kostenstellen",
|
||||
to: "/standardEntity/costcentres",
|
||||
icon: "i-heroicons-document-currency-euro"
|
||||
} : null,
|
||||
featureEnabled("accounts") ? {
|
||||
label: "Buchungskonten",
|
||||
to: "/accounts",
|
||||
icon: "i-heroicons-document-text",
|
||||
} : null,
|
||||
featureEnabled("ownaccounts") ? {
|
||||
label: "Zusätzliche Buchungskonten",
|
||||
to: "/standardEntity/ownaccounts",
|
||||
icon: "i-heroicons-document-text"
|
||||
} : null,
|
||||
])
|
||||
} : null,
|
||||
featureEnabled("banking") ? {
|
||||
label: "Bank",
|
||||
@@ -217,6 +234,16 @@ const links = computed(() => {
|
||||
to: "/standardEntity/memberrelations",
|
||||
icon: "i-heroicons-identification"
|
||||
} : null,
|
||||
featureEnabled("branches") ? {
|
||||
label: "Niederlassungen",
|
||||
to: "/standardEntity/branches",
|
||||
icon: "i-heroicons-building-office-2"
|
||||
} : null,
|
||||
featureEnabled("teams") ? {
|
||||
label: "Teams",
|
||||
to: "/standardEntity/teams",
|
||||
icon: "i-heroicons-users"
|
||||
} : null,
|
||||
featureEnabled("staffProfiles") ? {
|
||||
label: "Mitarbeiter",
|
||||
to: "/staff/profiles",
|
||||
@@ -270,11 +297,6 @@ const links = computed(() => {
|
||||
to: "/settings/tenant",
|
||||
icon: "i-heroicons-building-office",
|
||||
} : null,
|
||||
isAdmin.value ? {
|
||||
label: "Administration",
|
||||
to: "/settings/admin",
|
||||
icon: "i-heroicons-shield-check",
|
||||
} : null,
|
||||
featureEnabled("export") ? {
|
||||
label: "Export",
|
||||
to: "/export",
|
||||
@@ -282,6 +304,19 @@ const links = computed(() => {
|
||||
} : null,
|
||||
]
|
||||
|
||||
const administrationChildren = isAdmin.value ? [
|
||||
{
|
||||
label: "Benutzer",
|
||||
to: "/administration/users",
|
||||
icon: "i-heroicons-users",
|
||||
},
|
||||
{
|
||||
label: "Tenants",
|
||||
to: "/administration/tenants",
|
||||
icon: "i-heroicons-building-office-2",
|
||||
},
|
||||
] : []
|
||||
|
||||
const visibleOrganisationChildren = visibleItems(organisationChildren)
|
||||
const visibleDocumentChildren = visibleItems(documentChildren)
|
||||
const visibleCommunicationChildren = visibleItems(communicationChildren)
|
||||
@@ -291,6 +326,7 @@ const links = computed(() => {
|
||||
const visibleInventoryChildren = visibleItems(inventoryChildren)
|
||||
const visibleMasterDataChildren = visibleItems(masterDataChildren)
|
||||
const visibleSettingsChildren = visibleItems(settingsChildren)
|
||||
const visibleAdministrationChildren = visibleItems(administrationChildren)
|
||||
|
||||
return visibleItems([
|
||||
...(auth.profile?.pinned_on_navigation || []).map(pin => {
|
||||
@@ -385,7 +421,12 @@ const links = computed(() => {
|
||||
icon: "i-heroicons-clipboard-document",
|
||||
children: visibleMasterDataChildren
|
||||
}] : []),
|
||||
|
||||
...(visibleAdministrationChildren.length > 0 ? [{
|
||||
label: "Administration",
|
||||
defaultOpen: false,
|
||||
icon: "i-heroicons-shield-check",
|
||||
children: visibleAdministrationChildren
|
||||
}] : []),
|
||||
|
||||
...(visibleSettingsChildren.length > 0 ? [{
|
||||
label: "Einstellungen",
|
||||
@@ -396,31 +437,31 @@ const links = computed(() => {
|
||||
])
|
||||
})
|
||||
|
||||
const mapNavItem = (item, valuePrefix = "item") => {
|
||||
const children = Array.isArray(item.children)
|
||||
? item.children
|
||||
.filter(Boolean)
|
||||
.map((child, index) => mapNavItem(child, `${valuePrefix}-${index}`))
|
||||
: undefined
|
||||
|
||||
const active = item.active || isRouteActive(item.to) || Boolean(children?.some(child => child.active))
|
||||
|
||||
return {
|
||||
...item,
|
||||
children,
|
||||
value: item.id || item.label || valuePrefix,
|
||||
defaultOpen: item.defaultOpen || active,
|
||||
active,
|
||||
tooltip: true,
|
||||
popover: true,
|
||||
trailingIcon: children?.length ? undefined : ''
|
||||
}
|
||||
}
|
||||
|
||||
const navItems = computed(() =>
|
||||
links.value
|
||||
.filter(Boolean)
|
||||
.map((item, index) => {
|
||||
const children = Array.isArray(item.children)
|
||||
? item.children.map((child, childIndex) => ({
|
||||
...child,
|
||||
value: child.id || child.label || `${index}-${childIndex}`,
|
||||
active: isRouteActive(child.to)
|
||||
}))
|
||||
: undefined
|
||||
|
||||
const active = item.active || isRouteActive(item.to) || Boolean(children?.some(child => child.active))
|
||||
|
||||
return {
|
||||
...item,
|
||||
children,
|
||||
value: item.id || item.label || String(index),
|
||||
defaultOpen: item.defaultOpen || active,
|
||||
active,
|
||||
tooltip: true,
|
||||
popover: true,
|
||||
trailingIcon: children?.length ? undefined : ''
|
||||
}
|
||||
})
|
||||
.map((item, index) => mapNavItem(item, String(index)))
|
||||
)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import dayjs from 'dayjs'
|
||||
import { parseDate } from '@internationalized/date'
|
||||
|
||||
const props = defineProps({
|
||||
context: { type: Object, required: true },
|
||||
@@ -28,6 +29,23 @@ const form = ref({
|
||||
const isSubmitting = ref(false)
|
||||
const errors = ref({})
|
||||
|
||||
const deliveryDateValue = computed({
|
||||
get: () => {
|
||||
if (!form.value.deliveryDate) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return parseDate(form.value.deliveryDate)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
set: (value) => {
|
||||
form.value.deliveryDate = value ? value.toString() : ''
|
||||
}
|
||||
})
|
||||
|
||||
// Validierung basierend auf JSON Config & neuen Anforderungen
|
||||
const validate = () => {
|
||||
errors.value = {}
|
||||
@@ -109,30 +127,64 @@ const setDeliveryDateToToday = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard :ui="{ body: { padding: 'p-6 sm:p-8' } }" v-if="props.context && props.token">
|
||||
<UCard
|
||||
v-if="props.context && props.token"
|
||||
class="overflow-hidden border-white/70 shadow-xl ring-1 ring-black/5"
|
||||
:ui="{ body: { padding: 'p-6 sm:p-8' }, header: { padding: 'p-6 sm:p-8 pb-0' }, footer: { padding: 'p-6 sm:p-8 pt-0' } }"
|
||||
>
|
||||
<template #header>
|
||||
<div class="text-center">
|
||||
<h1 class="text-xl font-bold text-gray-900">{{ config?.ui?.title || 'Erfassung' }}</h1>
|
||||
<p v-if="config?.ui?.description" class="text-sm text-gray-500 mt-1">{{ config?.ui?.description }}</p>
|
||||
<div class="space-y-3 text-center">
|
||||
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary ring-1 ring-primary/15">
|
||||
<UIcon name="i-heroicons-clipboard-document-check" class="h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-highlighted">{{ config?.ui?.title || 'Erfassung' }}</h1>
|
||||
<p v-if="config?.ui?.description" class="mt-1 text-sm text-muted">{{ config?.ui?.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-5">
|
||||
<UAlert
|
||||
color="primary"
|
||||
variant="soft"
|
||||
icon="i-heroicons-sparkles"
|
||||
title="Schnelle Erfassung"
|
||||
description="Alle Angaben werden direkt dem passenden Workflow zugeordnet."
|
||||
/>
|
||||
|
||||
<UFormField
|
||||
label="Datum der Ausführung"
|
||||
:error="errors.deliveryDate"
|
||||
required
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<UInput
|
||||
v-model="form.deliveryDate"
|
||||
type="date"
|
||||
size="lg"
|
||||
icon="i-heroicons-calendar-days"
|
||||
class="flex-1"
|
||||
/>
|
||||
<UButton color="gray" variant="soft" size="lg" label="Heute" @click="setDeliveryDateToToday" />
|
||||
<div class="flex gap-2">
|
||||
<UPopover>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
icon="i-heroicons-calendar-days"
|
||||
class="min-w-0 flex-1 justify-between"
|
||||
>
|
||||
<span class="truncate text-left">
|
||||
{{ form.deliveryDate ? dayjs(form.deliveryDate).format('DD.MM.YYYY') : 'Kein Datum' }}
|
||||
</span>
|
||||
</UButton>
|
||||
|
||||
<template #content>
|
||||
<div class="p-2">
|
||||
<UCalendar v-model="deliveryDateValue" />
|
||||
<div class="flex justify-end border-t border-default pt-2">
|
||||
<UButton color="neutral" variant="ghost" size="sm" @click="setDeliveryDateToToday">
|
||||
Heute
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
|
||||
<UButton color="neutral" variant="soft" size="lg" label="Heute" @click="setDeliveryDateToToday" />
|
||||
</div>
|
||||
</UFormField>
|
||||
|
||||
@@ -148,8 +200,10 @@ const setDeliveryDateToToday = () => {
|
||||
label-key="fullName"
|
||||
value-key="id"
|
||||
placeholder="Name auswählen..."
|
||||
searchable
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:search-input="{ placeholder: 'Mitarbeiter suchen...' }"
|
||||
:filter-fields="['fullName']"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
@@ -165,8 +219,10 @@ const setDeliveryDateToToday = () => {
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
placeholder="Wählen..."
|
||||
searchable
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:search-input="{ placeholder: 'Projekt suchen...' }"
|
||||
:filter-fields="['name']"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
@@ -182,8 +238,10 @@ const setDeliveryDateToToday = () => {
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
placeholder="Wählen..."
|
||||
searchable
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:search-input="{ placeholder: 'Tätigkeit suchen...' }"
|
||||
:filter-fields="['name']"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
@@ -198,9 +256,10 @@ const setDeliveryDateToToday = () => {
|
||||
step="0.25"
|
||||
size="lg"
|
||||
placeholder="0.00"
|
||||
class="w-full"
|
||||
>
|
||||
<template #trailing>
|
||||
<span class="text-gray-500 text-sm pr-2">{{ currentUnit }}</span>
|
||||
<span class="pr-2 text-sm text-muted">{{ currentUnit }}</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
@@ -211,23 +270,23 @@ const setDeliveryDateToToday = () => {
|
||||
:error="errors.diesel"
|
||||
:required="config?.validation?.requireDiesel"
|
||||
>
|
||||
<UInput v-model="form.dieselUsage" type="number" step="0.1" placeholder="0.0" size="lg">
|
||||
<UInput v-model="form.dieselUsage" type="number" step="0.1" placeholder="0.0" size="lg" class="w-full">
|
||||
<template #trailing>
|
||||
<span class="text-gray-500 text-xs">Liter</span>
|
||||
<span class="text-xs text-muted">Liter</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UFormField :label="config?.ui?.labels?.description || 'Notiz / Vorkommnisse'">
|
||||
<UTextarea v-model="form.description" :rows="3" placeholder="Optional..." />
|
||||
<UTextarea v-model="form.description" :rows="4" autoresize class="w-full" placeholder="Optional..." />
|
||||
</UFormField>
|
||||
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<UButton
|
||||
block
|
||||
size="xl"
|
||||
block
|
||||
:loading="isSubmitting"
|
||||
@click="submit"
|
||||
:label="config?.ui?.submitButtonText || 'Speichern'"
|
||||
|
||||
@@ -75,7 +75,7 @@ setupPage()
|
||||
:type="props.type"
|
||||
:item="item"
|
||||
:inModal="true"
|
||||
@return-data="(data) => emit('return-data',data)"
|
||||
@return-data="(data) => emit('returnData', data)"
|
||||
:createQuery="props.createQuery"
|
||||
:mode="props.mode"
|
||||
/>
|
||||
|
||||
@@ -14,8 +14,12 @@ const tenantInitials = computed(() => {
|
||||
.join('') || 'M'
|
||||
})
|
||||
|
||||
const activeTenants = computed(() =>
|
||||
auth.tenants.filter((tenant) => tenant.hasActiveLicense)
|
||||
)
|
||||
|
||||
const tenantItems = computed(() => [
|
||||
auth.tenants.map((tenant) => ({
|
||||
activeTenants.value.map((tenant) => ({
|
||||
label: tenant.name,
|
||||
icon: tenant.id === auth.activeTenant ? 'i-heroicons-check' : undefined,
|
||||
disabled: Boolean(tenant.locked),
|
||||
|
||||
166
frontend/components/UCalendar.vue
Normal file
166
frontend/components/UCalendar.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script>
|
||||
import theme from "#build/ui/calendar";
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useForwardPropsEmits } from "reka-ui";
|
||||
import { Calendar as SingleCalendar, RangeCalendar } from "reka-ui/namespaced";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { useAppConfig } from "#imports";
|
||||
import { useLocale } from "@nuxt/ui/composables/useLocale";
|
||||
import { tv } from "@nuxt/ui/utils/tv";
|
||||
import UButton from "@nuxt/ui/components/Button.vue";
|
||||
|
||||
const props = defineProps({
|
||||
as: { type: null, required: false },
|
||||
nextYearIcon: { type: String, required: false },
|
||||
nextYear: { type: Object, required: false },
|
||||
nextMonthIcon: { type: String, required: false },
|
||||
nextMonth: { type: Object, required: false },
|
||||
prevYearIcon: { type: String, required: false },
|
||||
prevYear: { type: Object, required: false },
|
||||
prevMonthIcon: { type: String, required: false },
|
||||
prevMonth: { type: Object, required: false },
|
||||
color: { type: null, required: false },
|
||||
size: { type: null, required: false },
|
||||
range: { type: Boolean, required: false },
|
||||
multiple: { type: Boolean, required: false },
|
||||
monthControls: { type: Boolean, required: false, default: true },
|
||||
yearControls: { type: Boolean, required: false, default: true },
|
||||
defaultValue: { type: null, required: false },
|
||||
modelValue: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
ui: { type: null, required: false },
|
||||
defaultPlaceholder: { type: null, required: false },
|
||||
placeholder: { type: null, required: false },
|
||||
allowNonContiguousRanges: { type: Boolean, required: false },
|
||||
pagedNavigation: { type: Boolean, required: false },
|
||||
preventDeselect: { type: Boolean, required: false },
|
||||
maximumDays: { type: Number, required: false },
|
||||
weekStartsOn: { type: Number, required: false, default: 1 },
|
||||
weekdayFormat: { type: String, required: false },
|
||||
fixedWeeks: { type: Boolean, required: false, default: true },
|
||||
maxValue: { type: null, required: false },
|
||||
minValue: { type: null, required: false },
|
||||
numberOfMonths: { type: Number, required: false },
|
||||
disabled: { type: Boolean, required: false },
|
||||
readonly: { type: Boolean, required: false },
|
||||
initialFocus: { type: Boolean, required: false },
|
||||
isDateDisabled: { type: Function, required: false },
|
||||
isDateUnavailable: { type: Function, required: false },
|
||||
isDateHighlightable: { type: Function, required: false },
|
||||
nextPage: { type: Function, required: false },
|
||||
prevPage: { type: Function, required: false },
|
||||
disableDaysOutsideCurrentView: { type: Boolean, required: false },
|
||||
fixedDate: { type: String, required: false }
|
||||
});
|
||||
|
||||
const emits = defineEmits(["update:modelValue", "update:placeholder", "update:validModelValue", "update:startValue"]);
|
||||
|
||||
defineSlots();
|
||||
|
||||
const { code: locale, dir, t } = useLocale();
|
||||
const appConfig = useAppConfig();
|
||||
const rootProps = useForwardPropsEmits(
|
||||
reactiveOmit(props, "range", "modelValue", "defaultValue", "color", "size", "monthControls", "yearControls", "class", "ui"),
|
||||
emits
|
||||
);
|
||||
|
||||
const nextYearIcon = computed(() => props.nextYearIcon || (dir.value === "rtl" ? appConfig.ui.icons.chevronDoubleLeft : appConfig.ui.icons.chevronDoubleRight));
|
||||
const nextMonthIcon = computed(() => props.nextMonthIcon || (dir.value === "rtl" ? appConfig.ui.icons.chevronLeft : appConfig.ui.icons.chevronRight));
|
||||
const prevYearIcon = computed(() => props.prevYearIcon || (dir.value === "rtl" ? appConfig.ui.icons.chevronDoubleRight : appConfig.ui.icons.chevronDoubleLeft));
|
||||
const prevMonthIcon = computed(() => props.prevMonthIcon || (dir.value === "rtl" ? appConfig.ui.icons.chevronRight : appConfig.ui.icons.chevronLeft));
|
||||
const ui = computed(() => tv({ extend: tv(theme), ...appConfig.ui?.calendar || {} })({
|
||||
color: props.color,
|
||||
size: props.size
|
||||
}));
|
||||
const Calendar = computed(() => props.range ? RangeCalendar : SingleCalendar);
|
||||
|
||||
function paginateYear(date, sign) {
|
||||
if (sign === -1) {
|
||||
return date.subtract({ years: 1 });
|
||||
}
|
||||
|
||||
return date.add({ years: 1 });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Calendar.Root
|
||||
v-slot="{ weekDays, grid }"
|
||||
v-bind="rootProps"
|
||||
:model-value="modelValue"
|
||||
:default-value="defaultValue"
|
||||
:locale="locale"
|
||||
:dir="dir"
|
||||
:class="ui.root({ class: [props.ui?.root, props.class] })"
|
||||
>
|
||||
<Calendar.Header :class="ui.header({ class: props.ui?.header })">
|
||||
<Calendar.Prev v-if="props.yearControls" :prev-page="(date) => paginateYear(date, -1)" :aria-label="t('calendar.prevYear')" as-child>
|
||||
<UButton :icon="prevYearIcon" :size="props.size" color="neutral" variant="ghost" v-bind="props.prevYear" />
|
||||
</Calendar.Prev>
|
||||
<Calendar.Prev v-if="props.monthControls" :aria-label="t('calendar.prevMonth')" as-child>
|
||||
<UButton :icon="prevMonthIcon" :size="props.size" color="neutral" variant="ghost" v-bind="props.prevMonth" />
|
||||
</Calendar.Prev>
|
||||
<Calendar.Heading v-slot="{ headingValue }" :class="ui.heading({ class: props.ui?.heading })">
|
||||
<slot name="heading" :value="headingValue">
|
||||
{{ headingValue }}
|
||||
</slot>
|
||||
</Calendar.Heading>
|
||||
<Calendar.Next v-if="props.monthControls" :aria-label="t('calendar.nextMonth')" as-child>
|
||||
<UButton :icon="nextMonthIcon" :size="props.size" color="neutral" variant="ghost" v-bind="props.nextMonth" />
|
||||
</Calendar.Next>
|
||||
<Calendar.Next v-if="props.yearControls" :next-page="(date) => paginateYear(date, 1)" :aria-label="t('calendar.nextYear')" as-child>
|
||||
<UButton :icon="nextYearIcon" :size="props.size" color="neutral" variant="ghost" v-bind="props.nextYear" />
|
||||
</Calendar.Next>
|
||||
</Calendar.Header>
|
||||
|
||||
<div :class="ui.body({ class: props.ui?.body })">
|
||||
<Calendar.Grid
|
||||
v-for="month in grid"
|
||||
:key="month.value.toString()"
|
||||
:class="ui.grid({ class: props.ui?.grid })"
|
||||
>
|
||||
<Calendar.GridHead>
|
||||
<Calendar.GridRow :class="ui.gridWeekDaysRow({ class: props.ui?.gridWeekDaysRow })">
|
||||
<Calendar.HeadCell
|
||||
v-for="day in weekDays"
|
||||
:key="day"
|
||||
:class="ui.headCell({ class: props.ui?.headCell })"
|
||||
>
|
||||
<slot name="week-day" :day="day">
|
||||
{{ day }}
|
||||
</slot>
|
||||
</Calendar.HeadCell>
|
||||
</Calendar.GridRow>
|
||||
</Calendar.GridHead>
|
||||
|
||||
<Calendar.GridBody :class="ui.gridBody({ class: props.ui?.gridBody })">
|
||||
<Calendar.GridRow
|
||||
v-for="(weekDates, index) in month.rows"
|
||||
:key="`weekDate-${index}`"
|
||||
:class="ui.gridRow({ class: props.ui?.gridRow })"
|
||||
>
|
||||
<Calendar.Cell
|
||||
v-for="weekDate in weekDates"
|
||||
:key="weekDate.toString()"
|
||||
:date="weekDate"
|
||||
:class="ui.cell({ class: props.ui?.cell })"
|
||||
>
|
||||
<Calendar.CellTrigger
|
||||
:day="weekDate"
|
||||
:month="month.value"
|
||||
:class="ui.cellTrigger({ class: props.ui?.cellTrigger })"
|
||||
>
|
||||
<slot name="day" :day="weekDate">
|
||||
{{ weekDate.day }}
|
||||
</slot>
|
||||
</Calendar.CellTrigger>
|
||||
</Calendar.Cell>
|
||||
</Calendar.GridRow>
|
||||
</Calendar.GridBody>
|
||||
</Calendar.Grid>
|
||||
</div>
|
||||
</Calendar.Root>
|
||||
</template>
|
||||
94
frontend/components/UDashboardNavbar.vue
Normal file
94
frontend/components/UDashboardNavbar.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import DashboardNavbarBase from "@nuxt/ui-pro/runtime/components/DashboardNavbar.vue"
|
||||
import UBadge from "@nuxt/ui/components/Badge.vue"
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = defineProps({
|
||||
as: {
|
||||
type: null,
|
||||
required: false
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
toggle: {
|
||||
type: [Boolean, Object],
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
toggleSide: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "left"
|
||||
},
|
||||
badge: {
|
||||
type: [String, Number],
|
||||
required: false
|
||||
},
|
||||
class: {
|
||||
type: null,
|
||||
required: false
|
||||
},
|
||||
ui: {
|
||||
type: null,
|
||||
required: false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardNavbarBase
|
||||
:as="as"
|
||||
:icon="icon"
|
||||
:title="title"
|
||||
:toggle="toggle"
|
||||
:toggle-side="toggleSide"
|
||||
:class="props.class"
|
||||
:ui="ui"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<template v-if="$slots.toggle" #toggle="slotProps">
|
||||
<slot name="toggle" v-bind="slotProps" />
|
||||
</template>
|
||||
|
||||
<template v-if="$slots.left" #left="slotProps">
|
||||
<slot name="left" v-bind="slotProps" />
|
||||
</template>
|
||||
|
||||
<template v-if="$slots.leading" #leading="slotProps">
|
||||
<slot name="leading" v-bind="slotProps" />
|
||||
</template>
|
||||
|
||||
<template #title>
|
||||
<slot name="title">
|
||||
<span class="inline-flex min-w-0 items-center gap-2">
|
||||
<span class="truncate">{{ title }}</span>
|
||||
<UBadge
|
||||
v-if="badge !== undefined && badge !== null && badge !== ''"
|
||||
size="sm"
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
>
|
||||
{{ badge }}
|
||||
</UBadge>
|
||||
</span>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<template v-if="$slots.trailing" #trailing="slotProps">
|
||||
<slot name="trailing" v-bind="slotProps" />
|
||||
</template>
|
||||
|
||||
<slot />
|
||||
|
||||
<template v-if="$slots.right" #right="slotProps">
|
||||
<slot name="right" v-bind="slotProps" />
|
||||
</template>
|
||||
</DashboardNavbarBase>
|
||||
</template>
|
||||
@@ -28,16 +28,16 @@ const userItems = computed(() => [[
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
class="w-full min-w-0 justify-start gap-2 rounded-lg px-2.5 py-2 text-left"
|
||||
class="w-full min-w-0 justify-start gap-2 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
:class="[open && 'bg-gray-100 dark:bg-gray-800']"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate font-medium text-gray-900 dark:text-white">
|
||||
<div class="flex items-space gap-2">
|
||||
<span class="min-w-0 flex-1 truncate font-medium text-gray-900 dark:text-white">
|
||||
{{ auth.user.email }}
|
||||
</span>
|
||||
|
||||
<template #trailing>
|
||||
<UIcon name="i-heroicons-ellipsis-vertical" class="h-5 w-5 shrink-0" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</UButton>
|
||||
</template>
|
||||
</UDropdownMenu>
|
||||
|
||||
13
frontend/components/columnRenderings/branch.vue
Normal file
13
frontend/components/columnRenderings/branch.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
row: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: {}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>{{ props.row.branch?.name || '' }}</span>
|
||||
</template>
|
||||
48
frontend/components/columnRenderings/externalLink.vue
Normal file
48
frontend/components/columnRenderings/externalLink.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
row: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({})
|
||||
},
|
||||
keyName: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
})
|
||||
|
||||
const resolvedKey = computed(() => {
|
||||
if (props.keyName) return props.keyName
|
||||
if (typeof props.row?.supplier_link === "string") return "supplier_link"
|
||||
if (typeof props.row?.link === "string") return "link"
|
||||
if (typeof props.row?.url === "string") return "url"
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const normalizedUrl = computed(() => {
|
||||
const rawValue = resolvedKey.value ? props.row?.[resolvedKey.value] : null
|
||||
|
||||
if (!rawValue || typeof rawValue !== "string") return null
|
||||
|
||||
const trimmedValue = rawValue.trim()
|
||||
if (!trimmedValue) return null
|
||||
|
||||
if (/^https?:\/\//i.test(trimmedValue)) return trimmedValue
|
||||
|
||||
return `https://${trimmedValue}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
v-if="normalizedUrl"
|
||||
:href="normalizedUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:underline break-all"
|
||||
>
|
||||
{{ resolvedKey ? row[resolvedKey] : "" }}
|
||||
</a>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
@@ -19,6 +19,27 @@ const props = defineProps({
|
||||
const emit = defineEmits(["updateNeeded","returnData"])
|
||||
|
||||
const documentTypeToUse = ref(props.type)
|
||||
const documentTypeItems = computed(() => {
|
||||
return Object.keys(dataStore.documentTypesForCreation).map((key) => ({
|
||||
...dataStore.documentTypesForCreation[key],
|
||||
key
|
||||
}))
|
||||
})
|
||||
|
||||
const selectedDocumentType = computed(() => {
|
||||
return documentTypeItems.value.find((item) => item.key === documentTypeToUse.value) || null
|
||||
})
|
||||
|
||||
const visibleImportKeys = computed(() => {
|
||||
return Object.keys(optionsToImport.value).filter((key) => {
|
||||
if (documentTypeToUse.value !== props.type) {
|
||||
return !['startText', 'endText'].includes(key)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const optionsToImport = ref({
|
||||
taxType: true,
|
||||
customer: true,
|
||||
@@ -66,42 +87,69 @@ const startImport = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal :fullscreen="false">
|
||||
<UModal :ui="{ content: 'sm:max-w-2xl' }">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
Erstelltes Dokument Kopieren
|
||||
</template>
|
||||
<div class="mx-auto flex max-h-[85vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-default shadow-xl ring-1 ring-black/5">
|
||||
<div class="border-b border-default px-6 py-5 sm:px-7">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary ring-1 ring-primary/15">
|
||||
<UIcon name="i-heroicons-document-duplicate" class="h-6 w-6" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold text-highlighted">Erstelltes Dokument kopieren</h2>
|
||||
<p class="mt-1 text-sm text-muted">Wähle den Zieltyp und welche Inhalte in das neue Dokument übernommen werden sollen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UFormField
|
||||
label="Dokumententyp:"
|
||||
class="mb-3"
|
||||
>
|
||||
<USelectMenu
|
||||
:options="Object.keys(dataStore.documentTypesForCreation).map(key => { return { ...dataStore.documentTypesForCreation[key], key}})"
|
||||
value-attribute="key"
|
||||
option-attribute="labelSingle"
|
||||
v-model="documentTypeToUse"
|
||||
>
|
||||
<div class="flex-1 space-y-6 overflow-y-auto px-6 py-5 sm:px-7">
|
||||
<UFormField label="Dokumententyp" required class="w-full">
|
||||
<USelectMenu
|
||||
v-model="documentTypeToUse"
|
||||
:items="documentTypeItems"
|
||||
value-key="key"
|
||||
label-key="labelSingle"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:placeholder="selectedDocumentType?.labelSingle || 'Dokumententyp wählen'"
|
||||
:search-input="{ placeholder: 'Dokumententyp suchen...' }"
|
||||
:filter-fields="['labelSingle']"
|
||||
>
|
||||
<template #default>
|
||||
<span class="block truncate">
|
||||
{{ selectedDocumentType?.labelSingle || "Dokumententyp wählen" }}
|
||||
</span>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</UFormField>
|
||||
|
||||
</USelectMenu>
|
||||
</UFormField>
|
||||
<UCheckbox
|
||||
v-for="key in Object.keys(optionsToImport).filter(i => documentTypeToUse !== props.type ? !['startText','endText'].includes(i) : true)"
|
||||
v-model="optionsToImport[key]"
|
||||
:label="mappings[key]"
|
||||
/>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-highlighted">Inhalte übernehmen</h3>
|
||||
<p class="mt-1 text-sm text-muted">Nur die aktivierten Bereiche werden in das neue Dokument kopiert.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<UCheckbox
|
||||
v-for="key in visibleImportKeys"
|
||||
:key="key"
|
||||
v-model="optionsToImport[key]"
|
||||
:label="mappings[key]"
|
||||
class="rounded-xl border border-default px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<UButton
|
||||
@click="startImport"
|
||||
>
|
||||
<div class="flex shrink-0 justify-end gap-2 border-t border-default px-6 py-4 sm:px-7">
|
||||
<UButton color="neutral" variant="ghost" @click="modal.close()">
|
||||
Abbrechen
|
||||
</UButton>
|
||||
<UButton @click="startImport">
|
||||
Kopieren
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
@@ -1,26 +1,205 @@
|
||||
<script setup>
|
||||
import dayjs from "dayjs"
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
required: true,
|
||||
type: String
|
||||
type: Object
|
||||
}
|
||||
})
|
||||
|
||||
const incomingInvoices = ref({})
|
||||
const loading = ref(true)
|
||||
const incomingInvoices = ref([])
|
||||
const selectedYear = ref(String(dayjs().year()))
|
||||
const selectedMonth = ref("all")
|
||||
|
||||
const currency = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} EUR`
|
||||
|
||||
const yearItems = computed(() => {
|
||||
const years = [...new Set(
|
||||
incomingInvoices.value
|
||||
.map((invoice) => invoice.date ? String(dayjs(invoice.date).year()) : null)
|
||||
.filter(Boolean)
|
||||
)].sort((a, b) => Number(b) - Number(a))
|
||||
|
||||
return years.length > 0 ? years.map((year) => ({ label: year, value: year })) : [{ label: String(dayjs().year()), value: String(dayjs().year()) }]
|
||||
})
|
||||
|
||||
const monthItems = [
|
||||
{ label: "Ganzes Jahr", value: "all" },
|
||||
{ label: "Januar", value: "1" },
|
||||
{ label: "Februar", value: "2" },
|
||||
{ label: "Maerz", value: "3" },
|
||||
{ label: "April", value: "4" },
|
||||
{ label: "Mai", value: "5" },
|
||||
{ label: "Juni", value: "6" },
|
||||
{ label: "Juli", value: "7" },
|
||||
{ label: "August", value: "8" },
|
||||
{ label: "September", value: "9" },
|
||||
{ label: "Oktober", value: "10" },
|
||||
{ label: "November", value: "11" },
|
||||
{ label: "Dezember", value: "12" }
|
||||
]
|
||||
|
||||
const reportRows = computed(() => {
|
||||
return incomingInvoices.value.flatMap((invoice) => {
|
||||
const invoiceDate = invoice.date ? dayjs(invoice.date) : null
|
||||
|
||||
if (invoiceDate && invoiceDate.year().toString() !== selectedYear.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (invoiceDate && selectedMonth.value !== "all" && invoiceDate.month() + 1 !== Number(selectedMonth.value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const matchingAccounts = (invoice.accounts || []).filter((account) => account.costCentre === props.item.id)
|
||||
|
||||
return matchingAccounts.map((account, index) => {
|
||||
const amountNet = Number(account.amountNet || 0)
|
||||
const amountTax = Number(account.amountTax || 0)
|
||||
const amountGross = Number(account.amountGross || amountNet + amountTax || 0)
|
||||
|
||||
return {
|
||||
id: `${invoice.id}-${index}`,
|
||||
invoiceId: invoice.id,
|
||||
reference: invoice.reference || "-",
|
||||
date: invoice.date,
|
||||
state: invoice.state || "-",
|
||||
vendorName: invoice.vendor?.name || "-",
|
||||
accountLabel: account.account?.label || account.accountLabel || "-",
|
||||
description: account.description || invoice.description || "-",
|
||||
amountNet,
|
||||
amountTax,
|
||||
amountGross
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const totals = computed(() => {
|
||||
return reportRows.value.reduce((acc, row) => {
|
||||
acc.net += row.amountNet
|
||||
acc.tax += row.amountTax
|
||||
acc.gross += row.amountGross
|
||||
return acc
|
||||
}, { net: 0, tax: 0, gross: 0 })
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ accessorKey: "reference", header: "Beleg" },
|
||||
{ accessorKey: "date", header: "Datum" },
|
||||
{ accessorKey: "vendorName", header: "Lieferant" },
|
||||
{ accessorKey: "accountLabel", header: "Konto" },
|
||||
{ accessorKey: "description", header: "Beschreibung" },
|
||||
{ accessorKey: "amountNet", header: "Netto" },
|
||||
{ accessorKey: "amountTax", header: "Steuer" },
|
||||
{ accessorKey: "amountGross", header: "Brutto" }
|
||||
]
|
||||
|
||||
const setupPage = async () => {
|
||||
incomingInvoices.value = (await useEntities("incominginvoices").select()).filter(i => i.accounts.find(x => x.costCentre === props.item.id))
|
||||
loading.value = true
|
||||
|
||||
const invoices = await useEntities("incominginvoices").select("*, vendor(id,name)")
|
||||
|
||||
incomingInvoices.value = invoices.filter((invoice) =>
|
||||
(invoice.accounts || []).some((account) => account.costCentre === props.item.id)
|
||||
)
|
||||
|
||||
const firstYear = yearItems.value[0]?.value
|
||||
if (firstYear && !yearItems.value.some((item) => item.value === selectedYear.value)) {
|
||||
selectedYear.value = firstYear
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
setupPage()
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
{{props.item}}
|
||||
{{incomingInvoices}}
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<UFormField label="Jahr" class="w-full md:w-48">
|
||||
<USelectMenu
|
||||
v-model="selectedYear"
|
||||
:items="yearItems"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Monat" class="w-full md:w-56">
|
||||
<USelectMenu
|
||||
v-model="selectedMonth"
|
||||
:items="monthItems"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Netto gesamt</div>
|
||||
<div class="mt-1 text-xl font-semibold">{{ currency(totals.net) }}</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Steuer gesamt</div>
|
||||
<div class="mt-1 text-xl font-semibold">{{ currency(totals.tax) }}</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Brutto gesamt</div>
|
||||
<div class="mt-1 text-xl font-semibold">{{ currency(totals.gross) }}</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<UTable
|
||||
v-if="!loading"
|
||||
:data="reportRows"
|
||||
:columns="columns"
|
||||
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Eingangsbelege mit dieser Kostenstelle gefunden' }"
|
||||
class="w-full"
|
||||
>
|
||||
<template #reference-cell="{ row }">
|
||||
<div class="truncate font-medium">{{ row.original.reference }}</div>
|
||||
</template>
|
||||
|
||||
<template #date-cell="{ row }">
|
||||
<div class="truncate">{{ row.original.date ? dayjs(row.original.date).format("DD.MM.YYYY") : "-" }}</div>
|
||||
</template>
|
||||
|
||||
<template #vendorName-cell="{ row }">
|
||||
<div class="truncate">{{ row.original.vendorName }}</div>
|
||||
</template>
|
||||
|
||||
<template #accountLabel-cell="{ row }">
|
||||
<div class="truncate">{{ row.original.accountLabel }}</div>
|
||||
</template>
|
||||
|
||||
<template #description-cell="{ row }">
|
||||
<UTooltip :text="row.original.description">
|
||||
<div class="max-w-[18rem] truncate">{{ row.original.description }}</div>
|
||||
</UTooltip>
|
||||
</template>
|
||||
|
||||
<template #amountNet-cell="{ row }">
|
||||
<div class="text-right tabular-nums">{{ currency(row.original.amountNet) }}</div>
|
||||
</template>
|
||||
|
||||
<template #amountTax-cell="{ row }">
|
||||
<div class="text-right tabular-nums">{{ currency(row.original.amountTax) }}</div>
|
||||
</template>
|
||||
|
||||
<template #amountGross-cell="{ row }">
|
||||
<div class="text-right font-medium tabular-nums">{{ currency(row.original.amountGross) }}</div>
|
||||
</template>
|
||||
</UTable>
|
||||
|
||||
<UProgress v-else animation="carousel" class="w-3/4 mx-auto" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
||||
243
frontend/components/displayBWASummary.vue
Normal file
243
frontend/components/displayBWASummary.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from "dayjs"
|
||||
import {
|
||||
getCreatedDocumentTaxBreakdown,
|
||||
getIncomingInvoiceTaxBreakdown
|
||||
} from "~/composables/useTaxEvaluation"
|
||||
import {
|
||||
getIncomingInvoiceDepreciationRows,
|
||||
getIncomingInvoiceImmediateExpenseNet,
|
||||
getStatementAllocationImmediateExpenseAmount
|
||||
} from "~/composables/useDepreciation"
|
||||
|
||||
const loading = ref(true)
|
||||
const summary = ref({
|
||||
label: "",
|
||||
income: 0,
|
||||
expenses: 0,
|
||||
depreciations: 0,
|
||||
result: 0,
|
||||
taxBalance: 0,
|
||||
incomeCount: 0,
|
||||
expenseCount: 0,
|
||||
depreciationCount: 0
|
||||
})
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR"
|
||||
}).format(Number(value || 0))
|
||||
}
|
||||
|
||||
const isRelevantOutputDocument = (doc: any) => {
|
||||
return doc?.state === "Gebucht" && ["invoices", "advanceInvoices", "cancellationInvoices"].includes(doc?.type)
|
||||
}
|
||||
|
||||
const isRelevantInputInvoice = (invoice: any) => {
|
||||
return invoice?.state === "Gebucht" && !!invoice?.date
|
||||
}
|
||||
|
||||
const loadSummary = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const bounds = {
|
||||
start: dayjs().startOf("month"),
|
||||
end: dayjs().endOf("month")
|
||||
}
|
||||
|
||||
const [docs, incoming, allocations] = await Promise.all([
|
||||
useEntities("createddocuments").select(),
|
||||
useEntities("incominginvoices").select(),
|
||||
useEntities("statementallocations").select("*, bankstatement(*)")
|
||||
])
|
||||
|
||||
const outputDocs = (docs || []).filter((doc: any) => {
|
||||
if (!isRelevantOutputDocument(doc)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const date = dayjs(doc.documentDate)
|
||||
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day")
|
||||
})
|
||||
|
||||
const inputDocs = (incoming || []).filter((invoice: any) => {
|
||||
if (!isRelevantInputInvoice(invoice)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const date = dayjs(invoice.date)
|
||||
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day")
|
||||
})
|
||||
|
||||
const directExpenses = (allocations || []).filter((allocation: any) => {
|
||||
if (allocation?.account === null || typeof allocation?.account === "undefined") {
|
||||
return false
|
||||
}
|
||||
|
||||
const statementDate = allocation?.bankstatement?.date || allocation?.bankstatement?.valueDate || allocation?.date || allocation?.created_at
|
||||
const date = dayjs(statementDate)
|
||||
const amount = getStatementAllocationImmediateExpenseAmount(allocation)
|
||||
|
||||
return amount > 0 && date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day")
|
||||
})
|
||||
|
||||
const income = outputDocs.reduce((sum: number, doc: any) => {
|
||||
return sum + (doc.rows || []).reduce((rowSum: number, row: any) => {
|
||||
if (!row || ["pagebreak", "title", "text"].includes(row.mode)) {
|
||||
return rowSum
|
||||
}
|
||||
|
||||
const quantity = Number(row.quantity || 0)
|
||||
const price = Number(row.price || 0)
|
||||
const discountPercent = Number(row.discountPercent || 0)
|
||||
|
||||
return rowSum + (quantity * price * (1 - discountPercent / 100))
|
||||
}, 0)
|
||||
}, 0)
|
||||
|
||||
const invoiceExpenses = inputDocs.reduce((sum: number, invoice: any) => {
|
||||
return sum + getIncomingInvoiceImmediateExpenseNet(invoice)
|
||||
}, 0)
|
||||
|
||||
const directAccountExpenses = directExpenses.reduce((sum: number, allocation: any) => {
|
||||
return sum + Number(getStatementAllocationImmediateExpenseAmount(allocation) || 0)
|
||||
}, 0)
|
||||
|
||||
const depreciationRows = [
|
||||
...inputDocs.flatMap((invoice: any) => getIncomingInvoiceDepreciationRows(invoice, bounds.start, bounds.end))
|
||||
]
|
||||
|
||||
const depreciations = depreciationRows.reduce((sum: number, row: any) => sum + Number(row.amount || 0), 0)
|
||||
|
||||
const outputTax = outputDocs.reduce((sum: number, doc: any) => {
|
||||
const breakdown = getCreatedDocumentTaxBreakdown(doc)
|
||||
return sum + breakdown.tax19 + breakdown.tax7
|
||||
}, 0)
|
||||
|
||||
const inputTax = inputDocs.reduce((sum: number, invoice: any) => {
|
||||
const breakdown = getIncomingInvoiceTaxBreakdown(invoice)
|
||||
return sum + breakdown.tax19 + breakdown.tax7
|
||||
}, 0)
|
||||
|
||||
const expenses = invoiceExpenses + directAccountExpenses + depreciations
|
||||
|
||||
summary.value = {
|
||||
label: dayjs().format("MMMM YYYY"),
|
||||
income: Number(income.toFixed(2)),
|
||||
expenses: Number(expenses.toFixed(2)),
|
||||
depreciations: Number(depreciations.toFixed(2)),
|
||||
result: Number((income - expenses).toFixed(2)),
|
||||
taxBalance: Number((outputTax - inputTax).toFixed(2)),
|
||||
incomeCount: outputDocs.length,
|
||||
expenseCount: inputDocs.filter((invoice: any) => getIncomingInvoiceImmediateExpenseNet(invoice) > 0).length + directExpenses.filter((allocation: any) => getStatementAllocationImmediateExpenseAmount(allocation) > 0).length,
|
||||
depreciationCount: depreciationRows.length
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSummary)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="bwa-summary-top">
|
||||
<div>
|
||||
<p class="bwa-summary-period">{{ summary.label }}</p>
|
||||
<p class="bwa-summary-range">Aktueller Monat</p>
|
||||
</div>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="soft"
|
||||
color="gray"
|
||||
icon="i-heroicons-arrow-top-right-on-square"
|
||||
@click="navigateTo('/accounting/bwa')"
|
||||
>
|
||||
Details
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div class="bwa-summary-row">
|
||||
<span class="bwa-summary-label">Einnahmen</span>
|
||||
<span class="bwa-summary-value text-primary-500">
|
||||
{{ loading ? "..." : formatCurrency(summary.income) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="bwa-summary-row">
|
||||
<span class="bwa-summary-label">Ausgaben</span>
|
||||
<span class="bwa-summary-value text-error">
|
||||
{{ loading ? "..." : formatCurrency(summary.expenses) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="bwa-summary-row">
|
||||
<span class="bwa-summary-label">Davon Abschreibungen</span>
|
||||
<span class="bwa-summary-value text-amber-600">
|
||||
{{ loading ? "..." : formatCurrency(summary.depreciations) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="bwa-summary-row">
|
||||
<span class="bwa-summary-label">Ergebnis</span>
|
||||
<span class="bwa-summary-value" :class="summary.result >= 0 ? 'text-primary-500' : 'text-error'">
|
||||
{{ loading ? "..." : formatCurrency(summary.result) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="bwa-summary-meta">
|
||||
{{ summary.incomeCount }} Einnahmenbelege | {{ summary.expenseCount }} Ausgabenbelege | {{ summary.depreciationCount }} Abschreibungen | USt-Saldo {{ formatCurrency(summary.taxBalance) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bwa-summary-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.bwa-summary-period {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
color: rgb(17 24 39);
|
||||
}
|
||||
|
||||
.bwa-summary-range,
|
||||
.bwa-summary-meta {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(107 114 128);
|
||||
}
|
||||
|
||||
.bwa-summary-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bwa-summary-label {
|
||||
color: rgb(55 65 81);
|
||||
}
|
||||
|
||||
.bwa-summary-value {
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
:deep(.dark) .bwa-summary-period {
|
||||
color: rgb(243 244 246);
|
||||
}
|
||||
|
||||
:deep(.dark) .bwa-summary-range,
|
||||
:deep(.dark) .bwa-summary-meta,
|
||||
:deep(.dark) .bwa-summary-label {
|
||||
color: rgb(156 163 175);
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,10 @@
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import dayjs from "dayjs";
|
||||
import { Line } from "vue-chartjs";
|
||||
import {
|
||||
getIncomingInvoiceImmediateExpenseGross,
|
||||
getIncomingInvoiceImmediateExpenseNet
|
||||
} from "~/composables/useDepreciation";
|
||||
|
||||
dayjs.extend(customParseFormat)
|
||||
|
||||
@@ -84,7 +88,7 @@ const loadData = async () => {
|
||||
])
|
||||
|
||||
incomeDocuments.value = (docs || []).filter((item) => item.state === "Gebucht" && ["invoices", "advanceInvoices", "cancellationInvoices"].includes(item.type))
|
||||
expenseInvoices.value = (incoming || []).filter((item) => item.date)
|
||||
expenseInvoices.value = (incoming || []).filter((item) => item.state === "Gebucht" && item.date)
|
||||
}
|
||||
|
||||
const yearsInData = computed(() => {
|
||||
@@ -128,18 +132,9 @@ const computeDocumentAmount = (doc) => {
|
||||
}
|
||||
|
||||
const computeIncomingInvoiceAmount = (invoice) => {
|
||||
let amount = 0
|
||||
|
||||
;(invoice.accounts || []).forEach((account) => {
|
||||
const net = Number(account.amountNet || 0)
|
||||
const tax = Number(account.amountTax || 0)
|
||||
const grossValue = Number(account.amountGross)
|
||||
const gross = Number.isFinite(grossValue) ? grossValue : (net + tax)
|
||||
|
||||
amount += amountMode.value === "gross" ? gross : net
|
||||
})
|
||||
|
||||
return Number(amount.toFixed(2))
|
||||
return amountMode.value === "gross"
|
||||
? getIncomingInvoiceImmediateExpenseGross(invoice)
|
||||
: getIncomingInvoiceImmediateExpenseNet(invoice)
|
||||
}
|
||||
|
||||
const buckets = computed(() => {
|
||||
|
||||
@@ -72,18 +72,26 @@ const setRowData = (row) => {
|
||||
+ Artikel
|
||||
</UButton>
|
||||
|
||||
<table class="w-full mt-3">
|
||||
<tr>
|
||||
<th>Artikel</th>
|
||||
<th>Menge</th>
|
||||
<th>Einheit</th>
|
||||
<th>Verkaufspreis</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="product in props.item.materialComposition"
|
||||
>
|
||||
<td>
|
||||
<div class="mt-3 overflow-x-auto">
|
||||
<table class="w-full min-w-[44rem] table-fixed">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-800">
|
||||
<th class="px-2 py-2 text-left font-medium">Artikel</th>
|
||||
<th class="px-2 py-2 text-left font-medium">Menge</th>
|
||||
<th class="px-2 py-2 text-left font-medium">Einheit</th>
|
||||
<th class="px-2 py-2 text-left font-medium">Verkaufspreis</th>
|
||||
<th class="w-12 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="product in props.item.materialComposition"
|
||||
:key="product.id"
|
||||
class="border-b border-gray-100 align-top dark:border-gray-800"
|
||||
>
|
||||
<td class="px-2 py-2">
|
||||
<USelectMenu
|
||||
class="w-full"
|
||||
:items="products"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
@@ -91,38 +99,45 @@ const setRowData = (row) => {
|
||||
:filter-fields="['name']"
|
||||
v-model="product.product"
|
||||
:color="product.product ? 'primary' : 'error'"
|
||||
@change="setRowData(product)"
|
||||
@update:model-value="setRowData(product)"
|
||||
>
|
||||
<template #default>
|
||||
{{products.find(i => i.id === product.product) ? products.find(i => i.id === product.product).name : 'Kein Artikel ausgewählt'}}
|
||||
{{ products.find(i => i.id === product.product)?.name || 'Kein Artikel ausgewählt' }}
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</td>
|
||||
<td>
|
||||
<td class="px-2 py-2">
|
||||
<UInput
|
||||
class="w-full"
|
||||
type="number"
|
||||
v-model="product.quantity"
|
||||
:step="units.find(i => i.id === product.unit) ? units.find(i => i.id === product.unit).step : 1"
|
||||
@change="calculateTotalMaterialPrice"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<td class="px-2 py-2">
|
||||
<USelectMenu
|
||||
class="w-full"
|
||||
:items="units"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
v-model="product.unit"
|
||||
></USelectMenu>
|
||||
>
|
||||
<template #default>
|
||||
{{ units.find(i => i.id === product.unit)?.name || 'Einheit wählen' }}
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</td>
|
||||
<td>
|
||||
<td class="px-2 py-2">
|
||||
<UInput
|
||||
class="w-full"
|
||||
type="number"
|
||||
v-model="product.price"
|
||||
step="0.01"
|
||||
@change="calculateTotalMaterialPrice"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<td class="px-2 py-2">
|
||||
<UButton
|
||||
icon="i-heroicons-x-mark"
|
||||
@click="removeProductFromMaterialComposition(product.id)"
|
||||
@@ -130,8 +145,10 @@ const setRowData = (row) => {
|
||||
color="error"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -73,19 +73,27 @@ const setRowData = (row) => {
|
||||
+ Stundensatz
|
||||
</UButton>
|
||||
|
||||
<table class="w-full mt-3">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Menge</th>
|
||||
<th>Einheit</th>
|
||||
<th>Einkaufpreis</th>
|
||||
<th>Verkaufspreis</th>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="row in props.item.personalComposition"
|
||||
>
|
||||
<td>
|
||||
<div class="mt-3 overflow-x-auto">
|
||||
<table class="w-full min-w-[52rem] table-fixed">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-800">
|
||||
<th class="px-2 py-2 text-left font-medium">Name</th>
|
||||
<th class="px-2 py-2 text-left font-medium">Menge</th>
|
||||
<th class="px-2 py-2 text-left font-medium">Einheit</th>
|
||||
<th class="px-2 py-2 text-left font-medium">Einkaufspreis</th>
|
||||
<th class="px-2 py-2 text-left font-medium">Verkaufspreis</th>
|
||||
<th class="w-12 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in props.item.personalComposition"
|
||||
:key="row.id"
|
||||
class="border-b border-gray-100 align-top dark:border-gray-800"
|
||||
>
|
||||
<td class="px-2 py-2">
|
||||
<USelectMenu
|
||||
class="w-full"
|
||||
:items="hourrates"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
@@ -93,47 +101,55 @@ const setRowData = (row) => {
|
||||
:filter-fields="['name']"
|
||||
v-model="row.hourrate"
|
||||
:color="row.hourrate ? 'primary' : 'error'"
|
||||
@change="setRowData(row)"
|
||||
@update:model-value="setRowData(row)"
|
||||
>
|
||||
<!-- <template #label>
|
||||
{{products.find(i => i.id === product.product) ? products.find(i => i.id === product.product).name : 'Kein Artikel ausgewählt'}}
|
||||
</template>-->
|
||||
<template #default>
|
||||
{{ hourrates.find(i => i.id === row.hourrate)?.name || 'Kein Stundensatz ausgewählt' }}
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</td>
|
||||
<td>
|
||||
<td class="px-2 py-2">
|
||||
<UInput
|
||||
class="w-full"
|
||||
type="number"
|
||||
v-model="row.quantity"
|
||||
:step="units.find(i => i.id === row.unit) ? units.find(i => i.id === row.unit).step : 1"
|
||||
@change="calculateTotalPersonalPrice"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<td class="px-2 py-2">
|
||||
<USelectMenu
|
||||
class="w-full"
|
||||
:items="units"
|
||||
disabled
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
v-model="row.unit"
|
||||
></USelectMenu>
|
||||
>
|
||||
<template #default>
|
||||
{{ units.find(i => i.id === row.unit)?.name || 'Einheit' }}
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</td>
|
||||
<td>
|
||||
<td class="px-2 py-2">
|
||||
<UInput
|
||||
class="w-full"
|
||||
type="number"
|
||||
v-model="row.purchasePrice"
|
||||
step="0.01"
|
||||
@change="calculateTotalPersonalPrice"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<td class="px-2 py-2">
|
||||
<UInput
|
||||
class="w-full"
|
||||
type="number"
|
||||
v-model="row.price"
|
||||
step="0.01"
|
||||
@change="calculateTotalPersonalPrice"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<td class="px-2 py-2">
|
||||
<UButton
|
||||
icon="i-heroicons-x-mark"
|
||||
@click="removeRowFromPersonalComposition(row.id)"
|
||||
@@ -141,8 +157,10 @@ const setRowData = (row) => {
|
||||
color="error"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
|
||||
115
frontend/composables/useAdmin.ts
Normal file
115
frontend/composables/useAdmin.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
export type AdminRole = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
tenant_id: number | null
|
||||
}
|
||||
|
||||
export type AdminTenant = {
|
||||
id: number
|
||||
name: string
|
||||
short: string
|
||||
user_count: number
|
||||
locked?: string | null
|
||||
}
|
||||
|
||||
export type AdminUserProfile = {
|
||||
id: string
|
||||
user_id: string | null
|
||||
tenant_id: number
|
||||
full_name: string | null
|
||||
first_name: string
|
||||
last_name: string
|
||||
email?: string | null
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export type AdminUser = {
|
||||
id: string
|
||||
email: string
|
||||
display_name: string
|
||||
multiTenant: boolean
|
||||
must_change_password: boolean
|
||||
is_admin: boolean
|
||||
profile_defaults: {
|
||||
first_name: string
|
||||
last_name: string
|
||||
}
|
||||
tenant_ids: number[]
|
||||
role_assignments: { tenant_id: number; role_id: string }[]
|
||||
profile_assignments?: { tenant_id: number; profile_id?: string | null }[]
|
||||
profiles: AdminUserProfile[]
|
||||
}
|
||||
|
||||
export type AdminOverview = {
|
||||
users: AdminUser[]
|
||||
tenants: AdminTenant[]
|
||||
roles: AdminRole[]
|
||||
unassignedProfiles: AdminUserProfile[]
|
||||
}
|
||||
|
||||
export const useAdmin = () => {
|
||||
const { $api } = useNuxtApp()
|
||||
|
||||
const getOverview = async (): Promise<AdminOverview> => {
|
||||
const response = await $api("/api/admin/overview")
|
||||
|
||||
return {
|
||||
users: response?.users || [],
|
||||
tenants: response?.tenants || [],
|
||||
roles: response?.roles || [],
|
||||
unassignedProfiles: response?.unassignedProfiles || [],
|
||||
}
|
||||
}
|
||||
|
||||
const createUser = async (body: Record<string, any>) => {
|
||||
return await $api("/api/admin/users", {
|
||||
method: "POST",
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
const updateUser = async (id: string, body: Record<string, any>) => {
|
||||
return await $api(`/api/admin/users/${id}`, {
|
||||
method: "PUT",
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
const updateUserAccess = async (id: string, body: Record<string, any>) => {
|
||||
return await $api(`/api/admin/users/${id}/access`, {
|
||||
method: "PUT",
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
const createTenant = async (body: Record<string, any>) => {
|
||||
return await $api("/api/admin/tenants", {
|
||||
method: "POST",
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
const invitePortalUser = async (customerId: number) => {
|
||||
return await $api(`/api/admin/customers/${customerId}/invite-portal-user`, {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
|
||||
const updateTenant = async (id: number, body: Record<string, any>) => {
|
||||
return await $api(`/api/admin/tenants/${id}`, {
|
||||
method: "PUT",
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
getOverview,
|
||||
createUser,
|
||||
updateUser,
|
||||
updateUserAccess,
|
||||
createTenant,
|
||||
invitePortalUser,
|
||||
updateTenant,
|
||||
}
|
||||
}
|
||||
397
frontend/composables/useDepreciation.ts
Normal file
397
frontend/composables/useDepreciation.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import dayjs from "dayjs"
|
||||
|
||||
export const EXPENSE_BOOKING_MODE_ITEMS = [
|
||||
{ label: "Sofortaufwand", value: "expense" },
|
||||
{ label: "Abschreibung einzeln", value: "depreciation_single" },
|
||||
{ label: "Abschreibung Sammelposten", value: "depreciation_bundle" },
|
||||
]
|
||||
|
||||
export const DEPRECIATION_METHOD_ITEMS = [
|
||||
{ label: "Linear", value: "linear" },
|
||||
{ label: "Degressiv", value: "degressive" },
|
||||
]
|
||||
|
||||
export const normalizeExpenseBookingMode = (value?: string | null) => {
|
||||
if (value === "depreciation_single" || value === "depreciation_bundle") return value
|
||||
return "expense"
|
||||
}
|
||||
|
||||
export const isDepreciationBookingMode = (value?: string | null) => {
|
||||
return normalizeExpenseBookingMode(value) !== "expense"
|
||||
}
|
||||
|
||||
export const normalizeDepreciationMethod = (value?: string | null) => {
|
||||
return value === "degressive" ? "degressive" : "linear"
|
||||
}
|
||||
|
||||
export const createIncomingInvoiceAccount = (overrides: Record<string, any> = {}) => ({
|
||||
account: null,
|
||||
amountNet: null,
|
||||
amountTax: null,
|
||||
taxType: "19",
|
||||
costCentre: null,
|
||||
amountGross: null,
|
||||
description: "",
|
||||
bookingMode: "expense",
|
||||
depreciationMonths: null,
|
||||
depreciationStartDate: null,
|
||||
depreciationMethod: "linear",
|
||||
depreciationLabel: "",
|
||||
depreciationGroup: "",
|
||||
residualValue: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
export const normalizeIncomingInvoiceAccount = (account: Record<string, any> = {}, fallbackDate?: any) => {
|
||||
const bookingMode = normalizeExpenseBookingMode(account?.bookingMode)
|
||||
|
||||
return {
|
||||
...createIncomingInvoiceAccount(account),
|
||||
bookingMode,
|
||||
depreciationMonths: account?.depreciationMonths ? Number(account.depreciationMonths) : null,
|
||||
depreciationStartDate: account?.depreciationStartDate || fallbackDate || null,
|
||||
depreciationMethod: normalizeDepreciationMethod(account?.depreciationMethod),
|
||||
depreciationLabel: String(account?.depreciationLabel || account?.description || "").trim(),
|
||||
depreciationGroup: String(account?.depreciationGroup || "").trim(),
|
||||
residualValue: Number(account?.residualValue || 0),
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeIncomingInvoiceAccounts = (accounts: any[] = [], fallbackDate?: any) => {
|
||||
const normalized = (accounts || []).map((account) => normalizeIncomingInvoiceAccount(account, fallbackDate))
|
||||
return normalized.length > 0 ? normalized : [createIncomingInvoiceAccount({ depreciationStartDate: fallbackDate || null })]
|
||||
}
|
||||
|
||||
export const ensureDepreciationDefaults = (item: Record<string, any>, fallbackDate?: any) => {
|
||||
item.bookingMode = normalizeExpenseBookingMode(item?.bookingMode)
|
||||
|
||||
if (!isDepreciationBookingMode(item.bookingMode)) {
|
||||
item.depreciationMonths = null
|
||||
item.depreciationStartDate = null
|
||||
item.depreciationMethod = "linear"
|
||||
item.depreciationGroup = ""
|
||||
item.residualValue = 0
|
||||
if (!item.depreciationLabel) item.depreciationLabel = item.description || ""
|
||||
return item
|
||||
}
|
||||
|
||||
if (!Number(item.depreciationMonths)) item.depreciationMonths = 36
|
||||
if (!item.depreciationStartDate) item.depreciationStartDate = fallbackDate || null
|
||||
item.depreciationMethod = normalizeDepreciationMethod(item.depreciationMethod)
|
||||
if (!item.depreciationLabel) item.depreciationLabel = item.description || ""
|
||||
if (item.bookingMode !== "depreciation_bundle") item.depreciationGroup = ""
|
||||
item.residualValue = Number(item.residualValue || 0)
|
||||
return item
|
||||
}
|
||||
|
||||
const distributeLinearDepreciation = (amount: number, months: number) => {
|
||||
const totalCents = Math.round(Number(amount || 0) * 100)
|
||||
const totalMonths = Math.max(1, Math.round(Number(months || 0)))
|
||||
const base = Math.trunc(totalCents / totalMonths)
|
||||
const remainder = totalCents - (base * totalMonths)
|
||||
|
||||
return Array.from({ length: totalMonths }, (_, index) => {
|
||||
return base + (index < remainder ? 1 : 0)
|
||||
})
|
||||
}
|
||||
|
||||
const distributeDegressiveDepreciation = (amount: number, months: number, residualValue: number) => {
|
||||
const startCents = Math.round(Math.max(0, Number(amount || 0)) * 100)
|
||||
const residualCents = Math.round(Math.max(0, Number(residualValue || 0)) * 100)
|
||||
const totalMonths = Math.max(1, Math.round(Number(months || 0)))
|
||||
const targetResidual = Math.min(startCents, residualCents)
|
||||
const depreciable = Math.max(0, startCents - targetResidual)
|
||||
|
||||
if (!depreciable) return Array.from({ length: totalMonths }, () => 0)
|
||||
|
||||
// Monthly degressive rate derived from start and target residual over the selected duration.
|
||||
const rate = targetResidual > 0
|
||||
? 1 - Math.pow(targetResidual / startCents, 1 / totalMonths)
|
||||
: 1 - Math.pow(0.01 / Math.max(startCents, 1), 1 / totalMonths)
|
||||
|
||||
const values: number[] = []
|
||||
let currentBookValue = startCents
|
||||
let depreciated = 0
|
||||
|
||||
for (let index = 0; index < totalMonths; index += 1) {
|
||||
const remainingDepreciable = Math.max(0, depreciable - depreciated)
|
||||
if (remainingDepreciable <= 0) {
|
||||
values.push(0)
|
||||
continue
|
||||
}
|
||||
|
||||
if (index === totalMonths - 1) {
|
||||
values.push(remainingDepreciable)
|
||||
depreciated += remainingDepreciable
|
||||
currentBookValue = targetResidual
|
||||
continue
|
||||
}
|
||||
|
||||
const raw = Math.round(currentBookValue * rate)
|
||||
const maxAllowed = Math.max(1, remainingDepreciable - Math.max(0, totalMonths - index - 1))
|
||||
const monthValue = Math.max(1, Math.min(raw || 1, maxAllowed))
|
||||
values.push(monthValue)
|
||||
depreciated += monthValue
|
||||
currentBookValue = Math.max(targetResidual, currentBookValue - monthValue)
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
export const getDepreciationPlan = ({
|
||||
amount,
|
||||
months,
|
||||
startDate,
|
||||
method,
|
||||
residualValue,
|
||||
}: {
|
||||
amount: number
|
||||
months: number
|
||||
startDate: any
|
||||
method?: string
|
||||
residualValue?: number
|
||||
}) => {
|
||||
const start = dayjs(startDate).startOf("month")
|
||||
const totalAmount = Number(amount || 0)
|
||||
const totalMonths = Math.max(1, Math.round(Number(months || 0)))
|
||||
const normalizedMethod = normalizeDepreciationMethod(method)
|
||||
const normalizedResidual = Number(residualValue || 0)
|
||||
|
||||
if (!start.isValid() || totalAmount <= 0) return []
|
||||
|
||||
const distributed = normalizedMethod === "degressive"
|
||||
? distributeDegressiveDepreciation(totalAmount, totalMonths, normalizedResidual)
|
||||
: distributeLinearDepreciation(Math.max(0, totalAmount - normalizedResidual), totalMonths)
|
||||
|
||||
return distributed.map((cents, index) => ({
|
||||
index,
|
||||
date: start.add(index, "month"),
|
||||
amount: Number((cents / 100).toFixed(2)),
|
||||
}))
|
||||
}
|
||||
|
||||
export const getLinearDepreciationAmountForRange = ({
|
||||
amount,
|
||||
months,
|
||||
startDate,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
method,
|
||||
residualValue,
|
||||
}: {
|
||||
amount: number
|
||||
months: number
|
||||
startDate: any
|
||||
rangeStart: any
|
||||
rangeEnd: any
|
||||
method?: string
|
||||
residualValue?: number
|
||||
}) => {
|
||||
const start = dayjs(startDate).startOf("month")
|
||||
const periodStart = dayjs(rangeStart).startOf("month")
|
||||
const periodEnd = dayjs(rangeEnd).endOf("month")
|
||||
|
||||
if (!start.isValid() || !periodStart.isValid() || !periodEnd.isValid()) return 0
|
||||
|
||||
const distributed = getDepreciationPlan({
|
||||
amount,
|
||||
months,
|
||||
startDate,
|
||||
method,
|
||||
residualValue,
|
||||
})
|
||||
let resultCents = 0
|
||||
|
||||
distributed.forEach((value) => {
|
||||
const monthDate = dayjs(value.date)
|
||||
if ((monthDate.isAfter(periodStart) || monthDate.isSame(periodStart, "month"))
|
||||
&& (monthDate.isBefore(periodEnd) || monthDate.isSame(periodEnd, "month"))) {
|
||||
resultCents += Math.round(Number(value.amount || 0) * 100)
|
||||
}
|
||||
})
|
||||
|
||||
return Number((resultCents / 100).toFixed(2))
|
||||
}
|
||||
|
||||
export const getIncomingInvoiceImmediateExpenseNet = (invoice: any) => {
|
||||
return Number(((invoice?.accounts || []).reduce((sum: number, account: any) => {
|
||||
const normalized = normalizeIncomingInvoiceAccount(account, invoice?.date)
|
||||
if (isDepreciationBookingMode(normalized.bookingMode)) return sum
|
||||
return sum + Number(normalized.amountNet || 0)
|
||||
}, 0)).toFixed(2))
|
||||
}
|
||||
|
||||
export const getIncomingInvoiceImmediateExpenseGross = (invoice: any) => {
|
||||
return Number(((invoice?.accounts || []).reduce((sum: number, account: any) => {
|
||||
const normalized = normalizeIncomingInvoiceAccount(account, invoice?.date)
|
||||
if (isDepreciationBookingMode(normalized.bookingMode)) return sum
|
||||
|
||||
const amountGross = Number(normalized.amountGross)
|
||||
return sum + (Number.isFinite(amountGross) ? amountGross : Number(normalized.amountNet || 0) + Number(normalized.amountTax || 0))
|
||||
}, 0)).toFixed(2))
|
||||
}
|
||||
|
||||
export const getIncomingInvoiceDepreciationRows = (invoice: any, rangeStart: any, rangeEnd: any) => {
|
||||
return (invoice?.accounts || [])
|
||||
.map((account: any, index: number) => {
|
||||
const normalized = ensureDepreciationDefaults(
|
||||
normalizeIncomingInvoiceAccount(account, invoice?.date),
|
||||
invoice?.date
|
||||
)
|
||||
|
||||
if (!isDepreciationBookingMode(normalized.bookingMode)) return null
|
||||
|
||||
const amount = Number(normalized.amountNet || 0)
|
||||
const months = Number(normalized.depreciationMonths || 0)
|
||||
const plan = getDepreciationPlan({
|
||||
amount,
|
||||
months,
|
||||
startDate: normalized.depreciationStartDate || invoice?.date,
|
||||
method: normalized.depreciationMethod,
|
||||
residualValue: normalized.residualValue,
|
||||
})
|
||||
const periodAmount = Number(plan.reduce((sum, item) => {
|
||||
const monthDate = dayjs(item.date)
|
||||
const periodStart = dayjs(rangeStart).startOf("month")
|
||||
const periodEnd = dayjs(rangeEnd).endOf("month")
|
||||
if ((monthDate.isAfter(periodStart) || monthDate.isSame(periodStart, "month"))
|
||||
&& (monthDate.isBefore(periodEnd) || monthDate.isSame(periodEnd, "month"))) {
|
||||
return sum + Number(item.amount || 0)
|
||||
}
|
||||
return sum
|
||||
}, 0).toFixed(2))
|
||||
|
||||
if (!periodAmount) return null
|
||||
|
||||
const alreadyDepreciated = Number(plan
|
||||
.filter((entry) => dayjs(entry.date).isBefore(dayjs().startOf("month")) || dayjs(entry.date).isSame(dayjs().startOf("month"), "month"))
|
||||
.reduce((sum, entry) => sum + Number(entry.amount || 0), 0)
|
||||
.toFixed(2))
|
||||
const residualBookValue = Number(Math.max(0, amount - alreadyDepreciated).toFixed(2))
|
||||
|
||||
return {
|
||||
source: "incominginvoice",
|
||||
sourceId: invoice?.id,
|
||||
index,
|
||||
mode: normalized.bookingMode,
|
||||
label: normalized.depreciationLabel || normalized.description || invoice?.reference || `Eingangsbeleg ${invoice?.id}`,
|
||||
group: normalized.depreciationGroup || null,
|
||||
amount: periodAmount,
|
||||
months,
|
||||
startDate: normalized.depreciationStartDate || invoice?.date,
|
||||
method: normalized.depreciationMethod,
|
||||
residualValue: Number(normalized.residualValue || 0),
|
||||
alreadyDepreciated,
|
||||
residualBookValue,
|
||||
vendor: invoice?.vendor,
|
||||
reference: invoice?.reference,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export const getStatementAllocationImmediateExpenseAmount = (allocation: any) => {
|
||||
const hasLinkedDocument = allocation?.incominginvoice || allocation?.createddocument || allocation?.ii_id || allocation?.cd_id
|
||||
if (hasLinkedDocument) return 0
|
||||
|
||||
const mode = normalizeExpenseBookingMode(allocation?.bookingMode)
|
||||
const amount = Number(allocation?.amount || 0)
|
||||
if (mode !== "expense" || amount >= 0) return 0
|
||||
return Number(Math.abs(amount).toFixed(2))
|
||||
}
|
||||
|
||||
export const getStatementAllocationDepreciationAmount = (allocation: any, rangeStart: any, rangeEnd: any) => {
|
||||
const mode = normalizeExpenseBookingMode(allocation?.bookingMode)
|
||||
const rawAmount = Number(allocation?.amount || 0)
|
||||
const amount = Math.abs(rawAmount)
|
||||
if (mode === "expense" || rawAmount >= 0 || amount <= 0) return 0
|
||||
|
||||
return getLinearDepreciationAmountForRange({
|
||||
amount,
|
||||
months: Number(allocation?.depreciationMonths || 0),
|
||||
startDate: allocation?.depreciationStartDate || allocation?.created_at,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
method: allocation?.depreciationMethod,
|
||||
residualValue: allocation?.residualValue,
|
||||
})
|
||||
}
|
||||
|
||||
export const getStatementAllocationDepreciationRow = (allocation: any, rangeStart: any, rangeEnd: any) => {
|
||||
const totalAmount = Math.abs(Number(allocation?.amount || 0))
|
||||
const months = Number(allocation?.depreciationMonths || 0)
|
||||
const startDate = allocation?.depreciationStartDate || allocation?.created_at
|
||||
const method = normalizeDepreciationMethod(allocation?.depreciationMethod)
|
||||
const residualValue = Number(allocation?.residualValue || 0)
|
||||
const plan = getDepreciationPlan({
|
||||
amount: totalAmount,
|
||||
months,
|
||||
startDate,
|
||||
method,
|
||||
residualValue,
|
||||
})
|
||||
const amount = Number(plan.reduce((sum, item) => {
|
||||
const monthDate = dayjs(item.date)
|
||||
const periodStart = dayjs(rangeStart).startOf("month")
|
||||
const periodEnd = dayjs(rangeEnd).endOf("month")
|
||||
if ((monthDate.isAfter(periodStart) || monthDate.isSame(periodStart, "month"))
|
||||
&& (monthDate.isBefore(periodEnd) || monthDate.isSame(periodEnd, "month"))) {
|
||||
return sum + Number(item.amount || 0)
|
||||
}
|
||||
return sum
|
||||
}, 0).toFixed(2))
|
||||
if (!amount) return null
|
||||
|
||||
const alreadyDepreciated = Number(plan
|
||||
.filter((entry) => dayjs(entry.date).isBefore(dayjs().startOf("month")) || dayjs(entry.date).isSame(dayjs().startOf("month"), "month"))
|
||||
.reduce((sum, entry) => sum + Number(entry.amount || 0), 0)
|
||||
.toFixed(2))
|
||||
const residualBookValue = Number(Math.max(0, totalAmount - alreadyDepreciated).toFixed(2))
|
||||
|
||||
return {
|
||||
source: "statementallocation",
|
||||
sourceId: allocation?.id,
|
||||
mode: normalizeExpenseBookingMode(allocation?.bookingMode),
|
||||
label: allocation?.depreciationLabel || allocation?.description || "Direkte Abschreibung",
|
||||
group: allocation?.depreciationGroup || null,
|
||||
amount,
|
||||
months,
|
||||
startDate,
|
||||
method,
|
||||
residualValue,
|
||||
alreadyDepreciated,
|
||||
residualBookValue,
|
||||
}
|
||||
}
|
||||
|
||||
export const getAssetDepreciationStatus = (asset: any, asOfDate?: any) => {
|
||||
const amount = Number(asset?.amount || asset?.amountNet || 0)
|
||||
const months = Number(asset?.depreciationMonths || 0)
|
||||
const startDate = asset?.depreciationStartDate || asset?.date || asset?.created_at
|
||||
const method = normalizeDepreciationMethod(asset?.depreciationMethod)
|
||||
const residualValue = Number(asset?.residualValue || 0)
|
||||
const plan = getDepreciationPlan({
|
||||
amount,
|
||||
months,
|
||||
startDate,
|
||||
method,
|
||||
residualValue,
|
||||
})
|
||||
const cutoff = dayjs(asOfDate || new Date()).endOf("month")
|
||||
const depreciated = Number(plan
|
||||
.filter((entry) => dayjs(entry.date).isBefore(cutoff) || dayjs(entry.date).isSame(cutoff, "month"))
|
||||
.reduce((sum, entry) => sum + Number(entry.amount || 0), 0)
|
||||
.toFixed(2))
|
||||
const remaining = Number(Math.max(0, amount - depreciated).toFixed(2))
|
||||
const depreciableBase = Number(Math.max(0, amount - residualValue).toFixed(2))
|
||||
|
||||
return {
|
||||
plan,
|
||||
amount: Number(amount.toFixed(2)),
|
||||
depreciated,
|
||||
remaining,
|
||||
residualValue: Number(residualValue.toFixed(2)),
|
||||
depreciableBase,
|
||||
progressPercent: depreciableBase > 0 ? Math.min(100, Number(((depreciated / depreciableBase) * 100).toFixed(2))) : 0,
|
||||
}
|
||||
}
|
||||
@@ -244,6 +244,18 @@ export const useRole = () => {
|
||||
label: "Kostenstellen erstellen",
|
||||
parent: "costcentres"
|
||||
},
|
||||
teams: {
|
||||
label: "Teams",
|
||||
showToAllUsers: false
|
||||
},
|
||||
"teams-viewAll": {
|
||||
label: "Alle Teams einsehen",
|
||||
parent: "teams"
|
||||
},
|
||||
"teams-create": {
|
||||
label: "Teams erstellen",
|
||||
parent: "teams"
|
||||
},
|
||||
ownaccounts: {
|
||||
label: "Buchungskonten",
|
||||
showToAllUsers: false
|
||||
|
||||
@@ -270,7 +270,7 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<template #footer="{ collapsed }">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<UColorModeSwitch :class="[collapsed ? 'mx-auto' : 'ml-3']"/>
|
||||
<UDashboardSidebarCollapse v-if="collapsed" class="mx-auto" />
|
||||
@@ -284,7 +284,7 @@ onMounted(() => {
|
||||
:key="item.label"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
class="w-full min-w-0 justify-start rounded-lg px-2.5 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
:icon="item.icon"
|
||||
@click="item.click ? item.click() : null"
|
||||
>
|
||||
@@ -305,10 +305,10 @@ onMounted(() => {
|
||||
</UDashboardSidebar>
|
||||
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<UDashboardPanel class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<slot/>
|
||||
|
||||
</div>
|
||||
</UDashboardPanel>
|
||||
</UDashboardGroup>
|
||||
|
||||
<HelpSlideover/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
const auth = useAuthStore()
|
||||
const isPortalUser = Boolean(auth.profile?.customer_for_portal)
|
||||
|
||||
// DEBUG: Was sieht die Middleware wirklich?
|
||||
console.log("🔒 Middleware Check auf:", to.path)
|
||||
@@ -33,10 +34,14 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
if (auth.user.must_change_password) {
|
||||
return navigateTo("/password-change")
|
||||
}
|
||||
return navigateTo("/")
|
||||
return navigateTo(isPortalUser ? "/customer-portal" : "/")
|
||||
}
|
||||
|
||||
if (auth.user.must_change_password && to.path !== "/password-change") {
|
||||
return navigateTo("/password-change")
|
||||
}
|
||||
})
|
||||
|
||||
if (isPortalUser && !["/customer-portal", "/password-change"].includes(to.path)) {
|
||||
return navigateTo("/customer-portal")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -19,11 +19,26 @@
|
||||
"suffix": "",
|
||||
"nextNumber": 1000
|
||||
},
|
||||
"costEstimates": {
|
||||
"prefix": "KS-",
|
||||
"suffix": "",
|
||||
"nextNumber": 1000
|
||||
},
|
||||
"confirmationOrders": {
|
||||
"prefix": "AB-",
|
||||
"suffix": "",
|
||||
"nextNumber": 1000
|
||||
},
|
||||
"deliveryNotes": {
|
||||
"prefix": "LS-",
|
||||
"suffix": "",
|
||||
"nextNumber": 1000
|
||||
},
|
||||
"packingSlips": {
|
||||
"prefix": "PS-",
|
||||
"suffix": "",
|
||||
"nextNumber": 1000
|
||||
},
|
||||
"invoices": {
|
||||
"prefix": "RE-",
|
||||
"suffix": "",
|
||||
@@ -39,4 +54,4 @@
|
||||
"suffix": "",
|
||||
"nextNumber": 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ export default defineNuxtConfig({
|
||||
},
|
||||
|
||||
devOptions: {
|
||||
enabled: true, // Damit du es auch lokal testen kannst
|
||||
enabled: false,
|
||||
type: 'module',
|
||||
},
|
||||
},
|
||||
|
||||
686
frontend/pages/accounting/bwa.vue
Normal file
686
frontend/pages/accounting/bwa.vue
Normal file
@@ -0,0 +1,686 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from "dayjs"
|
||||
import {
|
||||
getCreatedDocumentTaxBreakdown,
|
||||
getIncomingInvoiceTaxBreakdown
|
||||
} from "~/composables/useTaxEvaluation"
|
||||
import {
|
||||
getIncomingInvoiceDepreciationRows,
|
||||
getIncomingInvoiceImmediateExpenseGross,
|
||||
getIncomingInvoiceImmediateExpenseNet,
|
||||
isDepreciationBookingMode,
|
||||
normalizeIncomingInvoiceAccount,
|
||||
getStatementAllocationImmediateExpenseAmount
|
||||
} from "~/composables/useDepreciation"
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const createdDocuments = ref<any[]>([])
|
||||
const incomingInvoices = ref<any[]>([])
|
||||
const accounts = ref<any[]>([])
|
||||
const ownAccounts = ref<any[]>([])
|
||||
const statementAllocations = ref<any[]>([])
|
||||
|
||||
const selectedYear = ref(String(dayjs().year()))
|
||||
const selectedMonth = ref("all")
|
||||
|
||||
const monthItems = [
|
||||
{ label: "Ganzes Jahr", value: "all" },
|
||||
{ label: "Januar", value: "1" },
|
||||
{ label: "Februar", value: "2" },
|
||||
{ label: "Maerz", value: "3" },
|
||||
{ label: "April", value: "4" },
|
||||
{ label: "Mai", value: "5" },
|
||||
{ label: "Juni", value: "6" },
|
||||
{ label: "Juli", value: "7" },
|
||||
{ label: "August", value: "8" },
|
||||
{ label: "September", value: "9" },
|
||||
{ label: "Oktober", value: "10" },
|
||||
{ label: "November", value: "11" },
|
||||
{ label: "Dezember", value: "12" }
|
||||
]
|
||||
|
||||
const accountColumns = [
|
||||
{ accessorKey: "gross", header: "Brutto" },
|
||||
{ accessorKey: "net", header: "Netto" },
|
||||
{ accessorKey: "tax", header: "Steuer" },
|
||||
{ accessorKey: "number", header: "Nummer" },
|
||||
{ accessorKey: "label", header: "Konto" },
|
||||
{ accessorKey: "bookings", header: "Buchungen" }
|
||||
]
|
||||
|
||||
const ownAccountColumns = [
|
||||
{ accessorKey: "balance", header: "Saldo" },
|
||||
{ accessorKey: "expenses", header: "Ausgaben" },
|
||||
{ accessorKey: "income", header: "Einnahmen" },
|
||||
{ accessorKey: "number", header: "Nummer" },
|
||||
{ accessorKey: "label", header: "Konto" },
|
||||
{ accessorKey: "bookings", header: "Buchungen" }
|
||||
]
|
||||
|
||||
const depreciationColumns = [
|
||||
{ accessorKey: "label", header: "Abschreibung" },
|
||||
{ accessorKey: "groupLabel", header: "Gruppe" },
|
||||
{ accessorKey: "modeLabel", header: "Art" },
|
||||
{ accessorKey: "amount", header: "Betrag" },
|
||||
{ accessorKey: "bookings", header: "Positionen" }
|
||||
]
|
||||
|
||||
const isRelevantOutputDocument = (doc: any) => {
|
||||
return doc?.state === "Gebucht" && ["invoices", "advanceInvoices", "cancellationInvoices"].includes(doc?.type)
|
||||
}
|
||||
|
||||
const isRelevantInputInvoice = (invoice: any) => {
|
||||
return invoice?.state === "Gebucht" && !!invoice?.date
|
||||
}
|
||||
|
||||
const sameId = (left: any, right: any) => String(left ?? "") === String(right ?? "")
|
||||
|
||||
const getStatementDate = (allocation: any) => {
|
||||
return allocation?.bankstatement?.date || allocation?.bankstatement?.valueDate || allocation?.date || allocation?.created_at || null
|
||||
}
|
||||
|
||||
const matchesSelectedPeriod = (dateValue: any) => {
|
||||
const parsed = dayjs(dateValue)
|
||||
|
||||
if (!parsed.isValid()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (String(parsed.year()) !== selectedYear.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (selectedMonth.value !== "all" && parsed.month() + 1 !== Number(selectedMonth.value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const computeDocumentNet = (doc: any) => {
|
||||
return Number((doc?.rows || []).reduce((sum: number, row: any) => {
|
||||
if (!row || ["pagebreak", "title", "text"].includes(row.mode)) {
|
||||
return sum
|
||||
}
|
||||
|
||||
const quantity = Number(row.quantity || 0)
|
||||
const price = Number(row.price || 0)
|
||||
const discountPercent = Number(row.discountPercent || 0)
|
||||
|
||||
return sum + (quantity * price * (1 - discountPercent / 100))
|
||||
}, 0).toFixed(2))
|
||||
}
|
||||
|
||||
const yearItems = computed(() => {
|
||||
const years = new Set<string>([String(dayjs().year())])
|
||||
|
||||
createdDocuments.value.forEach((doc) => {
|
||||
const parsed = dayjs(doc.documentDate)
|
||||
if (parsed.isValid()) {
|
||||
years.add(String(parsed.year()))
|
||||
}
|
||||
})
|
||||
|
||||
incomingInvoices.value.forEach((invoice) => {
|
||||
const parsed = dayjs(invoice.date)
|
||||
if (parsed.isValid()) {
|
||||
years.add(String(parsed.year()))
|
||||
}
|
||||
})
|
||||
|
||||
statementAllocations.value.forEach((allocation) => {
|
||||
const parsed = dayjs(getStatementDate(allocation))
|
||||
if (parsed.isValid()) {
|
||||
years.add(String(parsed.year()))
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(years)
|
||||
.sort((a, b) => Number(b) - Number(a))
|
||||
.map((year) => ({ label: year, value: year }))
|
||||
})
|
||||
|
||||
const filteredDocuments = computed(() => {
|
||||
return createdDocuments.value.filter((doc) => matchesSelectedPeriod(doc.documentDate))
|
||||
})
|
||||
|
||||
const filteredIncomingInvoices = computed(() => {
|
||||
return incomingInvoices.value.filter((invoice) => matchesSelectedPeriod(invoice.date))
|
||||
})
|
||||
|
||||
const filteredStatementAllocations = computed(() => {
|
||||
return statementAllocations.value.filter((allocation) => matchesSelectedPeriod(getStatementDate(allocation)))
|
||||
})
|
||||
|
||||
const filteredAccountStatementAllocations = computed(() => {
|
||||
return filteredStatementAllocations.value.filter((allocation) => {
|
||||
if (allocation.account === null || allocation.account === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
return getStatementAllocationImmediateExpenseAmount(allocation) > 0
|
||||
})
|
||||
})
|
||||
|
||||
const selectedPeriodBounds = computed(() => {
|
||||
const start = selectedMonth.value === "all"
|
||||
? dayjs(`${selectedYear.value}-01-01`).startOf("day")
|
||||
: dayjs(`${selectedYear.value}-${String(selectedMonth.value).padStart(2, "0")}-01`).startOf("month")
|
||||
|
||||
const end = selectedMonth.value === "all"
|
||||
? dayjs(`${selectedYear.value}-12-31`).endOf("day")
|
||||
: start.endOf("month")
|
||||
|
||||
return { start, end }
|
||||
})
|
||||
|
||||
const incomeTotal = computed(() => {
|
||||
return Number(filteredDocuments.value.reduce((sum, doc) => sum + computeDocumentNet(doc), 0).toFixed(2))
|
||||
})
|
||||
|
||||
const expenseNetTotal = computed(() => {
|
||||
const invoiceExpenses = filteredIncomingInvoices.value.reduce((sum, invoice) => {
|
||||
return sum + getIncomingInvoiceImmediateExpenseNet(invoice)
|
||||
}, 0)
|
||||
|
||||
const directAccountExpenses = filteredAccountStatementAllocations.value.reduce((sum, allocation) => {
|
||||
return sum + Number(getStatementAllocationImmediateExpenseAmount(allocation) || 0)
|
||||
}, 0)
|
||||
|
||||
const depreciations = depreciationTotal.value
|
||||
|
||||
return Number((invoiceExpenses + directAccountExpenses + depreciations).toFixed(2))
|
||||
})
|
||||
|
||||
const expenseGrossTotal = computed(() => {
|
||||
const invoiceExpenses = filteredIncomingInvoices.value.reduce((sum, invoice) => sum + getIncomingInvoiceImmediateExpenseGross(invoice), 0)
|
||||
|
||||
const directAccountExpenses = filteredAccountStatementAllocations.value.reduce((sum, allocation) => {
|
||||
return sum + Number(getStatementAllocationImmediateExpenseAmount(allocation) || 0)
|
||||
}, 0)
|
||||
|
||||
const depreciations = depreciationTotal.value
|
||||
|
||||
return Number((invoiceExpenses + directAccountExpenses + depreciations).toFixed(2))
|
||||
})
|
||||
|
||||
const taxSummary = computed(() => {
|
||||
const output = filteredDocuments.value.reduce((sum, doc) => {
|
||||
const breakdown = getCreatedDocumentTaxBreakdown(doc)
|
||||
return {
|
||||
net19: sum.net19 + breakdown.net19,
|
||||
tax19: sum.tax19 + breakdown.tax19,
|
||||
net7: sum.net7 + breakdown.net7,
|
||||
tax7: sum.tax7 + breakdown.tax7,
|
||||
net0: sum.net0 + breakdown.net0
|
||||
}
|
||||
}, { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 })
|
||||
|
||||
const input = filteredIncomingInvoices.value.reduce((sum, invoice) => {
|
||||
const breakdown = getIncomingInvoiceTaxBreakdown(invoice)
|
||||
return {
|
||||
net19: sum.net19 + breakdown.net19,
|
||||
tax19: sum.tax19 + breakdown.tax19,
|
||||
net7: sum.net7 + breakdown.net7,
|
||||
tax7: sum.tax7 + breakdown.tax7,
|
||||
net0: sum.net0 + breakdown.net0
|
||||
}
|
||||
}, { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 })
|
||||
|
||||
const outputTax = Number((output.tax19 + output.tax7).toFixed(2))
|
||||
const inputTax = Number((input.tax19 + input.tax7).toFixed(2))
|
||||
|
||||
return {
|
||||
output,
|
||||
input,
|
||||
outputTax,
|
||||
inputTax,
|
||||
balance: Number((outputTax - inputTax).toFixed(2))
|
||||
}
|
||||
})
|
||||
|
||||
const operatingResult = computed(() => {
|
||||
return Number((incomeTotal.value - expenseNetTotal.value).toFixed(2))
|
||||
})
|
||||
|
||||
const incomeDocumentCount = computed(() => filteredDocuments.value.length)
|
||||
const expenseDocumentCount = computed(() => {
|
||||
const directExpenseCount = filteredAccountStatementAllocations.value.filter((allocation) => getStatementAllocationImmediateExpenseAmount(allocation) > 0).length
|
||||
return filteredIncomingInvoices.value.filter((invoice) => getIncomingInvoiceImmediateExpenseNet(invoice) > 0).length + directExpenseCount
|
||||
})
|
||||
|
||||
const depreciationRows = computed(() => {
|
||||
const invoiceRows = filteredIncomingInvoices.value.flatMap((invoice) => getIncomingInvoiceDepreciationRows(invoice, selectedPeriodBounds.value.start, selectedPeriodBounds.value.end))
|
||||
|
||||
const grouped = new Map<string, any>()
|
||||
|
||||
;invoiceRows.forEach((row: any) => {
|
||||
const key = row.group || `${row.mode}:${row.label}`
|
||||
const current = grouped.get(key) || {
|
||||
id: key,
|
||||
label: row.group || row.label,
|
||||
groupLabel: row.group || "-",
|
||||
modeLabel: row.mode === "depreciation_bundle" ? "Sammelposten" : "Einzeln",
|
||||
amount: 0,
|
||||
bookings: 0
|
||||
}
|
||||
|
||||
current.amount += Number(row.amount || 0)
|
||||
current.bookings += 1
|
||||
grouped.set(key, current)
|
||||
})
|
||||
|
||||
return Array.from(grouped.values())
|
||||
.map((row) => ({ ...row, amount: Number(row.amount.toFixed(2)) }))
|
||||
.sort((left, right) => Number(right.amount) - Number(left.amount))
|
||||
})
|
||||
|
||||
const depreciationTotal = computed(() => {
|
||||
return Number(depreciationRows.value.reduce((sum, row) => sum + Number(row.amount || 0), 0).toFixed(2))
|
||||
})
|
||||
|
||||
const accountRows = computed(() => {
|
||||
return accounts.value
|
||||
.map((account) => {
|
||||
const invoiceBookings = filteredIncomingInvoices.value.flatMap((invoice) => {
|
||||
return (invoice.accounts || [])
|
||||
.filter((invoiceAccount: any) => !isDepreciationBookingMode(normalizeIncomingInvoiceAccount(invoiceAccount, invoice?.date).bookingMode))
|
||||
.filter((invoiceAccount: any) => sameId(invoiceAccount.account?.id || invoiceAccount.account, account.id))
|
||||
.map((invoiceAccount: any) => ({
|
||||
type: "incominginvoice",
|
||||
amountNet: Number(invoiceAccount.amountNet || 0),
|
||||
amountTax: Number(invoiceAccount.amountTax || 0),
|
||||
amountGross: Number.isFinite(Number(invoiceAccount.amountGross))
|
||||
? Number(invoiceAccount.amountGross)
|
||||
: Number(invoiceAccount.amountNet || 0) + Number(invoiceAccount.amountTax || 0)
|
||||
}))
|
||||
})
|
||||
|
||||
const directBookings = filteredAccountStatementAllocations.value
|
||||
.filter((allocation) => getStatementAllocationImmediateExpenseAmount(allocation) > 0)
|
||||
.filter((allocation) => sameId(allocation.account?.id || allocation.account, account.id))
|
||||
.map((allocation) => {
|
||||
const amount = Number(allocation.amount || 0)
|
||||
|
||||
return {
|
||||
type: "statementallocation",
|
||||
amountNet: amount,
|
||||
amountTax: 0,
|
||||
amountGross: amount
|
||||
}
|
||||
})
|
||||
|
||||
const bookings = [...invoiceBookings, ...directBookings]
|
||||
|
||||
if (bookings.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const net = bookings.reduce((sum, booking: any) => sum + Number(booking.amountNet || 0), 0)
|
||||
const tax = bookings.reduce((sum, booking: any) => sum + Number(booking.amountTax || 0), 0)
|
||||
const gross = bookings.reduce((sum, booking: any) => {
|
||||
const amountGross = Number(booking.amountGross)
|
||||
return sum + (Number.isFinite(amountGross) ? amountGross : Number(booking.amountNet || 0) + Number(booking.amountTax || 0))
|
||||
}, 0)
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
number: account.number || "-",
|
||||
label: account.label || account.name || "-",
|
||||
bookings: bookings.length,
|
||||
net: Number(net.toFixed(2)),
|
||||
tax: Number(tax.toFixed(2)),
|
||||
gross: Number(gross.toFixed(2))
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left: any, right: any) => Math.abs(Number(right.gross)) - Math.abs(Number(left.gross)))
|
||||
})
|
||||
|
||||
const ownAccountRows = computed(() => {
|
||||
return ownAccounts.value
|
||||
.map((account) => {
|
||||
const bookings = filteredStatementAllocations.value.filter((allocation) => sameId(allocation.ownaccount?.id || allocation.ownaccount, account.id))
|
||||
|
||||
if (bookings.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const income = bookings.reduce((sum, booking) => {
|
||||
const amount = Number(booking.amount || 0)
|
||||
return amount > 0 ? sum + amount : sum
|
||||
}, 0)
|
||||
|
||||
const expenses = bookings.reduce((sum, booking) => {
|
||||
const amount = Number(booking.amount || 0)
|
||||
return amount < 0 ? sum + Math.abs(amount) : sum
|
||||
}, 0)
|
||||
|
||||
const balance = bookings.reduce((sum, booking) => sum + Number(booking.amount || 0), 0)
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
number: account.number || "-",
|
||||
label: account.name || account.label || "-",
|
||||
bookings: bookings.length,
|
||||
income: Number(income.toFixed(2)),
|
||||
expenses: Number(expenses.toFixed(2)),
|
||||
balance: Number(balance.toFixed(2))
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left: any, right: any) => Math.abs(Number(right.balance)) - Math.abs(Number(left.balance)))
|
||||
})
|
||||
|
||||
const setupPage = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const [docs, invoices, accountItems, ownAccountItems, allocationItems] = await Promise.all([
|
||||
useEntities("createddocuments").select(),
|
||||
useEntities("incominginvoices").select("*, vendor(*)"),
|
||||
useEntities("accounts").selectSpecial(),
|
||||
useEntities("ownaccounts").select(),
|
||||
useEntities("statementallocations").select("*, bankstatement(*), createddocument(*), incominginvoice(*)")
|
||||
])
|
||||
|
||||
createdDocuments.value = (docs || []).filter(isRelevantOutputDocument)
|
||||
incomingInvoices.value = (invoices || []).filter(isRelevantInputInvoice)
|
||||
accounts.value = accountItems || []
|
||||
ownAccounts.value = ownAccountItems || []
|
||||
statementAllocations.value = allocationItems || []
|
||||
|
||||
const firstYear = yearItems.value[0]?.value
|
||||
if (firstYear && !yearItems.value.some((item) => item.value === selectedYear.value)) {
|
||||
selectedYear.value = firstYear
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openAccount = (rowLike: any) => {
|
||||
const row = rowLike?.original || rowLike
|
||||
if (row?.id) {
|
||||
router.push(`/accounts/show/${row.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const openOwnAccount = (rowLike: any) => {
|
||||
const row = rowLike?.original || rowLike
|
||||
if (row?.id) {
|
||||
router.push(`/standardEntity/ownaccounts/show/${row.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(setupPage)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardNavbar title="BWA">
|
||||
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UDashboardPanelContent class="min-w-0 space-y-6 overflow-x-hidden overflow-y-auto p-4 md:p-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<UFormField label="Jahr" class="w-full md:w-48">
|
||||
<USelectMenu
|
||||
v-model="selectedYear"
|
||||
:items="yearItems"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Monat" class="w-full md:w-56">
|
||||
<USelectMenu
|
||||
v-model="selectedMonth"
|
||||
:items="monthItems"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<UCard class="min-w-0">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Einnahmen netto</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ useCurrency(incomeTotal) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ incomeDocumentCount }} gebuchte Ausgangsbelege
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard class="min-w-0">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Ausgaben netto</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ useCurrency(expenseNetTotal) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Brutto: {{ useCurrency(expenseGrossTotal) }}
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard class="min-w-0">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Abschreibungen</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-amber-600 dark:text-amber-400">{{ useCurrency(depreciationTotal) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ depreciationRows.length }} periodisierte Buchungen
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard class="min-w-0">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Einnahmen Belege</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ incomeDocumentCount }}</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Ausgangsbelege im Zeitraum
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard class="min-w-0">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Ausgaben Belege</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ expenseDocumentCount }}</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Eingangsbelege plus direkte Buchungen
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-4 md:grid-cols-2">
|
||||
<UCard class="min-w-0">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Betriebsergebnis</div>
|
||||
<div class="mt-2 text-2xl font-semibold" :class="operatingResult >= 0 ? 'text-primary-500' : 'text-error'">
|
||||
{{ useCurrency(operatingResult) }}
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Einnahmen minus Ausgaben netto
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard class="min-w-0">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">USt-Saldo</div>
|
||||
<div class="mt-2 text-2xl font-semibold" :class="taxSummary.balance >= 0 ? 'text-amber-600 dark:text-amber-400' : 'text-primary-500'">
|
||||
{{ useCurrency(taxSummary.balance) }}
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
USt {{ useCurrency(taxSummary.outputTax) }} | Vorsteuer {{ useCurrency(taxSummary.inputTax) }}
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-4 xl:grid-cols-2">
|
||||
<UCard class="min-w-0">
|
||||
<template #header>
|
||||
<div class="font-semibold">USt-Details</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Netto 19% Ausgangsbelege</span>
|
||||
<span>{{ useCurrency(taxSummary.output.net19) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>USt 19%</span>
|
||||
<span>{{ useCurrency(taxSummary.output.tax19) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Netto 7% Ausgangsbelege</span>
|
||||
<span>{{ useCurrency(taxSummary.output.net7) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>USt 7%</span>
|
||||
<span>{{ useCurrency(taxSummary.output.tax7) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Steuerfrei</span>
|
||||
<span>{{ useCurrency(taxSummary.output.net0 + taxSummary.input.net0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard class="min-w-0">
|
||||
<template #header>
|
||||
<div class="font-semibold">Vorsteuer-Details</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Netto 19% Eingangsbelege</span>
|
||||
<span>{{ useCurrency(taxSummary.input.net19) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Vorsteuer 19%</span>
|
||||
<span>{{ useCurrency(taxSummary.input.tax19) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Netto 7% Eingangsbelege</span>
|
||||
<span>{{ useCurrency(taxSummary.input.net7) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Vorsteuer 7%</span>
|
||||
<span>{{ useCurrency(taxSummary.input.tax7) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Steuerfrei</span>
|
||||
<span>{{ useCurrency(taxSummary.input.net0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-4 xl:grid-cols-2">
|
||||
<UCard class="min-w-0">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="font-semibold">Buchungskonten</span>
|
||||
<UBadge color="neutral" variant="soft">{{ accountRows.length }}</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="min-w-0">
|
||||
<UTable
|
||||
:data="accountRows"
|
||||
:columns="normalizeTableColumns(accountColumns)"
|
||||
:loading="loading"
|
||||
:on-select="openAccount"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="flex flex-col items-center justify-center py-10 text-center">
|
||||
<UIcon name="i-heroicons-circle-stack-20-solid" class="mb-2 h-10 w-10 text-gray-400" />
|
||||
<p class="font-medium">Keine Buchungskonten im ausgewaehlten Zeitraum</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #label-cell="{ row }">
|
||||
<div class="truncate font-medium">{{ row.original.label }}</div>
|
||||
</template>
|
||||
|
||||
<template #bookings-cell="{ row }">
|
||||
<div class="text-right">{{ row.original.bookings }}</div>
|
||||
</template>
|
||||
|
||||
<template #net-cell="{ row }">
|
||||
<div class="text-right tabular-nums">{{ useCurrency(row.original.net) }}</div>
|
||||
</template>
|
||||
|
||||
<template #tax-cell="{ row }">
|
||||
<div class="text-right tabular-nums">{{ useCurrency(row.original.tax) }}</div>
|
||||
</template>
|
||||
|
||||
<template #gross-cell="{ row }">
|
||||
<div class="text-right font-medium tabular-nums">{{ useCurrency(row.original.gross) }}</div>
|
||||
</template>
|
||||
</UTable>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard class="min-w-0">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="font-semibold">Eigene Buchungskonten</span>
|
||||
<UBadge color="neutral" variant="soft">{{ ownAccountRows.length }}</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="min-w-0">
|
||||
<UTable
|
||||
:data="ownAccountRows"
|
||||
:columns="normalizeTableColumns(ownAccountColumns)"
|
||||
:loading="loading"
|
||||
:on-select="openOwnAccount"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="flex flex-col items-center justify-center py-10 text-center">
|
||||
<UIcon name="i-heroicons-circle-stack-20-solid" class="mb-2 h-10 w-10 text-gray-400" />
|
||||
<p class="font-medium">Keine eigenen Buchungen im ausgewaehlten Zeitraum</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #label-cell="{ row }">
|
||||
<div class="truncate font-medium">{{ row.original.label }}</div>
|
||||
</template>
|
||||
|
||||
<template #bookings-cell="{ row }">
|
||||
<div class="text-right">{{ row.original.bookings }}</div>
|
||||
</template>
|
||||
|
||||
<template #income-cell="{ row }">
|
||||
<div class="text-right text-primary-500 tabular-nums">{{ useCurrency(row.original.income) }}</div>
|
||||
</template>
|
||||
|
||||
<template #expenses-cell="{ row }">
|
||||
<div class="text-right text-error tabular-nums">{{ useCurrency(row.original.expenses) }}</div>
|
||||
</template>
|
||||
|
||||
<template #balance-cell="{ row }">
|
||||
<div class="text-right font-medium tabular-nums" :class="row.original.balance >= 0 ? 'text-primary-500' : 'text-error'">
|
||||
{{ useCurrency(row.original.balance) }}
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<UCard class="min-w-0" v-if="depreciationRows.length > 0">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="font-semibold">Abschreibungen</span>
|
||||
<UBadge color="warning" variant="soft">{{ depreciationRows.length }}</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UTable
|
||||
:data="depreciationRows"
|
||||
:columns="normalizeTableColumns(depreciationColumns)"
|
||||
:loading="loading"
|
||||
>
|
||||
<template #amount-cell="{ row }">
|
||||
<div class="text-right text-amber-600 dark:text-amber-400 tabular-nums">{{ useCurrency(row.original.amount) }}</div>
|
||||
</template>
|
||||
</UTable>
|
||||
</UCard>
|
||||
</UDashboardPanelContent>
|
||||
</template>
|
||||
429
frontend/pages/accounting/depreciation.vue
Normal file
429
frontend/pages/accounting/depreciation.vue
Normal file
@@ -0,0 +1,429 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from "dayjs"
|
||||
import {
|
||||
DEPRECIATION_METHOD_ITEMS,
|
||||
ensureDepreciationDefaults,
|
||||
getAssetDepreciationStatus,
|
||||
getIncomingInvoiceDepreciationRows,
|
||||
getStatementAllocationDepreciationRow,
|
||||
isDepreciationBookingMode,
|
||||
normalizeIncomingInvoiceAccount
|
||||
} from "~/composables/useDepreciation"
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const incomingInvoices = ref<any[]>([])
|
||||
const statementAllocations = ref<any[]>([])
|
||||
const periodStart = ref(dayjs().startOf("month").format("YYYY-MM-DD"))
|
||||
const periodEnd = ref(dayjs().endOf("month").format("YYYY-MM-DD"))
|
||||
const selectedAsset = ref<any | null>(null)
|
||||
const editState = ref<any | null>(null)
|
||||
const editOpen = computed({
|
||||
get: () => !!selectedAsset.value,
|
||||
set: (value: boolean) => {
|
||||
if (!value) closeEdit()
|
||||
}
|
||||
})
|
||||
|
||||
const formatCurrency = (value: number) => new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR"
|
||||
}).format(Number(value || 0))
|
||||
|
||||
const isRelevantInputInvoice = (invoice: any) => invoice?.state === "Gebucht" && !!invoice?.date
|
||||
const normalizedPeriod = computed(() => {
|
||||
const start = dayjs(periodStart.value)
|
||||
const end = dayjs(periodEnd.value)
|
||||
|
||||
if (start.isValid() && end.isValid() && start.isAfter(end, "day")) {
|
||||
return {
|
||||
start: end.startOf("day"),
|
||||
end: start.endOf("day"),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start: start.isValid() ? start.startOf("day") : dayjs().startOf("month"),
|
||||
end: end.isValid() ? end.endOf("day") : dayjs().endOf("month"),
|
||||
}
|
||||
})
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const [invoices, allocations] = await Promise.all([
|
||||
useEntities("incominginvoices").select("*, vendor(*)"),
|
||||
useEntities("statementallocations").select("*, bankstatement(*), vendor(*), customer(*)")
|
||||
])
|
||||
|
||||
incomingInvoices.value = (invoices || []).filter(isRelevantInputInvoice)
|
||||
statementAllocations.value = (allocations || []).filter((item: any) => Number(item?.amount || 0) < 0)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const depreciationAssets = computed(() => {
|
||||
const invoiceAssets = incomingInvoices.value.flatMap((invoice: any) => {
|
||||
return (invoice.accounts || [])
|
||||
.map((account: any, index: number) => {
|
||||
const normalized = ensureDepreciationDefaults(normalizeIncomingInvoiceAccount(account, invoice.date), invoice.date)
|
||||
if (!isDepreciationBookingMode(normalized.bookingMode)) return null
|
||||
|
||||
const status = getAssetDepreciationStatus({
|
||||
amountNet: Number(normalized.amountNet || 0),
|
||||
depreciationMonths: normalized.depreciationMonths,
|
||||
depreciationStartDate: normalized.depreciationStartDate || invoice.date,
|
||||
depreciationMethod: normalized.depreciationMethod,
|
||||
residualValue: normalized.residualValue,
|
||||
}, normalizedPeriod.value.end)
|
||||
|
||||
const currentPeriodRow = getIncomingInvoiceDepreciationRows(invoice, normalizedPeriod.value.start, normalizedPeriod.value.end)
|
||||
.find((row: any) => row.index === index)
|
||||
|
||||
return {
|
||||
key: `invoice-${invoice.id}-${index}`,
|
||||
sourceType: "incominginvoice",
|
||||
sourceId: invoice.id,
|
||||
accountIndex: index,
|
||||
label: normalized.depreciationLabel || normalized.description || invoice.reference || `Eingangsbeleg ${invoice.id}`,
|
||||
group: normalized.depreciationGroup || null,
|
||||
mode: normalized.bookingMode,
|
||||
method: normalized.depreciationMethod,
|
||||
months: Number(normalized.depreciationMonths || 0),
|
||||
startDate: normalized.depreciationStartDate || invoice.date,
|
||||
residualValue: Number(normalized.residualValue || 0),
|
||||
originalValue: Number(normalized.amountNet || 0),
|
||||
currentPeriodAmount: Number(currentPeriodRow?.amount || 0),
|
||||
depreciated: status.depreciated,
|
||||
remaining: status.remaining,
|
||||
progressPercent: status.progressPercent,
|
||||
vendorName: invoice.vendor?.name || "-",
|
||||
reference: invoice.reference || "-",
|
||||
sourceRecord: invoice,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
})
|
||||
|
||||
const allocationAssets = statementAllocations.value
|
||||
.map((allocation: any) => {
|
||||
if (!isDepreciationBookingMode(allocation?.bookingMode)) return null
|
||||
|
||||
const status = getAssetDepreciationStatus({
|
||||
amount: Math.abs(Number(allocation.amount || 0)),
|
||||
depreciationMonths: allocation.depreciationMonths,
|
||||
depreciationStartDate: allocation.depreciationStartDate || allocation.created_at,
|
||||
depreciationMethod: allocation.depreciationMethod,
|
||||
residualValue: allocation.residualValue,
|
||||
}, normalizedPeriod.value.end)
|
||||
|
||||
const currentPeriodRow = getStatementAllocationDepreciationRow(allocation, normalizedPeriod.value.start, normalizedPeriod.value.end)
|
||||
|
||||
return {
|
||||
key: `allocation-${allocation.id}`,
|
||||
sourceType: "statementallocation",
|
||||
sourceId: allocation.id,
|
||||
accountIndex: null,
|
||||
label: allocation.depreciationLabel || allocation.description || "Direkte Abschreibung",
|
||||
group: allocation.depreciationGroup || null,
|
||||
mode: allocation.bookingMode,
|
||||
method: allocation.depreciationMethod || "linear",
|
||||
months: Number(allocation.depreciationMonths || 0),
|
||||
startDate: allocation.depreciationStartDate || allocation.created_at,
|
||||
residualValue: Number(allocation.residualValue || 0),
|
||||
originalValue: Math.abs(Number(allocation.amount || 0)),
|
||||
currentPeriodAmount: Number(currentPeriodRow?.amount || 0),
|
||||
depreciated: status.depreciated,
|
||||
remaining: status.remaining,
|
||||
progressPercent: status.progressPercent,
|
||||
vendorName: allocation.vendor?.name || allocation.customer?.name || "-",
|
||||
reference: allocation.description || "-",
|
||||
sourceRecord: allocation,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
return [...invoiceAssets, ...allocationAssets]
|
||||
.sort((left: any, right: any) => Number(right.originalValue) - Number(left.originalValue))
|
||||
})
|
||||
|
||||
const groupedAssets = computed(() => {
|
||||
const groups = new Map<string, any>()
|
||||
|
||||
depreciationAssets.value.forEach((asset: any) => {
|
||||
const groupKey = asset.group || asset.key
|
||||
const current = groups.get(groupKey) || {
|
||||
key: groupKey,
|
||||
label: asset.group || asset.label,
|
||||
isBundle: !!asset.group,
|
||||
assets: [],
|
||||
originalValue: 0,
|
||||
depreciated: 0,
|
||||
remaining: 0,
|
||||
currentPeriodAmount: 0,
|
||||
}
|
||||
|
||||
current.assets.push(asset)
|
||||
current.originalValue += asset.originalValue
|
||||
current.depreciated += asset.depreciated
|
||||
current.remaining += asset.remaining
|
||||
current.currentPeriodAmount += asset.currentPeriodAmount
|
||||
groups.set(groupKey, current)
|
||||
})
|
||||
|
||||
return Array.from(groups.values()).map((group: any) => ({
|
||||
...group,
|
||||
originalValue: Number(group.originalValue.toFixed(2)),
|
||||
depreciated: Number(group.depreciated.toFixed(2)),
|
||||
remaining: Number(group.remaining.toFixed(2)),
|
||||
currentPeriodAmount: Number(group.currentPeriodAmount.toFixed(2)),
|
||||
residualValue: Number(group.assets.reduce((sum: number, asset: any) => sum + Number(asset.residualValue || 0), 0).toFixed(2)),
|
||||
progressPercent: getProgressPercent(
|
||||
group.depreciated,
|
||||
group.originalValue,
|
||||
group.assets.reduce((sum: number, asset: any) => sum + Number(asset.residualValue || 0), 0)
|
||||
),
|
||||
}))
|
||||
})
|
||||
|
||||
const totals = computed(() => {
|
||||
return groupedAssets.value.reduce((sum: any, group: any) => ({
|
||||
originalValue: Number((sum.originalValue + group.originalValue).toFixed(2)),
|
||||
depreciated: Number((sum.depreciated + group.depreciated).toFixed(2)),
|
||||
remaining: Number((sum.remaining + group.remaining).toFixed(2)),
|
||||
currentPeriodAmount: Number((sum.currentPeriodAmount + group.currentPeriodAmount).toFixed(2)),
|
||||
count: sum.count + group.assets.length,
|
||||
bundleCount: sum.bundleCount + (group.isBundle ? 1 : 0),
|
||||
}), { originalValue: 0, depreciated: 0, remaining: 0, currentPeriodAmount: 0, count: 0, bundleCount: 0 })
|
||||
})
|
||||
|
||||
const getProgressPercent = (depreciated: number, originalValue: number, residualValue: number) => {
|
||||
const depreciableBase = Math.max(0, Number(originalValue || 0) - Number(residualValue || 0))
|
||||
if (!depreciableBase) return 0
|
||||
return Math.min(100, Number(((Number(depreciated || 0) / depreciableBase) * 100).toFixed(2)))
|
||||
}
|
||||
|
||||
const startEdit = (asset: any) => {
|
||||
selectedAsset.value = asset
|
||||
editState.value = {
|
||||
depreciationLabel: asset.label,
|
||||
depreciationGroup: asset.group || "",
|
||||
depreciationMethod: asset.method || "linear",
|
||||
depreciationMonths: asset.months || 36,
|
||||
depreciationStartDate: dayjs(asset.startDate).format("YYYY-MM-DD"),
|
||||
residualValue: asset.residualValue || 0,
|
||||
}
|
||||
}
|
||||
|
||||
const closeEdit = () => {
|
||||
selectedAsset.value = null
|
||||
editState.value = null
|
||||
}
|
||||
|
||||
const saveAsset = async () => {
|
||||
if (!selectedAsset.value || !editState.value) return
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
if (selectedAsset.value.sourceType === "incominginvoice") {
|
||||
const source = selectedAsset.value.sourceRecord
|
||||
const nextAccounts = [...(source.accounts || [])]
|
||||
nextAccounts[selectedAsset.value.accountIndex] = {
|
||||
...nextAccounts[selectedAsset.value.accountIndex],
|
||||
depreciationLabel: editState.value.depreciationLabel,
|
||||
depreciationGroup: selectedAsset.value.mode === "depreciation_bundle" ? editState.value.depreciationGroup : "",
|
||||
depreciationMethod: editState.value.depreciationMethod,
|
||||
depreciationMonths: Number(editState.value.depreciationMonths || 0),
|
||||
depreciationStartDate: editState.value.depreciationStartDate,
|
||||
residualValue: Number(editState.value.residualValue || 0),
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...source,
|
||||
vendor: source.vendor?.id || source.vendor,
|
||||
accounts: nextAccounts,
|
||||
}
|
||||
delete payload.statementallocations
|
||||
delete payload.files
|
||||
await useEntities("incominginvoices").update(source.id, payload, true)
|
||||
} else {
|
||||
const source = selectedAsset.value.sourceRecord
|
||||
await useEntities("statementallocations").update(source.id, {
|
||||
depreciationLabel: editState.value.depreciationLabel,
|
||||
depreciationGroup: selectedAsset.value.mode === "depreciation_bundle" ? editState.value.depreciationGroup : null,
|
||||
depreciationMethod: editState.value.depreciationMethod,
|
||||
depreciationMonths: Number(editState.value.depreciationMonths || 0),
|
||||
depreciationStartDate: editState.value.depreciationStartDate,
|
||||
residualValue: Number(editState.value.residualValue || 0),
|
||||
}, true)
|
||||
}
|
||||
|
||||
toast.add({ title: "Abschreibung gespeichert", color: "success" })
|
||||
closeEdit()
|
||||
await loadData()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardNavbar title="Abschreibungen">
|
||||
<template #right>
|
||||
<div class="flex items-center gap-2">
|
||||
<UInput v-model="periodStart" type="date" class="w-44" />
|
||||
<UInput v-model="periodEnd" type="date" class="w-44" />
|
||||
<UButton icon="i-heroicons-arrow-path" variant="outline" :loading="loading" @click="loadData">
|
||||
Aktualisieren
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UDashboardPanelContent class="space-y-6 p-4 md:p-6">
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Anschaffungswert</div>
|
||||
<div class="mt-2 text-2xl font-semibold">{{ formatCurrency(totals.originalValue) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ totals.count }} Abschreibungspositionen</div>
|
||||
</UCard>
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Bereits abgeschrieben</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-primary-600 dark:text-primary-400">{{ formatCurrency(totals.depreciated) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Stand {{ normalizedPeriod.end.format("DD.MM.YYYY") }}</div>
|
||||
</UCard>
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Restbuchwert</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-amber-600 dark:text-amber-400">{{ formatCurrency(totals.remaining) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Nach Restwertlogik</div>
|
||||
</UCard>
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Abschreibung im Zeitraum</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-error">{{ formatCurrency(totals.currentPeriodAmount) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ normalizedPeriod.start.format("DD.MM.YYYY") }} - {{ normalizedPeriod.end.format("DD.MM.YYYY") }}</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<UCard v-for="group in groupedAssets" :key="group.key">
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold">{{ group.label }}</span>
|
||||
<UBadge v-if="group.isBundle" color="warning" variant="soft">Sammelposten</UBadge>
|
||||
<UBadge color="neutral" variant="soft">{{ group.assets.length }} Positionen</UBadge>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ formatCurrency(group.depreciated) }} abgeschrieben | {{ formatCurrency(group.remaining) }} Restbuchwert
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-52 space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>Fortschritt</span>
|
||||
<span>{{ group.progressPercent.toFixed(1) }}%</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary-500"
|
||||
:style="{ width: `${group.progressPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div v-for="asset in group.assets" :key="asset.key" class="rounded-lg border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium text-gray-900 dark:text-white">{{ asset.label }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ asset.vendorName }} | {{ asset.reference }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 text-xs">
|
||||
<UBadge color="neutral" variant="soft">{{ asset.method === "degressive" ? "Degressiv" : "Linear" }}</UBadge>
|
||||
<UBadge color="neutral" variant="soft">{{ asset.months }} Monate</UBadge>
|
||||
<UBadge color="neutral" variant="soft">Start {{ dayjs(asset.startDate).format("MM/YYYY") }}</UBadge>
|
||||
<UBadge color="neutral" variant="soft">Restwert {{ formatCurrency(asset.residualValue) }}</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
<UButton size="sm" variant="outline" icon="i-heroicons-pencil-square" @click="startEdit(asset)">
|
||||
Bearbeiten
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-4">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Anschaffungswert</div>
|
||||
<div class="mt-1 font-semibold">{{ formatCurrency(asset.originalValue) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Abgeschrieben</div>
|
||||
<div class="mt-1 font-semibold text-primary-600 dark:text-primary-400">{{ formatCurrency(asset.depreciated) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Im Zeitraum</div>
|
||||
<div class="mt-1 font-semibold text-error">{{ formatCurrency(asset.currentPeriodAmount) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Restbuchwert</div>
|
||||
<div class="mt-1 font-semibold text-amber-600 dark:text-amber-400">{{ formatCurrency(asset.remaining) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>Wirklicher Abschreibungsfortschritt</span>
|
||||
<span>{{ getProgressPercent(asset.depreciated, asset.originalValue, asset.residualValue).toFixed(1) }}%</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary-500"
|
||||
:style="{ width: `${getProgressPercent(asset.depreciated, asset.originalValue, asset.residualValue)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<USlideover v-model:open="editOpen" title="Abschreibung bearbeiten">
|
||||
<template #body>
|
||||
<div v-if="selectedAsset && editState" class="space-y-4 p-4">
|
||||
<UFormField label="Bezeichnung">
|
||||
<UInput v-model="editState.depreciationLabel" />
|
||||
</UFormField>
|
||||
<UFormField v-if="selectedAsset.mode === 'depreciation_bundle'" label="Sammelposten">
|
||||
<UInput v-model="editState.depreciationGroup" />
|
||||
</UFormField>
|
||||
<UFormField label="Methode">
|
||||
<USelectMenu v-model="editState.depreciationMethod" :items="DEPRECIATION_METHOD_ITEMS" value-key="value" label-key="label" />
|
||||
</UFormField>
|
||||
<UFormField label="Dauer (Monate)">
|
||||
<UInput v-model="editState.depreciationMonths" type="number" min="1" step="1" />
|
||||
</UFormField>
|
||||
<UFormField label="Start Abschreibung">
|
||||
<UInput v-model="editState.depreciationStartDate" type="date" />
|
||||
</UFormField>
|
||||
<UFormField label="Restwert">
|
||||
<UInput v-model="editState.residualValue" type="number" min="0" step="0.01" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2 p-4">
|
||||
<UButton variant="ghost" @click="closeEdit">Abbrechen</UButton>
|
||||
<UButton :loading="saving" @click="saveAsset">Speichern</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</UDashboardPanelContent>
|
||||
</template>
|
||||
@@ -126,164 +126,162 @@ onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UDashboardNavbar title="USt-Auswertung">
|
||||
<template #right>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
variant="outline"
|
||||
@click="loadData"
|
||||
:loading="loading"
|
||||
<UDashboardNavbar title="USt-Auswertung">
|
||||
<template #right>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
variant="outline"
|
||||
@click="loadData"
|
||||
:loading="loading"
|
||||
>
|
||||
Aktualisieren
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UDashboardPanelContent class="p-4 md:p-6">
|
||||
<div class="mb-6 flex flex-col gap-2">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Aktueller Zeitraum: {{ currentPeriod?.label }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Intervall: {{ periodType === "monthly" ? "monatlich" : periodType === "quarterly" ? "quartalsweise" : "jährlich" }}.
|
||||
Berücksichtigt werden gebuchte Ausgangsrechnungen, Abschlags- und Stornorechnungen sowie gebuchte Eingangsbelege mit Datum.
|
||||
</p>
|
||||
<p v-if="currentPeriod" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ currentPeriod.range }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="currentPeriod" class="grid gap-4 md:grid-cols-3">
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Berechnete USt</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ formatCurrency(currentPeriod.outputTax) }}
|
||||
</div>
|
||||
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
19%: {{ formatCurrency(currentPeriod.output.tax19) }} | 7%: {{ formatCurrency(currentPeriod.output.tax7) }}
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Vorsteuer</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ formatCurrency(currentPeriod.inputTax) }}
|
||||
</div>
|
||||
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
19%: {{ formatCurrency(currentPeriod.input.tax19) }} | 7%: {{ formatCurrency(currentPeriod.input.tax7) }}
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Verrechnetes Ergebnis</div>
|
||||
<div
|
||||
class="mt-2 text-2xl font-semibold"
|
||||
:class="currentPeriod.balance >= 0 ? 'text-amber-600 dark:text-amber-400' : 'text-emerald-600 dark:text-emerald-400'"
|
||||
>
|
||||
Aktualisieren
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
{{ formatCurrency(currentPeriod.balance) }}
|
||||
</div>
|
||||
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ currentPeriod.outputCount }} Ausgangsbelege | {{ currentPeriod.inputCount }} Eingangsbelege
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<UDashboardPanelContent class="p-4 md:p-6">
|
||||
<div class="mb-6 flex flex-col gap-2">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Aktueller Zeitraum: {{ currentPeriod?.label }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Intervall: {{ periodType === "monthly" ? "monatlich" : periodType === "quarterly" ? "quartalsweise" : "jährlich" }}.
|
||||
Berücksichtigt werden gebuchte Ausgangsrechnungen, Abschlags- und Stornorechnungen sowie gebuchte Eingangsbelege mit Datum.
|
||||
</p>
|
||||
<p v-if="currentPeriod" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ currentPeriod.range }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="currentPeriod" class="grid gap-4 md:grid-cols-3">
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Berechnete USt</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ formatCurrency(currentPeriod.outputTax) }}
|
||||
</div>
|
||||
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
19%: {{ formatCurrency(currentPeriod.output.tax19) }} | 7%: {{ formatCurrency(currentPeriod.output.tax7) }}
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Vorsteuer</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ formatCurrency(currentPeriod.inputTax) }}
|
||||
</div>
|
||||
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
19%: {{ formatCurrency(currentPeriod.input.tax19) }} | 7%: {{ formatCurrency(currentPeriod.input.tax7) }}
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Verrechnetes Ergebnis</div>
|
||||
<div
|
||||
class="mt-2 text-2xl font-semibold"
|
||||
:class="currentPeriod.balance >= 0 ? 'text-amber-600 dark:text-amber-400' : 'text-emerald-600 dark:text-emerald-400'"
|
||||
>
|
||||
{{ formatCurrency(currentPeriod.balance) }}
|
||||
</div>
|
||||
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ currentPeriod.outputCount }} Ausgangsbelege | {{ currentPeriod.inputCount }} Eingangsbelege
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div v-if="currentPeriod" class="mt-6 grid gap-4 xl:grid-cols-2">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="font-semibold">Ausgangsrechnungen</div>
|
||||
</template>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Netto 19%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.output.net19) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>USt 19%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.output.tax19) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Netto 7%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.output.net7) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>USt 7%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.output.tax7) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Netto 0%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.output.net0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="font-semibold">Eingangsbelege</div>
|
||||
</template>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Netto 19%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.input.net19) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Vorsteuer 19%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.input.tax19) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Netto 7%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.input.net7) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Vorsteuer 7%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.input.tax7) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Netto 0%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.input.net0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<UCard class="mt-6">
|
||||
<div v-if="currentPeriod" class="mt-6 grid gap-4 xl:grid-cols-2">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="font-semibold">Aktueller und vorherige Zeiträume</div>
|
||||
<div class="font-semibold">Ausgangsrechnungen</div>
|
||||
</template>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Netto 19%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.output.net19) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>USt 19%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.output.tax19) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Netto 7%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.output.net7) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>USt 7%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.output.tax7) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Netto 0%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.output.net0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="font-semibold">Eingangsbelege</div>
|
||||
</template>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Netto 19%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.input.net19) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Vorsteuer 19%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.input.tax19) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Netto 7%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.input.net7) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Vorsteuer 7%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.input.tax7) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Netto 0%</span>
|
||||
<span>{{ formatCurrency(currentPeriod.input.net0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<UCard class="mt-6">
|
||||
<template #header>
|
||||
<div class="font-semibold">Aktueller und vorherige Zeiträume</div>
|
||||
</template>
|
||||
|
||||
<UTable
|
||||
:columns="normalizeTableColumns(columns)"
|
||||
:data="periods"
|
||||
:loading="loading"
|
||||
:empty="{ icon: 'i-heroicons-calculator', label: 'Keine Daten für die USt-Auswertung vorhanden' }"
|
||||
>
|
||||
<template #label-cell="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ row.original.label }}</span>
|
||||
<UBadge v-if="row.original.isCurrent" color="primary" variant="soft">Aktuell</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UTable
|
||||
:columns="normalizeTableColumns(columns)"
|
||||
:data="periods"
|
||||
:loading="loading"
|
||||
:empty="{ icon: 'i-heroicons-calculator', label: 'Keine Daten für die USt-Auswertung vorhanden' }"
|
||||
>
|
||||
<template #label-cell="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ row.original.label }}</span>
|
||||
<UBadge v-if="row.original.isCurrent" color="primary" variant="soft">Aktuell</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
<template #outputTax-cell="{ row }">
|
||||
{{ formatCurrency(row.original.outputTax) }}
|
||||
</template>
|
||||
|
||||
<template #outputTax-cell="{ row }">
|
||||
{{ formatCurrency(row.original.outputTax) }}
|
||||
</template>
|
||||
<template #inputTax-cell="{ row }">
|
||||
{{ formatCurrency(row.original.inputTax) }}
|
||||
</template>
|
||||
|
||||
<template #inputTax-cell="{ row }">
|
||||
{{ formatCurrency(row.original.inputTax) }}
|
||||
</template>
|
||||
|
||||
<template #balance-cell="{ row }">
|
||||
<template #balance-cell="{ row }">
|
||||
<span :class="row.original.balance >= 0 ? 'text-amber-600 dark:text-amber-400 font-medium' : 'text-emerald-600 dark:text-emerald-400 font-medium'">
|
||||
{{ formatCurrency(row.original.balance) }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #documents-cell="{ row }">
|
||||
{{ row.original.outputCount }} / {{ row.original.inputCount }}
|
||||
</template>
|
||||
</UTable>
|
||||
</UCard>
|
||||
</UDashboardPanelContent>
|
||||
</div>
|
||||
<template #documents-cell="{ row }">
|
||||
{{ row.original.outputCount }} / {{ row.original.inputCount }}
|
||||
</template>
|
||||
</UTable>
|
||||
</UCard>
|
||||
</UDashboardPanelContent>
|
||||
</template>
|
||||
|
||||
@@ -198,7 +198,7 @@ setupPage()
|
||||
:columns="normalizeTableColumns(columns)"
|
||||
class="w-full"
|
||||
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
|
||||
:on-select="(i) => router.push(`/accounts/show/${i.id}`)"
|
||||
:on-select="(row) => router.push(`/accounts/show/${row.original.id}`)"
|
||||
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }"
|
||||
>
|
||||
<template #allocations-cell="{row}">
|
||||
|
||||
@@ -1,62 +1,44 @@
|
||||
<script setup>
|
||||
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const itemInfo = ref(null)
|
||||
const statementallocations = ref([])
|
||||
const incominginvoices = ref([])
|
||||
const currentAccountId = computed(() => String(route.params.id))
|
||||
const sameAccount = (value) => String(value ?? "") === currentAccountId.value
|
||||
|
||||
const setup = async () => {
|
||||
itemInfo.value = (await useEntities("accounts").selectSpecial("*")).find(i => i.id === Number(route.params.id))
|
||||
statementallocations.value = (await useEntities("statementallocations").select("*, bs_id(*)")).filter(i => i.account === Number(route.params.id))
|
||||
incominginvoices.value = (await useEntities("incominginvoices").select("*, vendor(*)")).filter(i => i.accounts.find(x => x.account === Number(route.params.id)))
|
||||
statementallocations.value = (await useEntities("statementallocations").select("*, bankstatement(*)"))
|
||||
.filter((allocation) => sameAccount(allocation.account?.id || allocation.account))
|
||||
incominginvoices.value = (await useEntities("incominginvoices").select("*, vendor(*)"))
|
||||
.filter((invoice) => (invoice.accounts || []).some((account) => sameAccount(account.account?.id || account.account)))
|
||||
}
|
||||
|
||||
setup()
|
||||
|
||||
const selectAllocation = (allocation) => {
|
||||
if(allocation.type === "statementallocation") {
|
||||
router.push(`/banking/statements/edit/${allocation.bs_id.id}`)
|
||||
} else if(allocation.type === "incominginvoice") {
|
||||
router.push(`/incominginvoices/show/${allocation.incominginvoiceid}`)
|
||||
}
|
||||
}
|
||||
|
||||
const renderedAllocations = computed(() => {
|
||||
const statementRows = statementallocations.value.map((allocation) => ({
|
||||
...allocation,
|
||||
type: "statementallocation",
|
||||
amount: Number(allocation.amount || 0)
|
||||
}))
|
||||
|
||||
let tempstatementallocations = statementallocations.value.map(i => {
|
||||
return {
|
||||
...i,
|
||||
type: "statementallocation",
|
||||
date: i.bs_id.date,
|
||||
partner: i.bs_id ? (i.bs_id.debName ? i.bs_id.debName : (i.bs_id.credName ? i.bs_id.credName : '')) : ''
|
||||
}
|
||||
const incomingInvoiceRows = incominginvoices.value.flatMap((invoice) => {
|
||||
return (invoice.accounts || [])
|
||||
.filter((account) => sameAccount(account.account?.id || account.account))
|
||||
.map((account, index) => ({
|
||||
id: `${invoice.id}-${index}`,
|
||||
incominginvoiceid: invoice.id,
|
||||
type: "incominginvoice",
|
||||
amount: Number(account.amountGross || account.amountNet || 0),
|
||||
expense: invoice.expense
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
let incominginvoicesallocations = []
|
||||
|
||||
incominginvoices.value.forEach(i => {
|
||||
|
||||
incominginvoicesallocations.push(...i.accounts.filter(x => x.account == route.params.id).map(x => {
|
||||
return {
|
||||
...x,
|
||||
incominginvoiceid: i.id,
|
||||
type: "incominginvoice",
|
||||
amount: x.amountGross ? x.amountGross : x.amountNet,
|
||||
date: i.date,
|
||||
partner: i.vendor.name,
|
||||
description: i.description,
|
||||
color: i.expense ? "red" : "green",
|
||||
expense: i.expense
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
return [...tempstatementallocations, ... incominginvoicesallocations]
|
||||
return [...statementRows, ...incomingInvoiceRows]
|
||||
})
|
||||
|
||||
const saldo = computed(() => {
|
||||
@@ -135,25 +117,12 @@ const saldo = computed(() => {
|
||||
</div>
|
||||
</UCard>
|
||||
<UCard class="mt-5" v-if="item.label === 'Buchungen'">
|
||||
<UTable
|
||||
v-if="statementallocations"
|
||||
:data="renderedAllocations"
|
||||
:columns="normalizeTableColumns([{key:'amount', label:'Betrag'},{key:'date', label:'Datum'},{key:'partner', label:'Partner'},{key:'description', label:'Beschreibung'}])"
|
||||
:on-select="(i) => selectAllocation(i)"
|
||||
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }"
|
||||
>
|
||||
<template #amount-cell="{row}">
|
||||
<span class="text-right text-rose-600" 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 v-else>{{useCurrency(row.original.amount)}}</span>
|
||||
</template>
|
||||
<template #date-cell="{row}">
|
||||
{{row.original.date ? dayjs(row.original.date).format('DD.MM.YYYY') : ''}}
|
||||
</template>
|
||||
<template #description-cell="{row}">
|
||||
{{row.original.description ? row.original.description : ''}}
|
||||
</template>
|
||||
</UTable>
|
||||
<EntityShowSubOwnAccountsStatements
|
||||
v-if="itemInfo"
|
||||
:item="itemInfo"
|
||||
top-level-type="accounts"
|
||||
platform="desktop"
|
||||
/>
|
||||
</UCard>
|
||||
</template>
|
||||
</UTabs>
|
||||
|
||||
279
frontend/pages/administration/tenants/[id].vue
Normal file
279
frontend/pages/administration/tenants/[id].vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTenant, AdminUser } from "~/composables/useAdmin"
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const admin = useAdmin()
|
||||
|
||||
const tenantId = Number(route.params.id)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const creatingUser = ref(false)
|
||||
const createUserModalOpen = ref(false)
|
||||
const createdUserPassword = ref("")
|
||||
|
||||
const tenantForm = ref<AdminTenant | null>(null)
|
||||
const assignedUsers = ref<AdminUser[]>([])
|
||||
const createUserForm = ref({
|
||||
email: "",
|
||||
password: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
is_admin: false,
|
||||
multiTenant: false,
|
||||
})
|
||||
|
||||
const fetchTenant = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const overview = await admin.getOverview()
|
||||
const tenant = overview.tenants.find((entry) => entry.id === tenantId)
|
||||
|
||||
if (!tenant) {
|
||||
toast.add({ title: "Tenant nicht gefunden", color: "red" })
|
||||
await router.push("/administration/tenants")
|
||||
return
|
||||
}
|
||||
|
||||
tenantForm.value = { ...tenant }
|
||||
assignedUsers.value = overview.users.filter((user) => user.tenant_ids.includes(tenantId))
|
||||
} catch (err: any) {
|
||||
console.error("[administration/tenants/show]", err)
|
||||
toast.add({
|
||||
title: "Tenant konnte nicht geladen werden",
|
||||
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
||||
color: "red",
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveTenant = async () => {
|
||||
if (!tenantForm.value || saving.value) return
|
||||
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
await admin.updateTenant(tenantForm.value.id, {
|
||||
name: tenantForm.value.name,
|
||||
short: tenantForm.value.short,
|
||||
})
|
||||
|
||||
await fetchTenant()
|
||||
await auth.fetchMe()
|
||||
|
||||
toast.add({ title: "Tenant gespeichert", color: "green" })
|
||||
} catch (err: any) {
|
||||
console.error("[administration/tenants/save]", err)
|
||||
toast.add({
|
||||
title: "Tenant konnte nicht gespeichert werden",
|
||||
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
||||
color: "red",
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createTenantUser = async () => {
|
||||
if (!tenantForm.value || creatingUser.value) return
|
||||
|
||||
creatingUser.value = true
|
||||
|
||||
try {
|
||||
const response = await admin.createUser(createUserForm.value)
|
||||
const createdUserId = response?.user?.id
|
||||
|
||||
if (!createdUserId) {
|
||||
throw new Error("Benutzer konnte nach dem Anlegen nicht zugeordnet werden.")
|
||||
}
|
||||
|
||||
await admin.updateUserAccess(createdUserId, {
|
||||
tenant_ids: [tenantForm.value.id],
|
||||
role_assignments: [],
|
||||
profile_defaults: {
|
||||
first_name: createUserForm.value.first_name,
|
||||
last_name: createUserForm.value.last_name,
|
||||
},
|
||||
profile_assignments: [
|
||||
{
|
||||
tenant_id: tenantForm.value.id,
|
||||
profile_id: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
createdUserPassword.value = response.initialPassword || ""
|
||||
createUserModalOpen.value = false
|
||||
createUserForm.value = {
|
||||
email: "",
|
||||
password: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
is_admin: false,
|
||||
multiTenant: false,
|
||||
}
|
||||
|
||||
await fetchTenant()
|
||||
|
||||
toast.add({
|
||||
title: "Benutzer angelegt",
|
||||
description: "Der Benutzer wurde direkt diesem Tenant zugeordnet und das Profil wurde erstellt.",
|
||||
color: "green",
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.error("[administration/tenants/create-user]", err)
|
||||
toast.add({
|
||||
title: "Benutzer konnte nicht angelegt werden",
|
||||
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
||||
color: "red",
|
||||
})
|
||||
} finally {
|
||||
creatingUser.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.user?.is_admin) {
|
||||
await router.push("/")
|
||||
return
|
||||
}
|
||||
|
||||
await fetchTenant()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardNavbar title="Administration: Tenants">
|
||||
<template #left>
|
||||
<UButton icon="i-heroicons-chevron-left" variant="outline" @click="router.push('/administration/tenants')">
|
||||
Tenants
|
||||
</UButton>
|
||||
</template>
|
||||
<template #right>
|
||||
<UButton icon="i-heroicons-user-plus" variant="soft" @click="createUserModalOpen = true">
|
||||
Benutzer anlegen
|
||||
</UButton>
|
||||
<UButton color="primary" :loading="saving" @click="saveTenant">
|
||||
Speichern
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UDashboardPanelContent>
|
||||
<UCard v-if="!loading && tenantForm">
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">{{ tenantForm.name }}</h2>
|
||||
<p class="text-sm text-gray-500">Tenant-ID {{ tenantForm.id }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<USeparator label="Tenant" />
|
||||
|
||||
<UForm :state="tenantForm" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
||||
<UFormField label="Name">
|
||||
<UInput v-model="tenantForm.name" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Kürzel">
|
||||
<UInput v-model="tenantForm.short" />
|
||||
</UFormField>
|
||||
</UForm>
|
||||
</UCard>
|
||||
|
||||
<UCard v-if="!loading && tenantForm" class="mt-3">
|
||||
<USeparator label="Zugeordnete Benutzer" />
|
||||
|
||||
<UTable
|
||||
:data="assignedUsers"
|
||||
:columns="normalizeTableColumns([
|
||||
{ key: 'display_name', label: 'Benutzer' },
|
||||
{ key: 'email', label: 'E-Mail' }
|
||||
])"
|
||||
:on-select="(row) => router.push(`/administration/users/${row.original?.id || row.id}`)"
|
||||
class="mt-4"
|
||||
/>
|
||||
</UCard>
|
||||
|
||||
<USkeleton v-if="loading" class="h-80" />
|
||||
</UDashboardPanelContent>
|
||||
|
||||
<UModal v-model:open="createUserModalOpen">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="text-lg font-semibold">Benutzer in Tenant anlegen</div>
|
||||
</template>
|
||||
|
||||
<UForm :state="createUserForm" class="space-y-4" @submit.prevent="createTenantUser">
|
||||
<UFormField label="Tenant">
|
||||
<UInput :model-value="tenantForm?.name || ''" readonly />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="E-Mail">
|
||||
<UInput v-model="createUserForm.email" type="email" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Initialpasswort">
|
||||
<UInput v-model="createUserForm.password" placeholder="Leer lassen für automatisches Passwort" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Vorname für Profil">
|
||||
<UInput v-model="createUserForm.first_name" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Nachname für Profil">
|
||||
<UInput v-model="createUserForm.last_name" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Administrative Freigabe">
|
||||
<div class="flex items-center gap-3 h-10">
|
||||
<USwitch v-model="createUserForm.is_admin" />
|
||||
<span class="text-sm text-gray-600">Benutzer darf die Administration öffnen</span>
|
||||
</div>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Multi-Tenant">
|
||||
<div class="flex items-center gap-3 h-10">
|
||||
<USwitch v-model="createUserForm.multiTenant" />
|
||||
<span class="text-sm text-gray-600">Weitere Tenant-Zuordnungen sind erlaubt</span>
|
||||
</div>
|
||||
</UFormField>
|
||||
|
||||
<UAlert
|
||||
title="Automatische Zuordnung"
|
||||
description="Der Benutzer wird nach dem Anlegen direkt diesem Tenant zugeordnet und bekommt dort automatisch ein Profil mit den angegebenen Stammdaten."
|
||||
color="primary"
|
||||
variant="soft"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<UButton color="gray" variant="soft" @click="createUserModalOpen = false">
|
||||
Abbrechen
|
||||
</UButton>
|
||||
<UButton type="submit" color="primary" :loading="creatingUser">
|
||||
Benutzer anlegen
|
||||
</UButton>
|
||||
</div>
|
||||
</UForm>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<div class="mx-5 mb-5">
|
||||
<UAlert
|
||||
v-if="createdUserPassword"
|
||||
title="Initialpasswort für neuen Benutzer"
|
||||
:description="createdUserPassword"
|
||||
color="amber"
|
||||
variant="soft"
|
||||
close-button
|
||||
@close="createdUserPassword = ''"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
160
frontend/pages/administration/tenants/index.vue
Normal file
160
frontend/pages/administration/tenants/index.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTenant } from "~/composables/useAdmin"
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
const admin = useAdmin()
|
||||
|
||||
const loading = ref(true)
|
||||
const creatingTenant = ref(false)
|
||||
const createTenantModalOpen = ref(false)
|
||||
const tenants = ref<AdminTenant[]>([])
|
||||
const searchString = ref("")
|
||||
|
||||
const createTenantForm = ref({
|
||||
name: "",
|
||||
short: "",
|
||||
})
|
||||
|
||||
const templateColumns = [
|
||||
{ key: "name", label: "Tenant" },
|
||||
{ key: "short", label: "Kürzel" },
|
||||
{ key: "user_count", label: "Benutzer" },
|
||||
]
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const search = searchString.value.trim().toLowerCase()
|
||||
if (!search) return tenants.value
|
||||
|
||||
return tenants.value.filter((tenant) =>
|
||||
[tenant.name, tenant.short]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(search))
|
||||
)
|
||||
})
|
||||
|
||||
const fetchTenants = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const overview = await admin.getOverview()
|
||||
tenants.value = overview.tenants
|
||||
} catch (err: any) {
|
||||
console.error("[administration/tenants/index]", err)
|
||||
toast.add({
|
||||
title: "Tenants konnten nicht geladen werden",
|
||||
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
||||
color: "red",
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createTenant = async () => {
|
||||
if (creatingTenant.value) return
|
||||
|
||||
creatingTenant.value = true
|
||||
|
||||
try {
|
||||
const response = await admin.createTenant(createTenantForm.value)
|
||||
|
||||
createTenantModalOpen.value = false
|
||||
createTenantForm.value = {
|
||||
name: "",
|
||||
short: "",
|
||||
}
|
||||
|
||||
await fetchTenants()
|
||||
|
||||
toast.add({
|
||||
title: "Tenant angelegt",
|
||||
description: "Standardordner und Datei-Tags wurden erstellt.",
|
||||
color: "green",
|
||||
})
|
||||
|
||||
if (response.tenant?.id) {
|
||||
await router.push(`/administration/tenants/${response.tenant.id}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[administration/tenants/create]", err)
|
||||
toast.add({
|
||||
title: "Tenant konnte nicht angelegt werden",
|
||||
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
||||
color: "red",
|
||||
})
|
||||
} finally {
|
||||
creatingTenant.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.user?.is_admin) {
|
||||
await router.push("/")
|
||||
return
|
||||
}
|
||||
|
||||
await fetchTenants()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardNavbar title="Administration: Tenants" :badge="filteredRows.length">
|
||||
<template #right>
|
||||
<UInput
|
||||
v-model="searchString"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Tenants suchen"
|
||||
class="hidden lg:block"
|
||||
/>
|
||||
<UButton icon="i-heroicons-plus" @click="createTenantModalOpen = true">
|
||||
Tenant
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UTable
|
||||
:data="filteredRows"
|
||||
:columns="normalizeTableColumns(templateColumns)"
|
||||
:loading="loading"
|
||||
:on-select="(row) => router.push(`/administration/tenants/${row.original?.id || row.id}`)"
|
||||
:empty="{ icon: 'i-heroicons-building-office-2', label: 'Keine Tenants gefunden' }"
|
||||
/>
|
||||
|
||||
<UModal v-model:open="createTenantModalOpen">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="text-lg font-semibold">Tenant anlegen</div>
|
||||
</template>
|
||||
|
||||
<UForm :state="createTenantForm" class="space-y-4" @submit.prevent="createTenant">
|
||||
<UFormField label="Name">
|
||||
<UInput v-model="createTenantForm.name" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Kürzel">
|
||||
<UInput v-model="createTenantForm.short" />
|
||||
</UFormField>
|
||||
|
||||
<UAlert
|
||||
title="Seed-Daten"
|
||||
description="Beim Anlegen werden Standard-Datei-Tags sowie Systemordner mit Jahresunterordnern erstellt."
|
||||
color="primary"
|
||||
variant="soft"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<UButton color="gray" variant="soft" @click="createTenantModalOpen = false">
|
||||
Abbrechen
|
||||
</UButton>
|
||||
<UButton type="submit" color="primary" :loading="creatingTenant">
|
||||
Tenant anlegen
|
||||
</UButton>
|
||||
</div>
|
||||
</UForm>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
330
frontend/pages/administration/users/[id].vue
Normal file
330
frontend/pages/administration/users/[id].vue
Normal file
@@ -0,0 +1,330 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminRole, AdminUser, AdminUserProfile } from "~/composables/useAdmin"
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const admin = useAdmin()
|
||||
|
||||
const userId = route.params.id as string
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const userForm = ref<AdminUser | null>(null)
|
||||
const roles = ref<AdminRole[]>([])
|
||||
const tenants = ref<{ id: number; name: string; short: string }[]>([])
|
||||
const unassignedProfiles = ref<AdminUserProfile[]>([])
|
||||
|
||||
const tenantOptions = computed(() =>
|
||||
tenants.value.map((tenant) => ({
|
||||
label: `${tenant.name} (${tenant.short})`,
|
||||
value: tenant.id,
|
||||
}))
|
||||
)
|
||||
|
||||
const getRoleOptionsForTenant = (tenantId: number) =>
|
||||
roles.value
|
||||
.filter((role) => role.tenant_id === null || role.tenant_id === tenantId)
|
||||
.map((role) => ({
|
||||
label: role.tenant_id === null ? `${role.name} (global)` : role.name,
|
||||
value: role.id,
|
||||
}))
|
||||
|
||||
const getFreeProfilesForTenant = (tenantId: number) =>
|
||||
unassignedProfiles.value.filter((profile) => profile.tenant_id === tenantId)
|
||||
|
||||
const normalizeUserAssignments = () => {
|
||||
if (!userForm.value) return
|
||||
|
||||
const uniqueTenantIds = Array.from(new Set((userForm.value.tenant_ids || []).map(Number))).sort((a, b) => a - b)
|
||||
const assignmentsByTenant = new Map<number, string>()
|
||||
const profileAssignmentByTenant = new Map<number, string | null>()
|
||||
|
||||
for (const assignment of userForm.value.role_assignments || []) {
|
||||
if (!uniqueTenantIds.includes(Number(assignment.tenant_id))) continue
|
||||
if (assignmentsByTenant.has(Number(assignment.tenant_id))) continue
|
||||
assignmentsByTenant.set(Number(assignment.tenant_id), assignment.role_id)
|
||||
}
|
||||
|
||||
for (const assignment of userForm.value.profile_assignments || []) {
|
||||
if (!uniqueTenantIds.includes(Number(assignment.tenant_id))) continue
|
||||
profileAssignmentByTenant.set(Number(assignment.tenant_id), assignment.profile_id || null)
|
||||
}
|
||||
|
||||
userForm.value.tenant_ids = uniqueTenantIds
|
||||
userForm.value.role_assignments = uniqueTenantIds
|
||||
.map((tenantId) => {
|
||||
const roleId = assignmentsByTenant.get(tenantId)
|
||||
return roleId ? { tenant_id: tenantId, role_id: roleId } : null
|
||||
})
|
||||
.filter(Boolean) as { tenant_id: number; role_id: string }[]
|
||||
userForm.value.profile_assignments = uniqueTenantIds.map((tenantId) => ({
|
||||
tenant_id: tenantId,
|
||||
profile_id: profileAssignmentByTenant.get(tenantId) || null,
|
||||
}))
|
||||
}
|
||||
|
||||
const updateUserTenants = (tenantIds: number[] = []) => {
|
||||
if (!userForm.value) return
|
||||
userForm.value.tenant_ids = tenantIds
|
||||
normalizeUserAssignments()
|
||||
}
|
||||
|
||||
const setRoleForTenant = (tenantId: number, roleId?: string | null) => {
|
||||
if (!userForm.value) return
|
||||
userForm.value.role_assignments = (userForm.value.role_assignments || []).filter((assignment) => assignment.tenant_id !== tenantId)
|
||||
if (roleId) {
|
||||
userForm.value.role_assignments.push({ tenant_id: tenantId, role_id: roleId })
|
||||
}
|
||||
}
|
||||
|
||||
const getRoleForTenant = (tenantId: number) =>
|
||||
userForm.value?.role_assignments?.find((assignment) => assignment.tenant_id === tenantId)?.role_id || null
|
||||
|
||||
const setProfileAssignmentForTenant = (tenantId: number, profileId?: string | null) => {
|
||||
if (!userForm.value) return
|
||||
userForm.value.profile_assignments = (userForm.value.profile_assignments || []).filter((assignment) => assignment.tenant_id !== tenantId)
|
||||
userForm.value.profile_assignments.push({
|
||||
tenant_id: tenantId,
|
||||
profile_id: profileId || null,
|
||||
})
|
||||
}
|
||||
|
||||
const getProfileAssignmentForTenant = (tenantId: number) =>
|
||||
userForm.value?.profile_assignments?.find((assignment) => assignment.tenant_id === tenantId)?.profile_id || null
|
||||
|
||||
const fetchUser = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const overview = await admin.getOverview()
|
||||
roles.value = overview.roles
|
||||
tenants.value = overview.tenants
|
||||
unassignedProfiles.value = overview.unassignedProfiles
|
||||
|
||||
const user = overview.users.find((entry) => entry.id === userId)
|
||||
if (!user) {
|
||||
toast.add({ title: "Benutzer nicht gefunden", color: "red" })
|
||||
await router.push("/administration/users")
|
||||
return
|
||||
}
|
||||
|
||||
userForm.value = {
|
||||
...user,
|
||||
profile_defaults: { ...user.profile_defaults },
|
||||
tenant_ids: [...user.tenant_ids],
|
||||
role_assignments: [...user.role_assignments],
|
||||
profile_assignments: [...(user.profile_assignments || [])],
|
||||
profiles: [...user.profiles],
|
||||
}
|
||||
normalizeUserAssignments()
|
||||
} catch (err: any) {
|
||||
console.error("[administration/users/show]", err)
|
||||
toast.add({
|
||||
title: "Benutzer konnte nicht geladen werden",
|
||||
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
||||
color: "red",
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveUser = async () => {
|
||||
if (!userForm.value || saving.value) return
|
||||
|
||||
saving.value = true
|
||||
normalizeUserAssignments()
|
||||
|
||||
try {
|
||||
await admin.updateUser(userForm.value.id, {
|
||||
email: userForm.value.email,
|
||||
multiTenant: userForm.value.multiTenant,
|
||||
must_change_password: userForm.value.must_change_password,
|
||||
is_admin: userForm.value.is_admin,
|
||||
})
|
||||
|
||||
await admin.updateUserAccess(userForm.value.id, {
|
||||
tenant_ids: userForm.value.tenant_ids,
|
||||
role_assignments: userForm.value.role_assignments,
|
||||
profile_defaults: userForm.value.profile_defaults,
|
||||
profile_assignments: userForm.value.profile_assignments,
|
||||
})
|
||||
|
||||
await fetchUser()
|
||||
await auth.fetchMe()
|
||||
|
||||
toast.add({ title: "Benutzer gespeichert", color: "green" })
|
||||
} catch (err: any) {
|
||||
console.error("[administration/users/save]", err)
|
||||
toast.add({
|
||||
title: "Benutzer konnte nicht gespeichert werden",
|
||||
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
||||
color: "red",
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.user?.is_admin) {
|
||||
await router.push("/")
|
||||
return
|
||||
}
|
||||
|
||||
await fetchUser()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardNavbar title="Administration: Benutzer">
|
||||
<template #left>
|
||||
<UButton icon="i-heroicons-chevron-left" variant="outline" @click="router.push('/administration/users')">
|
||||
Benutzer
|
||||
</UButton>
|
||||
</template>
|
||||
<template #right>
|
||||
<UButton color="primary" :loading="saving" @click="saveUser">
|
||||
Speichern
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UDashboardPanelContent>
|
||||
<UCard v-if="!loading && userForm">
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">{{ userForm.display_name }}</h2>
|
||||
<p class="text-sm text-gray-500">{{ userForm.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<USeparator label="Benutzer" />
|
||||
|
||||
<UForm :state="userForm" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
||||
<UFormField label="E-Mail">
|
||||
<UInput v-model="userForm.email" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Profil Vorname">
|
||||
<UInput v-model="userForm.profile_defaults.first_name" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Profil Nachname">
|
||||
<UInput v-model="userForm.profile_defaults.last_name" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Tenants">
|
||||
<USelectMenu
|
||||
:model-value="userForm.tenant_ids"
|
||||
:items="tenantOptions"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
multiple
|
||||
@update:model-value="updateUserTenants"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Administrative Freigabe">
|
||||
<div class="flex items-center gap-3 h-10">
|
||||
<USwitch v-model="userForm.is_admin" />
|
||||
<span class="text-sm text-gray-600">Darf Administrationsseiten und Admin-API nutzen</span>
|
||||
</div>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Multi-Tenant">
|
||||
<div class="flex items-center gap-3 h-10">
|
||||
<USwitch v-model="userForm.multiTenant" />
|
||||
<span class="text-sm text-gray-600">Mehrere Tenant-Zuordnungen erlauben</span>
|
||||
</div>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Passwortwechsel erzwingen">
|
||||
<div class="flex items-center gap-3 h-10">
|
||||
<USwitch v-model="userForm.must_change_password" />
|
||||
<span class="text-sm text-gray-600">Beim nächsten Login muss das Passwort geändert werden</span>
|
||||
</div>
|
||||
</UFormField>
|
||||
</UForm>
|
||||
</UCard>
|
||||
|
||||
<UCard v-if="!loading && userForm" class="mt-3">
|
||||
<USeparator label="Rollen und Profile" />
|
||||
|
||||
<div v-if="userForm.tenant_ids.length" class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<UCard
|
||||
v-for="tenantId in userForm.tenant_ids"
|
||||
:key="tenantId"
|
||||
class="border border-gray-200"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="font-medium">
|
||||
{{ tenants.find((tenant) => tenant.id === tenantId)?.name || `Tenant ${tenantId}` }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ tenants.find((tenant) => tenant.id === tenantId)?.short || "" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UFormField label="Rolle">
|
||||
<USelectMenu
|
||||
:model-value="getRoleForTenant(tenantId)"
|
||||
:items="getRoleOptionsForTenant(tenantId)"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Rolle auswählen"
|
||||
@update:model-value="(value) => setRoleForTenant(tenantId, value)"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Freies Profil">
|
||||
<USelectMenu
|
||||
:model-value="getProfileAssignmentForTenant(tenantId)"
|
||||
:items="[
|
||||
{ label: 'Neues Profil erzeugen', value: null },
|
||||
...getFreeProfilesForTenant(tenantId).map((profile) => ({
|
||||
label: profile.full_name || `${profile.first_name} ${profile.last_name}`,
|
||||
value: profile.id,
|
||||
}))
|
||||
]"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Profil auswählen"
|
||||
@update:model-value="(value) => setProfileAssignmentForTenant(tenantId, value)"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-else-if="!loading"
|
||||
title="Keine Tenant-Zuordnung"
|
||||
description="Weise dem Benutzer zuerst mindestens einen Tenant zu."
|
||||
color="amber"
|
||||
variant="soft"
|
||||
class="mt-4"
|
||||
/>
|
||||
</UCard>
|
||||
|
||||
<UCard v-if="!loading && userForm" class="mt-3">
|
||||
<USeparator label="Profile im System" />
|
||||
|
||||
<div class="flex flex-wrap gap-2 mt-4">
|
||||
<UBadge
|
||||
v-for="profile in userForm.profiles"
|
||||
:key="profile.id"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
>
|
||||
{{ profile.full_name || `${profile.first_name} ${profile.last_name}` }} · Tenant {{ profile.tenant_id }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<USkeleton v-if="loading" class="h-80" />
|
||||
</UDashboardPanelContent>
|
||||
</template>
|
||||
204
frontend/pages/administration/users/index.vue
Normal file
204
frontend/pages/administration/users/index.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminUser } from "~/composables/useAdmin"
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
const admin = useAdmin()
|
||||
|
||||
const loading = ref(true)
|
||||
const creatingUser = ref(false)
|
||||
const createUserModalOpen = ref(false)
|
||||
const createdUserPassword = ref("")
|
||||
const users = ref<AdminUser[]>([])
|
||||
const searchString = ref("")
|
||||
|
||||
const createUserForm = ref({
|
||||
email: "",
|
||||
password: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
is_admin: false,
|
||||
multiTenant: true,
|
||||
})
|
||||
|
||||
const templateColumns = [
|
||||
{ key: "display_name", label: "Benutzer" },
|
||||
{ key: "email", label: "E-Mail" },
|
||||
{ key: "tenant_count", label: "Tenants" },
|
||||
{ key: "is_admin", label: "Admin" },
|
||||
]
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const search = searchString.value.trim().toLowerCase()
|
||||
const rows = users.value.map((user) => ({
|
||||
...user,
|
||||
tenant_count: user.tenant_ids.length,
|
||||
is_admin: user.is_admin ? "Ja" : "Nein",
|
||||
}))
|
||||
|
||||
if (!search) return rows
|
||||
|
||||
return rows.filter((row) =>
|
||||
[row.display_name, row.email]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(search))
|
||||
)
|
||||
})
|
||||
|
||||
const fetchUsers = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const overview = await admin.getOverview()
|
||||
users.value = overview.users
|
||||
} catch (err: any) {
|
||||
console.error("[administration/users/index]", err)
|
||||
toast.add({
|
||||
title: "Benutzer konnten nicht geladen werden",
|
||||
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
||||
color: "red",
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createUser = async () => {
|
||||
if (creatingUser.value) return
|
||||
|
||||
creatingUser.value = true
|
||||
|
||||
try {
|
||||
const response = await admin.createUser(createUserForm.value)
|
||||
|
||||
createdUserPassword.value = response.initialPassword || ""
|
||||
createUserModalOpen.value = false
|
||||
createUserForm.value = {
|
||||
email: "",
|
||||
password: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
is_admin: false,
|
||||
multiTenant: true,
|
||||
}
|
||||
|
||||
await fetchUsers()
|
||||
|
||||
toast.add({
|
||||
title: "Benutzer angelegt",
|
||||
description: createdUserPassword.value ? `Initialpasswort: ${createdUserPassword.value}` : undefined,
|
||||
color: "green",
|
||||
})
|
||||
|
||||
if (response.user?.id) {
|
||||
await router.push(`/administration/users/${response.user.id}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[administration/users/create]", err)
|
||||
toast.add({
|
||||
title: "Benutzer konnte nicht angelegt werden",
|
||||
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
||||
color: "red",
|
||||
})
|
||||
} finally {
|
||||
creatingUser.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.user?.is_admin) {
|
||||
await router.push("/")
|
||||
return
|
||||
}
|
||||
|
||||
await fetchUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardNavbar title="Administration: Benutzer" :badge="filteredRows.length">
|
||||
<template #right>
|
||||
<UInput
|
||||
v-model="searchString"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Benutzer suchen"
|
||||
class="hidden lg:block"
|
||||
/>
|
||||
<UButton icon="i-heroicons-plus" @click="createUserModalOpen = true">
|
||||
Benutzer
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UTable
|
||||
:data="filteredRows"
|
||||
:columns="normalizeTableColumns(templateColumns)"
|
||||
:loading="loading"
|
||||
:on-select="(row) => router.push(`/administration/users/${row.original?.id || row.id}`)"
|
||||
:empty="{ icon: 'i-heroicons-users', label: 'Keine Benutzer gefunden' }"
|
||||
/>
|
||||
|
||||
<UModal v-model:open="createUserModalOpen">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="text-lg font-semibold">Benutzer anlegen</div>
|
||||
</template>
|
||||
|
||||
<UForm :state="createUserForm" class="space-y-4" @submit.prevent="createUser">
|
||||
<UFormField label="E-Mail">
|
||||
<UInput v-model="createUserForm.email" type="email" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Initialpasswort">
|
||||
<UInput v-model="createUserForm.password" placeholder="Leer lassen für automatisches Passwort" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Vorname für neues Profil">
|
||||
<UInput v-model="createUserForm.first_name" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Nachname für neues Profil">
|
||||
<UInput v-model="createUserForm.last_name" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Administrative Freigabe">
|
||||
<div class="flex items-center gap-3 h-10">
|
||||
<USwitch v-model="createUserForm.is_admin" />
|
||||
<span class="text-sm text-gray-600">Benutzer darf die Administration öffnen</span>
|
||||
</div>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Multi-Tenant">
|
||||
<div class="flex items-center gap-3 h-10">
|
||||
<USwitch v-model="createUserForm.multiTenant" />
|
||||
<span class="text-sm text-gray-600">Mehrere Tenant-Zuordnungen erlauben</span>
|
||||
</div>
|
||||
</UFormField>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<UButton color="gray" variant="soft" @click="createUserModalOpen = false">
|
||||
Abbrechen
|
||||
</UButton>
|
||||
<UButton type="submit" color="primary" :loading="creatingUser">
|
||||
Benutzer anlegen
|
||||
</UButton>
|
||||
</div>
|
||||
</UForm>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<div class="mx-5 mb-5">
|
||||
<UAlert
|
||||
v-if="createdUserPassword"
|
||||
title="Initialpasswort für neuen Benutzer"
|
||||
:description="createdUserPassword"
|
||||
color="amber"
|
||||
variant="soft"
|
||||
close-button
|
||||
@close="createdUserPassword = ''"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user