Compare commits

...

15 Commits

Author SHA1 Message Date
eb718021fd Added Kostenschätzung und Packschein
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 22s
Build and Push Docker Images / build-frontend (push) Successful in 59s
2026-03-25 15:38:28 +01:00
01b4d0f973 Fix for missing Phases
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 23s
Build and Push Docker Images / build-frontend (push) Successful in 59s
2026-03-25 15:19:33 +01:00
c29494dc0d redone admin
added branches
2026-03-25 14:59:44 +01:00
809a37a410 fix workflows 2026-03-25 14:59:32 +01:00
232e3f3260 Fix copy created document modal 2026-03-25 14:59:19 +01:00
b2657f5d52 Fix Worklow Form 2026-03-25 14:58:55 +01:00
cee0e1fa7d Fix Phases
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 59s
2026-03-25 08:02:24 +01:00
7dea2de7f3 Fix unit select
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 13s
Build and Push Docker Images / build-frontend (push) Successful in 58s
2026-03-23 14:11:40 +01:00
4db753d34a Fix Banking and Profiles
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 14s
Build and Push Docker Images / build-frontend (push) Successful in 59s
2026-03-23 08:29:54 +01:00
e0e99ba6f5 Fix #146 2026-03-23 08:26:21 +01:00
ace2213cc4 Fix #145 2026-03-23 08:16:44 +01:00
7e6c5cc189 Fix Changelog
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 21s
Build and Push Docker Images / build-frontend (push) Successful in 14s
2026-03-22 22:14:17 +01:00
7c644c941a fix #141
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 57s
fix #142
2026-03-22 22:10:41 +01:00
11a242d70d 4. Zwischenstand
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 15s
Build and Push Docker Images / build-frontend (push) Successful in 1m0s
2026-03-22 17:43:41 +01:00
9f665fc3b8 3. Zwischenstand 2026-03-22 13:53:29 +01:00
71 changed files with 5051 additions and 2079 deletions

View 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;

View File

@@ -162,6 +162,13 @@
"when": 1774080000000, "when": 1774080000000,
"tag": "0023_tax_evaluation_period", "tag": "0023_tax_evaluation_period",
"breakpoints": true "breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1774393200000,
"tag": "0024_tenant_branches",
"breakpoints": true
} }
] ]
} }

View 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

View File

@@ -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(),

View 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

View File

@@ -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
), ),

View File

@@ -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"

View File

@@ -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 },

View File

@@ -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) => {
@@ -89,7 +90,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
} }
// ---------------------------------------------------- // ----------------------------------------------------

View File

@@ -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)
})
} }
}) })

View File

@@ -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)

View File

@@ -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" })
} }
}) })

View File

@@ -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) {

View File

@@ -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,
}, },

View File

@@ -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,10 +902,10 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
opacity: 1, opacity: 1,
maxWidth: 500 maxWidth: 500
}) })
return await pdfDoc.saveAsBase64()
} }
return await pdfDoc.saveAsBase64()
} }
const pdfBytes = await genPDF(invoiceData, await getBackgroundSourceBuffer(server,backgroundPath)) const pdfBytes = await genPDF(invoiceData, await getBackgroundSourceBuffer(server,backgroundPath))

View 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,
})))
}

View File

@@ -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: {

View File

@@ -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/>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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, {

View File

@@ -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>

View File

@@ -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 statementId = getStatementId(allocation)
if (allocation.type === "statementallocation" && statementId) {
router.push(`/banking/statements/edit/${statementId}`)
} else if (allocation.type === "incominginvoice" && allocation.incominginvoiceid) {
router.push(`/incomingInvoices/show/${allocation.incominginvoiceid}`)
} }
} }
const renderedAllocations = computed(() => {
let tempstatementallocations = props.item.statementallocations.map(i => {
return {
...i,
type: "statementallocation",
date: i.bs_id.date,
partner: i.bs_id ? (i.bs_id.debName ? i.bs_id.debName : (i.bs_id.credName ? i.bs_id.credName : '')) : ''
}
})
/*let incominginvoicesallocations = []
incominginvoices.value.forEach(i => {
incominginvoicesallocations.push(...i.accounts.filter(x => x.account == route.params.id).map(x => {
return {
...x,
incominginvoiceid: i.id,
type: "incominginvoice",
amount: x.amountGross ? x.amountGross : x.amountNet,
date: i.date,
partner: i.vendor.name,
description: i.description,
color: i.expense ? "red" : "green"
}
}))
})*/
return [...tempstatementallocations/*, ... incominginvoicesallocations*/]
})
</script> </script>
<template> <template>
<UCard class="mt-5"> <UCard class="mt-5">
<UTable <div class="space-y-4">
v-if="props.item.statementallocations" <div class="flex flex-col gap-3 md:flex-row md:items-end">
:data="renderedAllocations" <UFormField label="Jahr" class="w-full md:w-48">
:columns="normalizeTableColumns([{key:'amount', label:'Betrag'},{key:'date', label:'Datum'},{key:'partner', label:'Partner'},{key:'description', label:'Beschreibung'}])" <USelectMenu
:on-select="(i) => selectAllocation(i)" v-model="selectedYear"
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Buchungen anzuzeigen' }" :items="yearItems"
> value-key="value"
<template #amount-cell="{row}"> label-key="label"
<span class="text-right text-rose-600" v-if="row.original.amount < 0 || row.original.color === 'red'">{{useCurrency(row.original.amount)}}</span> class="w-full"
<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> </UFormField>
</template>
<template #date-cell="{row}"> <UFormField label="Monat" class="w-full md:w-56">
{{row.original.date ? dayjs(row.original.date).format('DD.MM.YYYY') : ''}} <USelectMenu
</template> v-model="selectedMonth"
<template #description-cell="{row}"> :items="monthItems"
{{row.original.description ? row.original.description : ''}} value-key="value"
</template> label-key="label"
</UTable> class="w-full"
/>
</UFormField>
</div>
<div class="grid gap-3 md:grid-cols-3">
<UCard>
<div class="text-sm text-gray-500 dark:text-gray-400">Einnahmen</div>
<div class="mt-1 text-xl font-semibold">{{ currency(totals.income) }}</div>
</UCard>
<UCard>
<div class="text-sm text-gray-500 dark:text-gray-400">Ausgaben</div>
<div class="mt-1 text-xl font-semibold">{{ currency(totals.expenses) }}</div>
</UCard>
<UCard>
<div class="text-sm text-gray-500 dark:text-gray-400">Saldo</div>
<div class="mt-1 text-xl font-semibold">{{ currency(totals.balance) }}</div>
</UCard>
</div>
<div v-if="!loading" class="overflow-auto max-h-[60vh]">
<UTable
:data="renderedAllocations"
:columns="normalizeTableColumns(columns)"
:on-select="selectAllocation"
class="w-full"
>
<template #empty>
<div class="flex flex-col items-center justify-center py-10 text-center">
<UIcon name="i-heroicons-circle-stack-20-solid" class="mb-2 h-10 w-10 text-gray-400" />
<p class="font-medium">Keine Buchungen im ausgewaehlten Zeitraum</p>
</div>
</template>
<template #amount-cell="{ row }">
<span class="text-right text-error" v-if="row.original.amount < 0 || row.original.color === 'red'">{{ useCurrency(row.original.amount) }}</span>
<span class="text-right text-primary-500" v-else-if="row.original.amount > 0 || row.original.color === 'green'">{{ useCurrency(row.original.amount) }}</span>
<span v-else>{{ useCurrency(row.original.amount) }}</span>
</template>
<template #date-cell="{ row }">
{{ row.original.bankstatement.date && dayjs(row.original.bankstatement.date).isValid() ? dayjs(row.original.bankstatement.date).format('DD.MM.YYYY') : '-' }}
</template>
<template #partner-cell="{ row }">
<div class="truncate">{{ hasContent(row.original.partner) ? row.original.partner : '-' }}</div>
</template>
<template #description-cell="{ row }">
<UTooltip :text="hasContent(row.original.description) ? row.original.description : '-'">
<div class="max-w-[22rem] truncate">{{ hasContent(row.original.description) ? row.original.description : '-' }}</div>
</UTooltip>
</template>
</UTable>
</div>
<UProgress v-else animation="carousel" class="w-3/4 mx-auto" />
</div>
</UCard> </UCard>
</template> </template>
<style scoped>
</style>

