Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
16
backend/db/migrations/0065_document_templates.sql
Normal file
16
backend/db/migrations/0065_document_templates.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "documenttemplates" (
|
||||||
|
"id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"tenant" bigint NOT NULL REFERENCES "tenants"("id"),
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"document_type" text NOT NULL,
|
||||||
|
"template_data" jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
"default" boolean NOT NULL DEFAULT false,
|
||||||
|
"archived" boolean NOT NULL DEFAULT false,
|
||||||
|
"updated_at" timestamptz,
|
||||||
|
"updated_by" uuid REFERENCES "auth_users"("id"),
|
||||||
|
"created_by" uuid REFERENCES "auth_users"("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "documenttemplates_tenant_type_idx"
|
||||||
|
ON "documenttemplates" ("tenant", "document_type");
|
||||||
@@ -428,6 +428,20 @@
|
|||||||
"when": 1786287600000,
|
"when": 1786287600000,
|
||||||
"tag": "0063_skr03_output_tax_account",
|
"tag": "0063_skr03_output_tax_account",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 61,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788202715000,
|
||||||
|
"tag": "0064_tenant_import_job_status",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 62,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788202715001,
|
||||||
|
"tag": "0065_document_templates",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
39
backend/db/schema/documenttemplates.ts
Normal file
39
backend/db/schema/documenttemplates.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
bigint,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
boolean,
|
||||||
|
jsonb,
|
||||||
|
uuid,
|
||||||
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
|
import { tenants } from "./tenants"
|
||||||
|
import { authUsers } from "./auth_users"
|
||||||
|
|
||||||
|
export const documenttemplates = pgTable("documenttemplates", {
|
||||||
|
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(),
|
||||||
|
documentType: text("document_type").notNull(),
|
||||||
|
templateData: jsonb("template_data").notNull().default({}),
|
||||||
|
default: boolean("default").notNull().default(false),
|
||||||
|
archived: boolean("archived").notNull().default(false),
|
||||||
|
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
updatedBy: uuid("updated_by").references(() => authUsers.id),
|
||||||
|
createdBy: uuid("created_by").references(() => authUsers.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type DocumentTemplate = typeof documenttemplates.$inferSelect
|
||||||
|
export type NewDocumentTemplate = typeof documenttemplates.$inferInsert
|
||||||
@@ -22,6 +22,7 @@ export * from "./contracttypes"
|
|||||||
export * from "./costcentres"
|
export * from "./costcentres"
|
||||||
export * from "./countrys"
|
export * from "./countrys"
|
||||||
export * from "./createddocuments"
|
export * from "./createddocuments"
|
||||||
|
export * from "./documenttemplates"
|
||||||
export * from "./createdletters"
|
export * from "./createdletters"
|
||||||
export * from "./customers"
|
export * from "./customers"
|
||||||
export * from "./customerspaces"
|
export * from "./customerspaces"
|
||||||
|
|||||||
88
backend/src/mcp/projectPhases.ts
Normal file
88
backend/src/mcp/projectPhases.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
export type ProjectPhase = {
|
||||||
|
key?: string
|
||||||
|
label?: string
|
||||||
|
active?: boolean
|
||||||
|
optional?: boolean
|
||||||
|
activated_at?: string
|
||||||
|
activated_by?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type PhaseSelector = {
|
||||||
|
phaseKey?: string | null
|
||||||
|
phaseLabel?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const activateProjectPhase = (
|
||||||
|
value: unknown,
|
||||||
|
selector: PhaseSelector,
|
||||||
|
userId: string,
|
||||||
|
activatedAt = new Date().toISOString(),
|
||||||
|
) => {
|
||||||
|
if (!Array.isArray(value) || value.length === 0) {
|
||||||
|
throw new Error("Das Projekt hat keine Phasen")
|
||||||
|
}
|
||||||
|
|
||||||
|
const phases = value as ProjectPhase[]
|
||||||
|
const phaseKey = String(selector.phaseKey || "").trim()
|
||||||
|
const phaseLabel = String(selector.phaseLabel || "").trim()
|
||||||
|
|
||||||
|
if (Boolean(phaseKey) === Boolean(phaseLabel)) {
|
||||||
|
throw new Error("Genau phaseKey oder phaseLabel ist erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = phases
|
||||||
|
.map((phase, index) => ({ phase, index }))
|
||||||
|
.filter(({ phase }) => phaseKey ? phase.key === phaseKey : phase.label === phaseLabel)
|
||||||
|
|
||||||
|
if (matches.length === 0) throw new Error("Projektphase nicht gefunden")
|
||||||
|
if (matches.length > 1) throw new Error("Projektphase ist nicht eindeutig; bitte phaseKey verwenden")
|
||||||
|
|
||||||
|
const { phase: target, index: targetIndex } = matches[0]
|
||||||
|
const activeIndex = phases.findIndex((phase) => phase.active === true)
|
||||||
|
const active = activeIndex >= 0 ? phases[activeIndex] : null
|
||||||
|
|
||||||
|
if (target.active) throw new Error(`Projektphase „${target.label || target.key}“ ist bereits aktiv`)
|
||||||
|
if (target.activated_at) throw new Error(`Projektphase „${target.label || target.key}“ wurde bereits aktiviert`)
|
||||||
|
|
||||||
|
const completesProject = target.label === "Abgeschlossen"
|
||||||
|
if (!completesProject && activeIndex >= 0) {
|
||||||
|
if (targetIndex <= activeIndex) {
|
||||||
|
throw new Error("Eine bereits durchlaufene Projektphase kann nicht erneut aktiviert werden")
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredSkippedPhase = phases
|
||||||
|
.slice(activeIndex + 1, targetIndex)
|
||||||
|
.find((candidate) => !candidate.optional)
|
||||||
|
|
||||||
|
if (requiredSkippedPhase) {
|
||||||
|
throw new Error(`Die Phase „${requiredSkippedPhase.label || requiredSkippedPhase.key}“ muss zuerst aktiviert werden`)
|
||||||
|
}
|
||||||
|
} else if (!completesProject && activeIndex < 0 && targetIndex !== 0) {
|
||||||
|
throw new Error(`Die Phase „${phases[0].label || phases[0].key}“ muss zuerst aktiviert werden`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPhases = phases.map((phase, index) => {
|
||||||
|
if (index === targetIndex) {
|
||||||
|
return {
|
||||||
|
...phase,
|
||||||
|
active: true,
|
||||||
|
activated_at: activatedAt,
|
||||||
|
activated_by: userId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (phase.active) {
|
||||||
|
return {
|
||||||
|
...phase,
|
||||||
|
active: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...phase }
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
phases: nextPhases,
|
||||||
|
activePhase: String(target.label || "").trim() || null,
|
||||||
|
previousPhase: active ? String(active.label || "").trim() || null : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
141
backend/src/mcp/statementAllocations.ts
Normal file
141
backend/src/mcp/statementAllocations.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
|
||||||
|
|
||||||
|
const stringArg = (args: Record<string, unknown>, key: string) => {
|
||||||
|
const value = args[key]
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const statementAllocationTargetFields = [
|
||||||
|
"createddocument",
|
||||||
|
"incominginvoice",
|
||||||
|
"account",
|
||||||
|
"ownaccount",
|
||||||
|
"customer",
|
||||||
|
"vendor",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const manualDebitFields = ["account", "customer", "vendor", "ownaccount"] as const
|
||||||
|
const manualCreditFields = ["contraAccount", "contraCustomer", "contraVendor", "contraOwnaccount"] as const
|
||||||
|
const bookingModes = new Set(["expense", "depreciation_single", "depreciation_bundle"])
|
||||||
|
const depreciationMethods = new Set(["linear", "degressive"])
|
||||||
|
|
||||||
|
const requiredNumber = (args: Record<string, unknown>, key: string) => {
|
||||||
|
const value = Number(args[key])
|
||||||
|
if (!hasValue(args[key]) || !Number.isFinite(value)) throw new Error(`${key} muss eine gültige Zahl sein`)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionalNumericId = (args: Record<string, unknown>, key: string) => {
|
||||||
|
if (!hasValue(args[key])) return undefined
|
||||||
|
const value = Number(args[key])
|
||||||
|
if (!Number.isInteger(value) || value <= 0) throw new Error(`${key} muss eine gültige ID sein`)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export const statementAllocationUuidArg = (args: Record<string, unknown>, key: string) => {
|
||||||
|
if (!hasValue(args[key])) return undefined
|
||||||
|
const value = String(args[key]).trim()
|
||||||
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
|
||||||
|
throw new Error(`${key} muss eine gültige UUID sein`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const validDate = (value: string | null) =>
|
||||||
|
Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`)))
|
||||||
|
|
||||||
|
export const prepareStatementAllocationInput = (args: Record<string, unknown>) => {
|
||||||
|
const payload: Record<string, any> = {}
|
||||||
|
const amount = requiredNumber(args, "amount")
|
||||||
|
const bankstatement = optionalNumericId(args, "bankstatement")
|
||||||
|
const bookingMode = stringArg(args, "bookingMode") || "expense"
|
||||||
|
|
||||||
|
if (!bookingModes.has(bookingMode)) throw new Error(`Ungültige Aufwandsart: ${bookingMode}`)
|
||||||
|
|
||||||
|
payload.amount = amount
|
||||||
|
if (args.description !== undefined) payload.description = stringArg(args, "description")
|
||||||
|
payload.bookingMode = bookingMode
|
||||||
|
if (args.datevTaxKey !== undefined) payload.datevTaxKey = stringArg(args, "datevTaxKey")
|
||||||
|
|
||||||
|
for (const field of ["createddocument", "incominginvoice", "account", "customer", "vendor"] as const) {
|
||||||
|
const value = optionalNumericId(args, field)
|
||||||
|
if (value !== undefined) payload[field] = value
|
||||||
|
}
|
||||||
|
for (const field of ["ownaccount", "contraOwnaccount"] as const) {
|
||||||
|
const value = statementAllocationUuidArg(args, field)
|
||||||
|
if (value !== undefined) payload[field] = value
|
||||||
|
}
|
||||||
|
for (const field of ["contraAccount", "contraCustomer", "contraVendor"] as const) {
|
||||||
|
const value = optionalNumericId(args, field)
|
||||||
|
if (value !== undefined) payload[field] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bankstatement !== undefined) {
|
||||||
|
const selectedTargets = statementAllocationTargetFields.filter((field) => hasValue(payload[field]))
|
||||||
|
if (selectedTargets.length !== 1) {
|
||||||
|
throw new Error("Für eine Bankzuweisung muss genau ein Ziel ausgewählt werden")
|
||||||
|
}
|
||||||
|
if (amount === 0) throw new Error("Der Zuweisungsbetrag darf nicht 0 sein")
|
||||||
|
if (manualCreditFields.some((field) => hasValue(payload[field]))) {
|
||||||
|
throw new Error("Haben-Konten sind nur bei manuellen Buchungen zulässig")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.bankstatement = bankstatement
|
||||||
|
} else {
|
||||||
|
const manualBookingDate = stringArg(args, "manualBookingDate")
|
||||||
|
if (!validDate(manualBookingDate)) {
|
||||||
|
throw new Error("Für manuelle Buchungen ist ein gültiges Buchungsdatum erforderlich")
|
||||||
|
}
|
||||||
|
if (amount <= 0) throw new Error("Für manuelle Buchungen muss der Betrag größer als 0 sein")
|
||||||
|
|
||||||
|
const manualInvoiceSide = stringArg(args, "manualInvoiceSide")
|
||||||
|
const hasInvoice = hasValue(payload.incominginvoice)
|
||||||
|
if (hasInvoice && manualInvoiceSide !== "debit" && manualInvoiceSide !== "credit") {
|
||||||
|
throw new Error("Für zugewiesene Eingangsbelege muss Soll oder Haben ausgewählt sein")
|
||||||
|
}
|
||||||
|
if (!hasInvoice && manualInvoiceSide) {
|
||||||
|
throw new Error("manualInvoiceSide ist nur zusammen mit incominginvoice zulässig")
|
||||||
|
}
|
||||||
|
|
||||||
|
const debitCount = manualDebitFields.filter((field) => hasValue(payload[field])).length
|
||||||
|
+ (hasInvoice && manualInvoiceSide === "debit" ? 1 : 0)
|
||||||
|
const creditCount = manualCreditFields.filter((field) => hasValue(payload[field])).length
|
||||||
|
+ (hasInvoice && manualInvoiceSide === "credit" ? 1 : 0)
|
||||||
|
if (debitCount !== 1 || creditCount !== 1) {
|
||||||
|
throw new Error("Für manuelle Buchungen muss genau ein Soll- und ein Haben-Konto ausgewählt werden")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.bankstatement = null
|
||||||
|
payload.manualBookingDate = manualBookingDate
|
||||||
|
if (hasInvoice) payload.manualInvoiceSide = manualInvoiceSide
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bookingMode === "expense") return payload
|
||||||
|
|
||||||
|
const depreciationMonths = requiredNumber(args, "depreciationMonths")
|
||||||
|
const depreciationStartDate = stringArg(args, "depreciationStartDate")
|
||||||
|
const depreciationMethod = stringArg(args, "depreciationMethod") || "linear"
|
||||||
|
const residualValue = args.residualValue === undefined ? 0 : requiredNumber(args, "residualValue")
|
||||||
|
|
||||||
|
if (!Number.isInteger(depreciationMonths) || depreciationMonths <= 0) {
|
||||||
|
throw new Error("Die Abschreibungsdauer muss eine positive Anzahl Monate sein")
|
||||||
|
}
|
||||||
|
if (!validDate(depreciationStartDate)) throw new Error("Ein gültiger Abschreibungsbeginn ist erforderlich")
|
||||||
|
if (!depreciationMethods.has(depreciationMethod)) {
|
||||||
|
throw new Error(`Ungültige Abschreibungsmethode: ${depreciationMethod}`)
|
||||||
|
}
|
||||||
|
if (residualValue < 0) throw new Error("Der Restwert darf nicht negativ sein")
|
||||||
|
|
||||||
|
payload.depreciationMonths = depreciationMonths
|
||||||
|
payload.depreciationStartDate = depreciationStartDate
|
||||||
|
payload.depreciationMethod = depreciationMethod
|
||||||
|
if (args.depreciationLabel !== undefined) payload.depreciationLabel = stringArg(args, "depreciationLabel")
|
||||||
|
payload.depreciationGroup = bookingMode === "depreciation_bundle" ? stringArg(args, "depreciationGroup") : null
|
||||||
|
payload.residualValue = residualValue
|
||||||
|
|
||||||
|
if (bookingMode === "depreciation_bundle" && !payload.depreciationGroup) {
|
||||||
|
throw new Error("Für eine Sammelabschreibung ist eine Abschreibungsgruppe erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
@@ -4,16 +4,26 @@ import {
|
|||||||
accounts,
|
accounts,
|
||||||
bankstatements,
|
bankstatements,
|
||||||
createddocuments,
|
createddocuments,
|
||||||
|
customers,
|
||||||
filetags,
|
filetags,
|
||||||
files,
|
files,
|
||||||
folders,
|
folders,
|
||||||
incominginvoices,
|
incominginvoices,
|
||||||
|
ownaccounts,
|
||||||
statementallocations,
|
statementallocations,
|
||||||
|
vendors,
|
||||||
} from "../../../db/schema"
|
} from "../../../db/schema"
|
||||||
import { useNextNumberRangeNumber } from "../../utils/functions"
|
import { useNextNumberRangeNumber } from "../../utils/functions"
|
||||||
import { saveFile } from "../../utils/files"
|
import { saveFile } from "../../utils/files"
|
||||||
|
import { insertHistoryItem } from "../../utils/history"
|
||||||
|
import {
|
||||||
|
prepareStatementAllocationInput,
|
||||||
|
statementAllocationUuidArg,
|
||||||
|
} from "../statementAllocations"
|
||||||
import { McpTool } from "../types"
|
import { McpTool } from "../types"
|
||||||
|
|
||||||
|
export { prepareStatementAllocationInput } from "../statementAllocations"
|
||||||
|
|
||||||
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
||||||
const raw = Number(args.limit ?? fallback)
|
const raw = Number(args.limit ?? fallback)
|
||||||
if (!Number.isFinite(raw)) return fallback
|
if (!Number.isFinite(raw)) return fallback
|
||||||
@@ -357,6 +367,43 @@ const validateIncomingInvoiceData = (invoice: Record<string, any>) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const assertTenantEntityExists = async (
|
||||||
|
context: any,
|
||||||
|
table: any,
|
||||||
|
idColumn: any,
|
||||||
|
tenantColumn: any | null,
|
||||||
|
id: unknown,
|
||||||
|
label: string,
|
||||||
|
) => {
|
||||||
|
if (!hasValue(id)) return
|
||||||
|
|
||||||
|
const [row] = await context.server.db
|
||||||
|
.select({ id: idColumn })
|
||||||
|
.from(table)
|
||||||
|
.where(tenantColumn
|
||||||
|
? and(eq(idColumn, id as any), eq(tenantColumn, context.tenantId))
|
||||||
|
: eq(idColumn, id as any))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!row) throw new Error(`${label} nicht gefunden`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const assertStatementAllocationReferences = async (context: any, payload: Record<string, any>) => {
|
||||||
|
await Promise.all([
|
||||||
|
assertTenantEntityExists(context, bankstatements, bankstatements.id, bankstatements.tenant, payload.bankstatement, "Bankumsatz"),
|
||||||
|
assertTenantEntityExists(context, createddocuments, createddocuments.id, createddocuments.tenant, payload.createddocument, "Ausgangsbeleg"),
|
||||||
|
assertTenantEntityExists(context, incominginvoices, incominginvoices.id, incominginvoices.tenant, payload.incominginvoice, "Eingangsbeleg"),
|
||||||
|
assertTenantEntityExists(context, accounts, accounts.id, null, payload.account, "Sachkonto"),
|
||||||
|
assertTenantEntityExists(context, accounts, accounts.id, null, payload.contraAccount, "Haben-Sachkonto"),
|
||||||
|
assertTenantEntityExists(context, ownaccounts, ownaccounts.id, ownaccounts.tenant, payload.ownaccount, "Zusätzliches Konto"),
|
||||||
|
assertTenantEntityExists(context, ownaccounts, ownaccounts.id, ownaccounts.tenant, payload.contraOwnaccount, "Zusätzliches Haben-Konto"),
|
||||||
|
assertTenantEntityExists(context, customers, customers.id, customers.tenant, payload.customer, "Debitor"),
|
||||||
|
assertTenantEntityExists(context, customers, customers.id, customers.tenant, payload.contraCustomer, "Haben-Debitor"),
|
||||||
|
assertTenantEntityExists(context, vendors, vendors.id, vendors.tenant, payload.vendor, "Kreditor"),
|
||||||
|
assertTenantEntityExists(context, vendors, vendors.id, vendors.tenant, payload.contraVendor, "Haben-Kreditor"),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
export const accountingTools: McpTool[] = [
|
export const accountingTools: McpTool[] = [
|
||||||
{
|
{
|
||||||
name: "accounting.outgoing_documents.tax_types.list",
|
name: "accounting.outgoing_documents.tax_types.list",
|
||||||
@@ -1175,4 +1222,113 @@ export const accountingTools: McpTool[] = [
|
|||||||
return { rows }
|
return { rows }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.statement_allocations.create",
|
||||||
|
title: "Bankzuweisung erstellen",
|
||||||
|
description: "Erstellt eine Bankzuweisung oder manuelle Soll/Haben-Buchung. Unterstützt Ausgangs- und Eingangsbelege, Sachkonten, zusätzliche Konten, Debitoren, Kreditoren, Teilbeträge, DATEV-Steuerschlüssel und Abschreibungen.",
|
||||||
|
requiredPermissions: ["accounting.statement_allocations.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["amount"],
|
||||||
|
properties: {
|
||||||
|
bankstatement: { type: "number", description: "Bankumsatz-ID. Weglassen für eine manuelle Soll/Haben-Buchung." },
|
||||||
|
amount: { type: "number", description: "Zuweisungsbetrag mit Vorzeichen des Bankumsatzes; bei manuellen Buchungen positiv." },
|
||||||
|
createddocument: { type: "number", description: "Ausgangsbeleg-ID." },
|
||||||
|
incominginvoice: { type: "number", description: "Eingangsbeleg-ID." },
|
||||||
|
account: { type: "number", description: "Sachkonto-ID beziehungsweise Soll-Sachkonto." },
|
||||||
|
ownaccount: { type: "string", description: "UUID eines zusätzlichen Kontos beziehungsweise Soll-Kontos." },
|
||||||
|
customer: { type: "number", description: "Debitor-ID beziehungsweise Soll-Debitor." },
|
||||||
|
vendor: { type: "number", description: "Kreditor-ID beziehungsweise Soll-Kreditor." },
|
||||||
|
contraAccount: { type: "number", description: "Haben-Sachkonto für manuelle Buchungen." },
|
||||||
|
contraOwnaccount: { type: "string", description: "UUID eines zusätzlichen Haben-Kontos." },
|
||||||
|
contraCustomer: { type: "number", description: "Haben-Debitor für manuelle Buchungen." },
|
||||||
|
contraVendor: { type: "number", description: "Haben-Kreditor für manuelle Buchungen." },
|
||||||
|
manualBookingDate: { type: "string", format: "date", description: "Buchungsdatum einer manuellen Buchung." },
|
||||||
|
manualInvoiceSide: { type: "string", enum: ["debit", "credit"], description: "Soll/Haben-Seite eines Eingangsbelegs in einer manuellen Buchung." },
|
||||||
|
description: { type: "string" },
|
||||||
|
datevTaxKey: { type: "string", description: "Optionaler DATEV-Steuerschlüssel, z. B. 9, 8, 19 oder 18." },
|
||||||
|
bookingMode: { type: "string", enum: ["expense", "depreciation_single", "depreciation_bundle"], default: "expense" },
|
||||||
|
depreciationMonths: { type: "number", minimum: 1 },
|
||||||
|
depreciationStartDate: { type: "string", format: "date" },
|
||||||
|
depreciationMethod: { type: "string", enum: ["linear", "degressive"], default: "linear" },
|
||||||
|
depreciationLabel: { type: "string" },
|
||||||
|
depreciationGroup: { type: "string" },
|
||||||
|
residualValue: { type: "number", minimum: 0, default: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const payload = prepareStatementAllocationInput(args)
|
||||||
|
await assertStatementAllocationReferences(context, payload)
|
||||||
|
|
||||||
|
const [created] = await context.server.db
|
||||||
|
.insert(statementallocations)
|
||||||
|
.values({
|
||||||
|
...payload,
|
||||||
|
tenant: context.tenantId,
|
||||||
|
updated_at: new Date(),
|
||||||
|
updated_by: context.userId,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (created?.bankstatement) {
|
||||||
|
await insertHistoryItem(context.server, {
|
||||||
|
entity: "bankstatements",
|
||||||
|
entityId: Number(created.bankstatement),
|
||||||
|
action: "created",
|
||||||
|
created_by: context.userId,
|
||||||
|
tenant_id: context.tenantId,
|
||||||
|
oldVal: null,
|
||||||
|
newVal: created,
|
||||||
|
text: "Buchung über MCP erstellt",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allocation: created }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.statement_allocations.delete",
|
||||||
|
title: "Bankzuweisung löschen",
|
||||||
|
description: "Löscht eine Bankzuweisung oder manuelle Soll/Haben-Buchung im aktiven Mandanten.",
|
||||||
|
requiredPermissions: ["accounting.statement_allocations.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "UUID der Bankzuweisung." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = statementAllocationUuidArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
|
||||||
|
const [existing] = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(statementallocations)
|
||||||
|
.where(and(eq(statementallocations.id, id), eq(statementallocations.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing) throw new Error("Bankzuweisung nicht gefunden")
|
||||||
|
|
||||||
|
const [deleted] = await context.server.db
|
||||||
|
.delete(statementallocations)
|
||||||
|
.where(and(eq(statementallocations.id, id), eq(statementallocations.tenant, context.tenantId)))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (existing.bankstatement) {
|
||||||
|
await insertHistoryItem(context.server, {
|
||||||
|
entity: "bankstatements",
|
||||||
|
entityId: Number(existing.bankstatement),
|
||||||
|
action: "deleted",
|
||||||
|
created_by: context.userId,
|
||||||
|
tenant_id: context.tenantId,
|
||||||
|
oldVal: existing,
|
||||||
|
newVal: null,
|
||||||
|
text: "Buchung über MCP gelöscht",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deleted: true, allocation: deleted }
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
import { and, desc, eq, ilike, or } from "drizzle-orm"
|
||||||
import { customers, events, plants, projects, tasks } from "../../../db/schema"
|
import { customers, events, plants, projects, tasks } from "../../../db/schema"
|
||||||
|
import { insertHistoryItem } from "../../utils/history"
|
||||||
|
import { activateProjectPhase } from "../projectPhases"
|
||||||
import { McpTool } from "../types"
|
import { McpTool } from "../types"
|
||||||
|
|
||||||
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
||||||
@@ -136,6 +138,77 @@ export const organisationTools: McpTool[] = [
|
|||||||
return { project: rows[0] }
|
return { project: rows[0] }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "organisation.projects.phase.update",
|
||||||
|
title: "Projektphase aktualisieren",
|
||||||
|
description: "Aktiviert eine Projektphase anhand ihres Schlüssels oder ihrer eindeutigen Bezeichnung. Pflichtphasen können nicht übersprungen werden; optionale Phasen und der direkte Abschluss entsprechen dem Verhalten der Oberfläche.",
|
||||||
|
requiredPermissions: ["organisation.projects.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
oneOf: [
|
||||||
|
{ required: ["phaseKey"] },
|
||||||
|
{ required: ["phaseLabel"] },
|
||||||
|
],
|
||||||
|
properties: {
|
||||||
|
id: { type: "number", description: "Projekt-ID." },
|
||||||
|
phaseKey: { type: "string", description: "Technischer Schlüssel der zu aktivierenden Phase; alternativ zu phaseLabel." },
|
||||||
|
phaseLabel: { type: "string", description: "Eindeutige Bezeichnung der zu aktivierenden Phase, falls kein phaseKey bekannt ist." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = numberArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
|
||||||
|
const [existing] = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing) throw new Error("Projekt nicht gefunden")
|
||||||
|
if (existing.archived) throw new Error("Die Phase eines archivierten Projekts kann nicht geändert werden")
|
||||||
|
|
||||||
|
const transition = activateProjectPhase(existing.phases, {
|
||||||
|
phaseKey: stringArg(args, "phaseKey"),
|
||||||
|
phaseLabel: stringArg(args, "phaseLabel"),
|
||||||
|
}, context.userId)
|
||||||
|
|
||||||
|
const [updated] = await context.server.db
|
||||||
|
.update(projects)
|
||||||
|
.set({
|
||||||
|
phases: transition.phases,
|
||||||
|
active_phase: transition.activePhase,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: context.userId,
|
||||||
|
})
|
||||||
|
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId)))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!updated) throw new Error("Projekt nicht gefunden")
|
||||||
|
|
||||||
|
await insertHistoryItem(context.server, {
|
||||||
|
tenant_id: context.tenantId,
|
||||||
|
created_by: context.userId,
|
||||||
|
entity: "projects",
|
||||||
|
entityId: id,
|
||||||
|
action: "updated",
|
||||||
|
oldVal: existing,
|
||||||
|
newVal: updated,
|
||||||
|
text: transition.previousPhase
|
||||||
|
? `Projektphase von „${transition.previousPhase}“ auf „${transition.activePhase}“ geändert`
|
||||||
|
: `Projektphase „${transition.activePhase}“ aktiviert`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: updated,
|
||||||
|
phaseTransition: {
|
||||||
|
previousPhase: transition.previousPhase,
|
||||||
|
activePhase: transition.activePhase,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "organisation.plants.list",
|
name: "organisation.plants.list",
|
||||||
title: "Anlagen auflisten",
|
title: "Anlagen auflisten",
|
||||||
|
|||||||
@@ -42,8 +42,10 @@ const adminPermissions = [
|
|||||||
"accounting.incoming_invoices.write",
|
"accounting.incoming_invoices.write",
|
||||||
"accounting.bank.read",
|
"accounting.bank.read",
|
||||||
"accounting.statement_allocations.read",
|
"accounting.statement_allocations.read",
|
||||||
|
"accounting.statement_allocations.write",
|
||||||
"organisation.customers.read",
|
"organisation.customers.read",
|
||||||
"organisation.projects.read",
|
"organisation.projects.read",
|
||||||
|
"organisation.projects.write",
|
||||||
"organisation.plants.read",
|
"organisation.plants.read",
|
||||||
"organisation.events.read",
|
"organisation.events.read",
|
||||||
"organisation.tasks.read",
|
"organisation.tasks.read",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
contracttypes,
|
contracttypes,
|
||||||
costcentres,
|
costcentres,
|
||||||
createddocuments,
|
createddocuments,
|
||||||
|
documenttemplates,
|
||||||
customerinventoryitems,
|
customerinventoryitems,
|
||||||
customerspaces,
|
customerspaces,
|
||||||
customers,
|
customers,
|
||||||
@@ -237,6 +238,9 @@ export const resourceConfig = {
|
|||||||
texttemplates: {
|
texttemplates: {
|
||||||
table: texttemplates
|
table: texttemplates
|
||||||
},
|
},
|
||||||
|
documenttemplates: {
|
||||||
|
table: documenttemplates,
|
||||||
|
},
|
||||||
incominginvoices: {
|
incominginvoices: {
|
||||||
table: incominginvoices,
|
table: incominginvoices,
|
||||||
mtmLoad: ["statementallocations","files"],
|
mtmLoad: ["statementallocations","files"],
|
||||||
|
|||||||
75
backend/tests/mcpProjectPhases.test.ts
Normal file
75
backend/tests/mcpProjectPhases.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { organisationTools } from "../src/mcp/tools/organisation"
|
||||||
|
import { activateProjectPhase } from "../src/mcp/projectPhases"
|
||||||
|
|
||||||
|
const phases = [
|
||||||
|
{ key: "start", label: "Erstkontakt", active: true, optional: false },
|
||||||
|
{ key: "planning", label: "Planung", optional: false },
|
||||||
|
{ key: "review", label: "Prüfung", optional: true },
|
||||||
|
{ key: "execution", label: "Umsetzung", optional: false },
|
||||||
|
{ key: "done", label: "Abgeschlossen", optional: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
test("registers a write-protected project phase update tool", () => {
|
||||||
|
const tool = organisationTools.find((candidate) => candidate.name === "organisation.projects.phase.update")
|
||||||
|
|
||||||
|
assert.deepEqual(tool?.requiredPermissions, ["organisation.projects.write"])
|
||||||
|
assert.deepEqual((tool?.inputSchema as any).required, ["id"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("activates the next project phase by key and records actor and timestamp", () => {
|
||||||
|
const result = activateProjectPhase(phases, { phaseKey: "planning" }, "user-1", "2026-08-31T12:00:00.000Z")
|
||||||
|
|
||||||
|
assert.equal(result.activePhase, "Planung")
|
||||||
|
assert.equal(result.previousPhase, "Erstkontakt")
|
||||||
|
assert.equal(result.phases[0].active, false)
|
||||||
|
assert.deepEqual(result.phases[1], {
|
||||||
|
key: "planning",
|
||||||
|
label: "Planung",
|
||||||
|
optional: false,
|
||||||
|
active: true,
|
||||||
|
activated_at: "2026-08-31T12:00:00.000Z",
|
||||||
|
activated_by: "user-1",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows skipping optional phases and selecting a unique phase label", () => {
|
||||||
|
const planningActive = phases.map((phase) => ({ ...phase, active: phase.key === "planning" }))
|
||||||
|
const result = activateProjectPhase(planningActive, { phaseLabel: "Umsetzung" }, "user-1", "2026-08-31T12:00:00.000Z")
|
||||||
|
|
||||||
|
assert.equal(result.activePhase, "Umsetzung")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects skipping required phases", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => activateProjectPhase(phases, { phaseKey: "execution" }, "user-1", "2026-08-31T12:00:00.000Z"),
|
||||||
|
/Planung.*zuerst aktiviert/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows completing a project directly like the UI", () => {
|
||||||
|
const result = activateProjectPhase(phases, { phaseKey: "done" }, "user-1", "2026-08-31T12:00:00.000Z")
|
||||||
|
|
||||||
|
assert.equal(result.activePhase, "Abgeschlossen")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects unknown, ambiguous and already activated phases", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => activateProjectPhase(phases, { phaseKey: "missing" }, "user-1", "2026-08-31T12:00:00.000Z"),
|
||||||
|
/nicht gefunden/,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => activateProjectPhase([
|
||||||
|
...phases,
|
||||||
|
{ key: "planning-2", label: "Planung", optional: true },
|
||||||
|
], { phaseLabel: "Planung" }, "user-1", "2026-08-31T12:00:00.000Z"),
|
||||||
|
/nicht eindeutig/,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => activateProjectPhase(phases, { phaseKey: "start" }, "user-1", "2026-08-31T12:00:00.000Z"),
|
||||||
|
/bereits aktiv/,
|
||||||
|
)
|
||||||
|
})
|
||||||
114
backend/tests/mcpStatementAllocations.test.ts
Normal file
114
backend/tests/mcpStatementAllocations.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import {
|
||||||
|
accountingTools,
|
||||||
|
prepareStatementAllocationInput,
|
||||||
|
} from "../src/mcp/tools/accounting"
|
||||||
|
|
||||||
|
const createTool = () => accountingTools.find((tool) => tool.name === "accounting.statement_allocations.create")
|
||||||
|
const deleteTool = () => accountingTools.find((tool) => tool.name === "accounting.statement_allocations.delete")
|
||||||
|
|
||||||
|
test("registers write tools for creating and deleting statement allocations", () => {
|
||||||
|
assert.deepEqual(createTool()?.requiredPermissions, ["accounting.statement_allocations.write"])
|
||||||
|
assert.deepEqual(deleteTool()?.requiredPermissions, ["accounting.statement_allocations.write"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prepares a direct bank allocation to every target type exposed by the UI", () => {
|
||||||
|
const base = {
|
||||||
|
bankstatement: 42,
|
||||||
|
amount: -125.5,
|
||||||
|
description: "Teilzahlung",
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = [
|
||||||
|
["createddocument", 11],
|
||||||
|
["incominginvoice", 12],
|
||||||
|
["account", 13],
|
||||||
|
["ownaccount", "57d31d62-11a2-47c5-b074-69f9c5ab8bba"],
|
||||||
|
["customer", 14],
|
||||||
|
["vendor", 15],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
for (const [field, value] of targets) {
|
||||||
|
assert.deepEqual(prepareStatementAllocationInput({ ...base, [field]: value }), {
|
||||||
|
bankstatement: 42,
|
||||||
|
amount: -125.5,
|
||||||
|
description: "Teilzahlung",
|
||||||
|
bookingMode: "expense",
|
||||||
|
[field]: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prepares depreciation metadata for direct account allocations", () => {
|
||||||
|
assert.deepEqual(prepareStatementAllocationInput({
|
||||||
|
bankstatement: 42,
|
||||||
|
amount: -1200,
|
||||||
|
account: 13,
|
||||||
|
bookingMode: "depreciation_bundle",
|
||||||
|
depreciationMonths: 60,
|
||||||
|
depreciationStartDate: "2026-08-01",
|
||||||
|
depreciationMethod: "degressive",
|
||||||
|
depreciationLabel: "Werkzeug",
|
||||||
|
depreciationGroup: "BGA 2026",
|
||||||
|
residualValue: 100,
|
||||||
|
}), {
|
||||||
|
bankstatement: 42,
|
||||||
|
amount: -1200,
|
||||||
|
account: 13,
|
||||||
|
bookingMode: "depreciation_bundle",
|
||||||
|
depreciationMonths: 60,
|
||||||
|
depreciationStartDate: "2026-08-01",
|
||||||
|
depreciationMethod: "degressive",
|
||||||
|
depreciationLabel: "Werkzeug",
|
||||||
|
depreciationGroup: "BGA 2026",
|
||||||
|
residualValue: 100,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prepares manual Soll/Haben bookings including incoming invoices", () => {
|
||||||
|
assert.deepEqual(prepareStatementAllocationInput({
|
||||||
|
manualBookingDate: "2026-08-31",
|
||||||
|
amount: 99.95,
|
||||||
|
incominginvoice: 7,
|
||||||
|
manualInvoiceSide: "debit",
|
||||||
|
contraOwnaccount: "57d31d62-11a2-47c5-b074-69f9c5ab8bba",
|
||||||
|
datevTaxKey: "9",
|
||||||
|
description: "Manuelle Buchung",
|
||||||
|
}), {
|
||||||
|
bankstatement: null,
|
||||||
|
manualBookingDate: "2026-08-31",
|
||||||
|
amount: 99.95,
|
||||||
|
incominginvoice: 7,
|
||||||
|
manualInvoiceSide: "debit",
|
||||||
|
contraOwnaccount: "57d31d62-11a2-47c5-b074-69f9c5ab8bba",
|
||||||
|
datevTaxKey: "9",
|
||||||
|
description: "Manuelle Buchung",
|
||||||
|
bookingMode: "expense",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects ambiguous bank targets and invalid manual booking sides", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => prepareStatementAllocationInput({ bankstatement: 42, amount: 10, account: 1, vendor: 2 }),
|
||||||
|
/genau ein Ziel/,
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() => prepareStatementAllocationInput({ manualBookingDate: "2026-08-31", amount: 10, account: 1 }),
|
||||||
|
/Soll- und ein Haben-Konto/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects incomplete depreciation settings", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => prepareStatementAllocationInput({
|
||||||
|
bankstatement: 42,
|
||||||
|
amount: -1200,
|
||||||
|
account: 13,
|
||||||
|
bookingMode: "depreciation_bundle",
|
||||||
|
depreciationMonths: 0,
|
||||||
|
depreciationStartDate: "2026-08-01",
|
||||||
|
}),
|
||||||
|
/Abschreibungsdauer/,
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import {useSum} from "~/composables/useSum.js";
|
import {useSum} from "~/composables/useSum.js";
|
||||||
|
import CreateDocumentModal from "~/components/createDocumentModal.vue";
|
||||||
|
import CreateDocumentFromTemplateModal from "~/components/createDocumentFromTemplateModal.vue";
|
||||||
defineShortcuts({
|
defineShortcuts({
|
||||||
/*'/': () => {
|
/*'/': () => {
|
||||||
//console.log(searchinput)
|
//console.log(searchinput)
|
||||||
@@ -56,6 +58,7 @@ const dataStore = useDataStore()
|
|||||||
const tempStore = useTempStore()
|
const tempStore = useTempStore()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const modal = useModal()
|
||||||
const deliveryNoteLikeDocumentTypes = ['deliveryNotes', 'packingSlips']
|
const deliveryNoteLikeDocumentTypes = ['deliveryNotes', 'packingSlips']
|
||||||
|
|
||||||
const createddocuments = ref([])
|
const createddocuments = ref([])
|
||||||
@@ -154,34 +157,17 @@ const selectItem = (item) => {
|
|||||||
Lieferscheine/Packscheine abrechnen
|
Lieferscheine/Packscheine abrechnen
|
||||||
</UButton>
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'quotes'})}`)"
|
icon="i-heroicons-plus"
|
||||||
|
@click="modal.open(CreateDocumentModal, { queryStringData: props.queryStringData })"
|
||||||
>
|
>
|
||||||
+ Angebot
|
Dokument
|
||||||
</UButton>
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'costEstimates'})}`)"
|
icon="i-heroicons-document-duplicate"
|
||||||
|
variant="outline"
|
||||||
|
@click="modal.open(CreateDocumentFromTemplateModal, { queryStringData: props.queryStringData })"
|
||||||
>
|
>
|
||||||
+ Kostenschätzung
|
Dokument aus Vorlage
|
||||||
</UButton>
|
|
||||||
<UButton
|
|
||||||
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'confirmationOrders'})}`)"
|
|
||||||
>
|
|
||||||
+ Auftragsbestätigung
|
|
||||||
</UButton>
|
|
||||||
<UButton
|
|
||||||
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'deliveryNotes'})}`)"
|
|
||||||
>
|
|
||||||
+ Lieferschein
|
|
||||||
</UButton>
|
|
||||||
<UButton
|
|
||||||
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'packingSlips'})}`)"
|
|
||||||
>
|
|
||||||
+ Packschein
|
|
||||||
</UButton>
|
|
||||||
<UButton
|
|
||||||
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'advanceInvoices'})}`)"
|
|
||||||
>
|
|
||||||
+ Abschlagsrechnung
|
|
||||||
</UButton>
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
@click="showFinalInvoiceConfig = true"
|
@click="showFinalInvoiceConfig = true"
|
||||||
@@ -238,12 +224,6 @@ const selectItem = (item) => {
|
|||||||
</UCard>
|
</UCard>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</UModal>
|
||||||
<UButton
|
|
||||||
@click="router.push(`/createDocument/edit/?${getAvailableQueryStringData({type: 'invoices'})}`)"
|
|
||||||
>
|
|
||||||
+ Rechnung
|
|
||||||
</UButton>
|
|
||||||
|
|
||||||
<template #right>
|
<template #right>
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
v-model="selectedColumns"
|
v-model="selectedColumns"
|
||||||
|
|||||||
@@ -331,6 +331,11 @@ const links = computed(() => {
|
|||||||
to: "/settings/texttemplates",
|
to: "/settings/texttemplates",
|
||||||
icon: "i-heroicons-clipboard-document-list",
|
icon: "i-heroicons-clipboard-document-list",
|
||||||
} : null,
|
} : null,
|
||||||
|
featureEnabled("settingsDocumenttemplates") ? {
|
||||||
|
label: "Dokumentenvorlagen",
|
||||||
|
to: "/settings/documenttemplates",
|
||||||
|
icon: "i-heroicons-document-duplicate",
|
||||||
|
} : null,
|
||||||
featureEnabled("settingsLetterheads") ? {
|
featureEnabled("settingsLetterheads") ? {
|
||||||
label: "Briefpapiere",
|
label: "Briefpapiere",
|
||||||
to: "/settings/letterheads",
|
to: "/settings/letterheads",
|
||||||
|
|||||||
94
frontend/components/createDocumentFromTemplateModal.vue
Normal file
94
frontend/components/createDocumentFromTemplateModal.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup>
|
||||||
|
const dataStore = useDataStore()
|
||||||
|
const modal = useModal()
|
||||||
|
const router = useRouter()
|
||||||
|
const templates = ref([])
|
||||||
|
const selectedType = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const props = defineProps({
|
||||||
|
queryStringData: { type: String, default: "" },
|
||||||
|
})
|
||||||
|
|
||||||
|
const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {})
|
||||||
|
.filter(([key]) => key !== 'serialInvoices')
|
||||||
|
.map(([key, value]) => ({ key, ...value })))
|
||||||
|
const visibleTemplates = computed(() => templates.value.filter(template => {
|
||||||
|
return !selectedType.value || template.documentType === selectedType.value
|
||||||
|
}))
|
||||||
|
|
||||||
|
const loadTemplates = async () => {
|
||||||
|
try {
|
||||||
|
templates.value = await useEntities('documenttemplates').select('*')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectTemplate = (template) => {
|
||||||
|
const query = new URLSearchParams(props.queryStringData)
|
||||||
|
query.set('templateId', template.id)
|
||||||
|
query.set('type', template.documentType)
|
||||||
|
router.push(`/createDocument/edit?${query.toString()}`)
|
||||||
|
modal.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTemplates()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal :ui="{ content: 'sm:max-w-3xl' }">
|
||||||
|
<template #content>
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Dokument aus Vorlage erstellen</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted">Zuerst Dokumenttyp, anschließend die passende Vorlage auswählen.</p>
|
||||||
|
</div>
|
||||||
|
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" @click="modal.close()" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UFormField label="Dokumenttyp" required>
|
||||||
|
<USelectMenu
|
||||||
|
v-model="selectedType"
|
||||||
|
:items="documentTypes"
|
||||||
|
value-key="key"
|
||||||
|
label-key="labelSingle"
|
||||||
|
class="w-full"
|
||||||
|
placeholder="Dokumenttyp auswählen"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<div class="mt-5 space-y-2">
|
||||||
|
<p class="text-sm font-medium text-highlighted">Vorlagen</p>
|
||||||
|
<div v-if="loading" class="py-6 text-center text-sm text-muted">Vorlagen werden geladen …</div>
|
||||||
|
<div v-else-if="visibleTemplates.length === 0" class="rounded-lg border border-dashed border-default p-6 text-center text-sm text-muted">
|
||||||
|
Keine Vorlagen für diesen Dokumenttyp vorhanden.
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<button
|
||||||
|
v-for="template in visibleTemplates"
|
||||||
|
:key="template.id"
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-between rounded-lg border border-default p-3 text-left transition hover:border-primary hover:bg-primary/5"
|
||||||
|
@click="selectTemplate(template)"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span class="block font-medium text-highlighted">{{ template.name }}</span>
|
||||||
|
<span class="text-xs text-muted">{{ dataStore.documentTypesForCreation[template.documentType]?.labelSingle }}</span>
|
||||||
|
</span>
|
||||||
|
<UBadge v-if="template.default" color="primary" variant="soft">Standard</UBadge>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<UButton color="neutral" variant="ghost" @click="modal.close()">Abbrechen</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
62
frontend/components/createDocumentModal.vue
Normal file
62
frontend/components/createDocumentModal.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup>
|
||||||
|
const dataStore = useDataStore()
|
||||||
|
const modal = useModal()
|
||||||
|
const router = useRouter()
|
||||||
|
const props = defineProps({
|
||||||
|
queryStringData: { type: String, default: "" },
|
||||||
|
projectId: { type: [String, Number], default: null },
|
||||||
|
customerId: { type: [String, Number], default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {})
|
||||||
|
.filter(([key]) => key !== 'serialInvoices')
|
||||||
|
.map(([key, value]) => ({ key, ...value })))
|
||||||
|
|
||||||
|
const createDocument = (type) => {
|
||||||
|
const query = new URLSearchParams(props.queryStringData)
|
||||||
|
query.set('type', type)
|
||||||
|
if (props.projectId) query.set('project', props.projectId)
|
||||||
|
if (props.customerId) query.set('customer', props.customerId)
|
||||||
|
router.push(`/createDocument/edit?${query.toString()}`)
|
||||||
|
modal.close()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal :ui="{ content: 'sm:max-w-3xl' }">
|
||||||
|
<template #content>
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Dokument erstellen</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted">Wähle den gewünschten Ausgangsbeleg.</p>
|
||||||
|
</div>
|
||||||
|
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" @click="modal.close()" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<UButton
|
||||||
|
v-for="documentType in documentTypes"
|
||||||
|
:key="documentType.key"
|
||||||
|
block
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
class="flex h-auto min-h-24 flex-col items-start justify-center gap-1 p-4 text-left"
|
||||||
|
@click="createDocument(documentType.key)"
|
||||||
|
>
|
||||||
|
<span class="font-semibold text-highlighted">{{ documentType.labelSingle }}</span>
|
||||||
|
<span class="text-xs text-muted">{{ documentType.label }}</span>
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<UButton color="neutral" variant="ghost" @click="modal.close()">Abbrechen</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
@@ -13,6 +13,8 @@ const router = useRouter()
|
|||||||
const modal = useModal()
|
const modal = useModal()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const isTemplateMode = computed(() => Boolean(route.query.templateFromDocument || route.query.mode === "template"))
|
||||||
|
const templateName = ref("")
|
||||||
const quoteLikeDocumentTypes = ["quotes", "costEstimates"]
|
const quoteLikeDocumentTypes = ["quotes", "costEstimates"]
|
||||||
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
|
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
|
||||||
const documentStorageFallbackTypes = {
|
const documentStorageFallbackTypes = {
|
||||||
@@ -399,6 +401,24 @@ const setupPage = async () => {
|
|||||||
if (route.query) {
|
if (route.query) {
|
||||||
if (route.query.type) itemInfo.value.type = route.query.type
|
if (route.query.type) itemInfo.value.type = route.query.type
|
||||||
|
|
||||||
|
if (route.query.templateFromDocument) {
|
||||||
|
const sourceDocument = await useEntities("createddocuments").selectSingle(route.query.templateFromDocument, '', false)
|
||||||
|
const sourceData = JSON.parse(JSON.stringify(sourceDocument || {}))
|
||||||
|
;["id", "createdAt", "tenant", "documentNumber", "documentDate", "state", "customer", "contact", "address", "project", "costcentre", "createddocument", "availableInPortal", "archived", "statementallocations", "files", "linkedDocument", "createddocuments", "serialexecution"].forEach((key) => delete sourceData[key])
|
||||||
|
Object.assign(itemInfo.value, sourceData)
|
||||||
|
itemInfo.value.type = route.query.type || sourceDocument?.type || itemInfo.value.type
|
||||||
|
templateName.value = `${dataStore.documentTypesForCreation[itemInfo.value.type]?.labelSingle || "Dokument"} Vorlage`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.query.templateId) {
|
||||||
|
const template = await useEntities("documenttemplates").selectSingle(route.query.templateId, '', false)
|
||||||
|
if (template?.templateData) {
|
||||||
|
Object.assign(itemInfo.value, JSON.parse(JSON.stringify(template.templateData)))
|
||||||
|
itemInfo.value.type = template.documentType
|
||||||
|
templateName.value = template.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!itemInfo.value.startText && !itemInfo.value.endText) {
|
if (!itemInfo.value.startText && !itemInfo.value.endText) {
|
||||||
setDocumentTypeConfig(true)
|
setDocumentTypeConfig(true)
|
||||||
} else {
|
} else {
|
||||||
@@ -1756,6 +1776,31 @@ const saveSerialInvoice = async () => {
|
|||||||
await router.push(`/createDocument/edit/${data.id}`)
|
await router.push(`/createDocument/edit/${data.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const serializeTemplateData = () => {
|
||||||
|
const data = JSON.parse(JSON.stringify(itemInfo.value))
|
||||||
|
;["id", "createdAt", "tenant", "documentNumber", "documentDate", "state", "customer", "contact", "address", "project", "costcentre", "createddocument", "availableInPortal", "archived", "statementallocations", "files", "linkedDocument", "createddocuments", "serialexecution", "createdBy", "created_by"].forEach((key) => delete data[key])
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveDocumentTemplate = async () => {
|
||||||
|
if (!templateName.value.trim()) {
|
||||||
|
toast.add({ title: "Vorlagenname fehlt", description: "Bitte einen Namen für die Vorlage vergeben.", color: "error" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: templateName.value.trim(),
|
||||||
|
documentType: itemInfo.value.type,
|
||||||
|
templateData: serializeTemplateData(),
|
||||||
|
default: false,
|
||||||
|
}
|
||||||
|
const template = route.query.templateId
|
||||||
|
? await useEntities("documenttemplates").update(route.query.templateId, payload, true)
|
||||||
|
: await useEntities("documenttemplates").create(payload)
|
||||||
|
toast.add({ title: "Vorlage gespeichert", color: "success" })
|
||||||
|
await router.push(`/createDocument/edit?mode=template&templateId=${template.id || route.query.templateId}`)
|
||||||
|
}
|
||||||
|
|
||||||
const saveDocument = async (state, resetup = false) => {
|
const saveDocument = async (state, resetup = false) => {
|
||||||
|
|
||||||
itemInfo.value.state = state
|
itemInfo.value.state = state
|
||||||
@@ -2024,7 +2069,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
<ArchiveButton
|
<ArchiveButton
|
||||||
color="error"
|
color="error"
|
||||||
type="createddocuments"
|
type="createddocuments"
|
||||||
v-if="itemInfo.state === 'Entwurf' || itemInfo.type === 'serialInvoices'"
|
v-if="!isTemplateMode && (itemInfo.state === 'Entwurf' || itemInfo.type === 'serialInvoices')"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@confirmed="useEntities('createddocuments').update(itemInfo.id,{archived: true}),
|
@confirmed="useEntities('createddocuments').update(itemInfo.id,{archived: true}),
|
||||||
router.push('/')"
|
router.push('/')"
|
||||||
@@ -2032,27 +2077,46 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
<UButton
|
<UButton
|
||||||
icon="i-mdi-content-save"
|
icon="i-mdi-content-save"
|
||||||
@click="saveDocument('Entwurf',true)"
|
@click="saveDocument('Entwurf',true)"
|
||||||
v-if="itemInfo.type !== 'serialInvoices' "
|
v-if="itemInfo.type !== 'serialInvoices' && !isTemplateMode"
|
||||||
:disabled="!itemInfo.customer"
|
:disabled="!itemInfo.customer"
|
||||||
>
|
>
|
||||||
Speichern
|
Speichern
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="isTemplateMode"
|
||||||
|
icon="i-mdi-content-save"
|
||||||
|
color="primary"
|
||||||
|
@click="saveDocumentTemplate"
|
||||||
|
>
|
||||||
|
Als Vorlage speichern
|
||||||
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
@click="closeDocument"
|
@click="closeDocument"
|
||||||
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices'"
|
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices' && !isTemplateMode"
|
||||||
>
|
>
|
||||||
{{selectedTab === '0' ? "Vorschau zeigen" : "Fertigstellen"}}
|
{{selectedTab === '0' ? "Vorschau zeigen" : "Fertigstellen"}}
|
||||||
</UButton>
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-mdi-content-save"
|
icon="i-mdi-content-save"
|
||||||
@click="saveSerialInvoice"
|
@click="saveSerialInvoice"
|
||||||
v-if="itemInfo.type === 'serialInvoices'"
|
v-if="itemInfo.type === 'serialInvoices' && !isTemplateMode"
|
||||||
>
|
>
|
||||||
Serienrechnung
|
Serienrechnung
|
||||||
</UButton>
|
</UButton>
|
||||||
</template>
|
</template>
|
||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
<UDashboardPanelContent>
|
<UDashboardPanelContent>
|
||||||
|
<div v-if="isTemplateMode" class="px-5 pt-5">
|
||||||
|
<UAlert
|
||||||
|
color="primary"
|
||||||
|
variant="soft"
|
||||||
|
title="Vorlagenmodus"
|
||||||
|
description="Dieser Editor speichert ausschließlich eine Dokumentenvorlage. Es wird kein Ausgangsbeleg erstellt."
|
||||||
|
/>
|
||||||
|
<UFormField label="Vorlagenname" required class="mt-3">
|
||||||
|
<UInput v-model="templateName" placeholder="z. B. Standardrechnung" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
<UTabs class="p-5" :items="tabItems" @update:model-value="onChangeTab" v-if="loaded" v-model="selectedTab">
|
<UTabs class="p-5" :items="tabItems" @update:model-value="onChangeTab" v-if="loaded" v-model="selectedTab">
|
||||||
<template #content="{item}">
|
<template #content="{item}">
|
||||||
<div v-if="item.label === 'Editor'">
|
<div v-if="item.label === 'Editor'">
|
||||||
@@ -2084,6 +2148,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
<USelectMenu
|
<USelectMenu
|
||||||
:items="documentTypeItems"
|
:items="documentTypeItems"
|
||||||
v-model="itemInfo.type"
|
v-model="itemInfo.type"
|
||||||
|
:disabled="isTemplateMode"
|
||||||
value-key="type"
|
value-key="type"
|
||||||
label-key="label"
|
label-key="label"
|
||||||
@update:model-value="setDocumentTypeConfig"
|
@update:model-value="setDocumentTypeConfig"
|
||||||
|
|||||||
@@ -21,10 +21,11 @@
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
@click="clearSearchString()"
|
@click="clearSearchString()"
|
||||||
/>
|
/>
|
||||||
<UButton
|
<UButton icon="i-heroicons-plus" @click="modal.open(CreateDocumentModal)">
|
||||||
@click="router.push(`/createDocument/edit`)"
|
Dokument
|
||||||
>
|
</UButton>
|
||||||
+ Ausgangsbeleg
|
<UButton icon="i-heroicons-document-duplicate" variant="outline" @click="modal.open(CreateDocumentFromTemplateModal)">
|
||||||
|
Dokument aus Vorlage
|
||||||
</UButton>
|
</UButton>
|
||||||
</template>
|
</template>
|
||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
@@ -163,12 +164,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import CreateDocumentModal from "~/components/createDocumentModal.vue"
|
||||||
|
import CreateDocumentFromTemplateModal from "~/components/createDocumentFromTemplateModal.vue"
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { ref, computed, reactive, watch } from 'vue';
|
import { ref, computed, reactive, watch } from 'vue';
|
||||||
|
|
||||||
const dataStore = useDataStore()
|
const dataStore = useDataStore()
|
||||||
const tempStore = useTempStore()
|
const tempStore = useTempStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const modal = useModal()
|
||||||
const quoteLikeDocumentTypes = ['quotes', 'costEstimates']
|
const quoteLikeDocumentTypes = ['quotes', 'costEstimates']
|
||||||
const deliveryNoteLikeDocumentTypes = ['deliveryNotes', 'packingSlips']
|
const deliveryNoteLikeDocumentTypes = ['deliveryNotes', 'packingSlips']
|
||||||
|
|
||||||
@@ -209,7 +213,7 @@ defineShortcuts({
|
|||||||
document.getElementById("searchinput").focus()
|
document.getElementById("searchinput").focus()
|
||||||
},
|
},
|
||||||
'+': () => {
|
'+': () => {
|
||||||
router.push('/createDocument/edit')
|
modal.open(CreateDocumentModal)
|
||||||
},
|
},
|
||||||
'Enter': {
|
'Enter': {
|
||||||
usingInput: true,
|
usingInput: true,
|
||||||
|
|||||||
@@ -226,6 +226,13 @@ const togglePortalRelease = async () => {
|
|||||||
>
|
>
|
||||||
Kopieren
|
Kopieren
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-document-duplicate"
|
||||||
|
variant="outline"
|
||||||
|
@click="router.push(`/createDocument/edit?templateFromDocument=${itemInfo.id}&type=${itemInfo.type}`)"
|
||||||
|
>
|
||||||
|
Als Vorlage übernehmen
|
||||||
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
@click="openEmail"
|
@click="openEmail"
|
||||||
icon="i-heroicons-envelope"
|
icon="i-heroicons-envelope"
|
||||||
|
|||||||
81
frontend/pages/settings/documenttemplates.vue
Normal file
81
frontend/pages/settings/documenttemplates.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup>
|
||||||
|
const dataStore = useDataStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const templates = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const documentTypeItems = computed(() => dataStore.documentTypesForCreation || {})
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
templates.value = await useEntities("documenttemplates").select("*")
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({ title: "Vorlagen konnten nicht geladen werden", description: error.message, color: "error" })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openNew = () => router.push("/createDocument/edit?mode=template&type=invoices")
|
||||||
|
const openTemplate = (template) => router.push(`/createDocument/edit?mode=template&templateId=${template.id}`)
|
||||||
|
const archiveTemplate = async (template) => {
|
||||||
|
await useEntities("documenttemplates").update(template.id, { archived: true }, true)
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabel = (type) => documentTypeItems.value[type]?.labelSingle || type
|
||||||
|
|
||||||
|
refresh()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UDashboardNavbar title="Dokumentenvorlagen">
|
||||||
|
<template #right>
|
||||||
|
<UButton icon="i-heroicons-plus" @click="openNew">Neue Vorlage</UButton>
|
||||||
|
</template>
|
||||||
|
</UDashboardNavbar>
|
||||||
|
|
||||||
|
<UDashboardPanelContent>
|
||||||
|
<UAlert
|
||||||
|
class="mx-5 mt-2"
|
||||||
|
color="primary"
|
||||||
|
variant="soft"
|
||||||
|
title="Vorlagen für alle Ausgangsdokumente"
|
||||||
|
description="Dokumentenvorlagen verwenden denselben Editor wie die Ausgangsbelege. Jede Einstellung wird als Vorlage gespeichert."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UTable
|
||||||
|
class="mt-4"
|
||||||
|
:data="templates"
|
||||||
|
:loading="loading"
|
||||||
|
:columns="normalizeTableColumns([
|
||||||
|
{ key: 'name', label: 'Bezeichnung' },
|
||||||
|
{ key: 'documentType', label: 'Dokumenttyp' },
|
||||||
|
{ key: 'default', label: 'Standard' },
|
||||||
|
{ key: 'actions', label: '' }
|
||||||
|
])"
|
||||||
|
>
|
||||||
|
<template #name-cell="{ row }">
|
||||||
|
<span class="font-medium text-highlighted">{{ row.original.name }}</span>
|
||||||
|
</template>
|
||||||
|
<template #documentType-cell="{ row }">
|
||||||
|
<UBadge color="neutral" variant="soft">{{ typeLabel(row.original.documentType) }}</UBadge>
|
||||||
|
</template>
|
||||||
|
<template #default-cell="{ row }">
|
||||||
|
<UIcon v-if="row.original.default" name="i-heroicons-check-circle-20-solid" class="text-green-500" />
|
||||||
|
<span v-else class="text-muted">-</span>
|
||||||
|
</template>
|
||||||
|
<template #actions-cell="{ row }">
|
||||||
|
<div class="flex justify-end gap-1">
|
||||||
|
<UButton icon="i-heroicons-pencil-square" color="neutral" variant="ghost" @click="openTemplate(row.original)" />
|
||||||
|
<UButton icon="i-heroicons-archive-box" color="error" variant="ghost" @click="archiveTemplate(row.original)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #empty>
|
||||||
|
<TableEmptyState label="Keine Dokumentenvorlagen gefunden" icon="i-heroicons-document-duplicate" />
|
||||||
|
</template>
|
||||||
|
</UTable>
|
||||||
|
</UDashboardPanelContent>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user