Compare commits
24 Commits
uichange
...
d9e5df07bf
| Author | SHA1 | Date | |
|---|---|---|---|
| d9e5df07bf | |||
| 7996c746c3 | |||
| f679eb3624 | |||
| 7ad44544cf | |||
| 669bcd93ab | |||
| aee45e29fd | |||
| 42e0d7b35e | |||
| f6c9875320 | |||
| 05f3b678c4 | |||
| eb718021fd | |||
| 01b4d0f973 | |||
| c29494dc0d | |||
| 809a37a410 | |||
| 232e3f3260 | |||
| b2657f5d52 | |||
| cee0e1fa7d | |||
| 7dea2de7f3 | |||
| 4db753d34a | |||
| e0e99ba6f5 | |||
| ace2213cc4 | |||
| 7e6c5cc189 | |||
| 7c644c941a | |||
| 11a242d70d | |||
| 9f665fc3b8 |
@@ -6,10 +6,16 @@ import {secrets} from "../src/utils/secrets";
|
|||||||
|
|
||||||
console.log("[DB INIT] 1. Suche Connection String...");
|
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
|
// Checken woher die URL kommt
|
||||||
let connectionString = process.env.DATABASE_URL || secrets.DATABASE_URL;
|
let connectionString = process.env.DATABASE_URL || secrets.DATABASE_URL || fallbackConnectionString;
|
||||||
if (connectionString) {
|
if (process.env.DATABASE_URL) {
|
||||||
console.log("[DB INIT] -> Gefunden in 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 {
|
} else {
|
||||||
console.error("[DB INIT] ❌ KEIN CONNECTION STRING GEFUNDEN! .env nicht geladen?");
|
console.error("[DB INIT] ❌ KEIN CONNECTION STRING GEFUNDEN! .env nicht geladen?");
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
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,65 @@
|
|||||||
{
|
{
|
||||||
"idx": 19,
|
"idx": 19,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
|
"when": 1773489600000,
|
||||||
|
"tag": "0019_custom_surcharge_percentage_decimal",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 20,
|
||||||
|
"version": "7",
|
||||||
"when": 1773572400000,
|
"when": 1773572400000,
|
||||||
"tag": "0020_file_extracted_text",
|
"tag": "0020_file_extracted_text",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 20,
|
"idx": 21,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1773835200000,
|
"when": 1773835200000,
|
||||||
"tag": "0021_admin_user_flag",
|
"tag": "0021_admin_user_flag",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 21,
|
"idx": 22,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1773925200000,
|
"when": 1773925200000,
|
||||||
"tag": "0022_task_dependencies",
|
"tag": "0022_task_dependencies",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 22,
|
"idx": 23,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1774080000000,
|
"when": 1774080000000,
|
||||||
"tag": "0023_tax_evaluation_period",
|
"tag": "0023_tax_evaluation_period",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
jsonb,
|
jsonb,
|
||||||
} from "drizzle-orm/pg-core"
|
} from "drizzle-orm/pg-core"
|
||||||
import { authUsers } from "./auth_users"
|
import { authUsers } from "./auth_users"
|
||||||
|
import { branches } from "./branches"
|
||||||
|
|
||||||
export const authProfiles = pgTable("auth_profiles", {
|
export const authProfiles = pgTable("auth_profiles", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
@@ -18,6 +19,8 @@ export const authProfiles = pgTable("auth_profiles", {
|
|||||||
|
|
||||||
tenant_id: bigint("tenant_id", { mode: "number" }).notNull(),
|
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 })
|
created_at: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.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 { projects } from "./projects"
|
||||||
import { vehicles } from "./vehicles"
|
import { vehicles } from "./vehicles"
|
||||||
import { authUsers } from "./auth_users"
|
import { authUsers } from "./auth_users"
|
||||||
|
import { branches } from "./branches"
|
||||||
|
|
||||||
export const costcentres = pgTable("costcentres", {
|
export const costcentres = pgTable("costcentres", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
@@ -32,6 +33,8 @@ export const costcentres = pgTable("costcentres", {
|
|||||||
|
|
||||||
project: bigint("project", { mode: "number" }).references(() => projects.id),
|
project: bigint("project", { mode: "number" }).references(() => projects.id),
|
||||||
|
|
||||||
|
branch: bigint("branch", { mode: "number" }).references(() => branches.id),
|
||||||
|
|
||||||
inventoryitem: bigint("inventoryitem", { mode: "number" }).references(
|
inventoryitem: bigint("inventoryitem", { mode: "number" }).references(
|
||||||
() => inventoryitems.id
|
() => inventoryitems.id
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from "./accounts"
|
export * from "./accounts"
|
||||||
export * from "./auth_profiles"
|
export * from "./auth_profiles"
|
||||||
|
export * from "./auth_profile_branches"
|
||||||
export * from "./auth_role_permisssions"
|
export * from "./auth_role_permisssions"
|
||||||
export * from "./auth_roles"
|
export * from "./auth_roles"
|
||||||
export * from "./auth_tenant_users"
|
export * from "./auth_tenant_users"
|
||||||
@@ -8,6 +9,7 @@ export * from "./auth_users"
|
|||||||
export * from "./bankaccounts"
|
export * from "./bankaccounts"
|
||||||
export * from "./bankrequisitions"
|
export * from "./bankrequisitions"
|
||||||
export * from "./bankstatements"
|
export * from "./bankstatements"
|
||||||
|
export * from "./branches"
|
||||||
export * from "./checkexecutions"
|
export * from "./checkexecutions"
|
||||||
export * from "./checks"
|
export * from "./checks"
|
||||||
export * from "./citys"
|
export * from "./citys"
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const products = pgTable("products", {
|
|||||||
vendor_allocation: jsonb("vendorAllocation").default([]),
|
vendor_allocation: jsonb("vendorAllocation").default([]),
|
||||||
|
|
||||||
article_number: text("articleNumber"),
|
article_number: text("articleNumber"),
|
||||||
|
supplier_link: text("supplierLink"),
|
||||||
|
|
||||||
barcodes: text("barcodes").array().notNull().default([]),
|
barcodes: text("barcodes").array().notNull().default([]),
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ export const statementallocations = pgTable("statementallocations", {
|
|||||||
|
|
||||||
description: text("description"),
|
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(
|
customer: bigint("customer", { mode: "number" }).references(
|
||||||
() => customers.id
|
() => customers.id
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export const tenants = pgTable(
|
|||||||
serialInvoice: true,
|
serialInvoice: true,
|
||||||
incomingInvoices: true,
|
incomingInvoices: true,
|
||||||
costcentres: true,
|
costcentres: true,
|
||||||
|
branches: true,
|
||||||
accounts: true,
|
accounts: true,
|
||||||
ownaccounts: true,
|
ownaccounts: true,
|
||||||
banking: true,
|
banking: true,
|
||||||
@@ -127,8 +128,11 @@ export const tenants = pgTable(
|
|||||||
customers: { prefix: "", suffix: "", nextNumber: 10000 },
|
customers: { prefix: "", suffix: "", nextNumber: 10000 },
|
||||||
products: { prefix: "AT-", suffix: "", nextNumber: 1000 },
|
products: { prefix: "AT-", suffix: "", nextNumber: 1000 },
|
||||||
quotes: { prefix: "AN-", suffix: "", nextNumber: 1000 },
|
quotes: { prefix: "AN-", suffix: "", nextNumber: 1000 },
|
||||||
|
costEstimates: { prefix: "KS-", suffix: "", nextNumber: 1000 },
|
||||||
confirmationOrders: { prefix: "AB-", suffix: "", nextNumber: 1000 },
|
confirmationOrders: { prefix: "AB-", suffix: "", nextNumber: 1000 },
|
||||||
invoices: { prefix: "RE-", 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 },
|
spaces: { prefix: "LP-", suffix: "", nextNumber: 1000 },
|
||||||
customerspaces: { prefix: "KLP-", suffix: "", nextNumber: 1000 },
|
customerspaces: { prefix: "KLP-", suffix: "", nextNumber: 1000 },
|
||||||
inventoryitems: { prefix: "IA-", suffix: "", nextNumber: 1000 },
|
inventoryitems: { prefix: "IA-", suffix: "", nextNumber: 1000 },
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import "dotenv/config"
|
||||||
import { defineConfig } from "drizzle-kit"
|
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({
|
export default defineConfig({
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
schema: "./db/schema",
|
schema: "./db/schema",
|
||||||
out: "./db/migrations",
|
out: "./db/migrations",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: secrets.DATABASE_URL || "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo",
|
url: databaseUrl,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"migrate": "tsx scripts/migrate.ts",
|
||||||
"fill": "ts-node src/webdav/fill-file-sizes.ts",
|
"fill": "ts-node src/webdav/fill-file-sizes.ts",
|
||||||
"dev:dav": "tsx watch src/webdav/server.ts",
|
"dev:dav": "tsx watch src/webdav/server.ts",
|
||||||
"build": "tsc",
|
"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)
|
||||||
|
})
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
authRolePermissions,
|
authRolePermissions,
|
||||||
} from "../../../db/schema"
|
} from "../../../db/schema"
|
||||||
import { eq, and, or, isNull } from "drizzle-orm"
|
import { eq, and, or, isNull } from "drizzle-orm"
|
||||||
|
import { enrichProfilesWithBranches } from "../../utils/profileBranches"
|
||||||
|
|
||||||
export default async function meRoutes(server: FastifyInstance) {
|
export default async function meRoutes(server: FastifyInstance) {
|
||||||
server.get("/me", async (req, reply) => {
|
server.get("/me", async (req, reply) => {
|
||||||
@@ -51,6 +52,7 @@ export default async function meRoutes(server: FastifyInstance) {
|
|||||||
id: tenants.id,
|
id: tenants.id,
|
||||||
name: tenants.name,
|
name: tenants.name,
|
||||||
short: tenants.short,
|
short: tenants.short,
|
||||||
|
hasActiveLicense: tenants.hasActiveLicense,
|
||||||
locked: tenants.locked,
|
locked: tenants.locked,
|
||||||
features: tenants.features,
|
features: tenants.features,
|
||||||
extraModules: tenants.extraModules,
|
extraModules: tenants.extraModules,
|
||||||
@@ -89,7 +91,8 @@ export default async function meRoutes(server: FastifyInstance) {
|
|||||||
)
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
profile = profileResult?.[0] ?? null
|
const enrichedProfiles = await enrichProfilesWithBranches(server, profileResult)
|
||||||
|
profile = enrichedProfiles?.[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
|
|||||||
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
|
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
|
||||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
@@ -57,6 +57,42 @@ function resolveGitRoot() {
|
|||||||
return null
|
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) {
|
export default async function functionRoutes(server: FastifyInstance) {
|
||||||
const streamToBuffer = async (stream: any): Promise<Buffer> =>
|
const streamToBuffer = async (stream: any): Promise<Buffer> =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
@@ -201,7 +237,11 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
const gitRoot = resolveGitRoot()
|
const gitRoot = resolveGitRoot()
|
||||||
|
|
||||||
if (!gitRoot) {
|
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 {
|
try {
|
||||||
@@ -232,11 +272,16 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
repositoryRoot: gitRoot,
|
repositoryRoot: gitRoot,
|
||||||
|
source: 'git',
|
||||||
entries
|
entries
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
req.log.error(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,7 @@ import {
|
|||||||
} from "../../../db/schema"
|
} from "../../../db/schema"
|
||||||
|
|
||||||
import {and, eq, inArray} from "drizzle-orm"
|
import {and, eq, inArray} from "drizzle-orm"
|
||||||
|
import { enrichProfilesWithBranches } from "../../utils/profileBranches"
|
||||||
|
|
||||||
|
|
||||||
export default async function tenantRoutesInternal(server: FastifyInstance) {
|
export default async function tenantRoutesInternal(server: FastifyInstance) {
|
||||||
@@ -53,7 +54,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
|
|||||||
.where(inArray(authUsers.id, userIds))
|
.where(inArray(authUsers.id, userIds))
|
||||||
|
|
||||||
// 3) auth_profiles pro Tenant laden
|
// 3) auth_profiles pro Tenant laden
|
||||||
const profiles = await server.db
|
const profileRows = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(authProfiles)
|
.from(authProfiles)
|
||||||
.where(
|
.where(
|
||||||
@@ -61,6 +62,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
|
|||||||
eq(authProfiles.tenant_id, tenantId),
|
eq(authProfiles.tenant_id, tenantId),
|
||||||
inArray(authProfiles.user_id, userIds)
|
inArray(authProfiles.user_id, userIds)
|
||||||
))
|
))
|
||||||
|
const profiles = await enrichProfilesWithBranches(server, profileRows)
|
||||||
|
|
||||||
const combined = users.map(u => {
|
const combined = users.map(u => {
|
||||||
const profile = profiles.find(p => p.user_id === u.id)
|
const profile = profiles.find(p => p.user_id === u.id)
|
||||||
@@ -91,12 +93,12 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
|
|||||||
const tenantId = req.params.id
|
const tenantId = req.params.id
|
||||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
const data = await server.db
|
const profileRows = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(authProfiles)
|
.from(authProfiles)
|
||||||
.where(eq(authProfiles.tenant_id, tenantId))
|
.where(eq(authProfiles.tenant_id, tenantId))
|
||||||
|
|
||||||
return data
|
return await enrichProfilesWithBranches(server, profileRows)
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("/tenant/profiles ERROR:", err)
|
console.error("/tenant/profiles ERROR:", err)
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { eq, and } from "drizzle-orm";
|
|||||||
import {
|
import {
|
||||||
authProfiles,
|
authProfiles,
|
||||||
} from "../../db/schema";
|
} from "../../db/schema";
|
||||||
|
import {
|
||||||
|
loadProfileWithBranches,
|
||||||
|
resolveTenantBranchIds,
|
||||||
|
syncProfileBranches,
|
||||||
|
} from "../utils/profileBranches";
|
||||||
|
|
||||||
export default async function authProfilesRoutes(server: FastifyInstance) {
|
export default async function authProfilesRoutes(server: FastifyInstance) {
|
||||||
|
|
||||||
@@ -19,22 +24,13 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(400).send({ error: "No tenant selected" });
|
return reply.code(400).send({ error: "No tenant selected" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await server.db
|
const profile = await loadProfileWithBranches(server, id, tenantId)
|
||||||
.select()
|
|
||||||
.from(authProfiles)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(authProfiles.id, id),
|
|
||||||
eq(authProfiles.tenant_id, tenantId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!rows.length) {
|
if (!profile) {
|
||||||
return reply.code(404).send({ error: "User not found or not in tenant" });
|
return reply.code(404).send({ error: "User not found or not in tenant" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows[0];
|
return profile;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("GET /profiles/:id ERROR:", error);
|
console.error("GET /profiles/:id ERROR:", error);
|
||||||
@@ -48,7 +44,8 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
// ❌ Systemfelder entfernen
|
// ❌ Systemfelder entfernen
|
||||||
const forbidden = [
|
const forbidden = [
|
||||||
"id", "user_id", "tenant_id", "created_at", "updated_at",
|
"id", "user_id", "tenant_id", "created_at", "updated_at",
|
||||||
"updatedAt", "updatedBy", "old_profile_id", "full_name"
|
"updatedAt", "updatedBy", "old_profile_id", "full_name",
|
||||||
|
"branch", "branches", "branch_ids"
|
||||||
]
|
]
|
||||||
forbidden.forEach(f => delete cleaned[f])
|
forbidden.forEach(f => delete cleaned[f])
|
||||||
|
|
||||||
@@ -89,8 +86,19 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
// Clean + Normalize
|
// Clean + Normalize
|
||||||
body = sanitizeProfileUpdate(body)
|
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 updateData = {
|
const updateData = {
|
||||||
...body,
|
...body,
|
||||||
|
branch_id: primaryBranchId,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
updatedBy: userId
|
updatedBy: userId
|
||||||
}
|
}
|
||||||
@@ -110,10 +118,16 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(404).send({ error: "User not found or not in tenant" })
|
return reply.code(404).send({ error: "User not found or not in tenant" })
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated[0]
|
await syncProfileBranches(server, id, branchIds, userId)
|
||||||
|
|
||||||
|
const profile = await loadProfileWithBranches(server, id, tenantId)
|
||||||
|
return profile || updated[0]
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("PUT /profiles/:id ERROR:", 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" })
|
||||||
|
}
|
||||||
return reply.code(500).send({ error: "Internal Server Error" })
|
return reply.code(500).send({ error: "Internal Server Error" })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -136,6 +136,27 @@ function getTenantColumn(resource: string, table: any) {
|
|||||||
return table[tenantKey]
|
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) {
|
function isDateLikeField(key: string) {
|
||||||
if (key === "deliveryDateType") return false
|
if (key === "deliveryDateType") return false
|
||||||
if (key.includes("_at") || key.endsWith("At")) return true
|
if (key.includes("_at") || key.endsWith("At")) return true
|
||||||
@@ -261,7 +282,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
if (config.mtoLoad) {
|
if (config.mtoLoad) {
|
||||||
config.mtoLoad.forEach(rel => {
|
config.mtoLoad.forEach(rel => {
|
||||||
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel]
|
const relConfig = getRelationConfig(rel)
|
||||||
if (relConfig) {
|
if (relConfig) {
|
||||||
const relTable = relConfig.table
|
const relTable = relConfig.table
|
||||||
|
|
||||||
@@ -307,7 +328,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
|
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
|
||||||
})
|
})
|
||||||
for await (const rel of config.mtoLoad) {
|
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
|
const relTab = relConf.table
|
||||||
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : []
|
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]));
|
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
|
||||||
@@ -376,7 +398,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
if (config.mtoLoad) {
|
if (config.mtoLoad) {
|
||||||
config.mtoLoad.forEach(rel => {
|
config.mtoLoad.forEach(rel => {
|
||||||
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel];
|
const relConfig = getRelationConfig(rel)
|
||||||
if (relConfig) {
|
if (relConfig) {
|
||||||
const relTable = relConfig.table;
|
const relTable = relConfig.table;
|
||||||
|
|
||||||
@@ -457,7 +479,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
let distinctQuery = server.db.select({ v: col }).from(table).$dynamic();
|
let distinctQuery = server.db.select({ v: col }).from(table).$dynamic();
|
||||||
if (config.mtoLoad) {
|
if (config.mtoLoad) {
|
||||||
config.mtoLoad.forEach(rel => {
|
config.mtoLoad.forEach(rel => {
|
||||||
const relConfig = resourceConfig[rel + "s"] || resourceConfig[rel];
|
const relConfig = getRelationConfig(rel)
|
||||||
if (!relConfig) return;
|
if (!relConfig) return;
|
||||||
const relTable = relConfig.table;
|
const relTable = relConfig.table;
|
||||||
if (relTable !== table) {
|
if (relTable !== table) {
|
||||||
@@ -496,7 +518,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
|
ids[rel] = [...new Set(rows.map(r => r[rel]).filter(Boolean))];
|
||||||
});
|
});
|
||||||
for await (const rel of config.mtoLoad) {
|
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;
|
const relTab = relConf.table;
|
||||||
lists[rel] = ids[rel].length ? await server.db.select().from(relTab).where(inArray(relTab.id, ids[rel])) : [];
|
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]));
|
maps[rel] = Object.fromEntries(lists[rel].map((i: any) => [i.id, i]));
|
||||||
@@ -567,7 +590,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
if (resourceConfig[resource].mtoLoad) {
|
if (resourceConfig[resource].mtoLoad) {
|
||||||
for await (const relation of resourceConfig[resource].mtoLoad) {
|
for await (const relation of resourceConfig[resource].mtoLoad) {
|
||||||
if (data[relation]) {
|
if (data[relation]) {
|
||||||
const relConf = resourceConfig[relation + "s"] || resourceConfig[relation];
|
const relConf = getRelationConfig(relation)
|
||||||
|
if (!relConf) continue
|
||||||
const relTable = relConf.table
|
const relTable = relConf.table
|
||||||
const relData = await server.db.select().from(relTable).where(eq(relTable.id, data[relation]))
|
const relData = await server.db.select().from(relTable).where(eq(relTable.id, data[relation]))
|
||||||
data[relation] = relData[0] || null
|
data[relation] = relData[0] || null
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "../../db/schema"
|
} from "../../db/schema"
|
||||||
|
|
||||||
import {and, desc, eq, inArray} from "drizzle-orm"
|
import {and, desc, eq, inArray} from "drizzle-orm"
|
||||||
|
import { enrichProfilesWithBranches } from "../utils/profileBranches"
|
||||||
|
|
||||||
|
|
||||||
export default async function tenantRoutes(server: FastifyInstance) {
|
export default async function tenantRoutes(server: FastifyInstance) {
|
||||||
@@ -123,7 +124,7 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
|||||||
.where(inArray(authUsers.id, userIds))
|
.where(inArray(authUsers.id, userIds))
|
||||||
|
|
||||||
// 3) auth_profiles pro Tenant laden
|
// 3) auth_profiles pro Tenant laden
|
||||||
const profiles = await server.db
|
const profileRows = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(authProfiles)
|
.from(authProfiles)
|
||||||
.where(
|
.where(
|
||||||
@@ -131,6 +132,7 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
|||||||
eq(authProfiles.tenant_id, tenantId),
|
eq(authProfiles.tenant_id, tenantId),
|
||||||
inArray(authProfiles.user_id, userIds)
|
inArray(authProfiles.user_id, userIds)
|
||||||
))
|
))
|
||||||
|
const profiles = await enrichProfilesWithBranches(server, profileRows)
|
||||||
|
|
||||||
const combined = users.map(u => {
|
const combined = users.map(u => {
|
||||||
const profile = profiles.find(p => p.user_id === u.id)
|
const profile = profiles.find(p => p.user_id === u.id)
|
||||||
@@ -160,11 +162,12 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
|||||||
const tenantId = req.user?.tenant_id
|
const tenantId = req.user?.tenant_id
|
||||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
const data = await server.db
|
const profileRows = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(authProfiles)
|
.from(authProfiles)
|
||||||
.where(eq(authProfiles.tenant_id, tenantId))
|
.where(eq(authProfiles.tenant_id, tenantId))
|
||||||
|
|
||||||
|
const data = await enrichProfilesWithBranches(server, profileRows)
|
||||||
return { data }
|
return { data }
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ export const useNextNumberRangeNumber = async (
|
|||||||
tenantId: number,
|
tenantId: number,
|
||||||
numberRange: string
|
numberRange: string
|
||||||
) => {
|
) => {
|
||||||
|
const numberRangeFallbacks: Record<string, string> = {
|
||||||
|
costEstimates: "quotes",
|
||||||
|
packingSlips: "deliveryNotes",
|
||||||
|
advanceInvoices: "invoices",
|
||||||
|
cancellationInvoices: "invoices",
|
||||||
|
}
|
||||||
|
|
||||||
const [tenant] = await server.db
|
const [tenant] = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(tenants)
|
.from(tenants)
|
||||||
@@ -23,11 +30,15 @@ export const useNextNumberRangeNumber = async (
|
|||||||
|
|
||||||
const numberRanges = tenant.numberRanges || {}
|
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`)
|
throw new Error(`Number range '${numberRange}' not found`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = numberRanges[numberRange]
|
const current = numberRanges[resolvedNumberRange]
|
||||||
|
|
||||||
const usedNumber =
|
const usedNumber =
|
||||||
(current.prefix || "") +
|
(current.prefix || "") +
|
||||||
@@ -37,7 +48,7 @@ export const useNextNumberRangeNumber = async (
|
|||||||
const updatedRanges = {
|
const updatedRanges = {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
...numberRanges,
|
...numberRanges,
|
||||||
[numberRange]: {
|
[resolvedNumberRange]: {
|
||||||
...current,
|
...current,
|
||||||
nextNumber: current.nextNumber + 1,
|
nextNumber: current.nextNumber + 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ const getDuration = (time) => {
|
|||||||
|
|
||||||
|
|
||||||
export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoiceData, backgroundPath:string) => {
|
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 genPDF = async (invoiceData, backgroundSourceBuffer) => {
|
||||||
const pdfDoc = await PDFDocument.create()
|
const pdfDoc = await PDFDocument.create()
|
||||||
@@ -347,7 +349,19 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
font: fontBold
|
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", {
|
pages[pageCounter - 1].drawText("Steuer", {
|
||||||
...getCoordinatesForPDFLib(135, 137, page1),
|
...getCoordinatesForPDFLib(135, 137, page1),
|
||||||
size: 12,
|
size: 12,
|
||||||
@@ -414,9 +428,21 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
maxWidth: 240
|
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
|
let rowTextLines = 0
|
||||||
|
|
||||||
if (invoiceData.type !== "deliveryNotes") {
|
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, 35).join("\n"), {
|
pages[pageCounter - 1].drawText(splitStringBySpace(row.text, 35).join("\n"), {
|
||||||
...getCoordinatesForPDFLib(52, rowHeight, page1),
|
...getCoordinatesForPDFLib(52, rowHeight, page1),
|
||||||
size: 10,
|
size: 10,
|
||||||
@@ -428,7 +454,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
rowTextLines = splitStringBySpace(row.text, 35).length
|
rowTextLines = splitStringBySpace(row.text, 35).length
|
||||||
|
|
||||||
} else {
|
} 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),
|
...getCoordinatesForPDFLib(52, rowHeight, page1),
|
||||||
size: 10,
|
size: 10,
|
||||||
color: rgb(0, 0, 0),
|
color: rgb(0, 0, 0),
|
||||||
@@ -437,13 +463,13 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
font: fontBold
|
font: fontBold
|
||||||
})
|
})
|
||||||
|
|
||||||
rowTextLines = splitStringBySpace(row.text, 80).length
|
rowTextLines = splitStringBySpace(row.text, isPackingSlip ? 68 : 80).length
|
||||||
}
|
}
|
||||||
|
|
||||||
let rowDescriptionLines = 0
|
let rowDescriptionLines = 0
|
||||||
|
|
||||||
if (row.descriptionText) {
|
if (row.descriptionText) {
|
||||||
if (invoiceData.type !== "deliveryNotes") {
|
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||||
rowDescriptionLines = splitStringBySpace(row.descriptionText, 60).length
|
rowDescriptionLines = splitStringBySpace(row.descriptionText, 60).length
|
||||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, 60).join("\n"), {
|
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, 60).join("\n"), {
|
||||||
...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1),
|
...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1),
|
||||||
@@ -454,8 +480,8 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
})
|
})
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
rowDescriptionLines = splitStringBySpace(row.descriptionText, 80).length
|
rowDescriptionLines = splitStringBySpace(row.descriptionText, isPackingSlip ? 68 : 80).length
|
||||||
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, 80).join("\n"), {
|
pages[pageCounter - 1].drawText(splitStringBySpace(row.descriptionText, isPackingSlip ? 68 : 80).join("\n"), {
|
||||||
...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1),
|
...getCoordinatesForPDFLib(52, rowHeight + (rowTextLines * 5), page1),
|
||||||
size: 10,
|
size: 10,
|
||||||
color: rgb(0, 0, 0),
|
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} %`, {
|
pages[pageCounter - 1].drawText(`${row.taxPercent} %`, {
|
||||||
...getCoordinatesForPDFLib(135, rowHeight, page1),
|
...getCoordinatesForPDFLib(135, rowHeight, page1),
|
||||||
size: 10,
|
size: 10,
|
||||||
@@ -632,7 +658,19 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
font: fontBold
|
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", {
|
page.drawText("Steuer", {
|
||||||
...getCoordinatesForPDFLib(135, 22, page1),
|
...getCoordinatesForPDFLib(135, 22, page1),
|
||||||
size: 12,
|
size: 12,
|
||||||
@@ -742,7 +780,7 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
|
|
||||||
let endTextDiff = 35
|
let endTextDiff = 35
|
||||||
|
|
||||||
if (invoiceData.type !== "deliveryNotes") {
|
if (!deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) {
|
||||||
pages[pageCounter - 1].drawLine({
|
pages[pageCounter - 1].drawLine({
|
||||||
start: getCoordinatesForPDFLib(20, rowHeight, page1),
|
start: getCoordinatesForPDFLib(20, rowHeight, page1),
|
||||||
end: getCoordinatesForPDFLib(198, rowHeight, page1),
|
end: getCoordinatesForPDFLib(198, rowHeight, page1),
|
||||||
@@ -864,9 +902,9 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
maxWidth: 500
|
maxWidth: 500
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return await pdfDoc.saveAsBase64()
|
return await pdfDoc.saveAsBase64()
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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,
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
bankaccounts,
|
bankaccounts,
|
||||||
bankrequisitions,
|
bankrequisitions,
|
||||||
bankstatements,
|
bankstatements,
|
||||||
|
branches,
|
||||||
entitybankaccounts,
|
entitybankaccounts,
|
||||||
events,
|
events,
|
||||||
contacts,
|
contacts,
|
||||||
@@ -162,7 +163,12 @@ export const resourceConfig = {
|
|||||||
costcentres: {
|
costcentres: {
|
||||||
table: costcentres,
|
table: costcentres,
|
||||||
searchColumns: ["name","number","description"],
|
searchColumns: ["name","number","description"],
|
||||||
mtoLoad: ["vehicle","project","inventoryitem"],
|
mtoLoad: ["vehicle","project","inventoryitem","branch"],
|
||||||
|
numberRangeHolder: "number",
|
||||||
|
},
|
||||||
|
branches: {
|
||||||
|
table: branches,
|
||||||
|
searchColumns: ["name","number","description"],
|
||||||
numberRangeHolder: "number",
|
numberRangeHolder: "number",
|
||||||
},
|
},
|
||||||
tasks: {
|
tasks: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import * as Sentry from "@sentry/browser"
|
import * as Sentry from "@sentry/browser"
|
||||||
|
import { de as germanLocale } from "@nuxt/ui/locale"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ useSeoMeta({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UApp>
|
<UApp :locale="germanLocale">
|
||||||
<div class="safearea">
|
<div class="safearea">
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage/>
|
<NuxtPage/>
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
// Falls useDropZone nicht auto-importiert wird:
|
|
||||||
// import { useDropZone } from '@vueuse/core'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
fileData: {
|
fileData: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: {
|
default: () => ({
|
||||||
type: null
|
type: null
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(["uploadFinished"])
|
const emit = defineEmits(["uploadFinished"])
|
||||||
|
|
||||||
const modal = useModal()
|
const modal = useModal()
|
||||||
// const profileStore = useProfileStore() // Wird im Snippet nicht genutzt, aber ich lasse es drin
|
|
||||||
|
|
||||||
const uploadInProgress = ref(false)
|
const uploadInProgress = ref(false)
|
||||||
const availableFiletypes = ref([])
|
const availableFiletypes = ref([])
|
||||||
|
const localFileData = reactive({
|
||||||
|
...props.fileData
|
||||||
|
})
|
||||||
|
|
||||||
// 1. State für die Dateien und die Dropzone Referenz
|
// 1. State für die Dateien und die Dropzone Referenz
|
||||||
const selectedFiles = ref([])
|
const selectedFiles = ref([])
|
||||||
@@ -58,10 +57,8 @@ const uploadFiles = async () => {
|
|||||||
|
|
||||||
uploadInProgress.value = true;
|
uploadInProgress.value = true;
|
||||||
|
|
||||||
let fileData = props.fileData
|
const { typeEnabled, ...fileData } = localFileData
|
||||||
delete fileData.typeEnabled
|
|
||||||
|
|
||||||
// 4. Hier nutzen wir nun selectedFiles.value statt document.getElementById
|
|
||||||
await useFiles().uploadFiles(fileData, selectedFiles.value, [], true)
|
await useFiles().uploadFiles(fileData, selectedFiles.value, [], true)
|
||||||
|
|
||||||
uploadInProgress.value = false;
|
uploadInProgress.value = false;
|
||||||
@@ -80,12 +77,11 @@ const fileNames = computed(() => {
|
|||||||
<UModal>
|
<UModal>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div ref="dropZoneRef" class="relative h-full flex flex-col">
|
<div ref="dropZoneRef" class="relative h-full flex flex-col">
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isOverDropZone"
|
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
|
Dateien hier ablegen
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,16 +126,17 @@ const fileNames = computed(() => {
|
|||||||
class="mt-3"
|
class="mt-3"
|
||||||
>
|
>
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
option-attribute="name"
|
:items="availableFiletypes"
|
||||||
value-attribute="id"
|
v-model="localFileData.type"
|
||||||
searchable
|
value-key="id"
|
||||||
searchable-placeholder="Suchen..."
|
label-key="name"
|
||||||
:options="availableFiletypes"
|
:search-input="{ placeholder: 'Suchen...' }"
|
||||||
v-model="props.fileData.type"
|
:filter-fields="['name']"
|
||||||
:disabled="!props.fileData.typeEnabled"
|
:disabled="!localFileData.typeEnabled"
|
||||||
|
class="w-full"
|
||||||
>
|
>
|
||||||
<template #label>
|
<template #default>
|
||||||
<span v-if="availableFiletypes.find(x => x.id === props.fileData.type)">{{availableFiletypes.find(x => x.id === props.fileData.type).name}}</span>
|
<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>
|
<span v-else>Kein Typ ausgewählt</span>
|
||||||
</template>
|
</template>
|
||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
@@ -159,5 +156,4 @@ const fileNames = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Optional: Animationen für das Overlay */
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -174,6 +174,49 @@ const setupQuery = () => {
|
|||||||
setupQuery()
|
setupQuery()
|
||||||
|
|
||||||
const loadedOptions = ref({})
|
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 () => {
|
const loadOptions = async () => {
|
||||||
let optionsToLoad = dataType.templateColumns.filter(i => i.selectDataType).map(i => {
|
let optionsToLoad = dataType.templateColumns.filter(i => i.selectDataType).map(i => {
|
||||||
return {
|
return {
|
||||||
@@ -184,9 +227,9 @@ const loadOptions = async () => {
|
|||||||
|
|
||||||
for await(const option of optionsToLoad) {
|
for await(const option of optionsToLoad) {
|
||||||
if (option.option === "countrys") {
|
if (option.option === "countrys") {
|
||||||
loadedOptions.value[option.option] = useEntities("countrys").selectSpecial()
|
loadedOptions.value[option.option] = await useEntities("countrys").selectSpecial()
|
||||||
} else if (option.option === "units") {
|
} else if (option.option === "units") {
|
||||||
loadedOptions.value[option.option] = useEntities("units").selectSpecial()
|
loadedOptions.value[option.option] = await useEntities("units").selectSpecial()
|
||||||
} else {
|
} else {
|
||||||
loadedOptions.value[option.option] = (await useEntities(option.option).select())
|
loadedOptions.value[option.option] = (await useEntities(option.option).select())
|
||||||
|
|
||||||
@@ -197,7 +240,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) => {
|
const contentChanged = (content, datapoint) => {
|
||||||
if (datapoint.key.includes(".")) {
|
if (datapoint.key.includes(".")) {
|
||||||
@@ -227,6 +304,15 @@ const getSelectSearchInput = (datapoint) => {
|
|||||||
return datapoint.selectSearchAttributes ? { placeholder: 'Suche...' } : false
|
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 () => {
|
const createItem = async () => {
|
||||||
let ret = null
|
let ret = null
|
||||||
@@ -393,7 +479,7 @@ const updateItem = async () => {
|
|||||||
<USelectMenu
|
<USelectMenu
|
||||||
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
|
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
|
||||||
class="flex-auto"
|
class="flex-auto"
|
||||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
@update:model-value="triggerInputChange(datapoint)"
|
||||||
v-else-if="datapoint.inputType === 'select'"
|
v-else-if="datapoint.inputType === 'select'"
|
||||||
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
|
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
|
||||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||||
@@ -498,7 +584,7 @@ const updateItem = async () => {
|
|||||||
<USelectMenu
|
<USelectMenu
|
||||||
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
|
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
|
||||||
class="flex-auto"
|
class="flex-auto"
|
||||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
@update:model-value="triggerInputChange(datapoint)"
|
||||||
v-else-if="datapoint.inputType === 'select'"
|
v-else-if="datapoint.inputType === 'select'"
|
||||||
v-model="item[datapoint.key]"
|
v-model="item[datapoint.key]"
|
||||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||||
@@ -626,7 +712,7 @@ const updateItem = async () => {
|
|||||||
<USelectMenu
|
<USelectMenu
|
||||||
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
|
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
|
||||||
class="flex-auto"
|
class="flex-auto"
|
||||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
@update:model-value="triggerInputChange(datapoint)"
|
||||||
v-else-if="datapoint.inputType === 'select'"
|
v-else-if="datapoint.inputType === 'select'"
|
||||||
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
|
v-model="item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]]"
|
||||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||||
@@ -731,7 +817,7 @@ const updateItem = async () => {
|
|||||||
<USelectMenu
|
<USelectMenu
|
||||||
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
|
:color="datapoint.required ? (item[datapoint.key] ? 'primary' : 'error') : 'white'"
|
||||||
class="flex-auto"
|
class="flex-auto"
|
||||||
@change="datapoint.inputChangeFunction ? datapoint.inputChangeFunction(item,loadedOptions) : null"
|
@update:model-value="triggerInputChange(datapoint)"
|
||||||
v-else-if="datapoint.inputType === 'select'"
|
v-else-if="datapoint.inputType === 'select'"
|
||||||
v-model="item[datapoint.key]"
|
v-model="item[datapoint.key]"
|
||||||
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ const modal = useModal()
|
|||||||
<template>
|
<template>
|
||||||
<UButton
|
<UButton
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="w-25 ml-2"
|
size="sm"
|
||||||
|
square
|
||||||
|
class="ml-2 shrink-0"
|
||||||
v-if="props.id && props.buttonShow"
|
v-if="props.id && props.buttonShow"
|
||||||
icon="i-heroicons-eye"
|
icon="i-heroicons-eye"
|
||||||
@click="modal.open(StandardEntityModal, {
|
@click="modal.open(StandardEntityModal, {
|
||||||
@@ -50,7 +52,9 @@ const modal = useModal()
|
|||||||
/>
|
/>
|
||||||
<UButton
|
<UButton
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="w-25 ml-2"
|
size="sm"
|
||||||
|
square
|
||||||
|
class="ml-2 shrink-0"
|
||||||
v-if="props.id && props.buttonEdit"
|
v-if="props.id && props.buttonEdit"
|
||||||
icon="i-heroicons-pencil-solid"
|
icon="i-heroicons-pencil-solid"
|
||||||
@click="modal.open(StandardEntityModal, {
|
@click="modal.open(StandardEntityModal, {
|
||||||
@@ -64,7 +68,9 @@ const modal = useModal()
|
|||||||
/>
|
/>
|
||||||
<UButton
|
<UButton
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="w-25 ml-2"
|
size="sm"
|
||||||
|
square
|
||||||
|
class="ml-2 shrink-0"
|
||||||
v-if="!props.id && props.buttonCreate"
|
v-if="!props.id && props.buttonCreate"
|
||||||
icon="i-heroicons-plus"
|
icon="i-heroicons-plus"
|
||||||
@click="modal.open(StandardEntityModal, {
|
@click="modal.open(StandardEntityModal, {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const dataStore = useDataStore()
|
|||||||
const tempStore = useTempStore()
|
const tempStore = useTempStore()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const deliveryNoteLikeDocumentTypes = ['deliveryNotes', 'packingSlips']
|
||||||
|
|
||||||
const createddocuments = ref([])
|
const createddocuments = ref([])
|
||||||
|
|
||||||
@@ -117,7 +118,7 @@ const getAvailableQueryStringData = (keys) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const invoiceDeliveryNotes = () => {
|
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)
|
const showFinalInvoiceConfig = ref(false)
|
||||||
@@ -150,13 +151,18 @@ const selectItem = (item) => {
|
|||||||
@click="invoiceDeliveryNotes"
|
@click="invoiceDeliveryNotes"
|
||||||
v-if="props.topLevelType === 'projects'"
|
v-if="props.topLevelType === 'projects'"
|
||||||
>
|
>
|
||||||
Lieferscheine abrechnen
|
Lieferscheine/Packscheine abrechnen
|
||||||
</UButton>
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'quotes'})}`)"
|
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'quotes'})}`)"
|
||||||
>
|
>
|
||||||
+ Angebot
|
+ Angebot
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'costEstimates'})}`)"
|
||||||
|
>
|
||||||
|
+ Kostenschätzung
|
||||||
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'confirmationOrders'})}`)"
|
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'confirmationOrders'})}`)"
|
||||||
>
|
>
|
||||||
@@ -167,6 +173,11 @@ const selectItem = (item) => {
|
|||||||
>
|
>
|
||||||
+ Lieferschein
|
+ Lieferschein
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'packingSlips'})}`)"
|
||||||
|
>
|
||||||
|
+ Packschein
|
||||||
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'advanceInvoices'})}`)"
|
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'advanceInvoices'})}`)"
|
||||||
>
|
>
|
||||||
@@ -198,7 +209,7 @@ const selectItem = (item) => {
|
|||||||
label="Rechnungsvorlage"
|
label="Rechnungsvorlage"
|
||||||
>
|
>
|
||||||
<USelectMenu
|
<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"
|
value-key="id"
|
||||||
label-key="documentNumber"
|
label-key="documentNumber"
|
||||||
v-model="referenceDocument"
|
v-model="referenceDocument"
|
||||||
@@ -255,9 +266,14 @@ const selectItem = (item) => {
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
|
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
|
||||||
:on-select="(row) => selectItem(row.original)"
|
:on-select="(row) => selectItem(row.original)"
|
||||||
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
|
|
||||||
style="height: 70vh"
|
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 }">
|
<template #type-cell="{ row }">
|
||||||
{{ dataStore.documentTypesForCreation[row.original.type].labelSingle }}
|
{{ dataStore.documentTypesForCreation[row.original.type].labelSingle }}
|
||||||
</template>
|
</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>
|
<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>
|
||||||
<template #amount-cell="{ row }">
|
<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>
|
</template>
|
||||||
</UTable>
|
</UTable>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs"
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
queryStringData: {
|
queryStringData: {
|
||||||
@@ -21,83 +21,260 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const statementallocations = ref([])
|
const loading = ref(true)
|
||||||
const incominginvoices = ref([])
|
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 () => {
|
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()
|
setup()
|
||||||
|
|
||||||
const selectAllocation = (allocation) => {
|
const unwrapAllocationRow = (allocationLike) => allocationLike?.original || allocationLike
|
||||||
if(allocation.type === "statementallocation") {
|
|
||||||
router.push(`/banking/statements/edit/${allocation.bs_id.id}`)
|
const selectAllocation = (allocationLike) => {
|
||||||
} else if(allocation.type === "incominginvoice") {
|
const allocation = unwrapAllocationRow(allocationLike)
|
||||||
router.push(`/incominginvoices/show/${allocation.incominginvoiceid}`)
|
|
||||||
}
|
if (!allocation) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderedAllocations = computed(() => {
|
const statementId = getStatementId(allocation)
|
||||||
|
|
||||||
let tempstatementallocations = props.item.statementallocations.map(i => {
|
if (allocation.type === "statementallocation" && statementId) {
|
||||||
return {
|
router.push(`/banking/statements/edit/${statementId}`)
|
||||||
...i,
|
} else if (allocation.type === "incominginvoice" && allocation.incominginvoiceid) {
|
||||||
type: "statementallocation",
|
router.push(`/incomingInvoices/show/${allocation.incominginvoiceid}`)
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UCard class="mt-5">
|
<UCard class="mt-5">
|
||||||
|
<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
|
<UTable
|
||||||
v-if="props.item.statementallocations"
|
|
||||||
:data="renderedAllocations"
|
:data="renderedAllocations"
|
||||||
:columns="normalizeTableColumns([{key:'amount', label:'Betrag'},{key:'date', label:'Datum'},{key:'partner', label:'Partner'},{key:'description', label:'Beschreibung'}])"
|
:columns="normalizeTableColumns(columns)"
|
||||||
:on-select="(i) => selectAllocation(i)"
|
:on-select="selectAllocation"
|
||||||
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }"
|
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 }">
|
<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-error" v-if="row.original.amount < 0 || row.original.color === 'red'">{{ useCurrency(row.original.amount) }}</span>
|
||||||
<span class="text-right text-primary-500" v-else-if="row.original.amount > 0 || row.original.color === 'green'">{{ useCurrency(row.original.amount) }}</span>
|
<span class="text-right text-primary-500" v-else-if="row.original.amount > 0 || row.original.color === 'green'">{{ useCurrency(row.original.amount) }}</span>
|
||||||
<span v-else>{{ useCurrency(row.original.amount) }}</span>
|
<span v-else>{{ useCurrency(row.original.amount) }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #date-cell="{ row }">
|
<template #date-cell="{ row }">
|
||||||
{{row.original.date ? dayjs(row.original.date).format('DD.MM.YYYY') : ''}}
|
{{ row.original.bankstatement.date && dayjs(row.original.bankstatement.date).isValid() ? dayjs(row.original.bankstatement.date).format('DD.MM.YYYY') : '-' }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #partner-cell="{ row }">
|
||||||
|
<div class="truncate">{{ hasContent(row.original.partner) ? row.original.partner : '-' }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #description-cell="{ row }">
|
<template #description-cell="{ row }">
|
||||||
{{row.original.description ? row.original.description : ''}}
|
<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>
|
</template>
|
||||||
</UTable>
|
</UTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UProgress v-else animation="carousel" class="w-3/4 mx-auto" />
|
||||||
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -25,30 +25,42 @@ const emit = defineEmits(["updateNeeded"]);
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const profileStore = useProfileStore()
|
const profileStore = useProfileStore()
|
||||||
const auth = useAuthStore()
|
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(() => {
|
const renderedPhases = computed(() => {
|
||||||
if(props.topLevelType === "projects" && props.item.phases) {
|
if(props.topLevelType === "projects" && props.item.phases) {
|
||||||
return props.item.phases.map((phase,index,array) => {
|
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 {
|
return {
|
||||||
...phase,
|
...phase,
|
||||||
label: phase.optional ? `${phase.label}(optional)`: phase.label,
|
label: phase.optional ? `${phase.label}(optional)`: phase.label,
|
||||||
disabled: !isAvailable,
|
disabled: !isPhaseAvailable(phase, index, array),
|
||||||
defaultOpen: phase.active ? true : false
|
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) => {
|
const changeActivePhase = async (key) => {
|
||||||
console.log(props.item)
|
console.log(props.item)
|
||||||
let item = await useEntities("projects").selectSingle(props.item.id,'*')
|
let item = await useEntities("projects").selectSingle(props.item.id,'*')
|
||||||
@@ -92,41 +131,41 @@ const changeActivePhase = async (key) => {
|
|||||||
<template #header v-if="props.platform === 'mobile'">
|
<template #header v-if="props.platform === 'mobile'">
|
||||||
<span>Phasen</span>
|
<span>Phasen</span>
|
||||||
</template>
|
</template>
|
||||||
<UAccordion
|
<div class="space-y-2">
|
||||||
:items="renderedPhases"
|
<div
|
||||||
|
v-for="(item, index) in renderedPhases"
|
||||||
|
:key="item.key"
|
||||||
|
class="space-y-2"
|
||||||
>
|
>
|
||||||
<template #default="slotProps">
|
|
||||||
<UButton
|
<UButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
:color="slotProps.item.active ? 'primary' : 'white'"
|
:color="item.active ? 'primary' : 'neutral'"
|
||||||
class="mb-1"
|
class="w-full justify-start"
|
||||||
:disabled="true"
|
:disabled="item.disabled"
|
||||||
|
@click="togglePhasePanel(item)"
|
||||||
>
|
>
|
||||||
<template #leading>
|
<template #leading>
|
||||||
<div class="w-6 h-6 flex items-center justify-center -my-1">
|
<div class="w-6 h-6 flex items-center justify-center -my-1">
|
||||||
<UIcon :name="slotProps.item.icon" class="w-4 h-4 " />
|
<UIcon :name="item.icon" class="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<span class="truncate"> {{ slotProps.item.label }}</span>
|
<span class="truncate">{{ item.label }}</span>
|
||||||
|
|
||||||
<template #trailing>
|
<template #trailing>
|
||||||
<UIcon
|
<UIcon
|
||||||
name="i-heroicons-chevron-right-20-solid"
|
name="i-heroicons-chevron-right-20-solid"
|
||||||
class="w-5 h-5 ms-auto transform transition-transform duration-200"
|
class="w-5 h-5 ms-auto transform transition-transform duration-200"
|
||||||
:class="[slotProps?.open && 'rotate-90']"
|
:class="[openPhaseKey === item.key && 'rotate-90']"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</UButton>
|
</UButton>
|
||||||
</template>
|
|
||||||
<template #item="{item, index}">
|
<UCard v-if="openPhaseKey === item.key" class="mx-5">
|
||||||
<UCard class="mx-5">
|
|
||||||
<template #header>
|
<template #header>
|
||||||
<span class="dark:text-white text-black">{{ item.label }}</span>
|
<span class="dark:text-white text-black">{{ item.label }}</span>
|
||||||
</template>
|
</template>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<!-- TODO: Reactive Change Phase -->
|
|
||||||
<UButton
|
<UButton
|
||||||
v-if="!item.activated_at && index !== 0 "
|
v-if="!item.activated_at && index !== 0 "
|
||||||
@click="changeActivePhase(item.key)"
|
@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>
|
<p v-if="item.description" class="dark:text-white text-black">Beschreibung: {{item.description}}</p>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
|
||||||
</UAccordion>
|
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -69,8 +69,13 @@ const columns = [
|
|||||||
class="mt-3"
|
class="mt-3"
|
||||||
:columns="normalizeTableColumns(columns)"
|
:columns="normalizeTableColumns(columns)"
|
||||||
:data="props.item.times"
|
:data="props.item.times"
|
||||||
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Noch keine Einträge' }"
|
|
||||||
>
|
>
|
||||||
|
<template #empty>
|
||||||
|
<div class="flex flex-col items-center justify-center py-8 text-center text-sm text-muted">
|
||||||
|
<UIcon name="i-heroicons-circle-stack-20-solid" class="mb-2 size-5" />
|
||||||
|
<span>Noch keine Einträge</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template #state-cell="{ row }">
|
<template #state-cell="{ row }">
|
||||||
<span
|
<span
|
||||||
v-if="row.original.state === 'Entwurf'"
|
v-if="row.original.state === 'Entwurf'"
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const handleClick = async () => {
|
|||||||
:icon="labelPrinter.connected ? 'i-heroicons-printer' : 'i-heroicons-printer'"
|
:icon="labelPrinter.connected ? 'i-heroicons-printer' : 'i-heroicons-printer'"
|
||||||
:color="labelPrinter.connected ? 'green' : ''"
|
:color="labelPrinter.connected ? 'green' : ''"
|
||||||
variant="soft"
|
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"
|
:loading="labelPrinter.connectLoading"
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -136,11 +136,26 @@ const links = computed(() => {
|
|||||||
to: "/incomingInvoices",
|
to: "/incomingInvoices",
|
||||||
icon: "i-heroicons-document-text",
|
icon: "i-heroicons-document-text",
|
||||||
} : null,
|
} : null,
|
||||||
|
(featureEnabled("incomingInvoices") || featureEnabled("banking")) ? {
|
||||||
|
label: "Abschreibungen",
|
||||||
|
to: "/accounting/depreciation",
|
||||||
|
icon: "i-heroicons-calendar-days",
|
||||||
|
} : null,
|
||||||
|
((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")) ? {
|
(featureEnabled("createDocument") || featureEnabled("incomingInvoices")) ? {
|
||||||
label: "USt-Auswertung",
|
label: "USt",
|
||||||
to: "/accounting/tax",
|
to: "/accounting/tax",
|
||||||
icon: "i-heroicons-calculator",
|
icon: "i-heroicons-calculator",
|
||||||
} : null,
|
} : null,
|
||||||
|
(featureEnabled("createDocument") || featureEnabled("incomingInvoices") || featureEnabled("accounts") || featureEnabled("ownaccounts")) ? {
|
||||||
|
label: "BWA",
|
||||||
|
to: "/accounting/bwa",
|
||||||
|
icon: "i-heroicons-chart-bar-square",
|
||||||
|
} : null,
|
||||||
featureEnabled("costcentres") ? {
|
featureEnabled("costcentres") ? {
|
||||||
label: "Kostenstellen",
|
label: "Kostenstellen",
|
||||||
to: "/standardEntity/costcentres",
|
to: "/standardEntity/costcentres",
|
||||||
@@ -152,10 +167,12 @@ const links = computed(() => {
|
|||||||
icon: "i-heroicons-document-text",
|
icon: "i-heroicons-document-text",
|
||||||
} : null,
|
} : null,
|
||||||
featureEnabled("ownaccounts") ? {
|
featureEnabled("ownaccounts") ? {
|
||||||
label: "zusätzliche Buchungskonten",
|
label: "Zusätzliche Buchungskonten",
|
||||||
to: "/standardEntity/ownaccounts",
|
to: "/standardEntity/ownaccounts",
|
||||||
icon: "i-heroicons-document-text"
|
icon: "i-heroicons-document-text"
|
||||||
} : null,
|
} : null,
|
||||||
|
])
|
||||||
|
} : null,
|
||||||
featureEnabled("banking") ? {
|
featureEnabled("banking") ? {
|
||||||
label: "Bank",
|
label: "Bank",
|
||||||
to: "/banking",
|
to: "/banking",
|
||||||
@@ -217,6 +234,11 @@ const links = computed(() => {
|
|||||||
to: "/standardEntity/memberrelations",
|
to: "/standardEntity/memberrelations",
|
||||||
icon: "i-heroicons-identification"
|
icon: "i-heroicons-identification"
|
||||||
} : null,
|
} : null,
|
||||||
|
featureEnabled("branches") ? {
|
||||||
|
label: "Niederlassungen",
|
||||||
|
to: "/standardEntity/branches",
|
||||||
|
icon: "i-heroicons-building-office-2"
|
||||||
|
} : null,
|
||||||
featureEnabled("staffProfiles") ? {
|
featureEnabled("staffProfiles") ? {
|
||||||
label: "Mitarbeiter",
|
label: "Mitarbeiter",
|
||||||
to: "/staff/profiles",
|
to: "/staff/profiles",
|
||||||
@@ -270,11 +292,6 @@ const links = computed(() => {
|
|||||||
to: "/settings/tenant",
|
to: "/settings/tenant",
|
||||||
icon: "i-heroicons-building-office",
|
icon: "i-heroicons-building-office",
|
||||||
} : null,
|
} : null,
|
||||||
isAdmin.value ? {
|
|
||||||
label: "Administration",
|
|
||||||
to: "/settings/admin",
|
|
||||||
icon: "i-heroicons-shield-check",
|
|
||||||
} : null,
|
|
||||||
featureEnabled("export") ? {
|
featureEnabled("export") ? {
|
||||||
label: "Export",
|
label: "Export",
|
||||||
to: "/export",
|
to: "/export",
|
||||||
@@ -282,6 +299,19 @@ const links = computed(() => {
|
|||||||
} : null,
|
} : 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 visibleOrganisationChildren = visibleItems(organisationChildren)
|
||||||
const visibleDocumentChildren = visibleItems(documentChildren)
|
const visibleDocumentChildren = visibleItems(documentChildren)
|
||||||
const visibleCommunicationChildren = visibleItems(communicationChildren)
|
const visibleCommunicationChildren = visibleItems(communicationChildren)
|
||||||
@@ -291,6 +321,7 @@ const links = computed(() => {
|
|||||||
const visibleInventoryChildren = visibleItems(inventoryChildren)
|
const visibleInventoryChildren = visibleItems(inventoryChildren)
|
||||||
const visibleMasterDataChildren = visibleItems(masterDataChildren)
|
const visibleMasterDataChildren = visibleItems(masterDataChildren)
|
||||||
const visibleSettingsChildren = visibleItems(settingsChildren)
|
const visibleSettingsChildren = visibleItems(settingsChildren)
|
||||||
|
const visibleAdministrationChildren = visibleItems(administrationChildren)
|
||||||
|
|
||||||
return visibleItems([
|
return visibleItems([
|
||||||
...(auth.profile?.pinned_on_navigation || []).map(pin => {
|
...(auth.profile?.pinned_on_navigation || []).map(pin => {
|
||||||
@@ -385,7 +416,12 @@ const links = computed(() => {
|
|||||||
icon: "i-heroicons-clipboard-document",
|
icon: "i-heroicons-clipboard-document",
|
||||||
children: visibleMasterDataChildren
|
children: visibleMasterDataChildren
|
||||||
}] : []),
|
}] : []),
|
||||||
|
...(visibleAdministrationChildren.length > 0 ? [{
|
||||||
|
label: "Administration",
|
||||||
|
defaultOpen: false,
|
||||||
|
icon: "i-heroicons-shield-check",
|
||||||
|
children: visibleAdministrationChildren
|
||||||
|
}] : []),
|
||||||
|
|
||||||
...(visibleSettingsChildren.length > 0 ? [{
|
...(visibleSettingsChildren.length > 0 ? [{
|
||||||
label: "Einstellungen",
|
label: "Einstellungen",
|
||||||
@@ -396,16 +432,11 @@ const links = computed(() => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
const navItems = computed(() =>
|
const mapNavItem = (item, valuePrefix = "item") => {
|
||||||
links.value
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((item, index) => {
|
|
||||||
const children = Array.isArray(item.children)
|
const children = Array.isArray(item.children)
|
||||||
? item.children.map((child, childIndex) => ({
|
? item.children
|
||||||
...child,
|
.filter(Boolean)
|
||||||
value: child.id || child.label || `${index}-${childIndex}`,
|
.map((child, index) => mapNavItem(child, `${valuePrefix}-${index}`))
|
||||||
active: isRouteActive(child.to)
|
|
||||||
}))
|
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const active = item.active || isRouteActive(item.to) || Boolean(children?.some(child => child.active))
|
const active = item.active || isRouteActive(item.to) || Boolean(children?.some(child => child.active))
|
||||||
@@ -413,14 +444,19 @@ const navItems = computed(() =>
|
|||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
children,
|
children,
|
||||||
value: item.id || item.label || String(index),
|
value: item.id || item.label || valuePrefix,
|
||||||
defaultOpen: item.defaultOpen || active,
|
defaultOpen: item.defaultOpen || active,
|
||||||
active,
|
active,
|
||||||
tooltip: true,
|
tooltip: true,
|
||||||
popover: true,
|
popover: true,
|
||||||
trailingIcon: children?.length ? undefined : ''
|
trailingIcon: children?.length ? undefined : ''
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
const navItems = computed(() =>
|
||||||
|
links.value
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((item, index) => mapNavItem(item, String(index)))
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
import { parseDate } from '@internationalized/date'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
context: { type: Object, required: true },
|
context: { type: Object, required: true },
|
||||||
@@ -28,6 +29,23 @@ const form = ref({
|
|||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const errors = ref({})
|
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
|
// Validierung basierend auf JSON Config & neuen Anforderungen
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
errors.value = {}
|
errors.value = {}
|
||||||
@@ -109,30 +127,64 @@ const setDeliveryDateToToday = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<template #header>
|
||||||
<div class="text-center">
|
<div class="space-y-3 text-center">
|
||||||
<h1 class="text-xl font-bold text-gray-900">{{ config?.ui?.title || 'Erfassung' }}</h1>
|
<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">
|
||||||
<p v-if="config?.ui?.description" class="text-sm text-gray-500 mt-1">{{ config?.ui?.description }}</p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="space-y-5">
|
<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
|
<UFormField
|
||||||
label="Datum der Ausführung"
|
label="Datum der Ausführung"
|
||||||
:error="errors.deliveryDate"
|
:error="errors.deliveryDate"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex gap-2">
|
||||||
<UInput
|
<UPopover>
|
||||||
v-model="form.deliveryDate"
|
<UButton
|
||||||
type="date"
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
icon="i-heroicons-calendar-days"
|
icon="i-heroicons-calendar-days"
|
||||||
class="flex-1"
|
class="min-w-0 flex-1 justify-between"
|
||||||
/>
|
>
|
||||||
<UButton color="gray" variant="soft" size="lg" label="Heute" @click="setDeliveryDateToToday" />
|
<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>
|
</div>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
@@ -148,8 +200,10 @@ const setDeliveryDateToToday = () => {
|
|||||||
label-key="fullName"
|
label-key="fullName"
|
||||||
value-key="id"
|
value-key="id"
|
||||||
placeholder="Name auswählen..."
|
placeholder="Name auswählen..."
|
||||||
searchable
|
|
||||||
size="lg"
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:search-input="{ placeholder: 'Mitarbeiter suchen...' }"
|
||||||
|
:filter-fields="['fullName']"
|
||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
@@ -165,8 +219,10 @@ const setDeliveryDateToToday = () => {
|
|||||||
label-key="name"
|
label-key="name"
|
||||||
value-key="id"
|
value-key="id"
|
||||||
placeholder="Wählen..."
|
placeholder="Wählen..."
|
||||||
searchable
|
|
||||||
size="lg"
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:search-input="{ placeholder: 'Projekt suchen...' }"
|
||||||
|
:filter-fields="['name']"
|
||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
@@ -182,8 +238,10 @@ const setDeliveryDateToToday = () => {
|
|||||||
label-key="name"
|
label-key="name"
|
||||||
value-key="id"
|
value-key="id"
|
||||||
placeholder="Wählen..."
|
placeholder="Wählen..."
|
||||||
searchable
|
|
||||||
size="lg"
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:search-input="{ placeholder: 'Tätigkeit suchen...' }"
|
||||||
|
:filter-fields="['name']"
|
||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
@@ -198,9 +256,10 @@ const setDeliveryDateToToday = () => {
|
|||||||
step="0.25"
|
step="0.25"
|
||||||
size="lg"
|
size="lg"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
|
class="w-full"
|
||||||
>
|
>
|
||||||
<template #trailing>
|
<template #trailing>
|
||||||
<span class="text-gray-500 text-sm pr-2">{{ currentUnit }}</span>
|
<span class="pr-2 text-sm text-muted">{{ currentUnit }}</span>
|
||||||
</template>
|
</template>
|
||||||
</UInput>
|
</UInput>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
@@ -211,23 +270,23 @@ const setDeliveryDateToToday = () => {
|
|||||||
:error="errors.diesel"
|
:error="errors.diesel"
|
||||||
:required="config?.validation?.requireDiesel"
|
: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>
|
<template #trailing>
|
||||||
<span class="text-gray-500 text-xs">Liter</span>
|
<span class="text-xs text-muted">Liter</span>
|
||||||
</template>
|
</template>
|
||||||
</UInput>
|
</UInput>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField :label="config?.ui?.labels?.description || 'Notiz / Vorkommnisse'">
|
<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>
|
</UFormField>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<UButton
|
<UButton
|
||||||
block
|
|
||||||
size="xl"
|
size="xl"
|
||||||
|
block
|
||||||
:loading="isSubmitting"
|
:loading="isSubmitting"
|
||||||
@click="submit"
|
@click="submit"
|
||||||
:label="config?.ui?.submitButtonText || 'Speichern'"
|
:label="config?.ui?.submitButtonText || 'Speichern'"
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ setupPage()
|
|||||||
:type="props.type"
|
:type="props.type"
|
||||||
:item="item"
|
:item="item"
|
||||||
:inModal="true"
|
:inModal="true"
|
||||||
@return-data="(data) => emit('return-data',data)"
|
@return-data="(data) => emit('returnData', data)"
|
||||||
:createQuery="props.createQuery"
|
:createQuery="props.createQuery"
|
||||||
:mode="props.mode"
|
:mode="props.mode"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ const tenantInitials = computed(() => {
|
|||||||
.join('') || 'M'
|
.join('') || 'M'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const activeTenants = computed(() =>
|
||||||
|
auth.tenants.filter((tenant) => tenant.hasActiveLicense)
|
||||||
|
)
|
||||||
|
|
||||||
const tenantItems = computed(() => [
|
const tenantItems = computed(() => [
|
||||||
auth.tenants.map((tenant) => ({
|
activeTenants.value.map((tenant) => ({
|
||||||
label: tenant.name,
|
label: tenant.name,
|
||||||
icon: tenant.id === auth.activeTenant ? 'i-heroicons-check' : undefined,
|
icon: tenant.id === auth.activeTenant ? 'i-heroicons-check' : undefined,
|
||||||
disabled: Boolean(tenant.locked),
|
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
|
<UButton
|
||||||
color="gray"
|
color="gray"
|
||||||
variant="ghost"
|
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']"
|
:class="[open && 'bg-gray-100 dark:bg-gray-800']"
|
||||||
>
|
>
|
||||||
|
<div class="flex items-space gap-2">
|
||||||
<span class="min-w-0 flex-1 truncate font-medium text-gray-900 dark:text-white">
|
<span class="min-w-0 flex-1 truncate font-medium text-gray-900 dark:text-white">
|
||||||
{{ auth.user.email }}
|
{{ auth.user.email }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<template #trailing>
|
|
||||||
<UIcon name="i-heroicons-ellipsis-vertical" class="h-5 w-5 shrink-0" />
|
<UIcon name="i-heroicons-ellipsis-vertical" class="h-5 w-5 shrink-0" />
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
</UButton>
|
</UButton>
|
||||||
</template>
|
</template>
|
||||||
</UDropdownMenu>
|
</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,23 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(["updateNeeded","returnData"])
|
const emit = defineEmits(["updateNeeded","returnData"])
|
||||||
|
|
||||||
const documentTypeToUse = ref(props.type)
|
const documentTypeToUse = ref(props.type)
|
||||||
|
const documentTypeItems = computed(() => {
|
||||||
|
return Object.keys(dataStore.documentTypesForCreation).map((key) => ({
|
||||||
|
...dataStore.documentTypesForCreation[key],
|
||||||
|
key
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
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({
|
const optionsToImport = ref({
|
||||||
taxType: true,
|
taxType: true,
|
||||||
customer: true,
|
customer: true,
|
||||||
@@ -66,41 +83,63 @@ const startImport = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UModal :fullscreen="false">
|
<UModal>
|
||||||
<template #content>
|
<template #content>
|
||||||
<UCard>
|
<UCard class="mx-auto w-full max-w-2xl shadow-xl ring-1 ring-black/5">
|
||||||
<template #header>
|
<template #header>
|
||||||
Erstelltes Dokument Kopieren
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="flex h-12 w-12 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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<UFormField
|
<div class="space-y-6">
|
||||||
label="Dokumententyp:"
|
<UFormField label="Dokumententyp" required>
|
||||||
class="mb-3"
|
|
||||||
>
|
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
:options="Object.keys(dataStore.documentTypesForCreation).map(key => { return { ...dataStore.documentTypesForCreation[key], key}})"
|
|
||||||
value-attribute="key"
|
|
||||||
option-attribute="labelSingle"
|
|
||||||
v-model="documentTypeToUse"
|
v-model="documentTypeToUse"
|
||||||
>
|
:items="documentTypeItems"
|
||||||
|
value-key="key"
|
||||||
</USelectMenu>
|
label-key="labelSingle"
|
||||||
|
class="w-full"
|
||||||
|
size="lg"
|
||||||
|
:search-input="{ placeholder: 'Dokumententyp suchen...' }"
|
||||||
|
:filter-fields="['labelSingle']"
|
||||||
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
|
<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
|
<UCheckbox
|
||||||
v-for="key in Object.keys(optionsToImport).filter(i => documentTypeToUse !== props.type ? !['startText','endText'].includes(i) : true)"
|
v-for="key in visibleImportKeys"
|
||||||
|
:key="key"
|
||||||
v-model="optionsToImport[key]"
|
v-model="optionsToImport[key]"
|
||||||
:label="mappings[key]"
|
:label="mappings[key]"
|
||||||
|
class="rounded-xl border border-default px-3 py-2"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<UButton
|
<div class="flex justify-end gap-2">
|
||||||
@click="startImport"
|
<UButton color="neutral" variant="ghost" @click="modal.close()">
|
||||||
>
|
Abbrechen
|
||||||
|
</UButton>
|
||||||
|
<UButton @click="startImport">
|
||||||
Kopieren
|
Kopieren
|
||||||
</UButton>
|
</UButton>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</UCard>
|
</UCard>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</UModal>
|
||||||
|
|||||||
@@ -1,26 +1,205 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
item: {
|
item: {
|
||||||
required: true,
|
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 () => {
|
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()
|
setupPage()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
{{props.item}}
|
<div class="space-y-4">
|
||||||
{{incomingInvoices}}
|
<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>
|
||||||
|
|
||||||
<style scoped>
|
<template #date-cell="{ row }">
|
||||||
|
<div class="truncate">{{ row.original.date ? dayjs(row.original.date).format("DD.MM.YYYY") : "-" }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
</style>
|
<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>
|
||||||
|
|||||||
245
frontend/components/displayBWASummary.vue
Normal file
245
frontend/components/displayBWASummary.vue
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
import {
|
||||||
|
getCreatedDocumentTaxBreakdown,
|
||||||
|
getIncomingInvoiceTaxBreakdown
|
||||||
|
} from "~/composables/useTaxEvaluation"
|
||||||
|
import {
|
||||||
|
getIncomingInvoiceDepreciationRows,
|
||||||
|
getIncomingInvoiceImmediateExpenseNet,
|
||||||
|
getStatementAllocationDepreciationRow,
|
||||||
|
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 = Number(allocation?.amount || 0)
|
||||||
|
|
||||||
|
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 + getStatementAllocationImmediateExpenseAmount(allocation)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
const depreciationRows = [
|
||||||
|
...inputDocs.flatMap((invoice: any) => getIncomingInvoiceDepreciationRows(invoice, bounds.start, bounds.end)),
|
||||||
|
...(allocations || []).map((allocation: any) => getStatementAllocationDepreciationRow(allocation, bounds.start, bounds.end)).filter(Boolean)
|
||||||
|
]
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -72,18 +72,26 @@ const setRowData = (row) => {
|
|||||||
+ Artikel
|
+ Artikel
|
||||||
</UButton>
|
</UButton>
|
||||||
|
|
||||||
<table class="w-full mt-3">
|
<div class="mt-3 overflow-x-auto">
|
||||||
<tr>
|
<table class="w-full min-w-[44rem] table-fixed">
|
||||||
<th>Artikel</th>
|
<thead>
|
||||||
<th>Menge</th>
|
<tr class="border-b border-gray-200 dark:border-gray-800">
|
||||||
<th>Einheit</th>
|
<th class="px-2 py-2 text-left font-medium">Artikel</th>
|
||||||
<th>Verkaufspreis</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>
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="product in props.item.materialComposition"
|
v-for="product in props.item.materialComposition"
|
||||||
|
:key="product.id"
|
||||||
|
class="border-b border-gray-100 align-top dark:border-gray-800"
|
||||||
>
|
>
|
||||||
<td>
|
<td class="px-2 py-2">
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
|
class="w-full"
|
||||||
:items="products"
|
:items="products"
|
||||||
label-key="name"
|
label-key="name"
|
||||||
value-key="id"
|
value-key="id"
|
||||||
@@ -91,38 +99,45 @@ const setRowData = (row) => {
|
|||||||
:filter-fields="['name']"
|
:filter-fields="['name']"
|
||||||
v-model="product.product"
|
v-model="product.product"
|
||||||
:color="product.product ? 'primary' : 'error'"
|
:color="product.product ? 'primary' : 'error'"
|
||||||
@change="setRowData(product)"
|
@update:model-value="setRowData(product)"
|
||||||
>
|
>
|
||||||
<template #default>
|
<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>
|
</template>
|
||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="px-2 py-2">
|
||||||
<UInput
|
<UInput
|
||||||
|
class="w-full"
|
||||||
type="number"
|
type="number"
|
||||||
v-model="product.quantity"
|
v-model="product.quantity"
|
||||||
:step="units.find(i => i.id === product.unit) ? units.find(i => i.id === product.unit).step : 1"
|
:step="units.find(i => i.id === product.unit) ? units.find(i => i.id === product.unit).step : 1"
|
||||||
@change="calculateTotalMaterialPrice"
|
@change="calculateTotalMaterialPrice"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="px-2 py-2">
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
|
class="w-full"
|
||||||
:items="units"
|
:items="units"
|
||||||
label-key="name"
|
label-key="name"
|
||||||
value-key="id"
|
value-key="id"
|
||||||
v-model="product.unit"
|
v-model="product.unit"
|
||||||
></USelectMenu>
|
>
|
||||||
|
<template #default>
|
||||||
|
{{ units.find(i => i.id === product.unit)?.name || 'Einheit wählen' }}
|
||||||
|
</template>
|
||||||
|
</USelectMenu>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="px-2 py-2">
|
||||||
<UInput
|
<UInput
|
||||||
|
class="w-full"
|
||||||
type="number"
|
type="number"
|
||||||
v-model="product.price"
|
v-model="product.price"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
@change="calculateTotalMaterialPrice"
|
@change="calculateTotalMaterialPrice"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="px-2 py-2">
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-heroicons-x-mark"
|
icon="i-heroicons-x-mark"
|
||||||
@click="removeProductFromMaterialComposition(product.id)"
|
@click="removeProductFromMaterialComposition(product.id)"
|
||||||
@@ -131,7 +146,9 @@ const setRowData = (row) => {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -73,19 +73,27 @@ const setRowData = (row) => {
|
|||||||
+ Stundensatz
|
+ Stundensatz
|
||||||
</UButton>
|
</UButton>
|
||||||
|
|
||||||
<table class="w-full mt-3">
|
<div class="mt-3 overflow-x-auto">
|
||||||
<tr>
|
<table class="w-full min-w-[52rem] table-fixed">
|
||||||
<th>Name</th>
|
<thead>
|
||||||
<th>Menge</th>
|
<tr class="border-b border-gray-200 dark:border-gray-800">
|
||||||
<th>Einheit</th>
|
<th class="px-2 py-2 text-left font-medium">Name</th>
|
||||||
<th>Einkaufpreis</th>
|
<th class="px-2 py-2 text-left font-medium">Menge</th>
|
||||||
<th>Verkaufspreis</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>
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="row in props.item.personalComposition"
|
v-for="row in props.item.personalComposition"
|
||||||
|
:key="row.id"
|
||||||
|
class="border-b border-gray-100 align-top dark:border-gray-800"
|
||||||
>
|
>
|
||||||
<td>
|
<td class="px-2 py-2">
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
|
class="w-full"
|
||||||
:items="hourrates"
|
:items="hourrates"
|
||||||
label-key="name"
|
label-key="name"
|
||||||
value-key="id"
|
value-key="id"
|
||||||
@@ -93,47 +101,55 @@ const setRowData = (row) => {
|
|||||||
:filter-fields="['name']"
|
:filter-fields="['name']"
|
||||||
v-model="row.hourrate"
|
v-model="row.hourrate"
|
||||||
:color="row.hourrate ? 'primary' : 'error'"
|
:color="row.hourrate ? 'primary' : 'error'"
|
||||||
@change="setRowData(row)"
|
@update:model-value="setRowData(row)"
|
||||||
>
|
>
|
||||||
<!-- <template #label>
|
<template #default>
|
||||||
{{products.find(i => i.id === product.product) ? products.find(i => i.id === product.product).name : 'Kein Artikel ausgewählt'}}
|
{{ hourrates.find(i => i.id === row.hourrate)?.name || 'Kein Stundensatz ausgewählt' }}
|
||||||
</template>-->
|
</template>
|
||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="px-2 py-2">
|
||||||
<UInput
|
<UInput
|
||||||
|
class="w-full"
|
||||||
type="number"
|
type="number"
|
||||||
v-model="row.quantity"
|
v-model="row.quantity"
|
||||||
:step="units.find(i => i.id === row.unit) ? units.find(i => i.id === row.unit).step : 1"
|
:step="units.find(i => i.id === row.unit) ? units.find(i => i.id === row.unit).step : 1"
|
||||||
@change="calculateTotalPersonalPrice"
|
@change="calculateTotalPersonalPrice"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="px-2 py-2">
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
|
class="w-full"
|
||||||
:items="units"
|
:items="units"
|
||||||
disabled
|
disabled
|
||||||
label-key="name"
|
label-key="name"
|
||||||
value-key="id"
|
value-key="id"
|
||||||
v-model="row.unit"
|
v-model="row.unit"
|
||||||
></USelectMenu>
|
>
|
||||||
|
<template #default>
|
||||||
|
{{ units.find(i => i.id === row.unit)?.name || 'Einheit' }}
|
||||||
|
</template>
|
||||||
|
</USelectMenu>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="px-2 py-2">
|
||||||
<UInput
|
<UInput
|
||||||
|
class="w-full"
|
||||||
type="number"
|
type="number"
|
||||||
v-model="row.purchasePrice"
|
v-model="row.purchasePrice"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
@change="calculateTotalPersonalPrice"
|
@change="calculateTotalPersonalPrice"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="px-2 py-2">
|
||||||
<UInput
|
<UInput
|
||||||
|
class="w-full"
|
||||||
type="number"
|
type="number"
|
||||||
v-model="row.price"
|
v-model="row.price"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
@change="calculateTotalPersonalPrice"
|
@change="calculateTotalPersonalPrice"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="px-2 py-2">
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-heroicons-x-mark"
|
icon="i-heroicons-x-mark"
|
||||||
@click="removeRowFromPersonalComposition(row.id)"
|
@click="removeRowFromPersonalComposition(row.id)"
|
||||||
@@ -142,7 +158,9 @@ const setRowData = (row) => {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
108
frontend/composables/useAdmin.ts
Normal file
108
frontend/composables/useAdmin.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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 updateTenant = async (id: number, body: Record<string, any>) => {
|
||||||
|
return await $api(`/api/admin/tenants/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getOverview,
|
||||||
|
createUser,
|
||||||
|
updateUser,
|
||||||
|
updateUserAccess,
|
||||||
|
createTenant,
|
||||||
|
updateTenant,
|
||||||
|
}
|
||||||
|
}
|
||||||
394
frontend/composables/useDepreciation.ts
Normal file
394
frontend/composables/useDepreciation.ts
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
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 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -270,7 +270,7 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #footer="{ collapsed }">
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
<UColorModeSwitch :class="[collapsed ? 'mx-auto' : 'ml-3']"/>
|
<UColorModeSwitch :class="[collapsed ? 'mx-auto' : 'ml-3']"/>
|
||||||
<UDashboardSidebarCollapse v-if="collapsed" class="mx-auto" />
|
<UDashboardSidebarCollapse v-if="collapsed" class="mx-auto" />
|
||||||
@@ -284,7 +284,7 @@ onMounted(() => {
|
|||||||
:key="item.label"
|
:key="item.label"
|
||||||
color="gray"
|
color="gray"
|
||||||
variant="ghost"
|
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"
|
:icon="item.icon"
|
||||||
@click="item.click ? item.click() : null"
|
@click="item.click ? item.click() : null"
|
||||||
>
|
>
|
||||||
@@ -305,10 +305,10 @@ onMounted(() => {
|
|||||||
</UDashboardSidebar>
|
</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/>
|
<slot/>
|
||||||
|
|
||||||
</div>
|
</UDashboardPanel>
|
||||||
</UDashboardGroup>
|
</UDashboardGroup>
|
||||||
|
|
||||||
<HelpSlideover/>
|
<HelpSlideover/>
|
||||||
|
|||||||
@@ -19,11 +19,26 @@
|
|||||||
"suffix": "",
|
"suffix": "",
|
||||||
"nextNumber": 1000
|
"nextNumber": 1000
|
||||||
},
|
},
|
||||||
|
"costEstimates": {
|
||||||
|
"prefix": "KS-",
|
||||||
|
"suffix": "",
|
||||||
|
"nextNumber": 1000
|
||||||
|
},
|
||||||
"confirmationOrders": {
|
"confirmationOrders": {
|
||||||
"prefix": "AB-",
|
"prefix": "AB-",
|
||||||
"suffix": "",
|
"suffix": "",
|
||||||
"nextNumber": 1000
|
"nextNumber": 1000
|
||||||
},
|
},
|
||||||
|
"deliveryNotes": {
|
||||||
|
"prefix": "LS-",
|
||||||
|
"suffix": "",
|
||||||
|
"nextNumber": 1000
|
||||||
|
},
|
||||||
|
"packingSlips": {
|
||||||
|
"prefix": "PS-",
|
||||||
|
"suffix": "",
|
||||||
|
"nextNumber": 1000
|
||||||
|
},
|
||||||
"invoices": {
|
"invoices": {
|
||||||
"prefix": "RE-",
|
"prefix": "RE-",
|
||||||
"suffix": "",
|
"suffix": "",
|
||||||
|
|||||||
685
frontend/pages/accounting/bwa.vue
Normal file
685
frontend/pages/accounting/bwa.vue
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
import {
|
||||||
|
getCreatedDocumentTaxBreakdown,
|
||||||
|
getIncomingInvoiceTaxBreakdown
|
||||||
|
} from "~/composables/useTaxEvaluation"
|
||||||
|
import {
|
||||||
|
getIncomingInvoiceDepreciationRows,
|
||||||
|
getIncomingInvoiceImmediateExpenseGross,
|
||||||
|
getIncomingInvoiceImmediateExpenseNet,
|
||||||
|
isDepreciationBookingMode,
|
||||||
|
normalizeIncomingInvoiceAccount,
|
||||||
|
getStatementAllocationDepreciationRow,
|
||||||
|
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) => allocation.account !== null && allocation.account !== undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 + getStatementAllocationImmediateExpenseAmount(allocation)
|
||||||
|
}, 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 + getStatementAllocationImmediateExpenseAmount(allocation)
|
||||||
|
}, 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 allocationRows = filteredStatementAllocations.value
|
||||||
|
.map((allocation) => getStatementAllocationDepreciationRow(allocation, selectedPeriodBounds.value.start, selectedPeriodBounds.value.end))
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const grouped = new Map<string, any>()
|
||||||
|
|
||||||
|
;[...invoiceRows, ...allocationRows].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>
|
||||||
411
frontend/pages/accounting/depreciation.vue
Normal file
411
frontend/pages/accounting/depreciation.vue
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
<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 asOfDate = ref(dayjs().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 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,
|
||||||
|
}, asOfDate.value)
|
||||||
|
|
||||||
|
const currentPeriodRow = getIncomingInvoiceDepreciationRows(invoice, dayjs(asOfDate.value).startOf("month"), dayjs(asOfDate.value).endOf("month"))
|
||||||
|
.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,
|
||||||
|
}, asOfDate.value)
|
||||||
|
|
||||||
|
const currentPeriodRow = getStatementAllocationDepreciationRow(allocation, dayjs(asOfDate.value).startOf("month"), dayjs(asOfDate.value).endOf("month"))
|
||||||
|
|
||||||
|
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="asOfDate" 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 {{ dayjs(asOfDate).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">Aktuelle Abschreibung</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">{{ totals.bundleCount }} Sammelposten</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">Aktueller 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,7 +126,6 @@ onMounted(loadData)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
|
||||||
<UDashboardNavbar title="USt-Auswertung">
|
<UDashboardNavbar title="USt-Auswertung">
|
||||||
<template #right>
|
<template #right>
|
||||||
<UButton
|
<UButton
|
||||||
@@ -285,5 +284,4 @@ onMounted(loadData)
|
|||||||
</UTable>
|
</UTable>
|
||||||
</UCard>
|
</UCard>
|
||||||
</UDashboardPanelContent>
|
</UDashboardPanelContent>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ setupPage()
|
|||||||
:columns="normalizeTableColumns(columns)"
|
:columns="normalizeTableColumns(columns)"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
|
: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' }"
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }"
|
||||||
>
|
>
|
||||||
<template #allocations-cell="{row}">
|
<template #allocations-cell="{row}">
|
||||||
|
|||||||
@@ -1,62 +1,44 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const itemInfo = ref(null)
|
const itemInfo = ref(null)
|
||||||
const statementallocations = ref([])
|
const statementallocations = ref([])
|
||||||
const incominginvoices = ref([])
|
const incominginvoices = ref([])
|
||||||
|
const currentAccountId = computed(() => String(route.params.id))
|
||||||
|
const sameAccount = (value) => String(value ?? "") === currentAccountId.value
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
itemInfo.value = (await useEntities("accounts").selectSpecial("*")).find(i => i.id === Number(route.params.id))
|
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))
|
statementallocations.value = (await useEntities("statementallocations").select("*, bankstatement(*)"))
|
||||||
incominginvoices.value = (await useEntities("incominginvoices").select("*, vendor(*)")).filter(i => i.accounts.find(x => x.account === Number(route.params.id)))
|
.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()
|
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 renderedAllocations = computed(() => {
|
||||||
|
const statementRows = statementallocations.value.map((allocation) => ({
|
||||||
let tempstatementallocations = statementallocations.value.map(i => {
|
...allocation,
|
||||||
return {
|
|
||||||
...i,
|
|
||||||
type: "statementallocation",
|
type: "statementallocation",
|
||||||
date: i.bs_id.date,
|
amount: Number(allocation.amount || 0)
|
||||||
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) => {
|
||||||
let incominginvoicesallocations = []
|
return (invoice.accounts || [])
|
||||||
|
.filter((account) => sameAccount(account.account?.id || account.account))
|
||||||
incominginvoices.value.forEach(i => {
|
.map((account, index) => ({
|
||||||
|
id: `${invoice.id}-${index}`,
|
||||||
incominginvoicesallocations.push(...i.accounts.filter(x => x.account == route.params.id).map(x => {
|
incominginvoiceid: invoice.id,
|
||||||
return {
|
|
||||||
...x,
|
|
||||||
incominginvoiceid: i.id,
|
|
||||||
type: "incominginvoice",
|
type: "incominginvoice",
|
||||||
amount: x.amountGross ? x.amountGross : x.amountNet,
|
amount: Number(account.amountGross || account.amountNet || 0),
|
||||||
date: i.date,
|
expense: invoice.expense
|
||||||
partner: i.vendor.name,
|
|
||||||
description: i.description,
|
|
||||||
color: i.expense ? "red" : "green",
|
|
||||||
expense: i.expense
|
|
||||||
}
|
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
return [...tempstatementallocations, ... incominginvoicesallocations]
|
return [...statementRows, ...incomingInvoiceRows]
|
||||||
})
|
})
|
||||||
|
|
||||||
const saldo = computed(() => {
|
const saldo = computed(() => {
|
||||||
@@ -135,25 +117,12 @@ const saldo = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
<UCard class="mt-5" v-if="item.label === 'Buchungen'">
|
<UCard class="mt-5" v-if="item.label === 'Buchungen'">
|
||||||
<UTable
|
<EntityShowSubOwnAccountsStatements
|
||||||
v-if="statementallocations"
|
v-if="itemInfo"
|
||||||
:data="renderedAllocations"
|
:item="itemInfo"
|
||||||
:columns="normalizeTableColumns([{key:'amount', label:'Betrag'},{key:'date', label:'Datum'},{key:'partner', label:'Partner'},{key:'description', label:'Beschreibung'}])"
|
top-level-type="accounts"
|
||||||
:on-select="(i) => selectAllocation(i)"
|
platform="desktop"
|
||||||
: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>
|
|
||||||
</UCard>
|
</UCard>
|
||||||
</template>
|
</template>
|
||||||
</UTabs>
|
</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>
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { parseDate } from "@internationalized/date"
|
||||||
|
|
||||||
const {$api, $dayjs} = useNuxtApp()
|
const {$api, $dayjs} = useNuxtApp()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
@@ -34,13 +36,35 @@ const periodOptions = [
|
|||||||
{label: 'Benutzerdefiniert', key: 'custom'}
|
{label: 'Benutzerdefiniert', key: 'custom'}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const bankingFilterItems = [
|
||||||
|
{ label: 'Nur offene anzeigen', value: 'Nur offene anzeigen' },
|
||||||
|
{ label: 'Nur positive anzeigen', value: 'Nur positive anzeigen' },
|
||||||
|
{ label: 'Nur negative anzeigen', value: 'Nur negative anzeigen' }
|
||||||
|
]
|
||||||
|
|
||||||
// Initialisierungswerte
|
// Initialisierungswerte
|
||||||
const selectedPeriod = ref(periodOptions[0])
|
const selectedPeriod = ref(periodOptions[0].key)
|
||||||
|
const selectedPeriodOption = computed(() => {
|
||||||
|
return periodOptions.find(period => period.key === selectedPeriod.value) || periodOptions[0]
|
||||||
|
})
|
||||||
const dateRange = ref({
|
const dateRange = ref({
|
||||||
start: $dayjs().startOf('month').format('YYYY-MM-DD'),
|
start: $dayjs().startOf('month').format('YYYY-MM-DD'),
|
||||||
end: $dayjs().endOf('month').format('YYYY-MM-DD')
|
end: $dayjs().endOf('month').format('YYYY-MM-DD')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getCalendarValue = (value) => {
|
||||||
|
if (!value) return undefined
|
||||||
|
|
||||||
|
const formatted = $dayjs(value).format('YYYY-MM-DD')
|
||||||
|
return formatted ? parseDate(formatted) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDateRangeFromCalendar = (field, value) => {
|
||||||
|
dateRange.value[field] = value ? value.toString() : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDateButtonLabel = (value) => value ? $dayjs(value).format('DD.MM.YYYY') : 'Kein Datum'
|
||||||
|
|
||||||
const setDateRangeFieldToToday = (field) => {
|
const setDateRangeFieldToToday = (field) => {
|
||||||
dateRange.value[field] = $dayjs().format('YYYY-MM-DD')
|
dateRange.value[field] = $dayjs().format('YYYY-MM-DD')
|
||||||
}
|
}
|
||||||
@@ -86,7 +110,7 @@ const setupPage = async () => {
|
|||||||
const savedBanking = tempStore.settings?.['banking'] || {}
|
const savedBanking = tempStore.settings?.['banking'] || {}
|
||||||
if (savedBanking.periodKey) {
|
if (savedBanking.periodKey) {
|
||||||
const found = periodOptions.find(p => p.key === savedBanking.periodKey)
|
const found = periodOptions.find(p => p.key === savedBanking.periodKey)
|
||||||
if (found) selectedPeriod.value = found
|
if (found) selectedPeriod.value = found.key
|
||||||
}
|
}
|
||||||
if (savedBanking.range) {
|
if (savedBanking.range) {
|
||||||
dateRange.value = savedBanking.range
|
dateRange.value = savedBanking.range
|
||||||
@@ -99,12 +123,12 @@ const setupPage = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Watcher für Schnellwahlen & Persistenz
|
// Watcher für Schnellwahlen & Persistenz
|
||||||
watch([selectedPeriod, dateRange], ([newPeriod, newRange], [oldPeriod, oldRange]) => {
|
watch(selectedPeriod, (newPeriod, oldPeriod) => {
|
||||||
const now = $dayjs()
|
const now = $dayjs()
|
||||||
|
|
||||||
// Nur berechnen, wenn sich die Periode geändert hat
|
// Nur berechnen, wenn sich die Periode geändert hat
|
||||||
if (newPeriod.key !== oldPeriod?.key) {
|
if (newPeriod !== oldPeriod) {
|
||||||
switch (newPeriod.key) {
|
switch (newPeriod) {
|
||||||
case 'current_month':
|
case 'current_month':
|
||||||
dateRange.value = {start: now.startOf('month').format('YYYY-MM-DD'), end: now.endOf('month').format('YYYY-MM-DD')}
|
dateRange.value = {start: now.startOf('month').format('YYYY-MM-DD'), end: now.endOf('month').format('YYYY-MM-DD')}
|
||||||
break
|
break
|
||||||
@@ -121,8 +145,10 @@ watch([selectedPeriod, dateRange], ([newPeriod, newRange], [oldPeriod, oldRange]
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Speichern im Store
|
})
|
||||||
tempStore.modifyBankingPeriod(selectedPeriod.value.key, dateRange.value)
|
|
||||||
|
watch([selectedPeriod, dateRange], () => {
|
||||||
|
tempStore.modifyBankingPeriod(selectedPeriod.value, dateRange.value)
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
const syncBankStatements = async () => {
|
const syncBankStatements = async () => {
|
||||||
@@ -496,30 +522,77 @@ onMounted(() => {
|
|||||||
<template #left>
|
<template #left>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
:options="bankaccounts"
|
:items="bankaccounts"
|
||||||
v-model="filterAccount"
|
v-model="filterAccount"
|
||||||
option-attribute="iban"
|
value-key="id"
|
||||||
|
label-key="iban"
|
||||||
multiple
|
multiple
|
||||||
by="id"
|
by="id"
|
||||||
placeholder="Konten"
|
placeholder="Konten"
|
||||||
class="w-48"
|
class="w-48"
|
||||||
/>
|
>
|
||||||
|
<template #default>
|
||||||
|
{{ filterAccount.length > 0 ? `${filterAccount.length} Kont${filterAccount.length > 1 ? 'en' : 'o'}` : 'Konten' }}
|
||||||
|
</template>
|
||||||
|
</USelectMenu>
|
||||||
<USeparator orientation="vertical" class="h-6"/>
|
<USeparator orientation="vertical" class="h-6"/>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
v-model="selectedPeriod"
|
v-model="selectedPeriod"
|
||||||
:options="periodOptions"
|
:items="periodOptions"
|
||||||
|
value-key="key"
|
||||||
|
label-key="label"
|
||||||
class="w-44"
|
class="w-44"
|
||||||
icon="i-heroicons-calendar-days"
|
icon="i-heroicons-calendar-days"
|
||||||
/>
|
>
|
||||||
<div v-if="selectedPeriod.key === 'custom'" class="flex items-center gap-1">
|
<template #default>
|
||||||
|
{{ selectedPeriodOption.label || 'Zeitraum' }}
|
||||||
|
</template>
|
||||||
|
</USelectMenu>
|
||||||
|
<div v-if="selectedPeriod === 'custom'" class="flex items-center gap-1">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<UInput type="date" v-model="dateRange.start" size="xs" class="w-32"/>
|
<UPopover class="w-full" :content="{ side: 'bottom', align: 'start' }">
|
||||||
<UButton size="2xs" color="gray" variant="soft" label="Heute" @click="setDateRangeFieldToToday('start')" />
|
<UButton
|
||||||
|
block
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
class="w-36 justify-start"
|
||||||
|
icon="i-heroicons-calendar"
|
||||||
|
:label="getDateButtonLabel(dateRange.start)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<div class="p-2">
|
||||||
|
<UCalendar
|
||||||
|
:model-value="getCalendarValue(dateRange.start)"
|
||||||
|
@update:model-value="setDateRangeFromCalendar('start', $event)"
|
||||||
|
:week-starts-on="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPopover>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<UInput type="date" v-model="dateRange.end" size="xs" class="w-32"/>
|
<UPopover class="w-full" :content="{ side: 'bottom', align: 'start' }">
|
||||||
<UButton size="2xs" color="gray" variant="soft" label="Heute" @click="setDateRangeFieldToToday('end')" />
|
<UButton
|
||||||
|
block
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
class="w-36 justify-start"
|
||||||
|
icon="i-heroicons-calendar"
|
||||||
|
:label="getDateButtonLabel(dateRange.end)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<div class="p-2">
|
||||||
|
<UCalendar
|
||||||
|
:model-value="getCalendarValue(dateRange.end)"
|
||||||
|
@update:model-value="setDateRangeFromCalendar('end', $event)"
|
||||||
|
:week-starts-on="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPopover>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-xs text-gray-400 hidden sm:block italic">
|
<div v-else class="text-xs text-gray-400 hidden sm:block italic">
|
||||||
@@ -534,9 +607,15 @@ onMounted(() => {
|
|||||||
icon="i-heroicons-adjustments-horizontal"
|
icon="i-heroicons-adjustments-horizontal"
|
||||||
multiple
|
multiple
|
||||||
v-model="selectedFilters"
|
v-model="selectedFilters"
|
||||||
:options="['Nur offene anzeigen','Nur positive anzeigen','Nur negative anzeigen']"
|
:items="bankingFilterItems"
|
||||||
@change="tempStore.modifyFilter('banking','main',selectedFilters)"
|
value-key="value"
|
||||||
/>
|
label-key="label"
|
||||||
|
@update:model-value="tempStore.modifyFilter('banking','main',selectedFilters)"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
Filter
|
||||||
|
</template>
|
||||||
|
</USelectMenu>
|
||||||
</template>
|
</template>
|
||||||
</UDashboardToolbar>
|
</UDashboardToolbar>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import {
|
||||||
|
DEPRECIATION_METHOD_ITEMS,
|
||||||
|
EXPENSE_BOOKING_MODE_ITEMS,
|
||||||
|
isDepreciationBookingMode,
|
||||||
|
normalizeExpenseBookingMode
|
||||||
|
} from "~/composables/useDepreciation"
|
||||||
// import {filter} from "vuedraggable/dist/vuedraggable.common.js"; // Scheint nicht genutzt zu werden, auskommentiert
|
// import {filter} from "vuedraggable/dist/vuedraggable.common.js"; // Scheint nicht genutzt zu werden, auskommentiert
|
||||||
|
|
||||||
defineShortcuts({
|
defineShortcuts({
|
||||||
@@ -43,6 +49,7 @@ const setup = async () => {
|
|||||||
if (itemInfo.value) oldItemInfo.value = JSON.parse(JSON.stringify(itemInfo.value))
|
if (itemInfo.value) oldItemInfo.value = JSON.parse(JSON.stringify(itemInfo.value))
|
||||||
|
|
||||||
manualAllocationSum.value = calculateOpenSum.value
|
manualAllocationSum.value = calculateOpenSum.value
|
||||||
|
allocationDepreciationStartDate.value = dayjs(itemInfo.value?.date || new Date()).format("YYYY-MM-DD")
|
||||||
|
|
||||||
createddocuments.value = (await useEntities("createddocuments").select("*, statementallocations(*), customer(id,name)"))
|
createddocuments.value = (await useEntities("createddocuments").select("*, statementallocations(*), customer(id,name)"))
|
||||||
const documents = createddocuments.value.filter(i => i.type === "invoices" || i.type === "advanceInvoices")
|
const documents = createddocuments.value.filter(i => i.type === "invoices" || i.type === "advanceInvoices")
|
||||||
@@ -129,13 +136,56 @@ const selectAccount = (id) => {
|
|||||||
|
|
||||||
const manualAllocationSum = ref(itemInfo.value.amount || 0)
|
const manualAllocationSum = ref(itemInfo.value.amount || 0)
|
||||||
const allocationDescription = ref("")
|
const allocationDescription = ref("")
|
||||||
|
const allocationBookingMode = ref("expense")
|
||||||
|
const allocationDepreciationMonths = ref(36)
|
||||||
|
const allocationDepreciationStartDate = ref(dayjs().format("YYYY-MM-DD"))
|
||||||
|
const allocationDepreciationMethod = ref("linear")
|
||||||
|
const allocationDepreciationLabel = ref("")
|
||||||
|
const allocationDepreciationGroup = ref("")
|
||||||
|
const allocationResidualValue = ref(0)
|
||||||
const showMoreWithoutRecipe = ref(false)
|
const showMoreWithoutRecipe = ref(false)
|
||||||
const showMoreText = ref(false)
|
const showMoreText = ref(false)
|
||||||
|
|
||||||
|
const isDepreciationAllocation = computed(() => isDepreciationBookingMode(allocationBookingMode.value))
|
||||||
|
|
||||||
|
const buildAllocationPayload = (payload) => {
|
||||||
|
const base = {
|
||||||
|
...payload,
|
||||||
|
bookingMode: normalizeExpenseBookingMode(allocationBookingMode.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDepreciationAllocation.value) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
depreciationMonths: null,
|
||||||
|
depreciationStartDate: null,
|
||||||
|
depreciationLabel: null,
|
||||||
|
depreciationGroup: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
depreciationMonths: Number(allocationDepreciationMonths.value || 0),
|
||||||
|
depreciationStartDate: allocationDepreciationStartDate.value || itemInfo.value?.date || null,
|
||||||
|
depreciationMethod: allocationDepreciationMethod.value,
|
||||||
|
depreciationLabel: allocationDepreciationLabel.value || allocationDescription.value || "Direkte Abschreibung",
|
||||||
|
depreciationGroup: allocationBookingMode.value === "depreciation_bundle" ? allocationDepreciationGroup.value || null : null,
|
||||||
|
residualValue: Number(allocationResidualValue.value || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(allocationBookingMode, (value) => {
|
||||||
|
if (!isDepreciationBookingMode(value)) return
|
||||||
|
if (!allocationDepreciationStartDate.value) {
|
||||||
|
allocationDepreciationStartDate.value = dayjs(itemInfo.value?.date || new Date()).format("YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const saveAllocation = async (allocation) => {
|
const saveAllocation = async (allocation) => {
|
||||||
const res = await useNuxtApp().$api("/api/banking/statements", {
|
const res = await useNuxtApp().$api("/api/banking/statements", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {data: allocation}
|
body: {data: buildAllocationPayload(allocation)}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
@@ -144,6 +194,13 @@ const saveAllocation = async (allocation) => {
|
|||||||
vendorAccountToSave.value = null
|
vendorAccountToSave.value = null
|
||||||
customerAccountToSave.value = null
|
customerAccountToSave.value = null
|
||||||
ownAccountToSave.value = null
|
ownAccountToSave.value = null
|
||||||
|
allocationBookingMode.value = "expense"
|
||||||
|
allocationDepreciationMonths.value = 36
|
||||||
|
allocationDepreciationStartDate.value = dayjs(itemInfo.value?.date || new Date()).format("YYYY-MM-DD")
|
||||||
|
allocationDepreciationMethod.value = "linear"
|
||||||
|
allocationDepreciationLabel.value = ""
|
||||||
|
allocationDepreciationGroup.value = ""
|
||||||
|
allocationResidualValue.value = 0
|
||||||
// allocationDescription.value = null // Optional: Beschreibung behalten für nächste Buchung?
|
// allocationDescription.value = null // Optional: Beschreibung behalten für nächste Buchung?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -420,6 +477,13 @@ const getAllocationIcon = (item) => {
|
|||||||
return 'i-heroicons-question-mark-circle'
|
return 'i-heroicons-question-mark-circle'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAllocationBookingModeLabel = (item) => {
|
||||||
|
const mode = normalizeExpenseBookingMode(item?.bookingMode)
|
||||||
|
if (mode === "depreciation_bundle") return "Sammelabschreibung"
|
||||||
|
if (mode === "depreciation_single") return "Einzelabschreibung"
|
||||||
|
return "Sofortaufwand"
|
||||||
|
}
|
||||||
|
|
||||||
setup()
|
setup()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -526,6 +590,12 @@ setup()
|
|||||||
{{ getAllocationLabel(item) }}
|
{{ getAllocationLabel(item) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500 mt-0.5" v-if="item.description">{{ item.description }}</div>
|
<div class="text-xs text-gray-500 mt-0.5" v-if="item.description">{{ item.description }}</div>
|
||||||
|
<div class="mt-1 flex flex-wrap gap-1" v-if="item.bookingMode && item.bookingMode !== 'expense'">
|
||||||
|
<UBadge size="xs" color="warning" variant="soft">{{ getAllocationBookingModeLabel(item) }}</UBadge>
|
||||||
|
<UBadge size="xs" color="neutral" variant="soft" v-if="item.depreciationGroup">{{ item.depreciationGroup }}</UBadge>
|
||||||
|
<UBadge size="xs" color="neutral" variant="soft" v-if="item.depreciationMonths">{{ item.depreciationMonths }} Monate</UBadge>
|
||||||
|
<UBadge size="xs" color="neutral" variant="soft" v-if="item.depreciationMethod">{{ item.depreciationMethod === 'degressive' ? 'Degressiv' : 'Linear' }}</UBadge>
|
||||||
|
</div>
|
||||||
<div class="flex gap-2 mt-1" v-if="item.createddocument || item.incominginvoice">
|
<div class="flex gap-2 mt-1" v-if="item.createddocument || item.incominginvoice">
|
||||||
<UButton
|
<UButton
|
||||||
v-if="item.createddocument"
|
v-if="item.createddocument"
|
||||||
@@ -582,20 +652,20 @@ setup()
|
|||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:options="accounts"
|
:items="accounts"
|
||||||
value-attribute="id"
|
value-key="id"
|
||||||
option-attribute="label"
|
label-key="label"
|
||||||
v-model="accountToSave"
|
v-model="accountToSave"
|
||||||
searchable
|
:search-input="{ placeholder: 'Konto suchen...' }"
|
||||||
:search-attributes="['number','label']"
|
:filter-fields="['number','label']"
|
||||||
placeholder="Konto suchen..."
|
placeholder="Konto suchen..."
|
||||||
>
|
>
|
||||||
<template #label>
|
<template #default>
|
||||||
<span v-if="accountToSave"
|
<span v-if="accountToSave"
|
||||||
class="truncate">{{ accounts.find(i => i.id === accountToSave).number }} - {{ accounts.find(i => i.id === accountToSave).label }}</span>
|
class="truncate">{{ accounts.find(i => i.id === accountToSave).number }} - {{ accounts.find(i => i.id === accountToSave).label }}</span>
|
||||||
<span v-else>Direkt verbuchen...</span>
|
<span v-else>Direkt verbuchen...</span>
|
||||||
</template>
|
</template>
|
||||||
<template #option="{option}">
|
<template #item-label="{ item: option }">
|
||||||
<span class="font-mono text-xs text-gray-500 mr-2">{{ option.number }}</span> {{ option.label }}
|
<span class="font-mono text-xs text-gray-500 mr-2">{{ option.number }}</span> {{ option.label }}
|
||||||
</template>
|
</template>
|
||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
@@ -618,23 +688,73 @@ setup()
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<UFormField label="Aufwandsart" size="sm">
|
||||||
|
<USelectMenu
|
||||||
|
v-model="allocationBookingMode"
|
||||||
|
:items="EXPENSE_BOOKING_MODE_ITEMS"
|
||||||
|
value-key="value"
|
||||||
|
label-key="label"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="Abschreibungsdauer (Monate)" size="sm" v-if="isDepreciationAllocation">
|
||||||
|
<UInput v-model="allocationDepreciationMonths" type="number" min="1" step="1" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="Methode" size="sm" v-if="isDepreciationAllocation">
|
||||||
|
<USelectMenu
|
||||||
|
v-model="allocationDepreciationMethod"
|
||||||
|
:items="DEPRECIATION_METHOD_ITEMS"
|
||||||
|
value-key="value"
|
||||||
|
label-key="label"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 grid grid-cols-1 md:grid-cols-3 gap-3" v-if="isDepreciationAllocation">
|
||||||
|
<UFormField label="Start Abschreibung" size="sm">
|
||||||
|
<UInput v-model="allocationDepreciationStartDate" type="date" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="Restwert" size="sm">
|
||||||
|
<UInput v-model="allocationResidualValue" type="number" min="0" step="0.01" />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3" v-if="isDepreciationAllocation">
|
||||||
|
<UFormField v-if="allocationBookingMode === 'depreciation_bundle'" label="Sammelposten" size="sm">
|
||||||
|
<UInput v-model="allocationDepreciationGroup" placeholder="z. B. Betriebsausstattung 2026" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField v-else label="Bezeichnung Abschreibung" size="sm">
|
||||||
|
<UInput v-model="allocationDepreciationLabel" placeholder="z. B. Werkzeugkoffer" />
|
||||||
|
</UFormField>
|
||||||
|
<UAlert
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-heroicons-calendar-days"
|
||||||
|
title="BWA bucht nur die Abschreibungsrate"
|
||||||
|
description="Die Auszahlung erscheint dann nicht sofort als Aufwand, sondern periodisiert über die gewählte Laufzeit."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="showMoreWithoutRecipe"
|
<div v-if="showMoreWithoutRecipe"
|
||||||
class="mt-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 grid grid-cols-1 md:grid-cols-3 gap-3">
|
class="mt-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
<USelectMenu :options="ownaccounts" value-attribute="id" option-attribute="name" v-model="ownAccountToSave"
|
<USelectMenu :items="ownaccounts" value-key="id" label-key="name" v-model="ownAccountToSave"
|
||||||
searchable placeholder="Eigenes Konto">
|
:search-input="{ placeholder: 'Eigenes Konto' }" :filter-fields="['number','name']" placeholder="Eigenes Konto">
|
||||||
<template #label>
|
<template #default>
|
||||||
{{ ownAccountToSave ? ownaccounts.find(i => i.id === ownAccountToSave).name : 'Eigenes Konto' }}
|
{{ ownAccountToSave ? ownaccounts.find(i => i.id === ownAccountToSave).name : 'Eigenes Konto' }}
|
||||||
</template>
|
</template>
|
||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
<USelectMenu :options="customers" value-attribute="id" option-attribute="name"
|
<USelectMenu :items="customers" value-key="id" label-key="name"
|
||||||
v-model="customerAccountToSave" searchable placeholder="Kunde (Guthaben)">
|
v-model="customerAccountToSave" :search-input="{ placeholder: 'Kunde (Guthaben)' }" :filter-fields="['name','customerNumber']" placeholder="Kunde (Guthaben)">
|
||||||
<template #label>
|
<template #default>
|
||||||
{{ customerAccountToSave ? customers.find(i => i.id === customerAccountToSave).name : 'Kunde' }}
|
{{ customerAccountToSave ? customers.find(i => i.id === customerAccountToSave).name : 'Kunde' }}
|
||||||
</template>
|
</template>
|
||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
<USelectMenu :options="vendors" value-attribute="id" option-attribute="name" v-model="vendorAccountToSave"
|
<USelectMenu :items="vendors" value-key="id" label-key="name" v-model="vendorAccountToSave"
|
||||||
searchable placeholder="Lieferant (Guthaben)">
|
:search-input="{ placeholder: 'Lieferant (Guthaben)' }" :filter-fields="['name','vendorNumber']" placeholder="Lieferant (Guthaben)">
|
||||||
<template #label>
|
<template #default>
|
||||||
{{ vendorAccountToSave ? vendors.find(i => i.id === vendorAccountToSave).name : 'Lieferant' }}
|
{{ vendorAccountToSave ? vendors.find(i => i.id === vendorAccountToSave).name : 'Lieferant' }}
|
||||||
</template>
|
</template>
|
||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
@@ -687,9 +807,9 @@ setup()
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!topEntitySuggestion" class="mb-3">
|
<div v-if="!topEntitySuggestion" class="mb-3">
|
||||||
<div class="text-xs font-bold uppercase tracking-wide text-primary-700 dark:text-primary-300">Automatische Belegvorschlaege</div>
|
<div class="text-xs font-bold uppercase tracking-wide text-primary-700 dark:text-primary-300">Automatische Belegvorschläge</div>
|
||||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||||
Kein eindeutiger Kunde oder Lieferant erkannt. Vorschlaege basieren auf Betrag und Verwendungszweck.
|
Kein eindeutiger Kunde oder Lieferant erkannt. Vorschläge basieren auf Betrag und Verwendungszweck.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -124,12 +124,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #amount-cell="{row}">
|
<template #amount-cell="{row}">
|
||||||
<span v-if="row.original.type !== 'deliveryNotes'">{{ displayCurrency(useSum().getCreatedDocumentSum(row.original, items)) }}</span>
|
<span v-if="!deliveryNoteLikeDocumentTypes.includes(row.original.type)">{{ displayCurrency(useSum().getCreatedDocumentSum(row.original, items)) }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #amountOpen-cell="{row}">
|
<template #amountOpen-cell="{row}">
|
||||||
<span
|
<span
|
||||||
v-if="!['deliveryNotes','cancellationInvoices','quotes','confirmationOrders'].includes(row.original.type) && row.original.state !== 'Entwurf' && !hasCancellationInvoice(row.original) && !useSum().getIsPaid(row.original,items) ">
|
v-if="!['cancellationInvoices','confirmationOrders', ...quoteLikeDocumentTypes, ...deliveryNoteLikeDocumentTypes].includes(row.original.type) && row.original.state !== 'Entwurf' && !hasCancellationInvoice(row.original) && !useSum().getIsPaid(row.original,items) ">
|
||||||
{{ displayCurrency(useSum().getCreatedDocumentOpenAmount(row.original, items)) }}
|
{{ displayCurrency(useSum().getCreatedDocumentOpenAmount(row.original, items)) }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -146,6 +146,8 @@ import { ref, computed, watch } from 'vue';
|
|||||||
const dataStore = useDataStore()
|
const dataStore = useDataStore()
|
||||||
const tempStore = useTempStore()
|
const tempStore = useTempStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const quoteLikeDocumentTypes = ['quotes', 'costEstimates']
|
||||||
|
const deliveryNoteLikeDocumentTypes = ['deliveryNotes', 'packingSlips']
|
||||||
|
|
||||||
const type = "createddocuments"
|
const type = "createddocuments"
|
||||||
const dataType = dataStore.dataTypes[type]
|
const dataType = dataStore.dataTypes[type]
|
||||||
@@ -238,7 +240,9 @@ const templateTypes = [
|
|||||||
{ key: "drafts", label: "Entwürfe" },
|
{ key: "drafts", label: "Entwürfe" },
|
||||||
{ key: "invoices", label: "Rechnungen" },
|
{ key: "invoices", label: "Rechnungen" },
|
||||||
{ key: "quotes", label: "Angebote" },
|
{ key: "quotes", label: "Angebote" },
|
||||||
|
{ key: "costEstimates", label: "Kostenschätzungen" },
|
||||||
{ key: "deliveryNotes", label: "Lieferscheine" },
|
{ key: "deliveryNotes", label: "Lieferscheine" },
|
||||||
|
{ key: "packingSlips", label: "Packscheine" },
|
||||||
{ key: "confirmationOrders", label: "Auftragsbestätigungen" }
|
{ key: "confirmationOrders", label: "Auftragsbestätigungen" }
|
||||||
]
|
]
|
||||||
const selectedTypes = ref(tempStore.filters["createddocuments"] ? tempStore.filters["createddocuments"] : templateTypes)
|
const selectedTypes = ref(tempStore.filters["createddocuments"] ? tempStore.filters["createddocuments"] : templateTypes)
|
||||||
@@ -246,7 +250,15 @@ const types = computed(() => {
|
|||||||
return templateTypes.filter((type) => selectedTypes.value.find(i => i.key === type.key))
|
return templateTypes.filter((type) => selectedTypes.value.find(i => i.key === type.key))
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectItem = (item) => {
|
const unwrapSelectedRow = (itemLike) => itemLike?.original || itemLike
|
||||||
|
|
||||||
|
const selectItem = (itemLike) => {
|
||||||
|
const item = unwrapSelectedRow(itemLike)
|
||||||
|
|
||||||
|
if (!item?.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (item.state === "Entwurf") {
|
if (item.state === "Entwurf") {
|
||||||
router.push(`/createDocument/edit/${item.id}`)
|
router.push(`/createDocument/edit/${item.id}`)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -97,18 +97,18 @@
|
|||||||
:columns="normalizeTableColumns(columns)"
|
:columns="normalizeTableColumns(columns)"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
|
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
|
||||||
:on-select="(row) => router.push(`/createDocument/edit/${row.id}`)"
|
:on-select="(row) => router.push(`/createDocument/edit/${row.original?.id || row.id}`)"
|
||||||
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
|
empty="Keine Belege anzuzeigen"
|
||||||
>
|
>
|
||||||
<template #actions-cell="{ row }">
|
<template #actions-cell="{ row }">
|
||||||
<div @click.stop>
|
<div @click.stop>
|
||||||
<UDropdown :items="getActionItems(row.original)" :popper="{ placement: 'bottom-end' }">
|
<UDropdownMenu :items="getActionItems(row.original)" :content="{ align: 'end' }">
|
||||||
<UButton
|
<UButton
|
||||||
color="gray"
|
color="neutral"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
icon="i-heroicons-ellipsis-horizontal-20-solid"
|
icon="i-heroicons-ellipsis-horizontal-20-solid"
|
||||||
/>
|
/>
|
||||||
</UDropdown>
|
</UDropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="max-h-[70vh] space-y-4 overflow-y-auto pr-1">
|
||||||
<UFormField label="Ausführungsdatum (Belegdatum)" help="Dieses Datum steuert auch den Leistungszeitraum (z.B. Vormonat bei 'Rückwirkend').">
|
<UFormField label="Ausführungsdatum (Belegdatum)" help="Dieses Datum steuert auch den Leistungszeitraum (z.B. Vormonat bei 'Rückwirkend').">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<UInput type="date" v-model="executionDate" class="flex-1" />
|
<UInput type="date" v-model="executionDate" class="flex-1" />
|
||||||
@@ -197,18 +197,37 @@
|
|||||||
color="gray"
|
color="gray"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
label="Keine"
|
label="Keine"
|
||||||
@click="selectedExecutionRows = []"
|
@click="clearSelectedTemplates"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-800 rounded-md">
|
<div class="max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-800 rounded-md">
|
||||||
<UTable
|
<UTable
|
||||||
v-model="selectedExecutionRows"
|
v-model:row-selection="executionRowSelection"
|
||||||
:data="filteredExecutionList"
|
:data="filteredExecutionList"
|
||||||
:columns="normalizeTableColumns(executionColumns)"
|
:columns="normalizeTableColumns(executionColumns)"
|
||||||
|
:row-selection-options="{ enableMultiRowSelection: true }"
|
||||||
|
:get-row-id="(row) => row.id"
|
||||||
:ui="{ th: { base: 'whitespace-nowrap' } }"
|
:ui="{ th: { base: 'whitespace-nowrap' } }"
|
||||||
|
:on-select="toggleExecutionRow"
|
||||||
>
|
>
|
||||||
|
<template #select-header="{ table }">
|
||||||
|
<div class="flex justify-center" @click.stop>
|
||||||
|
<UCheckbox
|
||||||
|
:model-value="table.getIsAllPageRowsSelected() ? true : (table.getIsSomePageRowsSelected() ? 'indeterminate' : false)"
|
||||||
|
@update:model-value="table.toggleAllPageRowsSelected(!!$event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #select-cell="{ row }">
|
||||||
|
<div class="flex justify-center" @click.stop>
|
||||||
|
<UCheckbox
|
||||||
|
:model-value="row.getIsSelected()"
|
||||||
|
@update:model-value="row.toggleSelected(!!$event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template #partner-cell="{row}">
|
<template #partner-cell="{row}">
|
||||||
{{row.original.customer ? row.original.customer.name : "-"}}
|
{{row.original.customer ? row.original.customer.name : "-"}}
|
||||||
</template>
|
</template>
|
||||||
@@ -305,7 +324,7 @@ const selectedItem = ref(0)
|
|||||||
// --- Execution State ---
|
// --- Execution State ---
|
||||||
const showExecutionModal = ref(false)
|
const showExecutionModal = ref(false)
|
||||||
const executionDate = ref(dayjs().format('YYYY-MM-DD'))
|
const executionDate = ref(dayjs().format('YYYY-MM-DD'))
|
||||||
const selectedExecutionRows = ref([])
|
const executionRowSelection = ref({})
|
||||||
const isExecuting = ref(false)
|
const isExecuting = ref(false)
|
||||||
const modalSearch = ref("") // NEU: Suchstring für das Modal
|
const modalSearch = ref("") // NEU: Suchstring für das Modal
|
||||||
const selectedExecutionIntervall = ref("all")
|
const selectedExecutionIntervall = ref("all")
|
||||||
@@ -484,15 +503,33 @@ const filteredExecutionList = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const selectedExecutionRows = computed(() => {
|
||||||
|
return activeTemplates.value.filter(row => !!executionRowSelection.value[row.id])
|
||||||
|
})
|
||||||
|
|
||||||
watch(selectedExecutionIntervall, () => {
|
watch(selectedExecutionIntervall, () => {
|
||||||
selectedExecutionRows.value = [...filteredExecutionList.value]
|
executionRowSelection.value = filteredExecutionList.value.reduce((acc, row) => {
|
||||||
|
acc[row.id] = true
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
})
|
})
|
||||||
|
|
||||||
// NEU: Alle auswählen (nur die aktuell sichtbaren/gefilterten)
|
// NEU: Alle auswählen (nur die aktuell sichtbaren/gefilterten)
|
||||||
const selectAllTemplates = () => {
|
const selectAllTemplates = () => {
|
||||||
// WICHTIG: Überschreibt nicht bestehende Auswahl, sondern fügt hinzu oder ersetzt.
|
// WICHTIG: Überschreibt nicht bestehende Auswahl, sondern fügt hinzu oder ersetzt.
|
||||||
// Hier ersetzen wir die Auswahl komplett mit dem aktuellen Filterergebnis
|
// Hier ersetzen wir die Auswahl komplett mit dem aktuellen Filterergebnis
|
||||||
selectedExecutionRows.value = [...filteredExecutionList.value]
|
executionRowSelection.value = filteredExecutionList.value.reduce((acc, row) => {
|
||||||
|
acc[row.id] = true
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSelectedTemplates = () => {
|
||||||
|
executionRowSelection.value = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleExecutionRow = (row) => {
|
||||||
|
row.toggleSelected(!row.getIsSelected())
|
||||||
}
|
}
|
||||||
|
|
||||||
const getActionItems = (row) => {
|
const getActionItems = (row) => {
|
||||||
@@ -502,7 +539,7 @@ const getActionItems = (row) => {
|
|||||||
label: isActive ? 'Deaktivieren' : 'Aktivieren',
|
label: isActive ? 'Deaktivieren' : 'Aktivieren',
|
||||||
icon: isActive ? 'i-heroicons-pause' : 'i-heroicons-play',
|
icon: isActive ? 'i-heroicons-pause' : 'i-heroicons-play',
|
||||||
class: isActive ? 'text-red-500' : 'text-primary',
|
class: isActive ? 'text-red-500' : 'text-primary',
|
||||||
click: () => toggleActiveState(row)
|
onSelect: () => toggleActiveState(row)
|
||||||
}]
|
}]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -542,6 +579,7 @@ const templateColumns = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const executionColumns = [
|
const executionColumns = [
|
||||||
|
{key: 'select', label: ""},
|
||||||
{key: 'partner', label: "Kunde"},
|
{key: 'partner', label: "Kunde"},
|
||||||
{key: 'plant', label: "Objekt"},
|
{key: 'plant', label: "Objekt"},
|
||||||
{key: 'contract', label: "Vertrag"},
|
{key: 'contract', label: "Vertrag"},
|
||||||
@@ -586,7 +624,7 @@ const openExecutionModal = () => {
|
|||||||
executionDate.value = dayjs().format('YYYY-MM-DD')
|
executionDate.value = dayjs().format('YYYY-MM-DD')
|
||||||
modalSearch.value = "" // Reset Search
|
modalSearch.value = "" // Reset Search
|
||||||
selectedExecutionIntervall.value = "all"
|
selectedExecutionIntervall.value = "all"
|
||||||
selectedExecutionRows.value = []
|
executionRowSelection.value = {}
|
||||||
showExecutionModal.value = true
|
showExecutionModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,7 +653,7 @@ const executeSerialInvoices = async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
showExecutionModal.value = false
|
showExecutionModal.value = false
|
||||||
selectedExecutionRows.value = []
|
executionRowSelection.value = {}
|
||||||
|
|
||||||
await fetchExecutions()
|
await fetchExecutions()
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ const renameData = ref({id: null, name: '', type: ''})
|
|||||||
const selectedFileIndex = ref(0)
|
const selectedFileIndex = ref(0)
|
||||||
const draggedItem = ref(null)
|
const draggedItem = ref(null)
|
||||||
|
|
||||||
|
const getEntryActions = (entry) => [[
|
||||||
|
{
|
||||||
|
label: 'Umbenennen',
|
||||||
|
icon: 'i-heroicons-pencil-square',
|
||||||
|
onSelect: () => openRenameModal(entry)
|
||||||
|
}
|
||||||
|
]]
|
||||||
|
|
||||||
// --- Search & Debounce ---
|
// --- Search & Debounce ---
|
||||||
const searchString = ref(tempStore.searchStrings["files"] || '')
|
const searchString = ref(tempStore.searchStrings["files"] || '')
|
||||||
const debouncedSearch = ref(searchString.value)
|
const debouncedSearch = ref(searchString.value)
|
||||||
@@ -76,11 +84,15 @@ const setupPage = async () => {
|
|||||||
|
|
||||||
// --- Global Drag & Drop (Auto-Open Upload Modal) ---
|
// --- Global Drag & Drop (Auto-Open Upload Modal) ---
|
||||||
let dragCounter = 0
|
let dragCounter = 0
|
||||||
|
const uploadModalOpening = ref(false)
|
||||||
|
|
||||||
const handleGlobalDragEnter = (e) => {
|
const handleGlobalDragEnter = (e) => {
|
||||||
dragCounter++
|
dragCounter++
|
||||||
if (draggedItem.value) return
|
if (draggedItem.value) return
|
||||||
|
if (uploadModalOpening.value) return
|
||||||
if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('Files')) {
|
if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('Files')) {
|
||||||
|
uploadModalOpening.value = true
|
||||||
|
|
||||||
modal.open(DocumentUploadModal, {
|
modal.open(DocumentUploadModal, {
|
||||||
fileData: {
|
fileData: {
|
||||||
folder: currentFolder.value?.id,
|
folder: currentFolder.value?.id,
|
||||||
@@ -91,6 +103,9 @@ const handleGlobalDragEnter = (e) => {
|
|||||||
setupPage()
|
setupPage()
|
||||||
dragCounter = 0
|
dragCounter = 0
|
||||||
}
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
dragCounter = 0
|
||||||
|
uploadModalOpening.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,11 +297,15 @@ const showFile = (fileId) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDialogOpen = computed(() => createFolderModalOpen.value || renameModalOpen.value)
|
||||||
|
|
||||||
defineShortcuts({
|
defineShortcuts({
|
||||||
'/': () => document.getElementById("searchinput")?.focus(),
|
'/': () => document.getElementById("searchinput")?.focus(),
|
||||||
'Enter': {
|
'Enter': {
|
||||||
usingInput: true,
|
usingInput: false,
|
||||||
handler: () => {
|
handler: () => {
|
||||||
|
if (isDialogOpen.value) return
|
||||||
|
|
||||||
const entry = renderedFileList.value[selectedFileIndex.value]
|
const entry = renderedFileList.value[selectedFileIndex.value]
|
||||||
if (!entry) return
|
if (!entry) return
|
||||||
if (entry.type === "file") showFile(entry.id)
|
if (entry.type === "file") showFile(entry.id)
|
||||||
@@ -406,10 +425,13 @@ const syncdokubox = async () => {
|
|||||||
{{ entry.type !== 'up' ? dayjs(entry.createdAt).format("DD.MM.YY") : '-' }}
|
{{ entry.type !== 'up' ? dayjs(entry.createdAt).format("DD.MM.YY") : '-' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 text-right" @click.stop>
|
<td class="px-3 text-right" @click.stop>
|
||||||
<UDropdown v-if="entry.type !== 'up'"
|
<UDropdownMenu
|
||||||
:items="[[{ label: 'Umbenennen', icon: 'i-heroicons-pencil-square', click: () => openRenameModal(entry) }]]">
|
v-if="entry.type !== 'up'"
|
||||||
<UButton color="gray" variant="ghost" icon="i-heroicons-ellipsis-horizontal"/>
|
:items="getEntryActions(entry)"
|
||||||
</UDropdown>
|
:content="{ align: 'end' }"
|
||||||
|
>
|
||||||
|
<UButton color="neutral" variant="ghost" icon="i-heroicons-ellipsis-horizontal"/>
|
||||||
|
</UDropdownMenu>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="renderedFileList.length === 0">
|
<tr v-if="renderedFileList.length === 0">
|
||||||
@@ -431,11 +453,15 @@ const syncdokubox = async () => {
|
|||||||
class="group relative bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 flex flex-col items-center hover:border-primary-500 transition-all cursor-pointer"
|
class="group relative bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 flex flex-col items-center hover:border-primary-500 transition-all cursor-pointer"
|
||||||
@click="entry.type === 'folder' ? changeFolder(entry) : (entry.type === 'up' ? navigateUp() : showFile(entry.id))"
|
@click="entry.type === 'folder' ? changeFolder(entry) : (entry.type === 'up' ? navigateUp() : showFile(entry.id))"
|
||||||
>
|
>
|
||||||
<UDropdown v-if="entry.type !== 'up'"
|
<UDropdownMenu
|
||||||
:items="[[{ label: 'Umbenennen', icon: 'i-heroicons-pencil-square', click: () => openRenameModal(entry) }]]"
|
v-if="entry.type !== 'up'"
|
||||||
class="absolute top-1 right-1 opacity-0 group-hover:opacity-100" @click.stop>
|
:items="getEntryActions(entry)"
|
||||||
<UButton color="gray" variant="ghost" icon="i-heroicons-ellipsis-vertical" size="xs"/>
|
:content="{ align: 'end' }"
|
||||||
</UDropdown>
|
class="absolute top-1 right-1 opacity-0 group-hover:opacity-100"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<UButton color="neutral" variant="ghost" icon="i-heroicons-ellipsis-vertical" size="xs"/>
|
||||||
|
</UDropdownMenu>
|
||||||
<UIcon
|
<UIcon
|
||||||
:name="entry.type === 'up' ? 'i-heroicons-arrow-uturn-left' : (entry.type === 'folder' ? 'i-heroicons-folder-solid' : 'i-heroicons-document-text')"
|
:name="entry.type === 'up' ? 'i-heroicons-arrow-uturn-left' : (entry.type === 'folder' ? 'i-heroicons-folder-solid' : 'i-heroicons-document-text')"
|
||||||
class="w-12 h-12 mb-2"
|
class="w-12 h-12 mb-2"
|
||||||
@@ -455,12 +481,22 @@ const syncdokubox = async () => {
|
|||||||
|
|
||||||
<UModal v-model:open="createFolderModalOpen">
|
<UModal v-model:open="createFolderModalOpen">
|
||||||
<template #content>
|
<template #content>
|
||||||
<UCard>
|
<UCard class="shadow-xl ring-1 ring-black/5">
|
||||||
<template #header><h3 class="font-bold">Ordner erstellen</h3></template>
|
<template #header>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary ring-1 ring-primary/15">
|
||||||
|
<UIcon name="i-heroicons-folder-plus" class="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-highlighted">Ordner erstellen</h3>
|
||||||
|
<p class="mt-1 text-sm text-muted">Lege einen neuen Ordner im aktuellen Bereich an.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<form class="space-y-5" @submit.prevent="createFolder">
|
||||||
<UFormField label="Name" required>
|
<UFormField label="Name" required>
|
||||||
<UInput v-model="createFolderData.name" autofocus @keyup.enter="createFolder"/>
|
<UInput v-model="createFolderData.name" autofocus class="w-full" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField
|
<UFormField
|
||||||
@@ -473,8 +509,9 @@ const syncdokubox = async () => {
|
|||||||
value-key="id"
|
value-key="id"
|
||||||
label-key="name"
|
label-key="name"
|
||||||
placeholder="Kein Standardtyp"
|
placeholder="Kein Standardtyp"
|
||||||
searchable
|
class="w-full"
|
||||||
clear-search-on-close
|
:search-input="{ placeholder: 'Dateityp suchen...' }"
|
||||||
|
:filter-fields="['name']"
|
||||||
:disabled="isParentTypeMandatory"
|
:disabled="isParentTypeMandatory"
|
||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
@@ -484,11 +521,11 @@ const syncdokubox = async () => {
|
|||||||
label="Dateityp ist optional"
|
label="Dateityp ist optional"
|
||||||
:disabled="isParentTypeMandatory"
|
:disabled="isParentTypeMandatory"
|
||||||
/>
|
/>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<UButton color="gray" @click="createFolderModalOpen = false">Abbrechen</UButton>
|
<UButton color="neutral" variant="ghost" @click="createFolderModalOpen = false">Abbrechen</UButton>
|
||||||
<UButton color="primary" @click="createFolder">Erstellen</UButton>
|
<UButton color="primary" @click="createFolder">Erstellen</UButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -498,14 +535,16 @@ const syncdokubox = async () => {
|
|||||||
|
|
||||||
<UModal v-model:open="renameModalOpen">
|
<UModal v-model:open="renameModalOpen">
|
||||||
<template #content>
|
<template #content>
|
||||||
<UCard>
|
<UCard class="shadow-xl ring-1 ring-black/5">
|
||||||
<template #header><h3 class="font-bold">Umbenennen</h3></template>
|
<template #header><h3 class="text-lg font-semibold text-highlighted">Umbenennen</h3></template>
|
||||||
|
<form @submit.prevent="updateName">
|
||||||
<UFormField label="Neuer Name">
|
<UFormField label="Neuer Name">
|
||||||
<UInput v-model="renameData.name" autofocus @keyup.enter="updateName"/>
|
<UInput v-model="renameData.name" autofocus class="w-full" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
</form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<UButton color="gray" @click="renameModalOpen = false">Abbrechen</UButton>
|
<UButton color="neutral" variant="ghost" @click="renameModalOpen = false">Abbrechen</UButton>
|
||||||
<UButton color="primary" @click="updateName">Speichern</UButton>
|
<UButton color="primary" @click="updateName">Speichern</UButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import InputGroup from "~/components/InputGroup.vue";
|
import InputGroup from "~/components/InputGroup.vue";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import { parseDate } from "@internationalized/date"
|
||||||
import { useDraggable } from '@vueuse/core'
|
import { useDraggable } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
DEPRECIATION_METHOD_ITEMS,
|
||||||
|
EXPENSE_BOOKING_MODE_ITEMS,
|
||||||
|
createIncomingInvoiceAccount,
|
||||||
|
ensureDepreciationDefaults,
|
||||||
|
isDepreciationBookingMode,
|
||||||
|
normalizeIncomingInvoiceAccounts
|
||||||
|
} from "~/composables/useDepreciation"
|
||||||
|
|
||||||
// --- Standard Setup & Data ---
|
// --- Standard Setup & Data ---
|
||||||
const dataStore = useDataStore()
|
const dataStore = useDataStore()
|
||||||
@@ -29,14 +38,7 @@ const itemInfo = ref({
|
|||||||
description: "",
|
description: "",
|
||||||
state: "Entwurf",
|
state: "Entwurf",
|
||||||
accounts: [
|
accounts: [
|
||||||
{
|
createIncomingInvoiceAccount()
|
||||||
account: null,
|
|
||||||
amountNet: null,
|
|
||||||
amountTax: null,
|
|
||||||
taxType: "19",
|
|
||||||
costCentre: null,
|
|
||||||
amountGross: null
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -44,6 +46,9 @@ const costcentres = ref([])
|
|||||||
const vendors = ref([])
|
const vendors = ref([])
|
||||||
const accounts = ref([])
|
const accounts = ref([])
|
||||||
const loadedFileId = ref(null)
|
const loadedFileId = ref(null)
|
||||||
|
const invoiceFiles = ref([])
|
||||||
|
const paymentTypeItems = ['Überweisung', 'Lastschrift', 'Kreditkarte', 'PayPal', 'Bar', 'Sonstiges']
|
||||||
|
const files = useFiles()
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
// 1. Daten laden
|
// 1. Daten laden
|
||||||
@@ -57,17 +62,19 @@ const setup = async () => {
|
|||||||
itemInfo.value = {
|
itemInfo.value = {
|
||||||
...invoiceData,
|
...invoiceData,
|
||||||
vendor: invoiceData.vendor?.id || invoiceData.vendor,
|
vendor: invoiceData.vendor?.id || invoiceData.vendor,
|
||||||
accounts: invoiceData.accounts || []
|
accounts: normalizeIncomingInvoiceAccounts(invoiceData.accounts || [], invoiceData.date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback Accounts
|
// Fallback Accounts
|
||||||
if(itemInfo.value.accounts.length === 0) {
|
if(itemInfo.value.accounts.length === 0) {
|
||||||
itemInfo.value.accounts.push({ account: null, amountNet: null, amountTax: null, taxType: "19", costCentre: null })
|
itemInfo.value.accounts.push(createIncomingInvoiceAccount({ depreciationStartDate: itemInfo.value.date || null }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Datei laden
|
// Datei laden
|
||||||
if (itemInfo.value.files && itemInfo.value.files.length > 0) {
|
if (itemInfo.value.files && itemInfo.value.files.length > 0) {
|
||||||
loadedFileId.value = itemInfo.value.files[itemInfo.value.files.length-1].id
|
invoiceFiles.value = await files.selectSomeDocuments(itemInfo.value.files.map((file) => file.id))
|
||||||
|
const latestPdf = [...invoiceFiles.value].reverse().find((file) => file?.path?.toLowerCase().includes('.pdf'))
|
||||||
|
loadedFileId.value = latestPdf?.id || null
|
||||||
}
|
}
|
||||||
|
|
||||||
if(itemInfo.value.date && !itemInfo.value.dueDate) itemInfo.value.dueDate = itemInfo.value.date
|
if(itemInfo.value.date && !itemInfo.value.dueDate) itemInfo.value.dueDate = itemInfo.value.date
|
||||||
@@ -86,6 +93,14 @@ const setup = async () => {
|
|||||||
|
|
||||||
setup()
|
setup()
|
||||||
|
|
||||||
|
watch(() => itemInfo.value.date, (value) => {
|
||||||
|
;(itemInfo.value.accounts || []).forEach((account) => {
|
||||||
|
if (isDepreciationItem(account) && !account.depreciationStartDate) {
|
||||||
|
account.depreciationStartDate = value || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// --- Berechnungslogik ---
|
// --- Berechnungslogik ---
|
||||||
const useNetMode = ref(false)
|
const useNetMode = ref(false)
|
||||||
|
|
||||||
@@ -98,6 +113,23 @@ const taxOptions = ref([
|
|||||||
{ label: "Keine USt", percentage: 0, key: "null" },
|
{ label: "Keine USt", percentage: 0, key: "null" },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const getCalendarValue = (value) => {
|
||||||
|
if (!value) return undefined
|
||||||
|
|
||||||
|
const formatted = dayjs(value).format('YYYY-MM-DD')
|
||||||
|
return formatted ? parseDate(formatted) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDateField = (field, value) => {
|
||||||
|
itemInfo.value[field] = value ? dayjs(value.toString()).toDate() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDateFieldToToday = (field) => {
|
||||||
|
itemInfo.value[field] = dayjs().toDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDateButtonLabel = (value, emptyLabel = "Kein Datum") => value ? dayjs(value).format('DD.MM.YYYY') : emptyLabel
|
||||||
|
|
||||||
const totalCalculated = computed(() => {
|
const totalCalculated = computed(() => {
|
||||||
let totalNet = 0
|
let totalNet = 0
|
||||||
let totalAmount19Tax = 0
|
let totalAmount19Tax = 0
|
||||||
@@ -121,6 +153,11 @@ const totalCalculated = computed(() => {
|
|||||||
|
|
||||||
const hasAmount = (value) => value !== null && value !== undefined && value !== ""
|
const hasAmount = (value) => value !== null && value !== undefined && value !== ""
|
||||||
const hasValidNumber = (value) => hasAmount(value) && Number.isFinite(Number(value))
|
const hasValidNumber = (value) => hasAmount(value) && Number.isFinite(Number(value))
|
||||||
|
const isDepreciationItem = (item) => isDepreciationBookingMode(item?.bookingMode)
|
||||||
|
|
||||||
|
const updateBookingMode = (item) => {
|
||||||
|
ensureDepreciationDefaults(item, itemInfo.value.date)
|
||||||
|
}
|
||||||
|
|
||||||
const recalculateItem = (item, source) => {
|
const recalculateItem = (item, source) => {
|
||||||
const taxRate = Number(taxOptions.value.find(i => i.key === item.taxType)?.percentage || 0);
|
const taxRate = Number(taxOptions.value.find(i => i.key === item.taxType)?.percentage || 0);
|
||||||
@@ -163,6 +200,7 @@ const updateIncomingInvoice = async (setBooked = false) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let item = { ...itemInfo.value }
|
let item = { ...itemInfo.value }
|
||||||
|
item.accounts = (item.accounts || []).map((account) => ensureDepreciationDefaults({ ...account }, item.date))
|
||||||
delete item.files
|
delete item.files
|
||||||
item.state = setBooked ? "Gebucht" : "Entwurf"
|
item.state = setBooked ? "Gebucht" : "Entwurf"
|
||||||
|
|
||||||
@@ -182,6 +220,7 @@ const findIncomingInvoiceErrors = computed(() => {
|
|||||||
if(!i.accounts || i.accounts.length === 0) errors.push({message: "Es ist keine Position vorhanden", type: "breaking"})
|
if(!i.accounts || i.accounts.length === 0) errors.push({message: "Es ist keine Position vorhanden", type: "breaking"})
|
||||||
|
|
||||||
i.accounts.forEach((account, idx) => {
|
i.accounts.forEach((account, idx) => {
|
||||||
|
ensureDepreciationDefaults(account, i.date)
|
||||||
if(!account.account) errors.push({message: `Pos ${idx+1}: Keine Kategorie`, type: "breaking"})
|
if(!account.account) errors.push({message: `Pos ${idx+1}: Keine Kategorie`, type: "breaking"})
|
||||||
if(!hasValidNumber(account.amountNet) && !hasValidNumber(account.amountGross)) {
|
if(!hasValidNumber(account.amountNet) && !hasValidNumber(account.amountGross)) {
|
||||||
errors.push({message: `Pos ${idx+1}: Kein gültiger Betrag`, type: "breaking"})
|
errors.push({message: `Pos ${idx+1}: Kein gültiger Betrag`, type: "breaking"})
|
||||||
@@ -190,6 +229,12 @@ const findIncomingInvoiceErrors = computed(() => {
|
|||||||
if(hasValidNumber(account.amountNet) && !hasValidNumber(account.amountTax)) {
|
if(hasValidNumber(account.amountNet) && !hasValidNumber(account.amountTax)) {
|
||||||
errors.push({message: `Pos ${idx+1}: Steuerbetrag fehlt, bitte Steuer neu berechnen`, type: "warning"})
|
errors.push({message: `Pos ${idx+1}: Steuerbetrag fehlt, bitte Steuer neu berechnen`, type: "warning"})
|
||||||
}
|
}
|
||||||
|
if(isDepreciationItem(account) && !Number(account.depreciationMonths)) {
|
||||||
|
errors.push({message: `Pos ${idx+1}: Abschreibungsdauer fehlt`, type: "breaking"})
|
||||||
|
}
|
||||||
|
if(account.bookingMode === "depreciation_bundle" && !String(account.depreciationGroup || "").trim()) {
|
||||||
|
errors.push({message: `Pos ${idx+1}: Sammelposten benötigt einen Gruppennamen`, type: "breaking"})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const order = { breaking: 0, warning: 1 }
|
const order = { breaking: 0, warning: 1 }
|
||||||
@@ -335,18 +380,18 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
|
|||||||
<USelectMenu
|
<USelectMenu
|
||||||
class="w-full"
|
class="w-full"
|
||||||
v-model="itemInfo.vendor"
|
v-model="itemInfo.vendor"
|
||||||
:options="vendors"
|
:items="vendors"
|
||||||
option-attribute="name"
|
label-key="name"
|
||||||
value-attribute="id"
|
value-key="id"
|
||||||
searchable
|
:search-input="{ placeholder: 'Lieferant suchen...' }"
|
||||||
:disabled="mode === 'show'"
|
:disabled="mode === 'show'"
|
||||||
:search-attributes="['name', 'vendorNumber']"
|
:filter-fields="['name', 'vendorNumber']"
|
||||||
placeholder="Lieferant suchen..."
|
:color="itemInfo.vendor ? 'primary' : 'error'"
|
||||||
>
|
>
|
||||||
<template #label>
|
<template #default>
|
||||||
{{ vendors.find(v => v.id === itemInfo.vendor)?.name || 'Bitte wählen' }}
|
{{ vendors.find(v => v.id === itemInfo.vendor)?.name || 'Bitte wählen' }}
|
||||||
</template>
|
</template>
|
||||||
<template #option="{ option }">
|
<template #item="{ item: option }">
|
||||||
<span class="font-mono text-xs opacity-75 mr-2">{{ option.vendorNumber }}</span> {{ option.name }}
|
<span class="font-mono text-xs opacity-75 mr-2">{{ option.vendorNumber }}</span> {{ option.name }}
|
||||||
</template>
|
</template>
|
||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
@@ -368,33 +413,81 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
|
|||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Rechnungsnummer">
|
<UFormField label="Rechnungsnummer">
|
||||||
<UInput v-model="itemInfo.reference" icon="i-heroicons-hashtag" :disabled="mode === 'show'" />
|
<UInput class="w-full" v-model="itemInfo.reference" icon="i-heroicons-hashtag" :disabled="mode === 'show'" :color="itemInfo.reference ? 'primary' : 'error'" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Zahlart">
|
<UFormField label="Zahlart">
|
||||||
<USelectMenu v-model="itemInfo.paymentType" :options="['Überweisung', 'Lastschrift', 'Kreditkarte', 'PayPal', 'Bar', 'Sonstiges']" :disabled="mode === 'show'" />
|
<USelectMenu v-model="itemInfo.paymentType" :items="paymentTypeItems" :disabled="mode === 'show'" class="w-full" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Rechnungsdatum">
|
<UFormField label="Rechnungsdatum">
|
||||||
<UPopover :popper="{ placement: 'bottom-start' }">
|
<UPopover class="w-full" :content="{ side: 'bottom', align: 'start' }">
|
||||||
<UButton block color="white" icon="i-heroicons-calendar" :label="itemInfo.date ? dayjs(itemInfo.date).format('DD.MM.YYYY') : '-'" :disabled="mode === 'show'" />
|
<UButton
|
||||||
<template #panel="{ close }">
|
block
|
||||||
<LazyDatePicker v-model="itemInfo.date" @close="() => { if(!itemInfo.dueDate) itemInfo.dueDate = itemInfo.date; close() }" />
|
icon="i-heroicons-calendar"
|
||||||
|
:label="getDateButtonLabel(itemInfo.date)"
|
||||||
|
:disabled="mode === 'show'"
|
||||||
|
:color="itemInfo.date ? 'neutral' : 'error'"
|
||||||
|
variant="outline"
|
||||||
|
class="w-full justify-start"
|
||||||
|
/>
|
||||||
|
<template #content>
|
||||||
|
<div class="p-2">
|
||||||
|
<UCalendar
|
||||||
|
:model-value="getCalendarValue(itemInfo.date)"
|
||||||
|
@update:model-value="(value) => { setDateField('date', value); if (!itemInfo.dueDate) itemInfo.dueDate = itemInfo.date }"
|
||||||
|
:week-starts-on="1"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end px-2 pb-2">
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
color="gray"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-heroicons-calendar-days"
|
||||||
|
label="Heute"
|
||||||
|
@click="() => { setDateFieldToToday('date'); if (!itemInfo.dueDate) itemInfo.dueDate = itemInfo.date }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UPopover>
|
</UPopover>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Fälligkeitsdatum">
|
<UFormField label="Fälligkeitsdatum">
|
||||||
<UPopover :popper="{ placement: 'bottom-start' }">
|
<UPopover class="w-full" :content="{ side: 'bottom', align: 'start' }">
|
||||||
<UButton block color="white" icon="i-heroicons-calendar" :label="itemInfo.dueDate ? dayjs(itemInfo.dueDate).format('DD.MM.YYYY') : '-'" :disabled="mode === 'show'" />
|
<UButton
|
||||||
<template #panel="{ close }">
|
block
|
||||||
<LazyDatePicker v-model="itemInfo.dueDate" @close="close" />
|
icon="i-heroicons-calendar"
|
||||||
|
:label="getDateButtonLabel(itemInfo.dueDate)"
|
||||||
|
:disabled="mode === 'show'"
|
||||||
|
:color="itemInfo.dueDate ? 'neutral' : 'error'"
|
||||||
|
variant="outline"
|
||||||
|
class="w-full justify-start"
|
||||||
|
/>
|
||||||
|
<template #content>
|
||||||
|
<div class="p-2">
|
||||||
|
<UCalendar
|
||||||
|
:model-value="getCalendarValue(itemInfo.dueDate)"
|
||||||
|
@update:model-value="(value) => setDateField('dueDate', value)"
|
||||||
|
:week-starts-on="1"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end px-2 pb-2">
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
color="gray"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-heroicons-calendar-days"
|
||||||
|
label="Heute"
|
||||||
|
@click="setDateFieldToToday('dueDate')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UPopover>
|
</UPopover>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Beschreibung / Notiz" class="md:col-span-2">
|
<UFormField label="Beschreibung / Notiz" class="md:col-span-2">
|
||||||
<UTextarea v-model="itemInfo.description" :rows="2" autoresize :disabled="mode === 'show'" />
|
<UTextarea class="w-full" v-model="itemInfo.description" :rows="2" autoresize :disabled="mode === 'show'" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
@@ -430,46 +523,124 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
|
|||||||
<div class="col-span-12 md:col-span-6">
|
<div class="col-span-12 md:col-span-6">
|
||||||
<UFormField label="Konto / Kategorie">
|
<UFormField label="Konto / Kategorie">
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
|
class="w-full"
|
||||||
v-model="item.account"
|
v-model="item.account"
|
||||||
:options="accounts"
|
:items="accounts"
|
||||||
searchable
|
:search-input="{ placeholder: 'Kategorie wählen' }"
|
||||||
placeholder="Kategorie wählen"
|
label-key="label"
|
||||||
option-attribute="label"
|
value-key="id"
|
||||||
value-attribute="id"
|
|
||||||
:disabled="mode === 'show'"
|
:disabled="mode === 'show'"
|
||||||
:search-attributes="['label', 'number']"
|
:filter-fields="['label', 'number']"
|
||||||
|
:color="item.account ? 'primary' : 'error'"
|
||||||
>
|
>
|
||||||
<template #option="{ option }">
|
<template #item="{ item: option }">
|
||||||
<span class="font-mono text-xs text-gray-500 mr-2">{{ option.number }}</span> {{ option.label }}
|
<span class="font-mono text-xs text-gray-500 mr-2">{{ option.number }}</span> {{ option.label }}
|
||||||
</template>
|
</template>
|
||||||
<template #label>
|
<template #default>
|
||||||
{{ accounts.find(a => a.id === item.account)?.label || 'Auswählen' }}
|
{{ accounts.find(a => a.id === item.account)?.label || 'Auswählen' }}
|
||||||
</template>
|
</template>
|
||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-12 md:col-span-6">
|
||||||
|
<UFormField label="Aufwandsart">
|
||||||
|
<USelectMenu
|
||||||
|
class="w-full"
|
||||||
|
:items="EXPENSE_BOOKING_MODE_ITEMS"
|
||||||
|
value-key="value"
|
||||||
|
label-key="label"
|
||||||
|
v-model="item.bookingMode"
|
||||||
|
:disabled="mode === 'show'"
|
||||||
|
@update:model-value="updateBookingMode(item)"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-span-12 md:col-span-6">
|
<div class="col-span-12 md:col-span-6">
|
||||||
<UFormField label="Kostenstelle">
|
<UFormField label="Kostenstelle">
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
|
class="w-full"
|
||||||
v-model="item.costCentre"
|
v-model="item.costCentre"
|
||||||
:options="costcentres"
|
:items="costcentres"
|
||||||
searchable
|
:search-input="{ placeholder: 'Optional' }"
|
||||||
option-attribute="name"
|
label-key="name"
|
||||||
value-attribute="id"
|
value-key="id"
|
||||||
placeholder="Optional"
|
|
||||||
:disabled="mode === 'show'"
|
:disabled="mode === 'show'"
|
||||||
>
|
>
|
||||||
<template #label>
|
<template #default>
|
||||||
{{ costcentres.find(c => c.id === item.costCentre)?.name || 'Keine' }}
|
{{ costcentres.find(c => c.id === item.costCentre)?.name || 'Keine' }}
|
||||||
</template>
|
</template>
|
||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-if="isDepreciationItem(item)">
|
||||||
|
<div class="col-span-12 md:col-span-4">
|
||||||
|
<UFormField label="Abschreibungsdauer (Monate)">
|
||||||
|
<UInput
|
||||||
|
class="w-full"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
v-model="item.depreciationMonths"
|
||||||
|
:disabled="mode === 'show'"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-12 md:col-span-4">
|
||||||
|
<UFormField label="Methode">
|
||||||
|
<USelectMenu
|
||||||
|
class="w-full"
|
||||||
|
:items="DEPRECIATION_METHOD_ITEMS"
|
||||||
|
value-key="value"
|
||||||
|
label-key="label"
|
||||||
|
v-model="item.depreciationMethod"
|
||||||
|
:disabled="mode === 'show'"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-12 md:col-span-4">
|
||||||
|
<UFormField label="Start Abschreibung">
|
||||||
|
<UInput
|
||||||
|
class="w-full"
|
||||||
|
type="date"
|
||||||
|
v-model="item.depreciationStartDate"
|
||||||
|
:disabled="mode === 'show'"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-12 md:col-span-4">
|
||||||
|
<UFormField :label="item.bookingMode === 'depreciation_bundle' ? 'Sammelposten' : 'Bezeichnung Abschreibung'">
|
||||||
|
<UInput
|
||||||
|
class="w-full"
|
||||||
|
v-model="item[item.bookingMode === 'depreciation_bundle' ? 'depreciationGroup' : 'depreciationLabel']"
|
||||||
|
:disabled="mode === 'show'"
|
||||||
|
:placeholder="item.bookingMode === 'depreciation_bundle' ? 'z. B. IT-Hardware 2026' : 'z. B. Notebook Fuhrpark' "
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-12">
|
||||||
|
<UAlert
|
||||||
|
color="primary"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-heroicons-calendar-days"
|
||||||
|
title="Nur die monatliche Abschreibung erscheint in der BWA"
|
||||||
|
:description="item.bookingMode === 'depreciation_bundle'
|
||||||
|
? 'Diese Position wird nicht sofort als Aufwand gezählt, sondern als Sammelposten periodisiert.'
|
||||||
|
: 'Diese Position wird nicht sofort als Aufwand gezählt, sondern über die gewählte Laufzeit abgeschrieben.'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="col-span-12 md:col-span-3">
|
<div class="col-span-12 md:col-span-3">
|
||||||
<UFormField label="Betrag (Netto)">
|
<UFormField label="Betrag (Netto)">
|
||||||
<UInput
|
<UInput
|
||||||
|
class="w-full"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
:disabled="mode === 'show' || !useNetMode"
|
:disabled="mode === 'show' || !useNetMode"
|
||||||
@@ -484,6 +655,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
|
|||||||
<div class="col-span-12 md:col-span-3">
|
<div class="col-span-12 md:col-span-3">
|
||||||
<UFormField label="Betrag (Brutto)">
|
<UFormField label="Betrag (Brutto)">
|
||||||
<UInput
|
<UInput
|
||||||
|
class="w-full"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
:disabled="mode === 'show' || useNetMode"
|
:disabled="mode === 'show' || useNetMode"
|
||||||
@@ -498,19 +670,21 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
|
|||||||
<div class="col-span-6 md:col-span-3">
|
<div class="col-span-6 md:col-span-3">
|
||||||
<UFormField label="Steuerschlüssel">
|
<UFormField label="Steuerschlüssel">
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
|
class="w-full"
|
||||||
v-model="item.taxType"
|
v-model="item.taxType"
|
||||||
:options="taxOptions"
|
:items="taxOptions"
|
||||||
value-attribute="key"
|
value-key="key"
|
||||||
option-attribute="label"
|
label-key="label"
|
||||||
:disabled="mode === 'show'"
|
:disabled="mode === 'show'"
|
||||||
@change="recalculateItem(item, 'taxType')"
|
@update:model-value="recalculateItem(item, 'taxType')"
|
||||||
|
:color="item.taxType ? 'primary' : 'error'"
|
||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-6 md:col-span-3">
|
<div class="col-span-6 md:col-span-3">
|
||||||
<UFormField label="Steuerbetrag" help="Automatisch berechnet">
|
<UFormField label="Steuerbetrag" help="Automatisch berechnet">
|
||||||
<UInput :model-value="item.amountTax" disabled color="gray" >
|
<UInput class="w-full" :model-value="item.amountTax" disabled color="gray" >
|
||||||
<template #trailing>€</template>
|
<template #trailing>€</template>
|
||||||
</UInput>
|
</UInput>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
@@ -538,7 +712,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-12">
|
<div class="col-span-12">
|
||||||
<UInput v-model="item.description" :disabled="mode === 'show'" placeholder="Positionstext (optional)" icon="i-heroicons-bars-3-bottom-left" variant="none" class="border-b border-gray-100 dark:border-gray-800" />
|
<UInput v-model="item.description" :disabled="mode === 'show'" placeholder="Positionstext (optional)" icon="i-heroicons-bars-3-bottom-left" variant="none" class="w-full border-b border-gray-100 dark:border-gray-800" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
@@ -548,7 +722,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
|
|||||||
icon="i-heroicons-plus"
|
icon="i-heroicons-plus"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
block
|
block
|
||||||
@click="itemInfo.accounts.push({account:null, amountNet: null, amountTax:0, amountGross: null, taxType: '19'})"
|
@click="itemInfo.accounts.push(createIncomingInvoiceAccount({ depreciationStartDate: itemInfo.date || null }))"
|
||||||
>
|
>
|
||||||
Weitere Position hinzufügen
|
Weitere Position hinzufügen
|
||||||
</UButton>
|
</UButton>
|
||||||
|
|||||||
@@ -148,7 +148,15 @@ const isPaid = (item) => {
|
|||||||
return Math.abs(amountPaid) === Math.abs(Number(getInvoiceSum(item)))
|
return Math.abs(amountPaid) === Math.abs(Number(getInvoiceSum(item)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectIncomingInvoice = (invoice) => {
|
const unwrapInvoiceRow = (invoiceLike) => invoiceLike?.original || invoiceLike
|
||||||
|
|
||||||
|
const selectIncomingInvoice = (invoiceLike) => {
|
||||||
|
const invoice = unwrapInvoiceRow(invoiceLike)
|
||||||
|
|
||||||
|
if (!invoice?.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (invoice.state === "Gebucht") {
|
if (invoice.state === "Gebucht") {
|
||||||
router.push(`/incomingInvoices/show/${invoice.id}`)
|
router.push(`/incomingInvoices/show/${invoice.id}`)
|
||||||
} else {
|
} else {
|
||||||
@@ -254,7 +262,7 @@ const selectIncomingInvoice = (invoice) => {
|
|||||||
:columns="normalizeTableColumns(columns)"
|
:columns="normalizeTableColumns(columns)"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
|
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
|
||||||
:on-select="(i) => selectIncomingInvoice(i) "
|
:on-select="selectIncomingInvoice"
|
||||||
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
|
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
|
||||||
>
|
>
|
||||||
<template #reference-cell="{row}">
|
<template #reference-cell="{row}">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import DisplayBankaccounts from "~/components/displayBankaccounts.vue"
|
|||||||
import DisplayProjectsInPhases from "~/components/displayProjectsInPhases.vue"
|
import DisplayProjectsInPhases from "~/components/displayProjectsInPhases.vue"
|
||||||
import DisplayOpenTasks from "~/components/displayOpenTasks.vue"
|
import DisplayOpenTasks from "~/components/displayOpenTasks.vue"
|
||||||
import DisplayTaxSummary from "~/components/displayTaxSummary.vue"
|
import DisplayTaxSummary from "~/components/displayTaxSummary.vue"
|
||||||
|
import DisplayBWASummary from "~/components/displayBWASummary.vue"
|
||||||
|
|
||||||
setPageLayout("default")
|
setPageLayout("default")
|
||||||
|
|
||||||
@@ -78,11 +79,31 @@ const DASHBOARD_WIDGETS = [
|
|||||||
defaultLayout: { x: 4, y: 7, w: 4, h: 3 },
|
defaultLayout: { x: 4, y: 7, w: 4, h: 3 },
|
||||||
minW: 3,
|
minW: 3,
|
||||||
minH: 3
|
minH: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bwa-summary",
|
||||||
|
title: "BWA aktuell",
|
||||||
|
description: "Einnahmen, Ausgaben und Ergebnis des aktuellen Monats",
|
||||||
|
component: markRaw(DisplayBWASummary),
|
||||||
|
defaultLayout: { x: 8, y: 7, w: 4, h: 3 },
|
||||||
|
minW: 3,
|
||||||
|
minH: 3
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const widgetDefinitions = Object.fromEntries(DASHBOARD_WIDGETS.map((widget) => [widget.id, widget]))
|
const widgetDefinitions = Object.fromEntries(DASHBOARD_WIDGETS.map((widget) => [widget.id, widget]))
|
||||||
|
|
||||||
|
function getDefaultDashboardWidgets() {
|
||||||
|
return DASHBOARD_WIDGETS.map((definition) => ({
|
||||||
|
id: definition.id,
|
||||||
|
x: definition.defaultLayout.x,
|
||||||
|
y: definition.defaultLayout.y,
|
||||||
|
w: definition.defaultLayout.w,
|
||||||
|
h: definition.defaultLayout.h,
|
||||||
|
visible: true
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeNumber(value, fallback) {
|
function normalizeNumber(value, fallback) {
|
||||||
const parsed = Number(value)
|
const parsed = Number(value)
|
||||||
return Number.isFinite(parsed) ? parsed : fallback
|
return Number.isFinite(parsed) ? parsed : fallback
|
||||||
@@ -293,8 +314,13 @@ function removeWidget(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetDashboard() {
|
function resetDashboard() {
|
||||||
widgets.value = normalizeDashboardWidgets()
|
widgets.value = getDefaultDashboardWidgets()
|
||||||
persistWidgets()
|
persistWidgets()
|
||||||
|
toast.add({
|
||||||
|
title: "Dashboard zurückgesetzt",
|
||||||
|
description: "Das Standardlayout wurde wiederhergestellt.",
|
||||||
|
color: "primary"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleWidgets = computed(() =>
|
const visibleWidgets = computed(() =>
|
||||||
@@ -348,7 +374,6 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
|
||||||
<UDashboardNavbar title="Home">
|
<UDashboardNavbar title="Home">
|
||||||
<template #right>
|
<template #right>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -369,6 +394,15 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
Karte hinzufügen
|
Karte hinzufügen
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="isEditMode"
|
||||||
|
icon="i-heroicons-arrow-path"
|
||||||
|
color="gray"
|
||||||
|
variant="ghost"
|
||||||
|
@click="resetDashboard"
|
||||||
|
>
|
||||||
|
Standardlayout
|
||||||
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
v-if="isEditMode"
|
v-if="isEditMode"
|
||||||
icon="i-heroicons-squares-2x2"
|
icon="i-heroicons-squares-2x2"
|
||||||
@@ -501,7 +535,6 @@ onBeforeUnmount(() => {
|
|||||||
</UCard>
|
</UCard>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</UModal>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { FormSubmitEvent } from '#ui/types'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "notLoggedIn"
|
layout: "notLoggedIn"
|
||||||
})
|
})
|
||||||
@@ -7,16 +9,23 @@ const auth = useAuthStore()
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const doLogin = async (data:any) => {
|
const state = reactive({
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const doLogin = async (event: FormSubmitEvent<typeof state>) => {
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await auth.login(data.email, data.password)
|
await auth.login(event.data.email, event.data.password)
|
||||||
// Weiterleiten nach erfolgreichem Login
|
|
||||||
toast.add({title:"Einloggen erfolgreich"})
|
toast.add({title:"Einloggen erfolgreich"})
|
||||||
|
|
||||||
await router.push("/")
|
await router.push("/")
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.add({title:"Zugangsdaten falsch. Bitte überprüfen Sie Ihre Eingaben",color:"error"})
|
toast.add({title:"Zugangsdaten falsch. Bitte überprüfen Sie Ihre Eingaben",color:"error"})
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -29,60 +38,42 @@ const doLogin = async (data:any) => {
|
|||||||
dark="/Logo_Dark.png"
|
dark="/Logo_Dark.png"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UAuthForm
|
<div class="mt-6 space-y-5">
|
||||||
title="Login"
|
<div class="space-y-1">
|
||||||
description="Geben Sie Ihre Anmeldedaten ein um Zugriff auf Ihren Account zu erhalten."
|
<h1 class="text-xl font-semibold">Login</h1>
|
||||||
align="bottom"
|
<p class="text-sm text-muted">
|
||||||
:fields="[{
|
Geben Sie Ihre Anmeldedaten ein um Zugriff auf Ihren Account zu erhalten.
|
||||||
name: 'email',
|
</p>
|
||||||
type: 'text',
|
</div>
|
||||||
label: 'Email',
|
|
||||||
placeholder: 'Deine E-Mail Adresse'
|
|
||||||
}, {
|
|
||||||
name: 'password',
|
|
||||||
label: 'Passwort',
|
|
||||||
type: 'password',
|
|
||||||
placeholder: 'Dein Passwort'
|
|
||||||
}]"
|
|
||||||
:loading="false"
|
|
||||||
@submit="doLogin"
|
|
||||||
:submit-button="{label: 'Weiter'}"
|
|
||||||
divider="oder"
|
|
||||||
>
|
|
||||||
<template #password-hint>
|
|
||||||
<NuxtLink to="/password-reset" class="text-primary font-medium">Passwort vergessen?</NuxtLink>
|
|
||||||
</template>
|
|
||||||
</UAuthForm>
|
|
||||||
</UCard>
|
|
||||||
<!-- <div v-else class="mt-20 m-2 p-2">
|
|
||||||
<UColorModeImage
|
|
||||||
light="/Logo.png"
|
|
||||||
dark="/Logo_Dark.png"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UAuthForm
|
<UForm :state="state" class="space-y-4" @submit="doLogin">
|
||||||
title="Login"
|
<UFormField label="E-Mail" name="email">
|
||||||
description="Geben Sie Ihre Anmeldedaten ein um Zugriff auf Ihren Account zu erhalten."
|
<UInput
|
||||||
align="bottom"
|
v-model="state.email"
|
||||||
:fields="[{
|
type="email"
|
||||||
name: 'email',
|
class="w-full"
|
||||||
type: 'text',
|
placeholder="Deine E-Mail Adresse"
|
||||||
label: 'Email',
|
autocomplete="email"
|
||||||
placeholder: 'Deine E-Mail Adresse'
|
/>
|
||||||
}, {
|
</UFormField>
|
||||||
name: 'password',
|
|
||||||
label: 'Passwort',
|
<UFormField label="Passwort" name="password">
|
||||||
type: 'password',
|
<template #hint>
|
||||||
placeholder: 'Dein Passwort'
|
|
||||||
}]"
|
|
||||||
:loading="false"
|
|
||||||
@submit="doLogin"
|
|
||||||
:submit-button="{label: 'Weiter'}"
|
|
||||||
divider="oder"
|
|
||||||
>
|
|
||||||
<template #password-hint>
|
|
||||||
<NuxtLink to="/password-reset" class="text-primary font-medium">Passwort vergessen?</NuxtLink>
|
<NuxtLink to="/password-reset" class="text-primary font-medium">Passwort vergessen?</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
</UAuthForm>
|
<UInput
|
||||||
</div>-->
|
v-model="state.password"
|
||||||
|
type="password"
|
||||||
|
class="w-full"
|
||||||
|
placeholder="Dein Passwort"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UButton type="submit" block class="w-full" :loading="loading">
|
||||||
|
Weiter
|
||||||
|
</UButton>
|
||||||
|
</UForm>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
</template>
|
</template>
|
||||||
@@ -3,6 +3,7 @@ import deLocale from "@fullcalendar/core/locales/de"
|
|||||||
import FullCalendar from "@fullcalendar/vue3"
|
import FullCalendar from "@fullcalendar/vue3"
|
||||||
import interactionPlugin from "@fullcalendar/interaction"
|
import interactionPlugin from "@fullcalendar/interaction"
|
||||||
import resourceTimelinePlugin from "@fullcalendar/resource-timeline"
|
import resourceTimelinePlugin from "@fullcalendar/resource-timeline"
|
||||||
|
import { parseDate } from "@internationalized/date"
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const profileStore = useProfileStore()
|
const profileStore = useProfileStore()
|
||||||
@@ -13,6 +14,10 @@ const { list: listStaffTimeSpans, createEntry, update: updateStaffTimeEntry } =
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const savingAbsence = ref(false)
|
const savingAbsence = ref(false)
|
||||||
const selectedType = ref("all")
|
const selectedType = ref("all")
|
||||||
|
const calendarRef = ref(null)
|
||||||
|
const calendarView = ref("resourceTimelineWeek")
|
||||||
|
const calendarCurrentDate = ref($dayjs().format("YYYY-MM-DD"))
|
||||||
|
const calendarTitle = ref("")
|
||||||
const visibleRange = ref({
|
const visibleRange = ref({
|
||||||
from: $dayjs().startOf("month").format("YYYY-MM-DD"),
|
from: $dayjs().startOf("month").format("YYYY-MM-DD"),
|
||||||
to: $dayjs().endOf("month").format("YYYY-MM-DD")
|
to: $dayjs().endOf("month").format("YYYY-MM-DD")
|
||||||
@@ -38,17 +43,72 @@ const setAbsenceDateToToday = (field) => {
|
|||||||
absenceForm[field] = $dayjs().format("YYYY-MM-DD")
|
absenceForm[field] = $dayjs().format("YYYY-MM-DD")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startDateValue = computed({
|
||||||
|
get: () => {
|
||||||
|
if (!absenceForm.startDate) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return parseDate(absenceForm.startDate)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set: (value) => {
|
||||||
|
absenceForm.startDate = value ? value.toString() : ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const endDateValue = computed({
|
||||||
|
get: () => {
|
||||||
|
if (!absenceForm.endDate) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return parseDate(absenceForm.endDate)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set: (value) => {
|
||||||
|
absenceForm.endDate = value ? value.toString() : ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const resourceTypeOptions = [
|
const resourceTypeOptions = [
|
||||||
{ label: "Alle Ressourcen", value: "all" },
|
{ label: "Alle Ressourcen", value: "all" },
|
||||||
{ label: "Profile", value: "Profile" },
|
{ label: "Profile", value: "Profile" },
|
||||||
{ label: "Inventarartikel", value: "Inventarartikel" }
|
{ label: "Inventarartikel", value: "Inventarartikel" }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const calendarViewOptions = [
|
||||||
|
{ label: "Tag", value: "resourceTimelineDay" },
|
||||||
|
{ label: "Woche", value: "resourceTimelineWeek" },
|
||||||
|
{ label: "Monat", value: "resourceTimelineMonth" }
|
||||||
|
]
|
||||||
|
|
||||||
const absenceTypeOptions = [
|
const absenceTypeOptions = [
|
||||||
{ label: "Urlaub", value: "vacation" },
|
{ label: "Urlaub", value: "vacation" },
|
||||||
{ label: "Krank", value: "sick" }
|
{ label: "Krank", value: "sick" }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const calendarPickerValue = computed({
|
||||||
|
get: () => {
|
||||||
|
if (!calendarCurrentDate.value) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return parseDate(calendarCurrentDate.value)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set: (value) => {
|
||||||
|
calendarCurrentDate.value = value ? value.toString() : ""
|
||||||
|
if (value) {
|
||||||
|
const api = calendarRef.value?.getApi?.()
|
||||||
|
api?.gotoDate(value.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const profileOptions = computed(() =>
|
const profileOptions = computed(() =>
|
||||||
profiles.value
|
profiles.value
|
||||||
.filter((profile) => !profile.archived && profile.user_id)
|
.filter((profile) => !profile.archived && profile.user_id)
|
||||||
@@ -83,12 +143,9 @@ const calendarOptions = computed(() => ({
|
|||||||
schedulerLicenseKey: "CC-Attribution-NonCommercial-NoDerivatives",
|
schedulerLicenseKey: "CC-Attribution-NonCommercial-NoDerivatives",
|
||||||
locale: deLocale,
|
locale: deLocale,
|
||||||
plugins: [resourceTimelinePlugin, interactionPlugin],
|
plugins: [resourceTimelinePlugin, interactionPlugin],
|
||||||
initialView: "resourceTimelineWeek",
|
initialView: calendarView.value,
|
||||||
headerToolbar: {
|
initialDate: calendarCurrentDate.value,
|
||||||
left: "prev,next today",
|
headerToolbar: false,
|
||||||
center: "title",
|
|
||||||
right: "resourceTimelineDay,resourceTimelineWeek,resourceTimelineMonth"
|
|
||||||
},
|
|
||||||
resourceAreaWidth: "280px",
|
resourceAreaWidth: "280px",
|
||||||
resourceGroupField: "type",
|
resourceGroupField: "type",
|
||||||
resourceOrder: "type,title",
|
resourceOrder: "type,title",
|
||||||
@@ -149,6 +206,10 @@ const calendarOptions = computed(() => ({
|
|||||||
const nextTo = $dayjs(info.end).subtract(1, "day").format("YYYY-MM-DD")
|
const nextTo = $dayjs(info.end).subtract(1, "day").format("YYYY-MM-DD")
|
||||||
const nextKey = `${nextFrom}:${nextTo}`
|
const nextKey = `${nextFrom}:${nextTo}`
|
||||||
|
|
||||||
|
calendarView.value = info.view.type
|
||||||
|
calendarCurrentDate.value = $dayjs(info.view.currentStart).format("YYYY-MM-DD")
|
||||||
|
calendarTitle.value = info.view.title
|
||||||
|
|
||||||
if (nextKey === lastRangeKey.value) return
|
if (nextKey === lastRangeKey.value) return
|
||||||
|
|
||||||
lastRangeKey.value = nextKey
|
lastRangeKey.value = nextKey
|
||||||
@@ -264,6 +325,31 @@ function resetAbsenceForm() {
|
|||||||
absenceForm.description = ""
|
absenceForm.description = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCalendarApi() {
|
||||||
|
return calendarRef.value?.getApi?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeCalendarView(view) {
|
||||||
|
const api = getCalendarApi()
|
||||||
|
if (!api || !view) return
|
||||||
|
calendarView.value = view
|
||||||
|
api.changeView(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCalendar(direction) {
|
||||||
|
const api = getCalendarApi()
|
||||||
|
if (!api) return
|
||||||
|
|
||||||
|
if (direction === "prev") api.prev()
|
||||||
|
if (direction === "next") api.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCalendarToday() {
|
||||||
|
const api = getCalendarApi()
|
||||||
|
if (!api) return
|
||||||
|
api.today()
|
||||||
|
}
|
||||||
|
|
||||||
function openAbsenceModal(type = "vacation", preset = {}) {
|
function openAbsenceModal(type = "vacation", preset = {}) {
|
||||||
absenceForm.mode = preset.entry ? "edit" : "create"
|
absenceForm.mode = preset.entry ? "edit" : "create"
|
||||||
absenceForm.entry = preset.entry || null
|
absenceForm.entry = preset.entry || null
|
||||||
@@ -404,12 +490,49 @@ onMounted(() => {
|
|||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
v-model="selectedType"
|
v-model="selectedType"
|
||||||
:options="resourceTypeOptions"
|
:items="resourceTypeOptions"
|
||||||
value-attribute="value"
|
value-key="value"
|
||||||
option-attribute="label"
|
label-key="label"
|
||||||
:clearable="false"
|
:clearable="false"
|
||||||
class="min-w-[220px]"
|
class="min-w-[220px]"
|
||||||
/>
|
/>
|
||||||
|
<USelectMenu
|
||||||
|
v-model="calendarView"
|
||||||
|
:items="calendarViewOptions"
|
||||||
|
value-key="value"
|
||||||
|
label-key="label"
|
||||||
|
:clearable="false"
|
||||||
|
class="min-w-[160px]"
|
||||||
|
@update:model-value="changeCalendarView"
|
||||||
|
/>
|
||||||
|
<UPopover>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-heroicons-calendar-days"
|
||||||
|
class="min-w-[180px] justify-between"
|
||||||
|
>
|
||||||
|
{{ calendarCurrentDate ? $dayjs(calendarCurrentDate).format("DD.MM.YYYY") : "Datum wählen" }}
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<div class="p-2">
|
||||||
|
<UCalendar v-model="calendarPickerValue" />
|
||||||
|
<div class="flex justify-end border-t border-default pt-2">
|
||||||
|
<UButton color="neutral" variant="ghost" size="sm" @click="moveCalendarToday">
|
||||||
|
Heute
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPopover>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<UButton color="neutral" variant="ghost" icon="i-heroicons-chevron-left" @click="moveCalendar('prev')" />
|
||||||
|
<UButton color="neutral" variant="ghost" icon="i-heroicons-chevron-right" @click="moveCalendar('next')" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-highlighted">
|
||||||
|
{{ calendarTitle }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #right>
|
<template #right>
|
||||||
@@ -449,6 +572,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<FullCalendar
|
<FullCalendar
|
||||||
v-else
|
v-else
|
||||||
|
ref="calendarRef"
|
||||||
:options="calendarOptions"
|
:options="calendarOptions"
|
||||||
/>
|
/>
|
||||||
</UDashboardPanelContent>
|
</UDashboardPanelContent>
|
||||||
@@ -475,33 +599,82 @@ onMounted(() => {
|
|||||||
<UFormField label="Profil">
|
<UFormField label="Profil">
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
v-model="absenceForm.userId"
|
v-model="absenceForm.userId"
|
||||||
:options="profileOptions"
|
:items="profileOptions"
|
||||||
value-attribute="value"
|
value-key="value"
|
||||||
option-attribute="label"
|
label-key="label"
|
||||||
searchable
|
class="w-full"
|
||||||
|
:search-input="{ placeholder: 'Profil suchen...' }"
|
||||||
|
:filter-fields="['label']"
|
||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Typ">
|
<UFormField label="Typ">
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
v-model="absenceForm.type"
|
v-model="absenceForm.type"
|
||||||
:options="absenceTypeOptions"
|
:items="absenceTypeOptions"
|
||||||
value-attribute="value"
|
value-key="value"
|
||||||
option-attribute="label"
|
label-key="label"
|
||||||
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<UFormField label="Start">
|
<UFormField label="Start">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<UInput v-model="absenceForm.startDate" type="date" class="flex-1" />
|
<UPopover>
|
||||||
<UButton color="gray" variant="soft" label="Heute" @click="setAbsenceDateToToday('startDate')" />
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-heroicons-calendar-days"
|
||||||
|
class="min-w-0 flex-1 justify-between"
|
||||||
|
>
|
||||||
|
<span class="truncate text-left">
|
||||||
|
{{ absenceForm.startDate ? $dayjs(absenceForm.startDate).format("DD.MM.YYYY") : "Kein Datum" }}
|
||||||
|
</span>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<div class="p-2">
|
||||||
|
<UCalendar v-model="startDateValue" />
|
||||||
|
<div class="flex justify-end border-t border-default pt-2">
|
||||||
|
<UButton color="neutral" variant="ghost" size="sm" @click="setAbsenceDateToToday('startDate')">
|
||||||
|
Heute
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPopover>
|
||||||
|
|
||||||
|
<UButton color="neutral" variant="soft" label="Heute" @click="setAbsenceDateToToday('startDate')" />
|
||||||
</div>
|
</div>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
<UFormField label="Ende">
|
<UFormField label="Ende">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<UInput v-model="absenceForm.endDate" type="date" class="flex-1" />
|
<UPopover>
|
||||||
<UButton color="gray" variant="soft" label="Heute" @click="setAbsenceDateToToday('endDate')" />
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-heroicons-calendar-days"
|
||||||
|
class="min-w-0 flex-1 justify-between"
|
||||||
|
>
|
||||||
|
<span class="truncate text-left">
|
||||||
|
{{ absenceForm.endDate ? $dayjs(absenceForm.endDate).format("DD.MM.YYYY") : "Kein Datum" }}
|
||||||
|
</span>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<div class="p-2">
|
||||||
|
<UCalendar v-model="endDateValue" />
|
||||||
|
<div class="flex justify-end border-t border-default pt-2">
|
||||||
|
<UButton color="neutral" variant="ghost" size="sm" @click="setAbsenceDateToToday('endDate')">
|
||||||
|
Heute
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPopover>
|
||||||
|
|
||||||
|
<UButton color="neutral" variant="soft" label="Heute" @click="setAbsenceDateToToday('endDate')" />
|
||||||
</div>
|
</div>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { FormSubmitEvent } from '#ui/types'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "notLoggedIn"
|
layout: "notLoggedIn"
|
||||||
})
|
})
|
||||||
@@ -6,25 +8,31 @@ definePageMeta({
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
oldPassword: '',
|
||||||
|
newPassword: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const doChange = async (event: FormSubmitEvent<typeof state>) => {
|
||||||
const doChange = async (data:any) => {
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await useNuxtApp().$api("/api/auth/password/change", {
|
await useNuxtApp().$api("/api/auth/password/change", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
old_password: data.oldPassword,
|
old_password: event.data.oldPassword,
|
||||||
new_password: data.newPassword,
|
new_password: event.data.newPassword,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Weiterleiten nach erfolgreichem Login
|
|
||||||
toast.add({title:"Ändern erfolgreich"})
|
toast.add({title:"Ändern erfolgreich"})
|
||||||
await auth.logout()
|
await auth.logout()
|
||||||
return navigateTo("/login")
|
return navigateTo("/login")
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.add({title:"Es gab ein Problem beim ändern",color:"error"})
|
toast.add({title:"Es gab ein Problem beim ändern",color:"error"})
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -37,26 +45,39 @@ const doChange = async (data:any) => {
|
|||||||
dark="/Logo_Dark.png"
|
dark="/Logo_Dark.png"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UAuthForm
|
<div class="mt-6 space-y-5">
|
||||||
title="Passwort zurücksetzen"
|
<div class="space-y-1">
|
||||||
description="Geben Sie Ihre E-Mail ein um ein neues Passwort per E-Mail zu erhalten."
|
<h1 class="text-xl font-semibold">Passwort ändern</h1>
|
||||||
align="bottom"
|
<p class="text-sm text-muted">
|
||||||
:fields="[{
|
Geben Sie Ihr aktuelles und Ihr neues Passwort ein.
|
||||||
name: 'oldPassword',
|
</p>
|
||||||
label: 'Altes Passwort',
|
</div>
|
||||||
type: 'password',
|
|
||||||
placeholder: 'Dein altes Passwort'
|
<UForm :state="state" class="space-y-4" @submit="doChange">
|
||||||
},{
|
<UFormField label="Altes Passwort" name="oldPassword">
|
||||||
name: 'newPassword',
|
<UInput
|
||||||
label: 'Neues Passwort',
|
v-model="state.oldPassword"
|
||||||
type: 'password',
|
type="password"
|
||||||
placeholder: 'Dein neues Passwort'
|
class="w-full"
|
||||||
}]"
|
placeholder="Dein altes Passwort"
|
||||||
:loading="false"
|
autocomplete="current-password"
|
||||||
@submit="doChange"
|
/>
|
||||||
:submit-button="{label: 'Ändern'}"
|
</UFormField>
|
||||||
divider="oder"
|
|
||||||
>
|
<UFormField label="Neues Passwort" name="newPassword">
|
||||||
</UAuthForm>
|
<UInput
|
||||||
|
v-model="state.newPassword"
|
||||||
|
type="password"
|
||||||
|
class="w-full"
|
||||||
|
placeholder="Dein neues Passwort"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UButton type="submit" block class="w-full" :loading="loading">
|
||||||
|
Ändern
|
||||||
|
</UButton>
|
||||||
|
</UForm>
|
||||||
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,28 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { FormSubmitEvent } from '#ui/types'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "notLoggedIn"
|
layout: "notLoggedIn"
|
||||||
})
|
})
|
||||||
|
|
||||||
const auth = useAuthStore()
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
email: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const doReset = async (event: FormSubmitEvent<typeof state>) => {
|
||||||
const doReset = async (data:any) => {
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await useNuxtApp().$api("/auth/password/reset", {
|
await useNuxtApp().$api("/auth/password/reset", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
email: data.email
|
email: event.data.email
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Weiterleiten nach erfolgreichem Login
|
|
||||||
toast.add({title:"Zurücksetzen erfolgreich"})
|
toast.add({title:"Zurücksetzen erfolgreich"})
|
||||||
return navigateTo("/login")
|
return navigateTo("/login")
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.add({title:"Problem beim zurücksetzen",color:"error"})
|
toast.add({title:"Problem beim zurücksetzen",color:"error"})
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -35,21 +41,29 @@ const doReset = async (data:any) => {
|
|||||||
dark="/Logo_Dark.png"
|
dark="/Logo_Dark.png"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UAuthForm
|
<div class="mt-6 space-y-5">
|
||||||
title="Passwort zurücksetzen"
|
<div class="space-y-1">
|
||||||
description="Geben Sie Ihre E-Mail ein um ein neues Passwort per E-Mail zu erhalten."
|
<h1 class="text-xl font-semibold">Passwort zurücksetzen</h1>
|
||||||
align="bottom"
|
<p class="text-sm text-muted">
|
||||||
:fields="[{
|
Geben Sie Ihre E-Mail ein um ein neues Passwort per E-Mail zu erhalten.
|
||||||
name: 'email',
|
</p>
|
||||||
type: 'text',
|
</div>
|
||||||
label: 'Email',
|
|
||||||
placeholder: 'Deine E-Mail Adresse'
|
<UForm :state="state" class="space-y-4" @submit="doReset">
|
||||||
}]"
|
<UFormField label="E-Mail" name="email">
|
||||||
:loading="false"
|
<UInput
|
||||||
@submit="doReset"
|
v-model="state.email"
|
||||||
:submit-button="{label: 'Zurücksetzen'}"
|
type="email"
|
||||||
divider="oder"
|
class="w-full"
|
||||||
>
|
placeholder="Deine E-Mail Adresse"
|
||||||
</UAuthForm>
|
autocomplete="email"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UButton type="submit" block class="w-full" :loading="loading">
|
||||||
|
Zurücksetzen
|
||||||
|
</UButton>
|
||||||
|
</UForm>
|
||||||
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
</template>
|
</template>
|
||||||
@@ -262,12 +262,18 @@ const addPhase = () => {
|
|||||||
<UButton
|
<UButton
|
||||||
class="my-1"
|
class="my-1"
|
||||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Angebot',link:'/createDocument/edit/?type=quotes'})">Angebot Erstellen</UButton>
|
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Angebot',link:'/createDocument/edit/?type=quotes'})">Angebot Erstellen</UButton>
|
||||||
|
<UButton
|
||||||
|
class="my-1"
|
||||||
|
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Kostenschätzung',link:'/createDocument/edit/?type=costEstimates'})">Kostenschätzung Erstellen</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
class="my-1"
|
class="my-1"
|
||||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Auftrag',link:'/createDocument/edit/?type=confirmationOrders'})">Auftrag Erstellen</UButton>
|
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Auftrag',link:'/createDocument/edit/?type=confirmationOrders'})">Auftrag Erstellen</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
class="my-1"
|
class="my-1"
|
||||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Lieferschein',link:'/createDocument/edit/?type=deliveryNotes'})">Lieferschein Erstellen</UButton>
|
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Lieferschein',link:'/createDocument/edit/?type=deliveryNotes'})">Lieferschein Erstellen</UButton>
|
||||||
|
<UButton
|
||||||
|
class="my-1"
|
||||||
|
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Packschein',link:'/createDocument/edit/?type=packingSlips'})">Packschein Erstellen</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
class="my-1"
|
class="my-1"
|
||||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Rechnung',link:'/createDocument/edit/?type=invoices'})">Rechnung Erstellen</UButton>
|
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Rechnung',link:'/createDocument/edit/?type=invoices'})">Rechnung Erstellen</UButton>
|
||||||
|
|||||||
@@ -1,894 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const toast = useToast()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
type AdminRole = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
tenant_id: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type AdminTenant = {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
short: string
|
|
||||||
user_count: number
|
|
||||||
locked?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type AdminUserProfile = {
|
|
||||||
id: string
|
|
||||||
user_id: string
|
|
||||||
tenant_id: number
|
|
||||||
full_name: string | null
|
|
||||||
first_name: string
|
|
||||||
last_name: string
|
|
||||||
email?: string | null
|
|
||||||
active: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
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[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
const savingUser = ref(false)
|
|
||||||
const savingTenant = ref(false)
|
|
||||||
const creatingUser = ref(false)
|
|
||||||
const creatingTenant = ref(false)
|
|
||||||
const activeTab = ref("0")
|
|
||||||
const createUserModalOpen = ref(false)
|
|
||||||
const createTenantModalOpen = ref(false)
|
|
||||||
const createdUserPassword = ref("")
|
|
||||||
|
|
||||||
const users = ref<AdminUser[]>([])
|
|
||||||
const tenants = ref<AdminTenant[]>([])
|
|
||||||
const roles = ref<AdminRole[]>([])
|
|
||||||
const unassignedProfiles = ref<AdminUserProfile[]>([])
|
|
||||||
|
|
||||||
const selectedUserId = ref<string | null>(null)
|
|
||||||
const selectedTenantId = ref<number | null>(null)
|
|
||||||
|
|
||||||
const userForm = ref<AdminUser | null>(null)
|
|
||||||
const tenantForm = ref<AdminTenant | null>(null)
|
|
||||||
const createUserForm = ref({
|
|
||||||
email: "",
|
|
||||||
password: "",
|
|
||||||
first_name: "",
|
|
||||||
last_name: "",
|
|
||||||
is_admin: false,
|
|
||||||
multiTenant: true,
|
|
||||||
})
|
|
||||||
const createTenantForm = ref({
|
|
||||||
name: "",
|
|
||||||
short: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabItems = [
|
|
||||||
{ label: "Benutzer" },
|
|
||||||
{ label: "Tenants" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const sortedUsers = computed(() =>
|
|
||||||
[...users.value].sort((a, b) => a.display_name.localeCompare(b.display_name, "de"))
|
|
||||||
)
|
|
||||||
|
|
||||||
const sortedTenants = computed(() =>
|
|
||||||
[...tenants.value].sort((a, b) => a.name.localeCompare(b.name, "de"))
|
|
||||||
)
|
|
||||||
|
|
||||||
const tenantOptions = computed(() =>
|
|
||||||
sortedTenants.value.map((tenant) => ({
|
|
||||||
label: `${tenant.name} (${tenant.short})`,
|
|
||||||
value: tenant.id,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
const userTableColumns = [
|
|
||||||
{ key: "display_name", label: "Benutzer" },
|
|
||||||
{ key: "email", label: "E-Mail" },
|
|
||||||
{ key: "tenant_count", label: "Tenants" },
|
|
||||||
{ key: "is_admin", label: "Admin" },
|
|
||||||
]
|
|
||||||
const normalizedUserTableColumns = normalizeTableColumns(userTableColumns)
|
|
||||||
|
|
||||||
const tenantTableColumns = [
|
|
||||||
{ key: "name", label: "Tenant" },
|
|
||||||
{ key: "short", label: "Kürzel" },
|
|
||||||
{ key: "user_count", label: "Benutzer" },
|
|
||||||
]
|
|
||||||
const normalizedTenantTableColumns = normalizeTableColumns(tenantTableColumns)
|
|
||||||
|
|
||||||
const userTableRows = computed(() =>
|
|
||||||
sortedUsers.value.map((user) => ({
|
|
||||||
...user,
|
|
||||||
tenant_count: user.tenant_ids.length,
|
|
||||||
is_admin: user.is_admin ? "Ja" : "Nein",
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
const tenantTableRows = computed(() => sortedTenants.value)
|
|
||||||
|
|
||||||
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 getUsersForTenant = (tenantId: number) =>
|
|
||||||
sortedUsers.value.filter((user) => user.tenant_ids.includes(tenantId))
|
|
||||||
|
|
||||||
const getFreeProfilesForTenant = (tenantId: number) =>
|
|
||||||
unassignedProfiles.value.filter((profile) => profile.tenant_id === tenantId)
|
|
||||||
|
|
||||||
const cloneUser = (user: AdminUser): AdminUser => ({
|
|
||||||
...user,
|
|
||||||
profile_defaults: { ...user.profile_defaults },
|
|
||||||
tenant_ids: [...(user.tenant_ids || [])],
|
|
||||||
role_assignments: [...(user.role_assignments || [])],
|
|
||||||
profile_assignments: [...(user.profile_assignments || [])],
|
|
||||||
profiles: [...(user.profiles || [])],
|
|
||||||
})
|
|
||||||
|
|
||||||
const cloneTenant = (tenant: AdminTenant): AdminTenant => ({
|
|
||||||
...tenant,
|
|
||||||
})
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
return 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) => {
|
|
||||||
return userForm.value?.profile_assignments?.find((assignment) => assignment.tenant_id === tenantId)?.profile_id || null
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectUser = (row: any) => {
|
|
||||||
const user = users.value.find((entry) => entry.id === row.id)
|
|
||||||
if (!user) return
|
|
||||||
|
|
||||||
selectedUserId.value = user.id
|
|
||||||
userForm.value = cloneUser(user)
|
|
||||||
normalizeUserAssignments()
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectTenant = (row: any) => {
|
|
||||||
const tenant = tenants.value.find((entry) => entry.id === row.id)
|
|
||||||
if (!tenant) return
|
|
||||||
|
|
||||||
selectedTenantId.value = tenant.id
|
|
||||||
tenantForm.value = cloneTenant(tenant)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchOverview = async () => {
|
|
||||||
loading.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await useNuxtApp().$api("/api/admin/overview")
|
|
||||||
|
|
||||||
users.value = response.users || []
|
|
||||||
tenants.value = response.tenants || []
|
|
||||||
roles.value = response.roles || []
|
|
||||||
unassignedProfiles.value = response.unassignedProfiles || []
|
|
||||||
|
|
||||||
if (!selectedUserId.value && users.value.length) {
|
|
||||||
selectUser(users.value[0])
|
|
||||||
} else if (selectedUserId.value) {
|
|
||||||
const currentUser = users.value.find((user) => user.id === selectedUserId.value)
|
|
||||||
if (currentUser) selectUser(currentUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedTenantId.value && tenants.value.length) {
|
|
||||||
selectTenant(tenants.value[0])
|
|
||||||
} else if (selectedTenantId.value) {
|
|
||||||
const currentTenant = tenants.value.find((tenant) => tenant.id === selectedTenantId.value)
|
|
||||||
if (currentTenant) selectTenant(currentTenant)
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("[admin/fetchOverview]", err)
|
|
||||||
toast.add({
|
|
||||||
title: "Administration konnte nicht geladen werden",
|
|
||||||
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
|
||||||
color: "red",
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveUser = async () => {
|
|
||||||
if (!userForm.value || savingUser.value) return
|
|
||||||
|
|
||||||
savingUser.value = true
|
|
||||||
normalizeUserAssignments()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await useNuxtApp().$api(`/api/admin/users/${userForm.value.id}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: {
|
|
||||||
email: userForm.value.email,
|
|
||||||
multiTenant: userForm.value.multiTenant,
|
|
||||||
must_change_password: userForm.value.must_change_password,
|
|
||||||
is_admin: userForm.value.is_admin,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await useNuxtApp().$api(`/api/admin/users/${userForm.value.id}/access`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: {
|
|
||||||
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 fetchOverview()
|
|
||||||
await auth.fetchMe()
|
|
||||||
|
|
||||||
toast.add({
|
|
||||||
title: "Benutzer gespeichert",
|
|
||||||
color: "green",
|
|
||||||
})
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("[admin/saveUser]", err)
|
|
||||||
toast.add({
|
|
||||||
title: "Benutzer konnte nicht gespeichert werden",
|
|
||||||
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
|
||||||
color: "red",
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
savingUser.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const createUser = async () => {
|
|
||||||
if (creatingUser.value) return
|
|
||||||
|
|
||||||
creatingUser.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await useNuxtApp().$api("/api/admin/users", {
|
|
||||||
method: "POST",
|
|
||||||
body: createUserForm.value,
|
|
||||||
})
|
|
||||||
|
|
||||||
createdUserPassword.value = response.initialPassword || ""
|
|
||||||
createUserModalOpen.value = false
|
|
||||||
createUserForm.value = {
|
|
||||||
email: "",
|
|
||||||
password: "",
|
|
||||||
first_name: "",
|
|
||||||
last_name: "",
|
|
||||||
is_admin: false,
|
|
||||||
multiTenant: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
await fetchOverview()
|
|
||||||
|
|
||||||
if (response.user?.id) {
|
|
||||||
const createdUser = users.value.find((user) => user.id === response.user.id)
|
|
||||||
if (createdUser) selectUser(createdUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.add({
|
|
||||||
title: "Benutzer angelegt",
|
|
||||||
description: createdUserPassword.value ? `Initialpasswort: ${createdUserPassword.value}` : undefined,
|
|
||||||
color: "green",
|
|
||||||
})
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("[admin/createUser]", err)
|
|
||||||
toast.add({
|
|
||||||
title: "Benutzer konnte nicht angelegt werden",
|
|
||||||
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
|
||||||
color: "red",
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
creatingUser.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveTenant = async () => {
|
|
||||||
if (!tenantForm.value || savingTenant.value) return
|
|
||||||
|
|
||||||
savingTenant.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
await useNuxtApp().$api(`/api/admin/tenants/${tenantForm.value.id}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: {
|
|
||||||
name: tenantForm.value.name,
|
|
||||||
short: tenantForm.value.short,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await fetchOverview()
|
|
||||||
await auth.fetchMe()
|
|
||||||
|
|
||||||
toast.add({
|
|
||||||
title: "Tenant gespeichert",
|
|
||||||
color: "green",
|
|
||||||
})
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("[admin/saveTenant]", err)
|
|
||||||
toast.add({
|
|
||||||
title: "Tenant konnte nicht gespeichert werden",
|
|
||||||
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
|
||||||
color: "red",
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
savingTenant.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const createTenant = async () => {
|
|
||||||
if (creatingTenant.value) return
|
|
||||||
|
|
||||||
creatingTenant.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await useNuxtApp().$api("/api/admin/tenants", {
|
|
||||||
method: "POST",
|
|
||||||
body: createTenantForm.value,
|
|
||||||
})
|
|
||||||
|
|
||||||
createTenantModalOpen.value = false
|
|
||||||
createTenantForm.value = {
|
|
||||||
name: "",
|
|
||||||
short: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
await fetchOverview()
|
|
||||||
|
|
||||||
if (response.tenant?.id) {
|
|
||||||
const createdTenant = tenants.value.find((tenant) => tenant.id === response.tenant.id)
|
|
||||||
if (createdTenant) selectTenant(createdTenant)
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.add({
|
|
||||||
title: "Tenant angelegt",
|
|
||||||
description: "Standardordner und Datei-Tags wurden erstellt.",
|
|
||||||
color: "green",
|
|
||||||
})
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("[admin/createTenant]", 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 () => {
|
onMounted(async () => {
|
||||||
if (!auth.user?.is_admin) {
|
if (!auth.user?.is_admin) {
|
||||||
toast.add({
|
await router.replace("/")
|
||||||
title: "Zugriff verweigert",
|
|
||||||
description: "Diese Seite ist nur für administrative Benutzer verfügbar.",
|
|
||||||
color: "red",
|
|
||||||
})
|
|
||||||
await router.push("/")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetchOverview()
|
await router.replace("/administration/users")
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UDashboardNavbar title="Administration" />
|
<UDashboardNavbar title="Administration" />
|
||||||
|
|
||||||
<UDashboardPanelContent class="p-5 overflow-hidden">
|
<UDashboardPanelContent>
|
||||||
<UAlert
|
<UAlert
|
||||||
v-if="!auth.user?.is_admin"
|
title="Weiterleitung"
|
||||||
title="Kein Zugriff"
|
description="Die Administration wurde in eigene Bereiche fuer Benutzer und Tenants verschoben."
|
||||||
description="Für diese Seite wird ein administrativer Benutzer benötigt."
|
|
||||||
color="red"
|
|
||||||
variant="soft"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UTabs
|
|
||||||
v-else
|
|
||||||
v-model="activeTab"
|
|
||||||
:items="tabItems"
|
|
||||||
class="admin-tabs h-full"
|
|
||||||
>
|
|
||||||
<template #content="{ item }">
|
|
||||||
<div v-if="item.label === 'Benutzer'" class="admin-grid mt-5 grid grid-cols-1 xl:grid-cols-3 gap-5">
|
|
||||||
<UCard class="admin-card xl:col-span-1">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h2 class="text-lg font-semibold">Benutzer</h2>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UBadge variant="subtle">{{ users.length }}</UBadge>
|
|
||||||
<UButton
|
|
||||||
size="sm"
|
|
||||||
icon="i-heroicons-plus"
|
|
||||||
@click="createUserModalOpen = true"
|
|
||||||
>
|
|
||||||
Benutzer
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-scroll">
|
|
||||||
<UTable
|
|
||||||
v-if="!loading"
|
|
||||||
:data="userTableRows"
|
|
||||||
:columns="normalizedUserTableColumns"
|
|
||||||
:on-select="selectUser"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<USkeleton v-else class="h-80" />
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<UCard class="admin-card xl:col-span-2">
|
|
||||||
<div v-if="userForm" class="admin-scroll space-y-6">
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold">{{ userForm.display_name }}</h2>
|
|
||||||
<p class="text-sm text-gray-500">{{ userForm.email }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UButton
|
|
||||||
color="primary"
|
color="primary"
|
||||||
:loading="savingUser"
|
|
||||||
@click="saveUser"
|
|
||||||
>
|
|
||||||
Benutzer speichern
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UForm :state="userForm" class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<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 Administrationsseite 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">Benutzer darf mehreren Tenants zugeordnet sein</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>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<USeparator label="Rollen pro Tenant" class="mb-4" />
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="userForm.tenant_ids.length"
|
|
||||||
class="grid grid-cols-1 md:grid-cols-2 gap-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
|
|
||||||
title="Keine Tenant-Zuordnung"
|
|
||||||
description="Weise dem Benutzer zuerst mindestens einen Tenant zu."
|
|
||||||
color="amber"
|
|
||||||
variant="soft"
|
variant="soft"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<USeparator label="Profile im System" class="mb-4" />
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UAlert
|
|
||||||
v-else-if="!loading"
|
|
||||||
title="Kein Benutzer ausgewählt"
|
|
||||||
description="Wähle links einen Benutzer aus, um seine Zuordnungen zu bearbeiten."
|
|
||||||
color="gray"
|
|
||||||
variant="soft"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-else class="admin-scroll">
|
|
||||||
<USkeleton class="h-80" />
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="item.label === 'Tenants'" class="admin-grid mt-5 grid grid-cols-1 xl:grid-cols-3 gap-5">
|
|
||||||
<UCard class="admin-card xl:col-span-1">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h2 class="text-lg font-semibold">Tenants</h2>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UBadge variant="subtle">{{ tenants.length }}</UBadge>
|
|
||||||
<UButton
|
|
||||||
size="sm"
|
|
||||||
icon="i-heroicons-plus"
|
|
||||||
@click="createTenantModalOpen = true"
|
|
||||||
>
|
|
||||||
Tenant
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-scroll">
|
|
||||||
<UTable
|
|
||||||
v-if="!loading"
|
|
||||||
:data="tenantTableRows"
|
|
||||||
:columns="normalizedTenantTableColumns"
|
|
||||||
:on-select="selectTenant"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<USkeleton v-else class="h-80" />
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<UCard class="admin-card xl:col-span-2">
|
|
||||||
<div v-if="tenantForm" class="admin-scroll space-y-6">
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold">{{ tenantForm.name }}</h2>
|
|
||||||
<p class="text-sm text-gray-500">Tenant-ID {{ tenantForm.id }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UButton
|
|
||||||
color="primary"
|
|
||||||
:loading="savingTenant"
|
|
||||||
@click="saveTenant"
|
|
||||||
>
|
|
||||||
Tenant speichern
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UForm :state="tenantForm" class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<UFormField label="Name">
|
|
||||||
<UInput v-model="tenantForm.name" />
|
|
||||||
</UFormField>
|
|
||||||
|
|
||||||
<UFormField label="Kürzel">
|
|
||||||
<UInput v-model="tenantForm.short" />
|
|
||||||
</UFormField>
|
|
||||||
</UForm>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<USeparator label="Zugeordnete Benutzer" class="mb-4" />
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<UBadge
|
|
||||||
v-for="user in getUsersForTenant(tenantForm.id)"
|
|
||||||
:key="`${tenantForm.id}-${user.id}`"
|
|
||||||
:color="user.is_admin ? 'primary' : 'gray'"
|
|
||||||
variant="subtle"
|
|
||||||
>
|
|
||||||
{{ user.display_name }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UAlert
|
|
||||||
v-else-if="!loading"
|
|
||||||
title="Kein Tenant ausgewählt"
|
|
||||||
description="Wähle links einen Tenant aus, um ihn zu bearbeiten."
|
|
||||||
color="gray"
|
|
||||||
variant="soft"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-else class="admin-scroll">
|
|
||||||
<USkeleton class="h-80" />
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UTabs>
|
|
||||||
</UDashboardPanelContent>
|
</UDashboardPanelContent>
|
||||||
|
|
||||||
<UModal v-model:open="createUserModalOpen">
|
|
||||||
<template #content>
|
|
||||||
<UCard>
|
|
||||||
<template #header>
|
|
||||||
<div class="text-lg font-semibold">Benutzer anlegen</div>
|
|
||||||
</template>
|
</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"
|
|
||||||
type="text"
|
|
||||||
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>
|
|
||||||
|
|
||||||
<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 und Systemordner für Dokumente und Eingangsbelege erzeugt."
|
|
||||||
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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.admin-tabs :deep(.tabs-content) {
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-tabs :deep(.tab-pane) {
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-grid {
|
|
||||||
height: calc(100vh - 13rem);
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-card {
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-card :deep(.divide-y) {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-card :deep(.px-4.py-5.sm\:p-6) {
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-scroll {
|
|
||||||
min-height: 0;
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ const resources = {
|
|||||||
quotes: {
|
quotes: {
|
||||||
label: "Angebote"
|
label: "Angebote"
|
||||||
},
|
},
|
||||||
|
costEstimates: {
|
||||||
|
label: "Kostenschätzungen"
|
||||||
|
},
|
||||||
inventoryitems: {
|
inventoryitems: {
|
||||||
label: "Inventarartikel"
|
label: "Inventarartikel"
|
||||||
},
|
},
|
||||||
@@ -38,6 +41,9 @@ const resources = {
|
|||||||
deliveryNotes: {
|
deliveryNotes: {
|
||||||
label: "Lieferscheine"
|
label: "Lieferscheine"
|
||||||
},
|
},
|
||||||
|
packingSlips: {
|
||||||
|
label: "Packscheine"
|
||||||
|
},
|
||||||
costcentres: {
|
costcentres: {
|
||||||
label: "Kostenstellen"
|
label: "Kostenstellen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const defaultFeatures = {
|
|||||||
serialInvoice: true,
|
serialInvoice: true,
|
||||||
incomingInvoices: true,
|
incomingInvoices: true,
|
||||||
costcentres: true,
|
costcentres: true,
|
||||||
|
branches: true,
|
||||||
accounts: true,
|
accounts: true,
|
||||||
ownaccounts: true,
|
ownaccounts: true,
|
||||||
banking: true,
|
banking: true,
|
||||||
@@ -80,6 +81,7 @@ const featureOptions = [
|
|||||||
{ key: "serialInvoice", label: "Buchhaltung: Serienvorlagen" },
|
{ key: "serialInvoice", label: "Buchhaltung: Serienvorlagen" },
|
||||||
{ key: "incomingInvoices", label: "Buchhaltung: Eingangsbelege" },
|
{ key: "incomingInvoices", label: "Buchhaltung: Eingangsbelege" },
|
||||||
{ key: "costcentres", label: "Buchhaltung: Kostenstellen" },
|
{ key: "costcentres", label: "Buchhaltung: Kostenstellen" },
|
||||||
|
{ key: "branches", label: "Stammdaten: Niederlassungen" },
|
||||||
{ key: "accounts", label: "Buchhaltung: Buchungskonten" },
|
{ key: "accounts", label: "Buchhaltung: Buchungskonten" },
|
||||||
{ key: "ownaccounts", label: "Buchhaltung: Zusätzliche Buchungskonten" },
|
{ key: "ownaccounts", label: "Buchhaltung: Zusätzliche Buchungskonten" },
|
||||||
{ key: "banking", label: "Buchhaltung: Bank" },
|
{ key: "banking", label: "Buchhaltung: Bank" },
|
||||||
@@ -173,6 +175,8 @@ setupPage()
|
|||||||
<UAlert
|
<UAlert
|
||||||
class="mt-5"
|
class="mt-5"
|
||||||
title="DOKUBOX"
|
title="DOKUBOX"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
>
|
>
|
||||||
<template #description>
|
<template #description>
|
||||||
<p>Die Dokubox ist eine E-Mail Inbox um deine Anhänge direkt als Dokumente zu importieren. Leite Deine E-Mails einfach an die folgende E-Mail Adresse weiter, diese ist für dein Unternehmen einzigartig. Die E-Mails werden dann alle 5 min abgerufen.</p>
|
<p>Die Dokubox ist eine E-Mail Inbox um deine Anhänge direkt als Dokumente zu importieren. Leite Deine E-Mails einfach an die folgende E-Mail Adresse weiter, diese ist für dein Unternehmen einzigartig. Die E-Mails werden dann alle 5 min abgerufen.</p>
|
||||||
|
|||||||
@@ -6,15 +6,26 @@ const { $api } = useNuxtApp()
|
|||||||
|
|
||||||
const id = route.params.id as string
|
const id = route.params.id as string
|
||||||
const profile = ref<any>(null)
|
const profile = ref<any>(null)
|
||||||
|
const branches = ref<any[]>([])
|
||||||
const pending = ref(true)
|
const pending = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
|
async function fetchBranches() {
|
||||||
|
try {
|
||||||
|
branches.value = await useEntities("branches").select()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[fetchBranches]', err)
|
||||||
|
branches.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Profil laden **/
|
/** Profil laden **/
|
||||||
async function fetchProfile() {
|
async function fetchProfile() {
|
||||||
pending.value = true
|
pending.value = true
|
||||||
try {
|
try {
|
||||||
profile.value = await $api(`/api/profiles/${id}`)
|
profile.value = await $api(`/api/profiles/${id}`)
|
||||||
ensureWorkingHoursStructure()
|
ensureWorkingHoursStructure()
|
||||||
|
ensureBranchStructure()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[fetchProfile]', err)
|
console.error('[fetchProfile]', err)
|
||||||
toast.add({
|
toast.add({
|
||||||
@@ -27,6 +38,45 @@ async function fetchProfile() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureBranchStructure() {
|
||||||
|
if (!profile.value) return
|
||||||
|
|
||||||
|
profile.value.branch_id = profile.value.branch_id ?? profile.value.branch?.id ?? null
|
||||||
|
|
||||||
|
if (!Array.isArray(profile.value.branch_ids)) {
|
||||||
|
if (Array.isArray(profile.value.branches)) {
|
||||||
|
profile.value.branch_ids = profile.value.branches
|
||||||
|
.map((entry: any) => entry?.id ?? entry)
|
||||||
|
.filter((entry: any) => entry != null)
|
||||||
|
} else {
|
||||||
|
profile.value.branch_ids = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.value.branch_id && !profile.value.branch_ids.includes(profile.value.branch_id)) {
|
||||||
|
profile.value.branch_ids = [...profile.value.branch_ids, profile.value.branch_id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePrimaryBranch = (value: number | null) => {
|
||||||
|
if (!profile.value) return
|
||||||
|
profile.value.branch_id = value
|
||||||
|
|
||||||
|
if (value && !profile.value.branch_ids.includes(value)) {
|
||||||
|
profile.value.branch_ids = [...profile.value.branch_ids, value]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateBranchMemberships = (values: number[]) => {
|
||||||
|
if (!profile.value) return
|
||||||
|
|
||||||
|
profile.value.branch_ids = values || []
|
||||||
|
|
||||||
|
if (profile.value.branch_id && !profile.value.branch_ids.includes(profile.value.branch_id)) {
|
||||||
|
profile.value.branch_id = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Profil speichern **/
|
/** Profil speichern **/
|
||||||
async function saveProfile() {
|
async function saveProfile() {
|
||||||
if (saving.value) return
|
if (saving.value) return
|
||||||
@@ -129,7 +179,9 @@ const checkZip = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchProfile)
|
onMounted(async () => {
|
||||||
|
await Promise.all([fetchBranches(), fetchProfile()])
|
||||||
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -255,6 +307,33 @@ onMounted(fetchProfile)
|
|||||||
|
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
|
<UCard v-if="!pending && profile" class="mt-3">
|
||||||
|
<USeparator label="Niederlassungen" />
|
||||||
|
|
||||||
|
<UForm :state="profile" @submit.prevent="saveProfile" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
||||||
|
<UFormField label="Primäre Niederlassung">
|
||||||
|
<USelectMenu
|
||||||
|
:model-value="profile.branch_id"
|
||||||
|
:items="branches"
|
||||||
|
label-key="name"
|
||||||
|
value-key="id"
|
||||||
|
@update:model-value="updatePrimaryBranch"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="Weitere Niederlassungen">
|
||||||
|
<USelectMenu
|
||||||
|
:model-value="profile.branch_ids"
|
||||||
|
:items="branches"
|
||||||
|
label-key="name"
|
||||||
|
value-key="id"
|
||||||
|
multiple
|
||||||
|
@update:model-value="updateBranchMemberships"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</UForm>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
<UCard v-if="!pending && profile" class="mt-3">
|
<UCard v-if="!pending && profile" class="mt-3">
|
||||||
<USeparator label="Adresse & Standort" />
|
<USeparator label="Adresse & Standort" />
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,40 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
|
const pending = ref(true)
|
||||||
|
|
||||||
|
const mapProfileRow = (user) => {
|
||||||
|
const profile = user?.profile || {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: profile?.id || null,
|
||||||
|
employee_number: profile?.employee_number || '',
|
||||||
|
full_name: profile?.full_name || user?.full_name || user?.email || 'Ohne Profil',
|
||||||
|
email: user?.email || profile?.email || '',
|
||||||
|
branch_name: profile?.branch?.name || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const setupPage = async () => {
|
const setupPage = async () => {
|
||||||
items.value = (await useNuxtApp().$api("/api/tenant/users")).users
|
pending.value = true
|
||||||
items.value = items.value.map(i => i.profile)
|
|
||||||
|
try {
|
||||||
|
const response = await useNuxtApp().$api("/api/tenant/users")
|
||||||
|
items.value = (response?.users || [])
|
||||||
|
.map(mapProfileRow)
|
||||||
|
.filter((item) => !!item.id)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[staff/profiles/index]', err)
|
||||||
|
items.value = []
|
||||||
|
toast.add({
|
||||||
|
title: 'Profile konnten nicht geladen werden',
|
||||||
|
color: 'error'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
pending.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupPage()
|
setupPage()
|
||||||
@@ -20,6 +49,9 @@
|
|||||||
},{
|
},{
|
||||||
key: "email",
|
key: "email",
|
||||||
label: "E-Mail",
|
label: "E-Mail",
|
||||||
|
},{
|
||||||
|
key: "branch_name",
|
||||||
|
label: "Niederlassung",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
const selectedColumns = ref(templateColumns)
|
const selectedColumns = ref(templateColumns)
|
||||||
@@ -31,7 +63,7 @@
|
|||||||
<UDashboardNavbar title="Benutzer Einstellungen">
|
<UDashboardNavbar title="Benutzer Einstellungen">
|
||||||
<template #right>
|
<template #right>
|
||||||
<UButton
|
<UButton
|
||||||
@click="router.push(`/profiles/create`)"
|
@click="router.push(`/staff/profiles/create`)"
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
+ Mitarbeiter
|
+ Mitarbeiter
|
||||||
@@ -41,9 +73,14 @@
|
|||||||
<UTable
|
<UTable
|
||||||
:data="items"
|
:data="items"
|
||||||
:columns="normalizeTableColumns(columns)"
|
:columns="normalizeTableColumns(columns)"
|
||||||
:on-select="(i) => navigateTo(`/staff/profiles/${i.id}`)"
|
:loading="pending"
|
||||||
|
:on-select="(row) => navigateTo(`/staff/profiles/${row.original?.id || row.id}`)"
|
||||||
>
|
>
|
||||||
|
<template #empty>
|
||||||
|
<div class="py-10 text-center text-sm text-gray-500">
|
||||||
|
Keine Mitarbeiterprofile gefunden.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</UTable>
|
</UTable>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -313,6 +313,20 @@ const truncateValue = (value, maxLength) => {
|
|||||||
return `${stringValue.substring(0, maxLength)}...`
|
return `${stringValue.substring(0, maxLength)}...`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getDistinctFilterItems = (columnKey) => {
|
||||||
|
return (itemsMeta.value?.distinctValues?.[columnKey] || []).map((value) => ({
|
||||||
|
label: String(value),
|
||||||
|
value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDistinctFilterActive = (columnKey) => {
|
||||||
|
const available = itemsMeta.value?.distinctValues?.[columnKey] || []
|
||||||
|
const selected = columnsToFilter.value[columnKey] || []
|
||||||
|
|
||||||
|
return selected.length > 0 && selected.length !== available.length
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -371,20 +385,19 @@ const truncateValue = (value, maxLength) => {
|
|||||||
v-model="pageLimit"
|
v-model="pageLimit"
|
||||||
value-key="value"
|
value-key="value"
|
||||||
label-key="value"
|
label-key="value"
|
||||||
@change="setupPage"
|
@update:model-value="setupPage"
|
||||||
/>
|
/>
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
<UPagination
|
<UPagination
|
||||||
v-if="initialSetupDone && items.length > 0"
|
v-if="initialSetupDone && items.length > 0"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
v-model="page"
|
v-model:page="page"
|
||||||
:page-count="pageLimit"
|
:items-per-page="pageLimit"
|
||||||
:total="itemsMeta.total"
|
:total="itemsMeta.total"
|
||||||
@update:modelValue="(i) => changePage(i)"
|
@update:page="changePage"
|
||||||
show-first
|
:show-edges="true"
|
||||||
show-last
|
first-icon="i-heroicons-chevron-double-left"
|
||||||
:first-button="{ icon: 'i-heroicons-chevron-double-left', color: 'gray' }"
|
last-icon="i-heroicons-chevron-double-right"
|
||||||
:last-button="{ icon: 'i-heroicons-chevron-double-right', trailing: true, color: 'gray' }"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
@@ -400,7 +413,7 @@ const truncateValue = (value, maxLength) => {
|
|||||||
by="key"
|
by="key"
|
||||||
:color="selectedColumns.length !== dataType.templateColumns.filter(i => !i.disabledInTable).length ? 'primary' : 'white'"
|
:color="selectedColumns.length !== dataType.templateColumns.filter(i => !i.disabledInTable).length ? 'primary' : 'white'"
|
||||||
:content="{ width: 'min-w-max' }"
|
:content="{ width: 'min-w-max' }"
|
||||||
@change="tempStore.modifyColumns(type,selectedColumns)"
|
@update:model-value="tempStore.modifyColumns(type, selectedColumns)"
|
||||||
>
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
Spalten
|
Spalten
|
||||||
@@ -442,32 +455,26 @@ const truncateValue = (value, maxLength) => {
|
|||||||
:text="!columnsToFilter[column.key]?.length > 0 ? `Keine Einträge für ${column.label} verfügbar` : `${column.label} Spalte nach Einträgen filtern`"
|
:text="!columnsToFilter[column.key]?.length > 0 ? `Keine Einträge für ${column.label} verfügbar` : `${column.label} Spalte nach Einträgen filtern`"
|
||||||
>
|
>
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
:items="(itemsMeta?.distinctValues?.[column.key] || []).map(value => ({ label: value, value }))"
|
class="min-w-0"
|
||||||
|
:items="getDistinctFilterItems(column.key)"
|
||||||
v-model="columnsToFilter[column.key]"
|
v-model="columnsToFilter[column.key]"
|
||||||
multiple
|
multiple
|
||||||
@change="handleFilterChange('change', column.key)"
|
@update:model-value="handleFilterChange('change', column.key)"
|
||||||
:search-input="{ placeholder: 'Suche...' }"
|
:search-input="{ placeholder: 'Suche...' }"
|
||||||
value-key="value"
|
value-key="value"
|
||||||
label-key="label"
|
label-key="label"
|
||||||
:content="{ width: 'min-w-max' }"
|
:content="{ width: 'min-w-max' }"
|
||||||
|
:disabled="getDistinctFilterItems(column.key).length === 0"
|
||||||
>
|
>
|
||||||
|
|
||||||
<template #empty>
|
<template #empty>
|
||||||
Keine Einträge in der Spalte {{column.label}}
|
Keine Einträge in der Spalte {{column.label}}
|
||||||
</template>
|
</template>
|
||||||
<template #default="slotProps">
|
<template #default="slotProps">
|
||||||
<UButton
|
<span class="inline-flex min-w-0 items-center">
|
||||||
:disabled="!columnsToFilter[column.key]?.length > 0"
|
|
||||||
:variant="columnsToFilter[column.key]?.length !== itemsMeta.distinctValues?.[column.key]?.length ? 'outline' : 'solid'"
|
|
||||||
:color="columnsToFilter[column.key]?.length !== itemsMeta.distinctValues?.[column.key]?.length ? 'primary' : 'white'"
|
|
||||||
>
|
|
||||||
<span class="truncate">{{ column.label }}</span>
|
<span class="truncate">{{ column.label }}</span>
|
||||||
|
</span>
|
||||||
<UIcon name="i-heroicons-chevron-right-20-solid" class="w-5 h-5 transition-transform text-gray-400 dark:text-gray-500" :class="[slotProps?.open && 'transform rotate-90']" />
|
|
||||||
</UButton>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
</USelectMenu>
|
</USelectMenu>
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
<UButton
|
<UButton
|
||||||
@@ -645,14 +652,13 @@ const truncateValue = (value, maxLength) => {
|
|||||||
<UPagination
|
<UPagination
|
||||||
v-if="initialSetupDone && items.length > 0"
|
v-if="initialSetupDone && items.length > 0"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
v-model="page"
|
v-model:page="page"
|
||||||
:page-count="pageLimit"
|
:items-per-page="pageLimit"
|
||||||
:total="itemsMeta.total"
|
:total="itemsMeta.total"
|
||||||
@update:modelValue="(i) => changePage(i)"
|
@update:page="changePage"
|
||||||
show-first
|
:show-edges="true"
|
||||||
show-last
|
first-icon="i-heroicons-chevron-double-left"
|
||||||
:first-button="{ icon: 'i-heroicons-chevron-double-left', color: 'gray' }"
|
last-icon="i-heroicons-chevron-double-right"
|
||||||
:last-button="{ icon: 'i-heroicons-chevron-double-right', trailing: true, color: 'gray' }"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -700,12 +706,15 @@ const truncateValue = (value, maxLength) => {
|
|||||||
|
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
v-model="columnsToFilter[column.key]"
|
v-model="columnsToFilter[column.key]"
|
||||||
:options="itemsMeta?.distinctValues?.[column.key]"
|
:items="getDistinctFilterItems(column.key)"
|
||||||
multiple
|
multiple
|
||||||
searchable
|
value-key="value"
|
||||||
:search-attributes="[column.key]"
|
label-key="label"
|
||||||
|
:search-input="{ placeholder: `${column.label} filtern...` }"
|
||||||
|
:filter-fields="['label']"
|
||||||
placeholder="Auswählen…"
|
placeholder="Auswählen…"
|
||||||
:ui-menu="{ width: '100%' }"
|
:content="{ width: 'w-full' }"
|
||||||
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -62,28 +62,38 @@ const resetForm = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gray-50 flex items-center justify-center p-4 font-sans">
|
<div class="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(var(--ui-primary-rgb),0.12),_transparent_35%),linear-gradient(180deg,var(--ui-bg)_0%,color-mix(in_oklab,var(--ui-bg)_92%,white)_100%)] px-4 py-10">
|
||||||
|
<div class="mx-auto flex min-h-[calc(100vh-5rem)] w-full max-w-5xl items-center justify-center">
|
||||||
|
|
||||||
<div v-if="status === 'loading'" class="text-center">
|
<div v-if="status === 'loading'" class="space-y-4 text-center">
|
||||||
<UIcon name="i-heroicons-arrow-path" class="w-10 h-10 animate-spin text-primary-500 mx-auto" />
|
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary ring-1 ring-primary/15">
|
||||||
<p class="mt-4 text-gray-500">Lade Formular...</p>
|
<UIcon name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-base font-medium text-highlighted">Lade Formular...</p>
|
||||||
|
<p class="text-sm text-muted">Der Workflow wird vorbereitet.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UCard v-else-if="status === 'error'" class="w-full max-w-md border-red-200">
|
<UCard v-else-if="status === 'error'" class="w-full max-w-md border-error/20 shadow-xl">
|
||||||
<div class="text-center text-red-600 space-y-2">
|
<div class="space-y-3 text-center text-error">
|
||||||
<UIcon name="i-heroicons-exclamation-circle" class="w-12 h-12 mx-auto" />
|
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-error/10 ring-1 ring-error/15">
|
||||||
<h3 class="font-bold text-lg">Fehler</h3>
|
<UIcon name="i-heroicons-exclamation-circle" class="h-8 w-8" />
|
||||||
<p>{{ errorMsg }}</p>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold">Fehler</h3>
|
||||||
|
<p class="text-sm text-toned">{{ errorMsg }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
<UCard v-else-if="status === 'pin_required'" class="w-full max-w-sm shadow-xl">
|
<UCard v-else-if="status === 'pin_required'" class="w-full max-w-sm shadow-xl ring-1 ring-black/5">
|
||||||
<div class="text-center mb-6">
|
<div class="mb-6 text-center">
|
||||||
<div class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary ring-1 ring-primary/15">
|
||||||
<UIcon name="i-heroicons-lock-closed" class="w-6 h-6 text-primary-600" />
|
<UIcon name="i-heroicons-lock-closed" class="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-xl font-bold text-gray-900">Geschützter Bereich</h2>
|
<h2 class="text-xl font-semibold text-highlighted">Geschützter Bereich</h2>
|
||||||
<p class="text-sm text-gray-500">Bitte PIN eingeben</p>
|
<p class="text-sm text-muted">Bitte PIN eingeben</p>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="handlePinSubmit" class="space-y-4">
|
<form @submit.prevent="handlePinSubmit" class="space-y-4">
|
||||||
<UInput
|
<UInput
|
||||||
@@ -93,22 +103,24 @@ const resetForm = () => {
|
|||||||
input-class="text-center text-lg tracking-widest"
|
input-class="text-center text-lg tracking-widest"
|
||||||
autofocus
|
autofocus
|
||||||
icon="i-heroicons-key"
|
icon="i-heroicons-key"
|
||||||
|
class="w-full"
|
||||||
|
size="lg"
|
||||||
/>
|
/>
|
||||||
<div v-if="errorMsg" class="text-red-500 text-xs text-center font-medium">{{ errorMsg }}</div>
|
<div v-if="errorMsg" class="text-center text-xs font-medium text-error">{{ errorMsg }}</div>
|
||||||
<UButton type="submit" block label="Entsperren" size="lg" />
|
<UButton type="submit" block label="Entsperren" size="lg" />
|
||||||
</form>
|
</form>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
<UCard v-else-if="status === 'success'" class="w-full max-w-md text-center py-10">
|
<UCard v-else-if="status === 'success'" class="w-full max-w-md py-10 text-center shadow-xl ring-1 ring-black/5">
|
||||||
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-3xl bg-success/10 text-success ring-1 ring-success/15">
|
||||||
<UIcon name="i-heroicons-check" class="w-8 h-8 text-green-600" />
|
<UIcon name="i-heroicons-check" class="h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">Gespeichert!</h2>
|
<h2 class="mb-2 text-2xl font-semibold text-highlighted">Gespeichert!</h2>
|
||||||
<p class="text-gray-500 mb-6">Die Daten wurden erfolgreich übertragen.</p>
|
<p class="mb-6 text-sm text-muted">Die Daten wurden erfolgreich übertragen.</p>
|
||||||
<UButton variant="outline" @click="resetForm">Neuen Eintrag erfassen</UButton>
|
<UButton variant="outline" @click="resetForm">Neuen Eintrag erfassen</UButton>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
<div v-else-if="status === 'ready'" class="w-full max-w-lg">
|
<div v-else-if="status === 'ready'" class="w-full max-w-xl">
|
||||||
<PublicDynamicForm
|
<PublicDynamicForm
|
||||||
v-if="context && token"
|
v-if="context && token"
|
||||||
:key="formKey"
|
:key="formKey"
|
||||||
@@ -118,6 +130,6 @@ const resetForm = () => {
|
|||||||
@success="handleFormSuccess"
|
@success="handleFormSuccess"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -14,7 +14,7 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
is_admin?: boolean;
|
is_admin?: boolean;
|
||||||
},
|
},
|
||||||
profile: null as null | any,
|
profile: null as null | any,
|
||||||
tenants: [] as { tenant_id: string; role: string; tenants: { id: string; name: string } }[],
|
tenants: [] as { tenant_id: string; role: string; tenants: { id: string; name: string }; id?: string; name?: string; hasActiveLicense?: boolean; locked?: string | null }[],
|
||||||
permissions: [] as string[],
|
permissions: [] as string[],
|
||||||
activeTenant: null as any,
|
activeTenant: null as any,
|
||||||
activeTenantData: null as any,
|
activeTenantData: null as any,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import recurring from "~/components/columnRenderings/recurring.vue"
|
|||||||
import description from "~/components/columnRenderings/description.vue"
|
import description from "~/components/columnRenderings/description.vue"
|
||||||
import purchasePrice from "~/components/columnRenderings/purchasePrice.vue";
|
import purchasePrice from "~/components/columnRenderings/purchasePrice.vue";
|
||||||
import project from "~/components/columnRenderings/project.vue";
|
import project from "~/components/columnRenderings/project.vue";
|
||||||
|
import branch from "~/components/columnRenderings/branch.vue";
|
||||||
import created_at from "~/components/columnRenderings/created_at.vue";
|
import created_at from "~/components/columnRenderings/created_at.vue";
|
||||||
import profile from "~/components/columnRenderings/profile.vue";
|
import profile from "~/components/columnRenderings/profile.vue";
|
||||||
import profiles from "~/components/columnRenderings/profiles.vue";
|
import profiles from "~/components/columnRenderings/profiles.vue";
|
||||||
@@ -35,6 +36,7 @@ import startDateTime from "~/components/columnRenderings/startDateTime.vue"
|
|||||||
import endDateTime from "~/components/columnRenderings/endDateTime.vue"
|
import endDateTime from "~/components/columnRenderings/endDateTime.vue"
|
||||||
import serviceCategories from "~/components/columnRenderings/serviceCategories.vue"
|
import serviceCategories from "~/components/columnRenderings/serviceCategories.vue"
|
||||||
import productcategoriesWithLoad from "~/components/columnRenderings/productcategoriesWithLoad.vue"
|
import productcategoriesWithLoad from "~/components/columnRenderings/productcategoriesWithLoad.vue"
|
||||||
|
import externalLink from "~/components/columnRenderings/externalLink.vue"
|
||||||
import phase from "~/components/columnRenderings/phase.vue"
|
import phase from "~/components/columnRenderings/phase.vue"
|
||||||
import vehiclesWithLoad from "~/components/columnRenderings/vehiclesWithLoad.vue"
|
import vehiclesWithLoad from "~/components/columnRenderings/vehiclesWithLoad.vue"
|
||||||
import inventoryitemsWithLoad from "~/components/columnRenderings/inventoryitemsWithLoad.vue"
|
import inventoryitemsWithLoad from "~/components/columnRenderings/inventoryitemsWithLoad.vue"
|
||||||
@@ -1379,6 +1381,12 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
label: "Beschreibung",
|
label: "Beschreibung",
|
||||||
inputType:"textarea"
|
inputType:"textarea"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "supplier_link",
|
||||||
|
label: "Link zum Lieferanten",
|
||||||
|
inputType: "text",
|
||||||
|
component: externalLink
|
||||||
|
},
|
||||||
],
|
],
|
||||||
showTabs: [
|
showTabs: [
|
||||||
{
|
{
|
||||||
@@ -1442,7 +1450,28 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
selectOptionAttribute: "name",
|
selectOptionAttribute: "name",
|
||||||
selectSearchAttributes: ['name'],
|
selectSearchAttributes: ['name'],
|
||||||
inputChangeFunction: function (item,loadedOptions = {}) {
|
inputChangeFunction: function (item,loadedOptions = {}) {
|
||||||
item.phases = loadedOptions.projecttypes.find(i => i.id === item.projecttype).initialPhases
|
const selectedProjectType = loadedOptions.projecttypes?.find(i => i.id === item.projecttype)
|
||||||
|
|
||||||
|
if (!selectedProjectType || !Array.isArray(selectedProjectType.initialPhases)) {
|
||||||
|
item.phases = []
|
||||||
|
item.active_phase = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const phases = selectedProjectType.initialPhases.map((phase, index) => ({
|
||||||
|
key: phase?.key || crypto.randomUUID(),
|
||||||
|
icon: phase?.icon || '',
|
||||||
|
label: phase?.label || '',
|
||||||
|
optional: Boolean(phase?.optional),
|
||||||
|
description: phase?.description || '',
|
||||||
|
quickactions: Array.isArray(phase?.quickactions) ? phase.quickactions.map((quickaction) => ({
|
||||||
|
...quickaction
|
||||||
|
})) : [],
|
||||||
|
active: index === 0
|
||||||
|
}))
|
||||||
|
|
||||||
|
item.phases = phases
|
||||||
|
item.active_phase = phases.find(i => i.active)?.label || null
|
||||||
},
|
},
|
||||||
sortable: true
|
sortable: true
|
||||||
},{
|
},{
|
||||||
@@ -2970,6 +2999,51 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
redirect: true,
|
redirect: true,
|
||||||
historyItemHolder: "profile"
|
historyItemHolder: "profile"
|
||||||
},
|
},
|
||||||
|
branches: {
|
||||||
|
isArchivable: true,
|
||||||
|
label: "Niederlassungen",
|
||||||
|
labelSingle: "Niederlassung",
|
||||||
|
isStandardEntity: true,
|
||||||
|
redirect: true,
|
||||||
|
numberRangeHolder: "number",
|
||||||
|
historyItemHolder: "branch",
|
||||||
|
sortColumn: "name",
|
||||||
|
selectWithInformation: "*",
|
||||||
|
filters: [{
|
||||||
|
name: "Archivierte ausblenden",
|
||||||
|
default: true,
|
||||||
|
"filterFunction": function (row) {
|
||||||
|
if(!row.archived) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
templateColumns: [
|
||||||
|
{
|
||||||
|
key: "number",
|
||||||
|
label: "Nummer",
|
||||||
|
inputType: "text",
|
||||||
|
inputIsNumberRange: true,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
label: "Name",
|
||||||
|
required: true,
|
||||||
|
title: true,
|
||||||
|
inputType: "text",
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "description",
|
||||||
|
label: "Beschreibung",
|
||||||
|
inputType: "textarea"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
showTabs: [{label: 'Informationen'},{label: 'Wiki'}]
|
||||||
|
},
|
||||||
workingtimes: {
|
workingtimes: {
|
||||||
isArchivable: true,
|
isArchivable: true,
|
||||||
label: "Anwesenheiten",
|
label: "Anwesenheiten",
|
||||||
@@ -3178,7 +3252,7 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
numberRangeHolder: "number",
|
numberRangeHolder: "number",
|
||||||
historyItemHolder: "costcentre",
|
historyItemHolder: "costcentre",
|
||||||
sortColumn: "number",
|
sortColumn: "number",
|
||||||
selectWithInformation: "*, project(*), vehicle(*), inventoryitem(*)",
|
selectWithInformation: "*, project(*), vehicle(*), inventoryitem(*), branch(*)",
|
||||||
filters: [{
|
filters: [{
|
||||||
name: "Archivierte ausblenden",
|
name: "Archivierte ausblenden",
|
||||||
default: true,
|
default: true,
|
||||||
@@ -3236,6 +3310,15 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
selectOptionAttribute: "name",
|
selectOptionAttribute: "name",
|
||||||
selectSearchAttributes: ['name'],
|
selectSearchAttributes: ['name'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "branch",
|
||||||
|
label: "Niederlassung",
|
||||||
|
component: branch,
|
||||||
|
inputType: "select",
|
||||||
|
selectDataType: "branches",
|
||||||
|
selectOptionAttribute: "name",
|
||||||
|
selectSearchAttributes: ['name', 'number'],
|
||||||
|
},
|
||||||
/*{
|
/*{
|
||||||
key: "profiles",
|
key: "profiles",
|
||||||
label: "Berechtigte Benutzer",
|
label: "Berechtigte Benutzer",
|
||||||
@@ -3382,10 +3465,18 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
label: "Angebote",
|
label: "Angebote",
|
||||||
labelSingle: "Angebot"
|
labelSingle: "Angebot"
|
||||||
},
|
},
|
||||||
|
costEstimates: {
|
||||||
|
label: "Kostenschätzungen",
|
||||||
|
labelSingle: "Kostenschätzung"
|
||||||
|
},
|
||||||
deliveryNotes: {
|
deliveryNotes: {
|
||||||
label: "Lieferscheine",
|
label: "Lieferscheine",
|
||||||
labelSingle: "Lieferschein"
|
labelSingle: "Lieferschein"
|
||||||
},
|
},
|
||||||
|
packingSlips: {
|
||||||
|
label: "Packscheine",
|
||||||
|
labelSingle: "Packschein"
|
||||||
|
},
|
||||||
confirmationOrders: {
|
confirmationOrders: {
|
||||||
label: "Auftragsbestätigungen",
|
label: "Auftragsbestätigungen",
|
||||||
labelSingle: "Auftragsbestätigung"
|
labelSingle: "Auftragsbestätigung"
|
||||||
|
|||||||
@@ -69,8 +69,10 @@ function formatDocType(value: unknown): string {
|
|||||||
advanceInvoices: 'Abschlagsrechnung',
|
advanceInvoices: 'Abschlagsrechnung',
|
||||||
cancellationInvoices: 'Stornorechnung',
|
cancellationInvoices: 'Stornorechnung',
|
||||||
quotes: 'Angebot',
|
quotes: 'Angebot',
|
||||||
|
costEstimates: 'Kostenschätzung',
|
||||||
confirmationOrders: 'Auftragsbestätigung',
|
confirmationOrders: 'Auftragsbestätigung',
|
||||||
deliveryNotes: 'Lieferschein',
|
deliveryNotes: 'Lieferschein',
|
||||||
|
packingSlips: 'Packschein',
|
||||||
};
|
};
|
||||||
return labels[type] || type;
|
return labels[type] || type;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user