View File

@@ -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"
<template #default="slotProps"> :key="item.key"
class="space-y-2"
>
<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>

View File

@@ -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'"

View File

@@ -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"
> >

View File

@@ -136,25 +136,37 @@ const links = computed(() => {
to: "/incomingInvoices", to: "/incomingInvoices",
icon: "i-heroicons-document-text", icon: "i-heroicons-document-text",
} : null, } : null,
(featureEnabled("createDocument") || featureEnabled("incomingInvoices")) ? { ((featureEnabled("createDocument") || featureEnabled("incomingInvoices")) || featureEnabled("accounts") || featureEnabled("ownaccounts") || featureEnabled("costcentres")) ? {
label: "USt-Auswertung", label: "Auswertungen",
to: "/accounting/tax", icon: "i-heroicons-chart-pie",
icon: "i-heroicons-calculator", defaultOpen: false,
} : null, children: visibleItems([
featureEnabled("costcentres") ? { (featureEnabled("createDocument") || featureEnabled("incomingInvoices")) ? {
label: "Kostenstellen", label: "USt",
to: "/standardEntity/costcentres", to: "/accounting/tax",
icon: "i-heroicons-document-currency-euro" icon: "i-heroicons-calculator",
} : null, } : null,
featureEnabled("accounts") ? { (featureEnabled("createDocument") || featureEnabled("incomingInvoices") || featureEnabled("accounts") || featureEnabled("ownaccounts")) ? {
label: "Buchungskonten", label: "BWA",
to: "/accounts", to: "/accounting/bwa",
icon: "i-heroicons-document-text", icon: "i-heroicons-chart-bar-square",
} : null, } : null,
featureEnabled("ownaccounts") ? { featureEnabled("costcentres") ? {
label: "zusätzliche Buchungskonten", label: "Kostenstellen",
to: "/standardEntity/ownaccounts", to: "/standardEntity/costcentres",
icon: "i-heroicons-document-text" icon: "i-heroicons-document-currency-euro"
} : null,
featureEnabled("accounts") ? {
label: "Buchungskonten",
to: "/accounts",
icon: "i-heroicons-document-text",
} : null,
featureEnabled("ownaccounts") ? {
label: "Zusätzliche Buchungskonten",
to: "/standardEntity/ownaccounts",
icon: "i-heroicons-document-text"
} : null,
])
} : null, } : null,
featureEnabled("banking") ? { featureEnabled("banking") ? {
label: "Bank", label: "Bank",
@@ -217,6 +229,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 +287,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 +294,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 +316,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 +411,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,31 +427,31 @@ const links = computed(() => {
]) ])
}) })
const mapNavItem = (item, valuePrefix = "item") => {
const children = Array.isArray(item.children)
? item.children
.filter(Boolean)
.map((child, index) => mapNavItem(child, `${valuePrefix}-${index}`))
: undefined
const active = item.active || isRouteActive(item.to) || Boolean(children?.some(child => child.active))
return {
...item,
children,
value: item.id || item.label || valuePrefix,
defaultOpen: item.defaultOpen || active,
active,
tooltip: true,
popover: true,
trailingIcon: children?.length ? undefined : ''
}
}
const navItems = computed(() => const navItems = computed(() =>
links.value links.value
.filter(Boolean) .filter(Boolean)
.map((item, index) => { .map((item, index) => mapNavItem(item, String(index)))
const children = Array.isArray(item.children)
? item.children.map((child, childIndex) => ({
...child,
value: child.id || child.label || `${index}-${childIndex}`,
active: isRouteActive(child.to)
}))
: undefined
const active = item.active || isRouteActive(item.to) || Boolean(children?.some(child => child.active))
return {
...item,
children,
value: item.id || item.label || String(index),
defaultOpen: item.defaultOpen || active,
active,
tooltip: true,
popover: true,
trailingIcon: children?.length ? undefined : ''
}
})
) )
</script> </script>

View File

@@ -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"
size="lg" variant="outline"
icon="i-heroicons-calendar-days" size="lg"
class="flex-1" icon="i-heroicons-calendar-days"
/> 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'"

View File

@@ -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"
/> />

View 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>

View 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>

View File

@@ -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']"
> >
<span class="min-w-0 flex-1 truncate font-medium text-gray-900 dark:text-white"> <div class="flex items-space gap-2">
<span class="min-w-0 flex-1 truncate font-medium text-gray-900 dark:text-white">
{{ auth.user.email }} {{ 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>

View 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>

View File

@@ -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
> v-model="documentTypeToUse"
<USelectMenu :items="documentTypeItems"
:options="Object.keys(dataStore.documentTypesForCreation).map(key => { return { ...dataStore.documentTypesForCreation[key], key}})" value-key="key"
value-attribute="key" label-key="labelSingle"
option-attribute="labelSingle" class="w-full"
v-model="documentTypeToUse" size="lg"
> :search-input="{ placeholder: 'Dokumententyp suchen...' }"
:filter-fields="['labelSingle']"
/>
</UFormField>
</USelectMenu> <div class="space-y-3">
</UFormField> <div>
<UCheckbox <h3 class="text-sm font-medium text-highlighted">Inhalte übernehmen</h3>
v-for="key in Object.keys(optionsToImport).filter(i => documentTypeToUse !== props.type ? !['startText','endText'].includes(i) : true)" <p class="mt-1 text-sm text-muted">Nur die aktivierten Bereiche werden in das neue Dokument kopiert.</p>
v-model="optionsToImport[key]" </div>
:label="mappings[key]"
/>
<div class="grid gap-3 sm:grid-cols-2">
<UCheckbox
v-for="key in visibleImportKeys"
:key="key"
v-model="optionsToImport[key]"
:label="mappings[key]"
class="rounded-xl border border-default px-3 py-2"
/>
</div>
</div>
</div>
<template #footer> <template #footer>
<UButton <div class="flex justify-end gap-2">
@click="startImport" <UButton color="neutral" variant="ghost" @click="modal.close()">
> Abbrechen
Kopieren </UButton>
</UButton> <UButton @click="startImport">
Kopieren
</UButton>
</div>
</template> </template>
</UCard> </UCard>
</template> </template>
</UModal> </UModal>

View File

@@ -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 #date-cell="{ row }">
<div class="truncate">{{ row.original.date ? dayjs(row.original.date).format("DD.MM.YYYY") : "-" }}</div>
</template>
<template #vendorName-cell="{ row }">
<div class="truncate">{{ row.original.vendorName }}</div>
</template>
<template #accountLabel-cell="{ row }">
<div class="truncate">{{ row.original.accountLabel }}</div>
</template>
<template #description-cell="{ row }">
<UTooltip :text="row.original.description">
<div class="max-w-[18rem] truncate">{{ row.original.description }}</div>
</UTooltip>
</template>
<template #amountNet-cell="{ row }">
<div class="text-right tabular-nums">{{ currency(row.original.amountNet) }}</div>
</template>
<template #amountTax-cell="{ row }">
<div class="text-right tabular-nums">{{ currency(row.original.amountTax) }}</div>
</template>
<template #amountGross-cell="{ row }">
<div class="text-right font-medium tabular-nums">{{ currency(row.original.amountGross) }}</div>
</template>
</UTable>
<UProgress v-else animation="carousel" class="w-3/4 mx-auto" />
</div>
</template> </template>
<style scoped>
</style>

View File

@@ -0,0 +1,221 @@
<script setup lang="ts">
import dayjs from "dayjs"
import {
getCreatedDocumentTaxBreakdown,
getIncomingInvoiceTaxBreakdown
} from "~/composables/useTaxEvaluation"
const loading = ref(true)
const summary = ref({
label: "",
income: 0,
expenses: 0,
result: 0,
taxBalance: 0,
incomeCount: 0,
expenseCount: 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 + (invoice.accounts || []).reduce((accountSum: number, account: any) => accountSum + Number(account.amountNet || 0), 0)
}, 0)
const directAccountExpenses = directExpenses.reduce((sum: number, allocation: any) => {
return sum + Math.abs(Number(allocation.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
summary.value = {
label: dayjs().format("MMMM YYYY"),
income: Number(income.toFixed(2)),
expenses: Number(expenses.toFixed(2)),
result: Number((income - expenses).toFixed(2)),
taxBalance: Number((outputTax - inputTax).toFixed(2)),
incomeCount: outputDocs.length,
expenseCount: inputDocs.length + directExpenses.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">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 | 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>

View File

@@ -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>
</tr> <th class="px-2 py-2 text-left font-medium">Einheit</th>
<tr <th class="px-2 py-2 text-left font-medium">Verkaufspreis</th>
v-for="product in props.item.materialComposition" <th class="w-12 px-2 py-2"></th>
> </tr>
<td> </thead>
<tbody>
<tr
v-for="product in props.item.materialComposition"
:key="product.id"
class="border-b border-gray-100 align-top dark:border-gray-800"
>
<td class="px-2 py-2">
<USelectMenu <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)"
@@ -130,8 +145,10 @@ const setRowData = (row) => {
color="error" color="error"
/> />
</td> </td>
</tr> </tr>
</table> </tbody>
</table>
</div>
</UCard> </UCard>
</template> </template>

View File

@@ -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>
</tr> <th class="px-2 py-2 text-left font-medium">Einkaufspreis</th>
<tr <th class="px-2 py-2 text-left font-medium">Verkaufspreis</th>
v-for="row in props.item.personalComposition" <th class="w-12 px-2 py-2"></th>
> </tr>
<td> </thead>
<tbody>
<tr
v-for="row in props.item.personalComposition"
:key="row.id"
class="border-b border-gray-100 align-top dark:border-gray-800"
>
<td class="px-2 py-2">
<USelectMenu <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)"
@@ -141,8 +157,10 @@ const setRowData = (row) => {
color="error" color="error"
/> />
</td> </td>
</tr> </tr>
</table> </tbody>
</table>
</div>
</UCard> </UCard>
</template> </template>

View 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,
}
}

View File

@@ -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/>

View File

@@ -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": "",

View File

@@ -0,0 +1,600 @@
<script setup lang="ts">
import dayjs from "dayjs"
import {
getCreatedDocumentTaxBreakdown,
getIncomingInvoiceTaxBreakdown
} from "~/composables/useTaxEvaluation"
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 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 computeIncomingInvoiceGross = (invoice: any) => {
return Number((invoice?.accounts || []).reduce((sum: number, account: any) => {
const amountNet = Number(account?.amountNet || 0)
const amountTax = Number(account?.amountTax || 0)
const amountGross = Number(account?.amountGross)
return sum + (Number.isFinite(amountGross) ? amountGross : amountNet + amountTax)
}, 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 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 + (invoice.accounts || []).reduce((accountSum: number, account: any) => accountSum + Number(account.amountNet || 0), 0)
}, 0)
const directAccountExpenses = filteredAccountStatementAllocations.value.reduce((sum, allocation) => {
const amount = Number(allocation.amount || 0)
return amount < 0 ? sum + Math.abs(amount) : sum
}, 0)
return Number((invoiceExpenses + directAccountExpenses).toFixed(2))
})
const expenseGrossTotal = computed(() => {
const invoiceExpenses = filteredIncomingInvoices.value.reduce((sum, invoice) => sum + computeIncomingInvoiceGross(invoice), 0)
const directAccountExpenses = filteredAccountStatementAllocations.value.reduce((sum, allocation) => {
const amount = Number(allocation.amount || 0)
return amount < 0 ? sum + Math.abs(amount) : sum
}, 0)
return Number((invoiceExpenses + directAccountExpenses).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(() => {
return filteredIncomingInvoices.value.length + filteredAccountStatementAllocations.value.length
})
const accountRows = computed(() => {
return accounts.value
.map((account) => {
const invoiceBookings = filteredIncomingInvoices.value.flatMap((invoice) => {
return (invoice.accounts || [])
.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) => 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-4">
<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">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>
</UDashboardPanelContent>
</template>

View File

@@ -126,164 +126,162 @@ onMounted(loadData)
</script> </script>
<template> <template>
<div> <UDashboardNavbar title="USt-Auswertung">
<UDashboardNavbar title="USt-Auswertung"> <template #right>
<template #right> <UButton
<UButton icon="i-heroicons-arrow-path"
icon="i-heroicons-arrow-path" variant="outline"
variant="outline" @click="loadData"
@click="loadData" :loading="loading"
:loading="loading" >
Aktualisieren
</UButton>
</template>
</UDashboardNavbar>
<UDashboardPanelContent class="p-4 md:p-6">
<div class="mb-6 flex flex-col gap-2">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Aktueller Zeitraum: {{ currentPeriod?.label }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Intervall: {{ periodType === "monthly" ? "monatlich" : periodType === "quarterly" ? "quartalsweise" : "jährlich" }}.
Berücksichtigt werden gebuchte Ausgangsrechnungen, Abschlags- und Stornorechnungen sowie gebuchte Eingangsbelege mit Datum.
</p>
<p v-if="currentPeriod" class="text-sm text-gray-500 dark:text-gray-400">
{{ currentPeriod.range }}
</p>
</div>
<div v-if="currentPeriod" class="grid gap-4 md:grid-cols-3">
<UCard>
<div class="text-sm text-gray-500 dark:text-gray-400">Berechnete USt</div>
<div class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
{{ formatCurrency(currentPeriod.outputTax) }}
</div>
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
19%: {{ formatCurrency(currentPeriod.output.tax19) }} | 7%: {{ formatCurrency(currentPeriod.output.tax7) }}
</div>
</UCard>
<UCard>
<div class="text-sm text-gray-500 dark:text-gray-400">Vorsteuer</div>
<div class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
{{ formatCurrency(currentPeriod.inputTax) }}
</div>
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
19%: {{ formatCurrency(currentPeriod.input.tax19) }} | 7%: {{ formatCurrency(currentPeriod.input.tax7) }}
</div>
</UCard>
<UCard>
<div class="text-sm text-gray-500 dark:text-gray-400">Verrechnetes Ergebnis</div>
<div
class="mt-2 text-2xl font-semibold"
:class="currentPeriod.balance >= 0 ? 'text-amber-600 dark:text-amber-400' : 'text-emerald-600 dark:text-emerald-400'"
> >
Aktualisieren {{ formatCurrency(currentPeriod.balance) }}
</UButton> </div>
</template> <div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
</UDashboardNavbar> {{ currentPeriod.outputCount }} Ausgangsbelege | {{ currentPeriod.inputCount }} Eingangsbelege
</div>
</UCard>
</div>
<UDashboardPanelContent class="p-4 md:p-6"> <div v-if="currentPeriod" class="mt-6 grid gap-4 xl:grid-cols-2">
<div class="mb-6 flex flex-col gap-2"> <UCard>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Aktueller Zeitraum: {{ currentPeriod?.label }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Intervall: {{ periodType === "monthly" ? "monatlich" : periodType === "quarterly" ? "quartalsweise" : "jährlich" }}.
Berücksichtigt werden gebuchte Ausgangsrechnungen, Abschlags- und Stornorechnungen sowie gebuchte Eingangsbelege mit Datum.
</p>
<p v-if="currentPeriod" class="text-sm text-gray-500 dark:text-gray-400">
{{ currentPeriod.range }}
</p>
</div>
<div v-if="currentPeriod" class="grid gap-4 md:grid-cols-3">
<UCard>
<div class="text-sm text-gray-500 dark:text-gray-400">Berechnete USt</div>
<div class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
{{ formatCurrency(currentPeriod.outputTax) }}
</div>
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
19%: {{ formatCurrency(currentPeriod.output.tax19) }} | 7%: {{ formatCurrency(currentPeriod.output.tax7) }}
</div>
</UCard>
<UCard>
<div class="text-sm text-gray-500 dark:text-gray-400">Vorsteuer</div>
<div class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
{{ formatCurrency(currentPeriod.inputTax) }}
</div>
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
19%: {{ formatCurrency(currentPeriod.input.tax19) }} | 7%: {{ formatCurrency(currentPeriod.input.tax7) }}
</div>
</UCard>
<UCard>
<div class="text-sm text-gray-500 dark:text-gray-400">Verrechnetes Ergebnis</div>
<div
class="mt-2 text-2xl font-semibold"
:class="currentPeriod.balance >= 0 ? 'text-amber-600 dark:text-amber-400' : 'text-emerald-600 dark:text-emerald-400'"
>
{{ formatCurrency(currentPeriod.balance) }}
</div>
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
{{ currentPeriod.outputCount }} Ausgangsbelege | {{ currentPeriod.inputCount }} Eingangsbelege
</div>
</UCard>
</div>
<div v-if="currentPeriod" class="mt-6 grid gap-4 xl:grid-cols-2">
<UCard>
<template #header>
<div class="font-semibold">Ausgangsrechnungen</div>
</template>
<div class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<span>Netto 19%</span>
<span>{{ formatCurrency(currentPeriod.output.net19) }}</span>
</div>
<div class="flex items-center justify-between">
<span>USt 19%</span>
<span>{{ formatCurrency(currentPeriod.output.tax19) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Netto 7%</span>
<span>{{ formatCurrency(currentPeriod.output.net7) }}</span>
</div>
<div class="flex items-center justify-between">
<span>USt 7%</span>
<span>{{ formatCurrency(currentPeriod.output.tax7) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Netto 0%</span>
<span>{{ formatCurrency(currentPeriod.output.net0) }}</span>
</div>
</div>
</UCard>
<UCard>
<template #header>
<div class="font-semibold">Eingangsbelege</div>
</template>
<div class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<span>Netto 19%</span>
<span>{{ formatCurrency(currentPeriod.input.net19) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Vorsteuer 19%</span>
<span>{{ formatCurrency(currentPeriod.input.tax19) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Netto 7%</span>
<span>{{ formatCurrency(currentPeriod.input.net7) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Vorsteuer 7%</span>
<span>{{ formatCurrency(currentPeriod.input.tax7) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Netto 0%</span>
<span>{{ formatCurrency(currentPeriod.input.net0) }}</span>
</div>
</div>
</UCard>
</div>
<UCard class="mt-6">
<template #header> <template #header>
<div class="font-semibold">Aktueller und vorherige Zeiträume</div> <div class="font-semibold">Ausgangsrechnungen</div>
</template>
<div class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<span>Netto 19%</span>
<span>{{ formatCurrency(currentPeriod.output.net19) }}</span>
</div>
<div class="flex items-center justify-between">
<span>USt 19%</span>
<span>{{ formatCurrency(currentPeriod.output.tax19) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Netto 7%</span>
<span>{{ formatCurrency(currentPeriod.output.net7) }}</span>
</div>
<div class="flex items-center justify-between">
<span>USt 7%</span>
<span>{{ formatCurrency(currentPeriod.output.tax7) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Netto 0%</span>
<span>{{ formatCurrency(currentPeriod.output.net0) }}</span>
</div>
</div>
</UCard>
<UCard>
<template #header>
<div class="font-semibold">Eingangsbelege</div>
</template>
<div class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<span>Netto 19%</span>
<span>{{ formatCurrency(currentPeriod.input.net19) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Vorsteuer 19%</span>
<span>{{ formatCurrency(currentPeriod.input.tax19) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Netto 7%</span>
<span>{{ formatCurrency(currentPeriod.input.net7) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Vorsteuer 7%</span>
<span>{{ formatCurrency(currentPeriod.input.tax7) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Netto 0%</span>
<span>{{ formatCurrency(currentPeriod.input.net0) }}</span>
</div>
</div>
</UCard>
</div>
<UCard class="mt-6">
<template #header>
<div class="font-semibold">Aktueller und vorherige Zeiträume</div>
</template>
<UTable
:columns="normalizeTableColumns(columns)"
:data="periods"
:loading="loading"
:empty="{ icon: 'i-heroicons-calculator', label: 'Keine Daten für die USt-Auswertung vorhanden' }"
>
<template #label-cell="{ row }">
<div class="flex items-center gap-2">
<span>{{ row.original.label }}</span>
<UBadge v-if="row.original.isCurrent" color="primary" variant="soft">Aktuell</UBadge>
</div>
</template> </template>
<UTable <template #outputTax-cell="{ row }">
:columns="normalizeTableColumns(columns)" {{ formatCurrency(row.original.outputTax) }}
:data="periods" </template>
:loading="loading"
:empty="{ icon: 'i-heroicons-calculator', label: 'Keine Daten für die USt-Auswertung vorhanden' }"
>
<template #label-cell="{ row }">
<div class="flex items-center gap-2">
<span>{{ row.original.label }}</span>
<UBadge v-if="row.original.isCurrent" color="primary" variant="soft">Aktuell</UBadge>
</div>
</template>
<template #outputTax-cell="{ row }"> <template #inputTax-cell="{ row }">
{{ formatCurrency(row.original.outputTax) }} {{ formatCurrency(row.original.inputTax) }}
</template> </template>
<template #inputTax-cell="{ row }"> <template #balance-cell="{ row }">
{{ formatCurrency(row.original.inputTax) }}
</template>
<template #balance-cell="{ row }">
<span :class="row.original.balance >= 0 ? 'text-amber-600 dark:text-amber-400 font-medium' : 'text-emerald-600 dark:text-emerald-400 font-medium'"> <span :class="row.original.balance >= 0 ? 'text-amber-600 dark:text-amber-400 font-medium' : 'text-emerald-600 dark:text-emerald-400 font-medium'">
{{ formatCurrency(row.original.balance) }} {{ formatCurrency(row.original.balance) }}
</span> </span>
</template> </template>
<template #documents-cell="{ row }"> <template #documents-cell="{ row }">
{{ row.original.outputCount }} / {{ row.original.inputCount }} {{ row.original.outputCount }} / {{ row.original.inputCount }}
</template> </template>
</UTable> </UTable>
</UCard> </UCard>
</UDashboardPanelContent> </UDashboardPanelContent>
</div>
</template> </template>

View File

@@ -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}">

View File

@@ -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) => ({
...allocation,
type: "statementallocation",
amount: Number(allocation.amount || 0)
}))
let tempstatementallocations = statementallocations.value.map(i => { const incomingInvoiceRows = incominginvoices.value.flatMap((invoice) => {
return { return (invoice.accounts || [])
...i, .filter((account) => sameAccount(account.account?.id || account.account))
type: "statementallocation", .map((account, index) => ({
date: i.bs_id.date, id: `${invoice.id}-${index}`,
partner: i.bs_id ? (i.bs_id.debName ? i.bs_id.debName : (i.bs_id.credName ? i.bs_id.credName : '')) : '' incominginvoiceid: invoice.id,
} type: "incominginvoice",
amount: Number(account.amountGross || account.amountNet || 0),
expense: invoice.expense
}))
}) })
return [...statementRows, ...incomingInvoiceRows]
let incominginvoicesallocations = []
incominginvoices.value.forEach(i => {
incominginvoicesallocations.push(...i.accounts.filter(x => x.account == route.params.id).map(x => {
return {
...x,
incominginvoiceid: i.id,
type: "incominginvoice",
amount: x.amountGross ? x.amountGross : x.amountNet,
date: i.date,
partner: i.vendor.name,
description: i.description,
color: i.expense ? "red" : "green",
expense: i.expense
}
}))
})
return [...tempstatementallocations, ... incominginvoicesallocations]
}) })
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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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,6 +36,12 @@ 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])
const dateRange = ref({ const dateRange = ref({
@@ -41,6 +49,19 @@ const dateRange = ref({
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')
} }
@@ -496,30 +517,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>
{{ selectedPeriod?.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 +602,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>

View File

@@ -582,20 +582,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>
@@ -620,21 +620,21 @@ setup()
<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 +687,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

View File

@@ -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 {

View File

@@ -98,7 +98,7 @@
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.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>

View File

@@ -76,11 +76,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 +95,9 @@ const handleGlobalDragEnter = (e) => {
setupPage() setupPage()
dragCounter = 0 dragCounter = 0
} }
}).finally(() => {
dragCounter = 0
uploadModalOpening.value = false
}) })
} }
} }

View File

@@ -1,6 +1,7 @@
<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'
// --- Standard Setup & Data --- // --- Standard Setup & Data ---
@@ -44,6 +45,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
@@ -67,7 +71,9 @@ const setup = async () => {
// 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
@@ -98,6 +104,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
@@ -335,18 +358,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 +391,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,19 +501,20 @@ 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>
@@ -452,15 +524,15 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
<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>
@@ -470,6 +542,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 (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 +557,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 +572,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 +614,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>

View File

@@ -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}">

View File

@@ -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,160 +374,167 @@ 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"> <UButton
<UButton
:icon="isEditMode ? 'i-heroicons-check' : 'i-heroicons-pencil-square'" :icon="isEditMode ? 'i-heroicons-check' : 'i-heroicons-pencil-square'"
:color="isEditMode ? 'primary' : 'gray'" :color="isEditMode ? 'primary' : 'gray'"
:variant="isEditMode ? 'solid' : 'ghost'" :variant="isEditMode ? 'solid' : 'ghost'"
@click="toggleEditMode" @click="toggleEditMode"
> >
{{ isEditMode ? "Bearbeitung beenden" : "Dashboard bearbeiten" }} {{ isEditMode ? "Bearbeitung beenden" : "Dashboard bearbeiten" }}
</UButton> </UButton>
<UButton <UButton
v-if="isEditMode && hiddenWidgets.length > 0" v-if="isEditMode && hiddenWidgets.length > 0"
icon="i-heroicons-plus" icon="i-heroicons-plus"
color="white" color="white"
variant="soft" variant="soft"
@click="manageCardsOpen = true" @click="manageCardsOpen = true"
> >
Karte hinzufügen Karte hinzufügen
</UButton> </UButton>
<UButton <UButton
v-if="isEditMode"
icon="i-heroicons-arrow-path"
color="gray"
variant="ghost"
@click="resetDashboard"
>
Standardlayout
</UButton>
<UButton
v-if="isEditMode" v-if="isEditMode"
icon="i-heroicons-squares-2x2" icon="i-heroicons-squares-2x2"
color="gray" color="gray"
variant="ghost" variant="ghost"
@click="manageCardsOpen = true" @click="manageCardsOpen = true"
> >
Karten verwalten Karten verwalten
</UButton> </UButton>
</div> </div>
</template> </template>
</UDashboardNavbar> </UDashboardNavbar>
<div v-if="visibleWidgets.length > 0" ref="gridElement" class="grid-stack dashboard-grid overflow-y-auto"> <div v-if="visibleWidgets.length > 0" ref="gridElement" class="grid-stack dashboard-grid overflow-y-auto">
<div <div
v-for="widget in visibleWidgets" v-for="widget in visibleWidgets"
:key="widget.id" :key="widget.id"
:class="['grid-stack-item', isEditMode ? 'dashboard-widget-editing' : '']" :class="['grid-stack-item', isEditMode ? 'dashboard-widget-editing' : '']"
:data-widget-id="widget.id" :data-widget-id="widget.id"
:gs-x="widget.x" :gs-x="widget.x"
:gs-y="widget.y" :gs-y="widget.y"
:gs-w="widget.w" :gs-w="widget.w"
:gs-h="widget.h" :gs-h="widget.h"
:gs-min-w="widget.minW" :gs-min-w="widget.minW"
:gs-min-h="widget.minH" :gs-min-h="widget.minH"
> >
<div class="grid-stack-item-content dashboard-grid-item"> <div class="grid-stack-item-content dashboard-grid-item">
<div class="dashboard-widget-card border border-gray-200 dark:border-gray-800"> <div class="dashboard-widget-card border border-gray-200 dark:border-gray-800">
<div class="dashboard-widget-header border-b border-gray-200 dark:border-gray-800"> <div class="dashboard-widget-header border-b border-gray-200 dark:border-gray-800">
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="min-w-0"> <div class="min-w-0">
<div :class="['dashboard-widget-drag-handle font-semibold', isEditMode ? 'cursor-move' : 'cursor-default']"> <div :class="['dashboard-widget-drag-handle font-semibold', isEditMode ? 'cursor-move' : 'cursor-default']">
{{ widget.title }} {{ widget.title }}
</div>
<p class="mt-1 text-sm">
{{ widget.description }}
</p>
</div>
<div class="dashboard-widget-header-actions">
<div :id="`dashboard-widget-header-actions-${widget.id}`" class="dashboard-widget-header-target" />
<UButtonGroup v-if="isEditMode" size="xs">
<UButton
color="gray"
variant="soft"
icon="i-heroicons-arrows-pointing-out"
class="dashboard-widget-drag-handle"
/>
<UButton
color="gray"
variant="soft"
icon="i-heroicons-x-mark"
:disabled="visibleWidgets.length <= 1"
@click="removeWidget(widget.id)"
/>
</UButtonGroup>
</div> </div>
<p class="mt-1 text-sm">
{{ widget.description }}
</p>
</div>
<div class="dashboard-widget-header-actions">
<div :id="`dashboard-widget-header-actions-${widget.id}`" class="dashboard-widget-header-target" />
<UButtonGroup v-if="isEditMode" size="xs">
<UButton
color="gray"
variant="soft"
icon="i-heroicons-arrows-pointing-out"
class="dashboard-widget-drag-handle"
/>
<UButton
color="gray"
variant="soft"
icon="i-heroicons-x-mark"
:disabled="visibleWidgets.length <= 1"
@click="removeWidget(widget.id)"
/>
</UButtonGroup>
</div> </div>
</div> </div>
<div class="dashboard-widget-body"> </div>
<component <div class="dashboard-widget-body">
:is="widget.component" <component
v-bind="widget.id === 'income-expense' :is="widget.component"
v-bind="widget.id === 'income-expense'
? { headerTarget: `#dashboard-widget-header-actions-${widget.id}` } ? { headerTarget: `#dashboard-widget-header-actions-${widget.id}` }
: {}" : {}"
/> />
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div v-else class="rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-10 text-center"> <div v-else class="rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-10 text-center">
<p class="text-sm"> <p class="text-sm">
Es sind aktuell keine Dashboard-Karten sichtbar. Es sind aktuell keine Dashboard-Karten sichtbar.
</p> </p>
<UButton v-if="isEditMode" class="mt-4" icon="i-heroicons-plus" @click="manageCardsOpen = true"> <UButton v-if="isEditMode" class="mt-4" icon="i-heroicons-plus" @click="manageCardsOpen = true">
Karte hinzufügen Karte hinzufügen
</UButton> </UButton>
</div> </div>
<UModal v-model:open="manageCardsOpen"> <UModal v-model:open="manageCardsOpen">
<template #content> <template #content>
<UCard> <UCard>
<template #header> <template #header>
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div> <div>
<h2 class="font-semibold">Dashboard-Karten</h2> <h2 class="font-semibold">Dashboard-Karten</h2>
<p class="text-sm"> <p class="text-sm">
Karten ein- oder ausblenden und bei Bedarf auf das Standardlayout zurücksetzen. Karten ein- oder ausblenden und bei Bedarf auf das Standardlayout zurücksetzen.
</p> </p>
</div>
<UButton color="gray" variant="ghost" icon="i-heroicons-arrow-path" @click="resetDashboard">
Zurücksetzen
</UButton>
</div> </div>
</template> <UButton color="gray" variant="ghost" icon="i-heroicons-arrow-path" @click="resetDashboard">
Zurücksetzen
</UButton>
</div>
</template>
<div class="space-y-3"> <div class="space-y-3">
<div <div
v-for="definition in DASHBOARD_WIDGETS" v-for="definition in DASHBOARD_WIDGETS"
:key="definition.id" :key="definition.id"
class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 dark:border-gray-800 px-4 py-3" class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 dark:border-gray-800 px-4 py-3"
> >
<div> <div>
<p class="font-medium">{{ definition.title }}</p> <p class="font-medium">{{ definition.title }}</p>
<p class="text-sm">{{ definition.description }}</p> <p class="text-sm">{{ definition.description }}</p>
</div> </div>
<UButton <UButton
v-if="getWidgetLayout(definition.id)?.visible" v-if="getWidgetLayout(definition.id)?.visible"
color="gray" color="gray"
variant="soft" variant="soft"
icon="i-heroicons-minus" icon="i-heroicons-minus"
:disabled="visibleWidgets.length <= 1" :disabled="visibleWidgets.length <= 1"
@click="removeWidget(definition.id)" @click="removeWidget(definition.id)"
> >
Entfernen Entfernen
</UButton> </UButton>
<UButton <UButton
v-else v-else
color="primary" color="primary"
variant="soft" variant="soft"
icon="i-heroicons-plus" icon="i-heroicons-plus"
@click="addWidget(definition.id)" @click="addWidget(definition.id)"
> >
Hinzufügen Hinzufügen
</UButton> </UButton>
</div>
</div> </div>
</UCard> </div>
</template> </UCard>
</UModal> </template>
</div> </UModal>
</template> </template>
<style scoped> <style scoped>

View File

@@ -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' <NuxtLink to="/password-reset" class="text-primary font-medium">Passwort vergessen?</NuxtLink>
}]" </template>
:loading="false" <UInput
@submit="doLogin" v-model="state.password"
:submit-button="{label: 'Weiter'}" type="password"
divider="oder" class="w-full"
> placeholder="Dein Passwort"
<template #password-hint> autocomplete="current-password"
<NuxtLink to="/password-reset" class="text-primary font-medium">Passwort vergessen?</NuxtLink> />
</template> </UFormField>
</UAuthForm>
</div>--> <UButton type="submit" block class="w-full" :loading="loading">
Weiter
</UButton>
</UForm>
</div>
</UCard>
</template> </template>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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="primary"
color="red"
variant="soft" 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"
: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"
/>
</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>
<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> </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>

View File

@@ -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"
} }

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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>

View File

@@ -62,62 +62,74 @@ 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>
<div>
<UCard v-else-if="status === 'error'" class="w-full max-w-md border-red-200"> <p class="text-base font-medium text-highlighted">Lade Formular...</p>
<div class="text-center text-red-600 space-y-2"> <p class="text-sm text-muted">Der Workflow wird vorbereitet.</p>
<UIcon name="i-heroicons-exclamation-circle" class="w-12 h-12 mx-auto" />
<h3 class="font-bold text-lg">Fehler</h3>
<p>{{ errorMsg }}</p>
</div>
</UCard>
<UCard v-else-if="status === 'pin_required'" class="w-full max-w-sm shadow-xl">
<div class="text-center mb-6">
<div class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-3">
<UIcon name="i-heroicons-lock-closed" class="w-6 h-6 text-primary-600" />
</div> </div>
<h2 class="text-xl font-bold text-gray-900">Geschützter Bereich</h2>
<p class="text-sm text-gray-500">Bitte PIN eingeben</p>
</div> </div>
<form @submit.prevent="handlePinSubmit" class="space-y-4">
<UInput <UCard v-else-if="status === 'error'" class="w-full max-w-md border-error/20 shadow-xl">
v-model="pin" <div class="space-y-3 text-center text-error">
type="password" <div class="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-error/10 ring-1 ring-error/15">
placeholder="PIN" <UIcon name="i-heroicons-exclamation-circle" class="h-8 w-8" />
input-class="text-center text-lg tracking-widest" </div>
autofocus <div>
icon="i-heroicons-key" <h3 class="text-lg font-semibold">Fehler</h3>
<p class="text-sm text-toned">{{ errorMsg }}</p>
</div>
</div>
</UCard>
<UCard v-else-if="status === 'pin_required'" class="w-full max-w-sm shadow-xl ring-1 ring-black/5">
<div class="mb-6 text-center">
<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="h-6 w-6" />
</div>
<h2 class="text-xl font-semibold text-highlighted">Geschützter Bereich</h2>
<p class="text-sm text-muted">Bitte PIN eingeben</p>
</div>
<form @submit.prevent="handlePinSubmit" class="space-y-4">
<UInput
v-model="pin"
type="password"
placeholder="PIN"
input-class="text-center text-lg tracking-widest"
autofocus
icon="i-heroicons-key"
class="w-full"
size="lg"
/>
<div v-if="errorMsg" class="text-center text-xs font-medium text-error">{{ errorMsg }}</div>
<UButton type="submit" block label="Entsperren" size="lg" />
</form>
</UCard>
<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="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="h-8 w-8" />
</div>
<h2 class="mb-2 text-2xl font-semibold text-highlighted">Gespeichert!</h2>
<p class="mb-6 text-sm text-muted">Die Daten wurden erfolgreich übertragen.</p>
<UButton variant="outline" @click="resetForm">Neuen Eintrag erfassen</UButton>
</UCard>
<div v-else-if="status === 'ready'" class="w-full max-w-xl">
<PublicDynamicForm
v-if="context && token"
:key="formKey"
:context="context"
:token="token"
:pin="pin"
@success="handleFormSuccess"
/> />
<div v-if="errorMsg" class="text-red-500 text-xs text-center font-medium">{{ errorMsg }}</div>
<UButton type="submit" block label="Entsperren" size="lg" />
</form>
</UCard>
<UCard v-else-if="status === 'success'" class="w-full max-w-md text-center py-10">
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<UIcon name="i-heroicons-check" class="w-8 h-8 text-green-600" />
</div> </div>
<h2 class="text-2xl font-bold text-gray-900 mb-2">Gespeichert!</h2>
<p class="text-gray-500 mb-6">Die Daten wurden erfolgreich übertragen.</p>
<UButton variant="outline" @click="resetForm">Neuen Eintrag erfassen</UButton>
</UCard>
<div v-else-if="status === 'ready'" class="w-full max-w-lg">
<PublicDynamicForm
v-if="context && token"
:key="formKey"
:context="context"
:token="token"
:pin="pin"
@success="handleFormSuccess"
/>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -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";
@@ -1442,7 +1443,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 +2992,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 +3245,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 +3303,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 +3458,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"

View File

@@ -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;
} }