Compare commits
17 Commits
718ecefd46
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| a50ff256ee | |||
| ebbd1e0cd0 | |||
| 2ec162c0e7 | |||
| d0bc8a5e0f | |||
| a6f55b61e4 | |||
| c383a291e1 | |||
| 0eadf42973 | |||
| 616bb5d7f3 | |||
| 6297480b89 | |||
| 48199b0ed7 | |||
| bb18a974dd | |||
| 4e05906556 | |||
| df2308d407 | |||
| 5b6579423b | |||
| b3dd46195d | |||
| 54294ce7e9 | |||
| 5141a1f3ac |
@@ -1,16 +1,42 @@
|
|||||||
import { McpToolResult } from "./types"
|
import { McpToolResult } from "./types"
|
||||||
|
|
||||||
|
const OMIT_ARCHIVED = Symbol("omit-archived")
|
||||||
|
|
||||||
|
function omitArchivedRecords(value: unknown): unknown | typeof OMIT_ARCHIVED {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map(omitArchivedRecords)
|
||||||
|
.filter((item) => item !== OMIT_ARCHIVED)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value || typeof value !== "object") return value
|
||||||
|
|
||||||
|
const prototype = Object.getPrototypeOf(value)
|
||||||
|
if (prototype !== Object.prototype && prototype !== null) return value
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>
|
||||||
|
if (record.archived === true) return OMIT_ARCHIVED
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(record)
|
||||||
|
.map(([key, item]) => [key, omitArchivedRecords(item)] as const)
|
||||||
|
.filter(([, item]) => item !== OMIT_ARCHIVED),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function asToolResult(payload: unknown): McpToolResult {
|
export function asToolResult(payload: unknown): McpToolResult {
|
||||||
|
const sanitizedPayload = omitArchivedRecords(payload)
|
||||||
|
const resultPayload = sanitizedPayload === OMIT_ARCHIVED ? {} : sanitizedPayload
|
||||||
const structuredContent =
|
const structuredContent =
|
||||||
payload && typeof payload === "object" && !Array.isArray(payload)
|
resultPayload && typeof resultPayload === "object" && !Array.isArray(resultPayload)
|
||||||
? payload as Record<string, unknown>
|
? resultPayload as Record<string, unknown>
|
||||||
: { result: payload }
|
: { result: resultPayload }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify(payload, null, 2),
|
text: JSON.stringify(resultPayload, null, 2),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
structuredContent,
|
structuredContent,
|
||||||
@@ -33,4 +59,3 @@ export function asToolError(error: unknown): McpToolResult {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ import {
|
|||||||
folders,
|
folders,
|
||||||
incominginvoices,
|
incominginvoices,
|
||||||
ownaccounts,
|
ownaccounts,
|
||||||
|
serialExecutions,
|
||||||
statementallocations,
|
statementallocations,
|
||||||
vendors,
|
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 { insertHistoryItem } from "../../utils/history"
|
||||||
|
import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service"
|
||||||
|
import { updateOutgoingDocumentCostCentres } from "../../modules/outgoing-document-cost-centres.service"
|
||||||
import {
|
import {
|
||||||
prepareStatementAllocationInput,
|
prepareStatementAllocationInput,
|
||||||
statementAllocationUuidArg,
|
statementAllocationUuidArg,
|
||||||
@@ -43,6 +46,22 @@ const numberArg = (args: Record<string, unknown>, key: string) => {
|
|||||||
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
|
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
|
||||||
const hasValidNumber = (value: unknown) => hasValue(value) && Number.isFinite(Number(value))
|
const hasValidNumber = (value: unknown) => hasValue(value) && Number.isFinite(Number(value))
|
||||||
const MAX_MCP_UPLOAD_BYTES = 20 * 1024 * 1024
|
const MAX_MCP_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||||
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
|
|
||||||
|
const serialConfigSchema = {
|
||||||
|
type: "object",
|
||||||
|
description: "Ausführungsplan einer Serienrechnungsvorlage.",
|
||||||
|
properties: {
|
||||||
|
firstExecution: { type: "string", description: "Datum der ersten Ausführung als ISO-8601-Wert." },
|
||||||
|
executionUntil: { type: ["string", "null"], description: "Optionales Datum der letzten Ausführung als ISO-8601-Wert." },
|
||||||
|
intervall: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["wöchentlich", "2 - wöchentlich", "monatlich", "vierteljährlich", "halbjährlich", "jährlich"],
|
||||||
|
},
|
||||||
|
active: { type: "boolean" },
|
||||||
|
dateDirection: { type: "string", enum: ["Rückwirkend", "Im Voraus"] },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const allowedOutgoingDocumentTypes = new Set([
|
const allowedOutgoingDocumentTypes = new Set([
|
||||||
"quotes",
|
"quotes",
|
||||||
@@ -437,7 +456,6 @@ export const accountingTools: McpTool[] = [
|
|||||||
state: { type: "string", description: "Optionaler Statusfilter, z. B. Entwurf oder Gebucht." },
|
state: { type: "string", description: "Optionaler Statusfilter, z. B. Entwurf oder Gebucht." },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
project: { type: "number" },
|
project: { type: "number" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -455,7 +473,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
if (state) conditions.push(eq(createddocuments.state, state))
|
if (state) conditions.push(eq(createddocuments.state, state))
|
||||||
if (customer) conditions.push(eq(createddocuments.customer, customer))
|
if (customer) conditions.push(eq(createddocuments.customer, customer))
|
||||||
if (project) conditions.push(eq(createddocuments.project, project))
|
if (project) conditions.push(eq(createddocuments.project, project))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(createddocuments.archived, false))
|
conditions.push(eq(createddocuments.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -486,7 +504,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(createddocuments)
|
.from(createddocuments)
|
||||||
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId)))
|
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Ausgangsbeleg nicht gefunden")
|
if (!rows[0]) throw new Error("Ausgangsbeleg nicht gefunden")
|
||||||
@@ -502,7 +520,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
required: ["type"],
|
required: ["type"],
|
||||||
properties: {
|
properties: {
|
||||||
type: { type: "string" },
|
type: { type: "string", enum: [...allowedOutgoingDocumentTypes] },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
contact: { type: "number" },
|
contact: { type: "number" },
|
||||||
contract: { type: "number" },
|
contract: { type: "number" },
|
||||||
@@ -530,6 +548,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
availableInPortal: { type: "boolean" },
|
availableInPortal: { type: "boolean" },
|
||||||
customSurchargePercentage: { type: "number" },
|
customSurchargePercentage: { type: "number" },
|
||||||
report: { type: "object" },
|
report: { type: "object" },
|
||||||
|
serialConfig: serialConfigSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -556,7 +575,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
required: ["id"],
|
required: ["id"],
|
||||||
properties: {
|
properties: {
|
||||||
id: { type: "number" },
|
id: { type: "number" },
|
||||||
type: { type: "string" },
|
type: { type: "string", enum: [...allowedOutgoingDocumentTypes] },
|
||||||
state: { type: "string" },
|
state: { type: "string" },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
contact: { type: "number" },
|
contact: { type: "number" },
|
||||||
@@ -585,6 +604,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
availableInPortal: { type: "boolean" },
|
availableInPortal: { type: "boolean" },
|
||||||
customSurchargePercentage: { type: "number" },
|
customSurchargePercentage: { type: "number" },
|
||||||
report: { type: "object" },
|
report: { type: "object" },
|
||||||
|
serialConfig: serialConfigSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
@@ -614,6 +634,72 @@ export const accountingTools: McpTool[] = [
|
|||||||
return { document: updated }
|
return { document: updated }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.outgoing_documents.cost_centres.update",
|
||||||
|
title: "Kostenstellen eines Ausgangsbelegs ändern",
|
||||||
|
description: "Ändert ausschließlich die Beleg- und Positionskostenstellen eines bereits fertiggestellten Ausgangsbelegs.",
|
||||||
|
requiredPermissions: ["accounting.outgoing_documents.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
anyOf: [
|
||||||
|
{ required: ["costcentre"] },
|
||||||
|
{ required: ["rowCostCentres"] },
|
||||||
|
],
|
||||||
|
properties: {
|
||||||
|
id: { type: "number" },
|
||||||
|
costcentre: { type: ["string", "null"], description: "UUID der Beleg-Kostenstelle oder null." },
|
||||||
|
rowCostCentres: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
required: ["rowId", "costcentre"],
|
||||||
|
properties: {
|
||||||
|
rowId: { type: "string", description: "ID der Belegposition." },
|
||||||
|
costcentre: { type: ["string", "null"], description: "UUID der Positionskostenstelle oder null." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = numberArg(args, "id")
|
||||||
|
if (!id) throw new Error("id ist erforderlich")
|
||||||
|
|
||||||
|
const input: {
|
||||||
|
costcentre?: string | null
|
||||||
|
rowCostCentres?: Array<{ rowId: string, costcentre: string | null }>
|
||||||
|
} = {}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(args, "costcentre")) {
|
||||||
|
const costcentre = args.costcentre
|
||||||
|
if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) {
|
||||||
|
throw new Error("costcentre muss eine gültige UUID oder null sein")
|
||||||
|
}
|
||||||
|
input.costcentre = costcentre as string | null
|
||||||
|
}
|
||||||
|
if (args.rowCostCentres !== undefined) {
|
||||||
|
if (!Array.isArray(args.rowCostCentres)) throw new Error("rowCostCentres muss ein Array sein")
|
||||||
|
input.rowCostCentres = args.rowCostCentres.map((assignment: any) => {
|
||||||
|
const rowId = typeof assignment?.rowId === "string" ? assignment.rowId.trim() : ""
|
||||||
|
const costcentre = assignment?.costcentre
|
||||||
|
if (!rowId) throw new Error("Jede Positionszuordnung benötigt eine rowId")
|
||||||
|
if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) {
|
||||||
|
throw new Error(`Ungültige Kostenstelle für Position ${rowId}`)
|
||||||
|
}
|
||||||
|
return { rowId, costcentre }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await updateOutgoingDocumentCostCentres(
|
||||||
|
context.server,
|
||||||
|
context.tenantId,
|
||||||
|
context.userId,
|
||||||
|
id,
|
||||||
|
input,
|
||||||
|
)
|
||||||
|
return { document }
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "accounting.outgoing_documents.finalize",
|
name: "accounting.outgoing_documents.finalize",
|
||||||
title: "Ausgangsbeleg finalisieren",
|
title: "Ausgangsbeleg finalisieren",
|
||||||
@@ -669,6 +755,90 @@ export const accountingTools: McpTool[] = [
|
|||||||
return { document: updated }
|
return { document: updated }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.serial_invoices.execute",
|
||||||
|
title: "Serienrechnungslauf starten",
|
||||||
|
description: "Erzeugt aus ausgewählten aktiven Serienrechnungsvorlagen einen neuen Rechnungslauf.",
|
||||||
|
requiredPermissions: ["accounting.outgoing_documents.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["executionDate", "templateIds"],
|
||||||
|
properties: {
|
||||||
|
executionDate: { type: "string", description: "Ausführungsdatum als ISO-8601-Wert." },
|
||||||
|
templateIds: {
|
||||||
|
type: "array",
|
||||||
|
minItems: 1,
|
||||||
|
uniqueItems: true,
|
||||||
|
items: { type: "number" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const executionDate = stringArg(args, "executionDate")
|
||||||
|
const parsedExecutionDate = executionDate ? new Date(executionDate) : null
|
||||||
|
const templateIds = Array.isArray(args.templateIds)
|
||||||
|
? [...new Set(args.templateIds.map(Number).filter((id) => Number.isFinite(id) && id > 0))]
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (!parsedExecutionDate || Number.isNaN(parsedExecutionDate.getTime())) {
|
||||||
|
throw new Error("executionDate muss ein gültiger ISO-8601-Wert sein")
|
||||||
|
}
|
||||||
|
if (!templateIds.length) throw new Error("templateIds muss mindestens eine gültige ID enthalten")
|
||||||
|
|
||||||
|
return executeManualGeneration(
|
||||||
|
context.server,
|
||||||
|
parsedExecutionDate,
|
||||||
|
templateIds,
|
||||||
|
context.tenantId,
|
||||||
|
context.userId,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.serial_invoice_executions.list",
|
||||||
|
title: "Serienrechnungsläufe auflisten",
|
||||||
|
description: "Listet die zuletzt gestarteten Serienrechnungsläufe des aktiven Mandanten.",
|
||||||
|
requiredPermissions: ["accounting.outgoing_documents.read"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
status: { type: "string", enum: ["draft", "completed", "error"] },
|
||||||
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const conditions = [eq(serialExecutions.tenant, context.tenantId)]
|
||||||
|
const status = stringArg(args, "status")
|
||||||
|
if (status) conditions.push(eq(serialExecutions.status, status))
|
||||||
|
|
||||||
|
const rows = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(serialExecutions)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(serialExecutions.createdAt))
|
||||||
|
.limit(limitFromArgs(args))
|
||||||
|
|
||||||
|
return { rows }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.serial_invoice_executions.finish",
|
||||||
|
title: "Serienrechnungslauf abschließen",
|
||||||
|
description: "Finalisiert die erzeugten Rechnungen eines Serienrechnungslaufs und schließt den Lauf ab.",
|
||||||
|
requiredPermissions: ["accounting.outgoing_documents.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "UUID des Serienrechnungslaufs." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const id = stringArg(args, "id")
|
||||||
|
if (!id || !UUID_PATTERN.test(id)) throw new Error("id muss eine gültige UUID sein")
|
||||||
|
return finishManualGeneration(context.server, id, context.tenantId)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "accounting.outgoing_documents.archive",
|
name: "accounting.outgoing_documents.archive",
|
||||||
title: "Ausgangsbeleg archivieren",
|
title: "Ausgangsbeleg archivieren",
|
||||||
@@ -754,7 +924,6 @@ export const accountingTools: McpTool[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
state: { type: "string", description: "Optionaler Statusfilter." },
|
state: { type: "string", description: "Optionaler Statusfilter." },
|
||||||
paid: { type: "boolean", description: "Optionaler Zahlungsstatus." },
|
paid: { type: "boolean", description: "Optionaler Zahlungsstatus." },
|
||||||
includeArchived: { type: "boolean", default: false },
|
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -764,7 +933,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
|
|
||||||
if (state) conditions.push(eq(incominginvoices.state, state))
|
if (state) conditions.push(eq(incominginvoices.state, state))
|
||||||
if (typeof args.paid === "boolean") conditions.push(eq(incominginvoices.paid, args.paid))
|
if (typeof args.paid === "boolean") conditions.push(eq(incominginvoices.paid, args.paid))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(incominginvoices.archived, false))
|
conditions.push(eq(incominginvoices.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -795,7 +964,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(incominginvoices)
|
.from(incominginvoices)
|
||||||
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId)))
|
.where(and(eq(incominginvoices.id, id), eq(incominginvoices.tenant, context.tenantId), eq(incominginvoices.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Eingangsrechnung nicht gefunden")
|
if (!rows[0]) throw new Error("Eingangsrechnung nicht gefunden")
|
||||||
@@ -1142,7 +1311,6 @@ export const accountingTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
account: { type: "number", description: "Optionale Bankkonto-ID." },
|
account: { type: "number", description: "Optionale Bankkonto-ID." },
|
||||||
includeArchived: { type: "boolean", default: false },
|
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1151,7 +1319,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
const account = numberArg(args, "account")
|
const account = numberArg(args, "account")
|
||||||
|
|
||||||
if (account) conditions.push(eq(bankstatements.account, account))
|
if (account) conditions.push(eq(bankstatements.account, account))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(bankstatements.archived, false))
|
conditions.push(eq(bankstatements.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -1182,7 +1350,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(bankstatements)
|
.from(bankstatements)
|
||||||
.where(and(eq(bankstatements.id, id), eq(bankstatements.tenant, context.tenantId)))
|
.where(and(eq(bankstatements.id, id), eq(bankstatements.tenant, context.tenantId), eq(bankstatements.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Bankumsatz nicht gefunden")
|
if (!rows[0]) throw new Error("Bankumsatz nicht gefunden")
|
||||||
@@ -1199,7 +1367,6 @@ export const accountingTools: McpTool[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
bankstatement: { type: "number" },
|
bankstatement: { type: "number" },
|
||||||
incominginvoice: { type: "number" },
|
incominginvoice: { type: "number" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1210,7 +1377,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
|
|
||||||
if (bankstatement) conditions.push(eq(statementallocations.bankstatement, bankstatement))
|
if (bankstatement) conditions.push(eq(statementallocations.bankstatement, bankstatement))
|
||||||
if (incominginvoice) conditions.push(eq(statementallocations.incominginvoice, incominginvoice))
|
if (incominginvoice) conditions.push(eq(statementallocations.incominginvoice, incominginvoice))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(statementallocations.archived, false))
|
conditions.push(eq(statementallocations.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(customers)
|
.from(customers)
|
||||||
.where(and(eq(customers.id, id), eq(customers.tenant, context.tenantId)))
|
.where(and(eq(customers.id, id), eq(customers.tenant, context.tenantId), eq(customers.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Kunde nicht gefunden")
|
if (!rows[0]) throw new Error("Kunde nicht gefunden")
|
||||||
@@ -71,7 +71,6 @@ export const masterdataTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Lieferantennummer oder Notizen." },
|
query: { type: "string", description: "Suchtext für Name, Lieferantennummer oder Notizen." },
|
||||||
includeArchived: { type: "boolean", default: false },
|
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -86,7 +85,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
ilike(vendors.notes, `%${query}%`)
|
ilike(vendors.notes, `%${query}%`)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
if (args.includeArchived !== true) conditions.push(eq(vendors.archived, false))
|
conditions.push(eq(vendors.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -115,7 +114,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(vendors)
|
.from(vendors)
|
||||||
.where(and(eq(vendors.id, id), eq(vendors.tenant, context.tenantId)))
|
.where(and(eq(vendors.id, id), eq(vendors.tenant, context.tenantId), eq(vendors.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Lieferant nicht gefunden")
|
if (!rows[0]) throw new Error("Lieferant nicht gefunden")
|
||||||
@@ -133,7 +132,6 @@ export const masterdataTools: McpTool[] = [
|
|||||||
query: { type: "string", description: "Suchtext für Name, E-Mail, Telefon, Rolle oder Notizen." },
|
query: { type: "string", description: "Suchtext für Name, E-Mail, Telefon, Rolle oder Notizen." },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
vendor: { type: "number" },
|
vendor: { type: "number" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -157,7 +155,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
}
|
}
|
||||||
if (customer) conditions.push(eq(contacts.customer, customer))
|
if (customer) conditions.push(eq(contacts.customer, customer))
|
||||||
if (vendor) conditions.push(eq(contacts.vendor, vendor))
|
if (vendor) conditions.push(eq(contacts.vendor, vendor))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(contacts.archived, false))
|
conditions.push(eq(contacts.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -178,7 +176,6 @@ export const masterdataTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Hersteller, EAN, Barcode oder Beschreibung." },
|
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Hersteller, EAN, Barcode oder Beschreibung." },
|
||||||
includeArchived: { type: "boolean", default: false },
|
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -197,7 +194,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
ilike(products.description, `%${query}%`)
|
ilike(products.description, `%${query}%`)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
if (args.includeArchived !== true) conditions.push(eq(products.archived, false))
|
conditions.push(eq(products.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -226,7 +223,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(products)
|
.from(products)
|
||||||
.where(and(eq(products.id, id), eq(products.tenant, context.tenantId)))
|
.where(and(eq(products.id, id), eq(products.tenant, context.tenantId), eq(products.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Artikel nicht gefunden")
|
if (!rows[0]) throw new Error("Artikel nicht gefunden")
|
||||||
@@ -242,7 +239,6 @@ export const masterdataTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Leistungsnummer oder Beschreibung." },
|
query: { type: "string", description: "Suchtext für Name, Leistungsnummer oder Beschreibung." },
|
||||||
includeArchived: { type: "boolean", default: false },
|
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -256,7 +252,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
ilike(services.description, `%${query}%`)
|
ilike(services.description, `%${query}%`)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
if (args.includeArchived !== true) conditions.push(eq(services.archived, false))
|
conditions.push(eq(services.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -285,7 +281,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(services)
|
.from(services)
|
||||||
.where(and(eq(services.id, id), eq(services.tenant, context.tenantId)))
|
.where(and(eq(services.id, id), eq(services.tenant, context.tenantId), eq(services.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Leistung nicht gefunden")
|
if (!rows[0]) throw new Error("Leistung nicht gefunden")
|
||||||
@@ -303,7 +299,6 @@ export const masterdataTools: McpTool[] = [
|
|||||||
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
||||||
branch: { type: "number" },
|
branch: { type: "number" },
|
||||||
project: { type: "number" },
|
project: { type: "number" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -322,7 +317,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
}
|
}
|
||||||
if (branch) conditions.push(eq(costcentres.branch, branch))
|
if (branch) conditions.push(eq(costcentres.branch, branch))
|
||||||
if (project) conditions.push(eq(costcentres.project, project))
|
if (project) conditions.push(eq(costcentres.project, project))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(costcentres.archived, false))
|
conditions.push(eq(costcentres.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -351,7 +346,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(costcentres)
|
.from(costcentres)
|
||||||
.where(and(eq(costcentres.id, id), eq(costcentres.tenant, context.tenantId)))
|
.where(and(eq(costcentres.id, id), eq(costcentres.tenant, context.tenantId), eq(costcentres.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Kostenstelle nicht gefunden")
|
if (!rows[0]) throw new Error("Kostenstelle nicht gefunden")
|
||||||
@@ -367,7 +362,6 @@ export const masterdataTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
query: { type: "string", description: "Suchtext für Nummer, Name oder Beschreibung." },
|
||||||
includeArchived: { type: "boolean", default: false },
|
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -382,7 +376,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
ilike(branches.description, `%${query}%`)
|
ilike(branches.description, `%${query}%`)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
if (args.includeArchived !== true) conditions.push(eq(branches.archived, false))
|
conditions.push(eq(branches.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -404,7 +398,6 @@ export const masterdataTools: McpTool[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name oder Beschreibung." },
|
query: { type: "string", description: "Suchtext für Name oder Beschreibung." },
|
||||||
branch: { type: "number" },
|
branch: { type: "number" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -420,7 +413,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
if (branch) conditions.push(eq(teams.branch, branch))
|
if (branch) conditions.push(eq(teams.branch, branch))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(teams.archived, false))
|
conditions.push(eq(teams.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -441,7 +434,6 @@ export const masterdataTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Kennzeichen, FIN oder Farbe." },
|
query: { type: "string", description: "Suchtext für Name, Kennzeichen, FIN oder Farbe." },
|
||||||
includeArchived: { type: "boolean", default: false },
|
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -457,7 +449,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
ilike(vehicles.color, `%${query}%`)
|
ilike(vehicles.color, `%${query}%`)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
if (args.includeArchived !== true) conditions.push(eq(vehicles.archived, false))
|
conditions.push(eq(vehicles.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -479,7 +471,6 @@ export const masterdataTools: McpTool[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Seriennummer, Hersteller oder Beschreibung." },
|
query: { type: "string", description: "Suchtext für Name, Artikelnummer, Seriennummer, Hersteller oder Beschreibung." },
|
||||||
vendor: { type: "number" },
|
vendor: { type: "number" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -499,7 +490,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
if (vendor) conditions.push(eq(inventoryitems.vendor, vendor))
|
if (vendor) conditions.push(eq(inventoryitems.vendor, vendor))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(inventoryitems.archived, false))
|
conditions.push(eq(inventoryitems.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -528,7 +519,7 @@ export const masterdataTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(inventoryitems)
|
.from(inventoryitems)
|
||||||
.where(and(eq(inventoryitems.id, id), eq(inventoryitems.tenant, context.tenantId)))
|
.where(and(eq(inventoryitems.id, id), eq(inventoryitems.tenant, context.tenantId), eq(inventoryitems.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Inventar nicht gefunden")
|
if (!rows[0]) throw new Error("Inventar nicht gefunden")
|
||||||
@@ -568,4 +559,3 @@ export const masterdataTools: McpTool[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export const organisationTools: McpTool[] = [
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name, Kundennummer, Vorname, Nachname oder Notizen." },
|
query: { type: "string", description: "Suchtext für Name, Kundennummer, Vorname, Nachname oder Notizen." },
|
||||||
includeArchived: { type: "boolean", default: false },
|
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -47,7 +46,7 @@ export const organisationTools: McpTool[] = [
|
|||||||
ilike(customers.notes, `%${query}%`)
|
ilike(customers.notes, `%${query}%`)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
if (args.includeArchived !== true) conditions.push(eq(customers.archived, false))
|
conditions.push(eq(customers.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select({
|
.select({
|
||||||
@@ -80,7 +79,6 @@ export const organisationTools: McpTool[] = [
|
|||||||
query: { type: "string", description: "Suchtext für Name, Projektnummer, Kundenreferenz oder Notizen." },
|
query: { type: "string", description: "Suchtext für Name, Projektnummer, Kundenreferenz oder Notizen." },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
activePhase: { type: "string" },
|
activePhase: { type: "string" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -100,7 +98,7 @@ export const organisationTools: McpTool[] = [
|
|||||||
}
|
}
|
||||||
if (customer) conditions.push(eq(projects.customer, customer))
|
if (customer) conditions.push(eq(projects.customer, customer))
|
||||||
if (activePhase) conditions.push(eq(projects.active_phase, activePhase))
|
if (activePhase) conditions.push(eq(projects.active_phase, activePhase))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(projects.archived, false))
|
conditions.push(eq(projects.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -131,7 +129,7 @@ export const organisationTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId)))
|
.where(and(eq(projects.id, id), eq(projects.tenant, context.tenantId), eq(projects.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Projekt nicht gefunden")
|
if (!rows[0]) throw new Error("Projekt nicht gefunden")
|
||||||
@@ -219,7 +217,6 @@ export const organisationTools: McpTool[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
query: { type: "string", description: "Suchtext für Name." },
|
query: { type: "string", description: "Suchtext für Name." },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -230,7 +227,7 @@ export const organisationTools: McpTool[] = [
|
|||||||
|
|
||||||
if (query) conditions.push(ilike(plants.name, `%${query}%`))
|
if (query) conditions.push(ilike(plants.name, `%${query}%`))
|
||||||
if (customer) conditions.push(eq(plants.customer, customer))
|
if (customer) conditions.push(eq(plants.customer, customer))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(plants.archived, false))
|
conditions.push(eq(plants.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -254,7 +251,6 @@ export const organisationTools: McpTool[] = [
|
|||||||
project: { type: "number" },
|
project: { type: "number" },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
eventtype: { type: "string" },
|
eventtype: { type: "string" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -275,7 +271,7 @@ export const organisationTools: McpTool[] = [
|
|||||||
if (project) conditions.push(eq(events.project, project))
|
if (project) conditions.push(eq(events.project, project))
|
||||||
if (customer) conditions.push(eq(events.customer, customer))
|
if (customer) conditions.push(eq(events.customer, customer))
|
||||||
if (eventtype) conditions.push(eq(events.eventtype, eventtype))
|
if (eventtype) conditions.push(eq(events.eventtype, eventtype))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(events.archived, false))
|
conditions.push(eq(events.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -298,7 +294,6 @@ export const organisationTools: McpTool[] = [
|
|||||||
query: { type: "string", description: "Suchtext für Name, Beschreibung oder Kategorie." },
|
query: { type: "string", description: "Suchtext für Name, Beschreibung oder Kategorie." },
|
||||||
project: { type: "number" },
|
project: { type: "number" },
|
||||||
customer: { type: "number" },
|
customer: { type: "number" },
|
||||||
includeArchived: { type: "boolean", default: false },
|
|
||||||
limit: { type: "number", minimum: 1, maximum: 100 },
|
limit: { type: "number", minimum: 1, maximum: 100 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -317,7 +312,7 @@ export const organisationTools: McpTool[] = [
|
|||||||
}
|
}
|
||||||
if (project) conditions.push(eq(tasks.project, project))
|
if (project) conditions.push(eq(tasks.project, project))
|
||||||
if (customer) conditions.push(eq(tasks.customer, customer))
|
if (customer) conditions.push(eq(tasks.customer, customer))
|
||||||
if (args.includeArchived !== true) conditions.push(eq(tasks.archived, false))
|
conditions.push(eq(tasks.archived, false))
|
||||||
|
|
||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
@@ -348,7 +343,7 @@ export const organisationTools: McpTool[] = [
|
|||||||
const rows = await context.server.db
|
const rows = await context.server.db
|
||||||
.select()
|
.select()
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId)))
|
.where(and(eq(tasks.id, id), eq(tasks.tenant, context.tenantId), eq(tasks.archived, false)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!rows[0]) throw new Error("Aufgabe nicht gefunden")
|
if (!rows[0]) throw new Error("Aufgabe nicht gefunden")
|
||||||
|
|||||||
146
backend/src/modules/outgoing-document-cost-centres.service.ts
Normal file
146
backend/src/modules/outgoing-document-cost-centres.service.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm"
|
||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { costcentres, createddocuments } from "../../db/schema"
|
||||||
|
import { insertHistoryItem } from "../utils/history"
|
||||||
|
|
||||||
|
export type RowCostCentreAssignment = {
|
||||||
|
rowId: string
|
||||||
|
costcentre: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OutgoingDocumentCostCentreUpdate = {
|
||||||
|
costcentre?: string | null
|
||||||
|
rowCostCentres?: RowCostCentreAssignment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceError = (message: string, statusCode = 400) =>
|
||||||
|
Object.assign(new Error(message), { statusCode })
|
||||||
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
|
|
||||||
|
export function applyRowCostCentreAssignments(
|
||||||
|
rows: unknown,
|
||||||
|
assignments: RowCostCentreAssignment[] = [],
|
||||||
|
) {
|
||||||
|
if (!Array.isArray(rows)) return []
|
||||||
|
if (!assignments.length) return rows
|
||||||
|
|
||||||
|
const assignmentMap = new Map(assignments.map((assignment) => [String(assignment.rowId), assignment.costcentre]))
|
||||||
|
const existingIds = new Set(rows
|
||||||
|
.filter((row) => row && typeof row === "object" && !Array.isArray(row))
|
||||||
|
.map((row) => (row as Record<string, unknown>).id)
|
||||||
|
.filter((id) => id !== null && id !== undefined && id !== "")
|
||||||
|
.map(String))
|
||||||
|
|
||||||
|
for (const rowId of assignmentMap.keys()) {
|
||||||
|
if (!existingIds.has(rowId)) throw serviceError(`Position ${rowId} wurde im Ausgangsbeleg nicht gefunden`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map((row) => {
|
||||||
|
if (!row || typeof row !== "object" || Array.isArray(row)) return row
|
||||||
|
const rowRecord = row as Record<string, unknown>
|
||||||
|
if (rowRecord.id === null || rowRecord.id === undefined || rowRecord.id === "") return row
|
||||||
|
const rowId = String(rowRecord.id)
|
||||||
|
if (!assignmentMap.has(rowId)) return row
|
||||||
|
|
||||||
|
const { costcentre: _legacyCostCentre, ...rest } = rowRecord
|
||||||
|
return { ...rest, costCentre: assignmentMap.get(rowId) ?? null }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOutgoingDocumentCostCentres(
|
||||||
|
server: FastifyInstance,
|
||||||
|
tenantId: number,
|
||||||
|
userId: string,
|
||||||
|
documentId: number,
|
||||||
|
input: OutgoingDocumentCostCentreUpdate,
|
||||||
|
) {
|
||||||
|
const hasDocumentCostCentre = Object.prototype.hasOwnProperty.call(input, "costcentre")
|
||||||
|
if (hasDocumentCostCentre && input.costcentre !== null && !UUID_PATTERN.test(String(input.costcentre))) {
|
||||||
|
throw serviceError("costcentre muss eine gültige UUID oder null sein")
|
||||||
|
}
|
||||||
|
if (input.rowCostCentres !== undefined && !Array.isArray(input.rowCostCentres)) {
|
||||||
|
throw serviceError("rowCostCentres muss ein Array sein")
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowAssignments = (input.rowCostCentres || []).map((assignment) => {
|
||||||
|
const rowId = typeof assignment?.rowId === "string" ? assignment.rowId.trim() : ""
|
||||||
|
const costcentre = assignment?.costcentre
|
||||||
|
if (!rowId) throw serviceError("Jede Positionszuordnung benötigt eine rowId")
|
||||||
|
if (costcentre !== null && !UUID_PATTERN.test(String(costcentre))) {
|
||||||
|
throw serviceError(`Ungültige Kostenstelle für Position ${rowId}`)
|
||||||
|
}
|
||||||
|
return { rowId, costcentre }
|
||||||
|
})
|
||||||
|
if (!hasDocumentCostCentre && !rowAssignments.length) {
|
||||||
|
throw serviceError("Mindestens eine Beleg- oder Positionskostenstelle ist erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await server.db
|
||||||
|
.select()
|
||||||
|
.from(createddocuments)
|
||||||
|
.where(and(
|
||||||
|
eq(createddocuments.id, documentId),
|
||||||
|
eq(createddocuments.tenant, tenantId),
|
||||||
|
eq(createddocuments.archived, false),
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing) throw serviceError("Ausgangsbeleg nicht gefunden", 404)
|
||||||
|
if (existing.state !== "Gebucht") {
|
||||||
|
throw serviceError("Kostenstellen können über diesen Weg nur bei fertiggestellten Ausgangsbelegen geändert werden")
|
||||||
|
}
|
||||||
|
|
||||||
|
const referencedCostCentreIds = [...new Set([
|
||||||
|
...(hasDocumentCostCentre && input.costcentre ? [input.costcentre] : []),
|
||||||
|
...rowAssignments.map((assignment) => assignment.costcentre).filter((id): id is string => Boolean(id)),
|
||||||
|
])]
|
||||||
|
|
||||||
|
if (referencedCostCentreIds.length) {
|
||||||
|
const validCostCentres = await server.db
|
||||||
|
.select({ id: costcentres.id })
|
||||||
|
.from(costcentres)
|
||||||
|
.where(and(
|
||||||
|
eq(costcentres.tenant, tenantId),
|
||||||
|
eq(costcentres.archived, false),
|
||||||
|
inArray(costcentres.id, referencedCostCentreIds),
|
||||||
|
))
|
||||||
|
|
||||||
|
if (validCostCentres.length !== referencedCostCentreIds.length) {
|
||||||
|
throw serviceError("Mindestens eine Kostenstelle wurde nicht gefunden oder ist archiviert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRows = applyRowCostCentreAssignments(existing.rows, rowAssignments)
|
||||||
|
const update: Record<string, unknown> = {
|
||||||
|
rows: updatedRows,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId,
|
||||||
|
}
|
||||||
|
if (hasDocumentCostCentre) update.costcentre = input.costcentre ?? null
|
||||||
|
|
||||||
|
const [updated] = await server.db
|
||||||
|
.update(createddocuments)
|
||||||
|
.set(update)
|
||||||
|
.where(and(
|
||||||
|
eq(createddocuments.id, documentId),
|
||||||
|
eq(createddocuments.tenant, tenantId),
|
||||||
|
eq(createddocuments.archived, false),
|
||||||
|
eq(createddocuments.state, "Gebucht"),
|
||||||
|
))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!updated) throw serviceError("Ausgangsbeleg nicht gefunden", 404)
|
||||||
|
|
||||||
|
await insertHistoryItem(server, {
|
||||||
|
tenant_id: tenantId,
|
||||||
|
created_by: userId,
|
||||||
|
entity: "createddocuments",
|
||||||
|
entityId: documentId,
|
||||||
|
action: "updated",
|
||||||
|
oldVal: existing,
|
||||||
|
newVal: updated,
|
||||||
|
text: "Kostenstellen des fertiggestellten Ausgangsbelegs geändert",
|
||||||
|
})
|
||||||
|
|
||||||
|
return updated
|
||||||
|
}
|
||||||
@@ -14,9 +14,13 @@ import { documentTemplateHandlebars } from "../utils/handlebars";
|
|||||||
dayjs.extend(quarterOfYear);
|
dayjs.extend(quarterOfYear);
|
||||||
|
|
||||||
|
|
||||||
export const executeManualGeneration = async (server:FastifyInstance,executionDate,templateIds,tenantId,executedBy) => {
|
export const executeManualGeneration = async (
|
||||||
try {
|
server: FastifyInstance,
|
||||||
console.log(executedBy)
|
executionDate: string | Date,
|
||||||
|
templateIds: number[],
|
||||||
|
tenantId: number,
|
||||||
|
executedBy: string,
|
||||||
|
) => {
|
||||||
|
|
||||||
const executionDayjs = dayjs(executionDate);
|
const executionDayjs = dayjs(executionDate);
|
||||||
|
|
||||||
@@ -33,20 +37,22 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa
|
|||||||
if (!tenant) throw new Error(`Tenant mit ID ${tenantId} nicht gefunden.`);
|
if (!tenant) throw new Error(`Tenant mit ID ${tenantId} nicht gefunden.`);
|
||||||
|
|
||||||
// 2. Templates laden
|
// 2. Templates laden
|
||||||
const templates = await server.db
|
const uniqueTemplateIds = [...new Set(templateIds)]
|
||||||
|
const templates = (await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.createddocuments)
|
.from(schema.createddocuments)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(schema.createddocuments.tenant, tenantId),
|
eq(schema.createddocuments.tenant, tenantId),
|
||||||
eq(schema.createddocuments.type, "serialInvoices"),
|
eq(schema.createddocuments.type, "serialInvoices"),
|
||||||
inArray(schema.createddocuments.id, templateIds)
|
eq(schema.createddocuments.archived, false),
|
||||||
|
inArray(schema.createddocuments.id, uniqueTemplateIds)
|
||||||
)
|
)
|
||||||
);
|
))
|
||||||
|
.filter((template) => Boolean((template.serialConfig as any)?.active));
|
||||||
|
|
||||||
if (templates.length === 0) {
|
if (templates.length !== uniqueTemplateIds.length) {
|
||||||
console.warn("Keine passenden Vorlagen gefunden.");
|
throw new Error("Mindestens eine Serienrechnungsvorlage wurde nicht gefunden, ist archiviert oder inaktiv.");
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Folder & FileType IDs holen (Hilfsfunktionen unten)
|
// 3. Folder & FileType IDs holen (Hilfsfunktionen unten)
|
||||||
@@ -62,7 +68,7 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa
|
|||||||
executionDate: executionDayjs.toDate(),
|
executionDate: executionDayjs.toDate(),
|
||||||
status: "draft",
|
status: "draft",
|
||||||
createdBy: executedBy,
|
createdBy: executedBy,
|
||||||
summary: `${templateIds.length} Vorlagen verarbeitet`
|
summary: `${uniqueTemplateIds.length} Vorlagen verarbeitet`
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -88,13 +94,10 @@ export const executeManualGeneration = async (server:FastifyInstance,executionDa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return { execution: executionRecord, results };
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const finishManualGeneration = async (server: FastifyInstance, executionId: number) => {
|
export const finishManualGeneration = async (server: FastifyInstance, executionId: string, tenantId: number) => {
|
||||||
try {
|
try {
|
||||||
console.log(`Beende Ausführung ${executionId}...`);
|
console.log(`Beende Ausführung ${executionId}...`);
|
||||||
|
|
||||||
@@ -103,15 +106,16 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
const [executionRecord] = await server.db
|
const [executionRecord] = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.serialExecutions)// @ts-ignore
|
.from(schema.serialExecutions)// @ts-ignore
|
||||||
.where(eq(schema.serialExecutions.id, executionId))
|
.where(and(
|
||||||
|
eq(schema.serialExecutions.id, executionId),
|
||||||
|
eq(schema.serialExecutions.tenant, tenantId),
|
||||||
|
))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!executionRecord) throw new Error("Execution nicht gefunden");
|
if (!executionRecord) throw new Error("Execution nicht gefunden");
|
||||||
|
|
||||||
console.log(executionRecord);
|
console.log(executionRecord);
|
||||||
|
|
||||||
const tenantId = executionRecord.tenant;
|
|
||||||
|
|
||||||
console.log(tenantId)
|
console.log(tenantId)
|
||||||
|
|
||||||
// Tenant laden (für Settings etc.)
|
// Tenant laden (für Settings etc.)
|
||||||
@@ -132,7 +136,11 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
const documents = await server.db
|
const documents = await server.db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.createddocuments)
|
.from(schema.createddocuments)
|
||||||
.where(eq(schema.createddocuments.serialexecution, executionId));
|
.where(and(
|
||||||
|
eq(schema.createddocuments.serialexecution, executionId),
|
||||||
|
eq(schema.createddocuments.tenant, tenantId),
|
||||||
|
eq(schema.createddocuments.archived, false),
|
||||||
|
));
|
||||||
|
|
||||||
console.log(`${documents.length} Dokumente werden finalisiert...`);
|
console.log(`${documents.length} Dokumente werden finalisiert...`);
|
||||||
|
|
||||||
@@ -228,7 +236,10 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
status: finalStatus,
|
status: finalStatus,
|
||||||
summary: `Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`
|
summary: `Abgeschlossen: ${successCount} erfolgreich, ${errorCount} Fehler.`
|
||||||
})// @ts-ignore
|
})// @ts-ignore
|
||||||
.where(eq(schema.serialExecutions.id, executionId));
|
.where(and(
|
||||||
|
eq(schema.serialExecutions.id, executionId),
|
||||||
|
eq(schema.serialExecutions.tenant, tenantId),
|
||||||
|
));
|
||||||
|
|
||||||
return { success: true, processed: successCount, errors: errorCount };
|
return { success: true, processed: successCount, errors: errorCount };
|
||||||
|
|
||||||
@@ -240,7 +251,10 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
.update(schema.serialExecutions)
|
.update(schema.serialExecutions)
|
||||||
.set({ status: "error", summary: "Kritischer Fehler beim Finalisieren." })
|
.set({ status: "error", summary: "Kritischer Fehler beim Finalisieren." })
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
.where(eq(schema.serialExecutions.id, executionId));
|
.where(and(
|
||||||
|
eq(schema.serialExecutions.id, executionId),
|
||||||
|
eq(schema.serialExecutions.tenant, tenantId),
|
||||||
|
));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
|||||||
import {citys, files} from "../../db/schema";
|
import {citys, files} from "../../db/schema";
|
||||||
import {and, eq, isNull, not} from "drizzle-orm";
|
import {and, eq, isNull, not} from "drizzle-orm";
|
||||||
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
import {executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
||||||
|
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
|
||||||
import { s3 } from "../utils/s3";
|
import { s3 } from "../utils/s3";
|
||||||
import { secrets } from "../utils/secrets";
|
import { secrets } from "../utils/secrets";
|
||||||
import { storeExtractedTextForFile } from "../utils/documentText";
|
import { storeExtractedTextForFile } from "../utils/documentText";
|
||||||
@@ -296,15 +297,34 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
server.post('/functions/serial/start', async (req, reply) => {
|
server.post('/functions/serial/start', async (req, reply) => {
|
||||||
console.log(req.body)
|
const {executionDate, templateIds} = req.body as {executionDate:string, templateIds:number[]}
|
||||||
const {executionDate,templateIds,tenantId} = req.body as {executionDate:string,templateIds:Number[],tenantId:Number}
|
return executeManualGeneration(server, executionDate, templateIds, req.user.tenant_id, req.user.user_id)
|
||||||
await executeManualGeneration(server,executionDate,templateIds,tenantId,req.user.user_id)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
server.post('/functions/serial/finish/:execution_id', async (req, reply) => {
|
server.post('/functions/serial/finish/:execution_id', async (req, reply) => {
|
||||||
const {execution_id} = req.params as { execution_id: string }
|
const {execution_id} = req.params as { execution_id: string }
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
await finishManualGeneration(server,execution_id)
|
return finishManualGeneration(server, execution_id, req.user.tenant_id)
|
||||||
|
})
|
||||||
|
|
||||||
|
server.put('/functions/outgoing-documents/:id/cost-centres', async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const documentId = Number(id)
|
||||||
|
if (!Number.isFinite(documentId)) return reply.code(400).send({ error: "Ungültige Ausgangsbeleg-ID" })
|
||||||
|
|
||||||
|
const document = await updateOutgoingDocumentCostCentres(
|
||||||
|
server,
|
||||||
|
req.user.tenant_id,
|
||||||
|
req.user.user_id,
|
||||||
|
documentId,
|
||||||
|
req.body as any,
|
||||||
|
)
|
||||||
|
return { document }
|
||||||
|
} catch (error) {
|
||||||
|
const statusCode = (error as any)?.statusCode || 500
|
||||||
|
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Kostenstellen konnten nicht geändert werden" })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
server.post('/functions/services/bankstatementsync', async (req, reply) => {
|
server.post('/functions/services/bankstatementsync', async (req, reply) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||||
import { authProfiles, historyitems } from "../../db/schema";
|
import { authProfiles, historyitems } from "../../db/schema";
|
||||||
|
import { sanitizeHistoryItem, sanitizeHistoryText, sanitizeHistoryValue } from "../utils/historySanitization";
|
||||||
|
|
||||||
const columnMap: Record<string, any> = {
|
const columnMap: Record<string, any> = {
|
||||||
customers: historyitems.customer,
|
customers: historyitems.customer,
|
||||||
@@ -28,6 +29,7 @@ const columnMap: Record<string, any> = {
|
|||||||
customerinventoryitems: historyitems.customerinventoryitem,
|
customerinventoryitems: historyitems.customerinventoryitem,
|
||||||
memberrelations: historyitems.memberrelation,
|
memberrelations: historyitems.memberrelation,
|
||||||
outgoingsepamandates: historyitems.outgoingsepamandate,
|
outgoingsepamandates: historyitems.outgoingsepamandate,
|
||||||
|
projecttypes: historyitems.projecttype,
|
||||||
};
|
};
|
||||||
|
|
||||||
const insertFieldMap: Record<string, string> = {
|
const insertFieldMap: Record<string, string> = {
|
||||||
@@ -55,6 +57,7 @@ const insertFieldMap: Record<string, string> = {
|
|||||||
customerinventoryitems: "customerinventoryitem",
|
customerinventoryitems: "customerinventoryitem",
|
||||||
memberrelations: "memberrelation",
|
memberrelations: "memberrelation",
|
||||||
outgoingsepamandates: "outgoingsepamandate",
|
outgoingsepamandates: "outgoingsepamandate",
|
||||||
|
projecttypes: "projecttype",
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseId = (value: string) => {
|
const parseId = (value: string) => {
|
||||||
@@ -93,7 +96,7 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
|||||||
profiles.map((profile) => [profile.user_id, profile])
|
profiles.map((profile) => [profile.user_id, profile])
|
||||||
);
|
);
|
||||||
|
|
||||||
return data.map((historyitem) => ({
|
return data.map((historyitem) => sanitizeHistoryItem({
|
||||||
...historyitem,
|
...historyitem,
|
||||||
created_at: historyitem.createdAt,
|
created_at: historyitem.createdAt,
|
||||||
created_by: historyitem.createdBy,
|
created_by: historyitem.createdBy,
|
||||||
@@ -151,7 +154,7 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
|||||||
profiles.map((profile) => [profile.user_id, profile])
|
profiles.map((profile) => [profile.user_id, profile])
|
||||||
)
|
)
|
||||||
|
|
||||||
const dataCombined = data.map((historyitem) => ({
|
const dataCombined = data.map((historyitem) => sanitizeHistoryItem({
|
||||||
...historyitem,
|
...historyitem,
|
||||||
created_at: historyitem.createdAt,
|
created_at: historyitem.createdAt,
|
||||||
created_by: historyitem.createdBy,
|
created_by: historyitem.createdBy,
|
||||||
@@ -221,11 +224,11 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
|||||||
const inserted = await server.db
|
const inserted = await server.db
|
||||||
.insert(historyitems)
|
.insert(historyitems)
|
||||||
.values({
|
.values({
|
||||||
text,
|
text: sanitizeHistoryText(text),
|
||||||
[fkField]: parseId(id),
|
[fkField]: parseId(id),
|
||||||
oldVal: old_val || null,
|
oldVal: sanitizeHistoryValue(old_val) || null,
|
||||||
newVal: new_val || null,
|
newVal: sanitizeHistoryValue(new_val) || null,
|
||||||
config: config || null,
|
config: sanitizeHistoryValue(config) || null,
|
||||||
tenant: (req.user as any)?.tenant_id,
|
tenant: (req.user as any)?.tenant_id,
|
||||||
createdBy: userId
|
createdBy: userId
|
||||||
})
|
})
|
||||||
@@ -236,10 +239,10 @@ export default async function resourceHistoryRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(500).send({ error: "Failed to create history entry" });
|
return reply.code(500).send({ error: "Failed to create history entry" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.code(201).send({
|
return reply.code(201).send(sanitizeHistoryItem({
|
||||||
...data,
|
...data,
|
||||||
created_at: data.createdAt,
|
created_at: data.createdAt,
|
||||||
created_by: data.createdBy
|
created_by: data.createdBy
|
||||||
});
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { and, eq, isNull } from "drizzle-orm"
|
import { and, eq, isNull } from "drizzle-orm"
|
||||||
import { authUsers, notificationMobilePushDevices } from "../../db/schema"
|
import { authProfiles, authUsers, notificationMobilePushDevices } from "../../db/schema"
|
||||||
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
import { NotificationService, UserDirectory } from "../modules/notification.service"
|
||||||
import { pushServerClient } from "../modules/push-server.client"
|
import { pushServerClient } from "../modules/push-server.client"
|
||||||
|
|
||||||
@@ -174,6 +174,77 @@ export default async function notificationsRoutes(server: FastifyInstance) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
server.post("/notifications/test-push/profile/:profileId", async (req, reply) => {
|
||||||
|
const tenantId = requireTenant(req.user.tenant_id)
|
||||||
|
const { profileId } = req.params as { profileId: string }
|
||||||
|
const [profile] = await server.db
|
||||||
|
.select({
|
||||||
|
userId: authProfiles.user_id,
|
||||||
|
})
|
||||||
|
.from(authProfiles)
|
||||||
|
.where(and(
|
||||||
|
eq(authProfiles.id, profileId),
|
||||||
|
eq(authProfiles.tenant_id, tenantId)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return reply.code(404).send({ error: "Mitarbeiter nicht gefunden" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile.userId) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: "Der Mitarbeiter ist noch nicht mit einem Benutzerkonto verknüpft",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = await server.db
|
||||||
|
.select({ centralDeviceId: notificationMobilePushDevices.centralDeviceId })
|
||||||
|
.from(notificationMobilePushDevices)
|
||||||
|
.where(and(
|
||||||
|
eq(notificationMobilePushDevices.tenantId, tenantId),
|
||||||
|
eq(notificationMobilePushDevices.userId, profile.userId),
|
||||||
|
isNull(notificationMobilePushDevices.disabledAt)
|
||||||
|
))
|
||||||
|
|
||||||
|
if (!devices.length) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: "Für diesen Mitarbeiter ist kein aktives mobiles Push-Gerät registriert",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pushServerClient.sendPush({
|
||||||
|
idempotencyKey: `profile-mobile-test:${tenantId}:${profile.userId}:${Date.now()}`,
|
||||||
|
devices: devices.map((device) => device.centralDeviceId),
|
||||||
|
priority: "high",
|
||||||
|
ttlSeconds: 600,
|
||||||
|
notification: {
|
||||||
|
title: "FEDEO Push ist aktiv",
|
||||||
|
body: "Diese Testbenachrichtigung wurde einmalig über das Mitarbeiterprofil ausgelöst.",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: "system.test_mobile_push",
|
||||||
|
link: "/",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.accepted === 0) {
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: "Der zentrale Push-Server hat kein Gerät zur Zustellung angenommen",
|
||||||
|
result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error: any) {
|
||||||
|
server.log.error({ err: error, profileId, userId: profile.userId }, "Mitarbeiter-Test-Push fehlgeschlagen")
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: error?.message || "Der zentrale Push-Server konnte die Nachricht nicht annehmen",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
server.post("/notifications/trigger", async (req, reply) => {
|
server.post("/notifications/trigger", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
const body = req.body as any
|
const body = req.body as any
|
||||||
|
|||||||
@@ -1035,7 +1035,25 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
|
if (config.numberRangeHolder && !body[config.numberRangeHolder]) {
|
||||||
const numberRangeResource = resource === "members" ? "customers" : resource
|
const numberRangeResource = resource === "members" ? "customers" : resource
|
||||||
const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource)
|
const numberRangeColumn = table[config.numberRangeHolder]
|
||||||
|
const tenantColumn = getTenantColumn(resource, table)
|
||||||
|
const result = await useNextNumberRangeNumber(
|
||||||
|
server,
|
||||||
|
req.user.tenant_id,
|
||||||
|
numberRangeResource,
|
||||||
|
async (candidate, tx) => {
|
||||||
|
const existing = await tx
|
||||||
|
.select({ id: table.id })
|
||||||
|
.from(table)
|
||||||
|
.where(and(
|
||||||
|
eq(tenantColumn, req.user!.tenant_id),
|
||||||
|
eq(numberRangeColumn, candidate)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return existing.length === 0
|
||||||
|
}
|
||||||
|
)
|
||||||
createData[config.numberRangeHolder] = result.usedNumber
|
createData[config.numberRangeHolder] = result.usedNumber
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { eq, sql } from "drizzle-orm"
|
|||||||
export const useNextNumberRangeNumber = async (
|
export const useNextNumberRangeNumber = async (
|
||||||
server: FastifyInstance,
|
server: FastifyInstance,
|
||||||
tenantId: number,
|
tenantId: number,
|
||||||
numberRange: string
|
numberRange: string,
|
||||||
|
isAvailable: (candidate: string, tx: any) => Promise<boolean> = async () => true
|
||||||
) => {
|
) => {
|
||||||
const numberRangeFallbacks: Record<string, string> = {
|
const numberRangeFallbacks: Record<string, string> = {
|
||||||
costEstimates: "quotes",
|
costEstimates: "quotes",
|
||||||
@@ -44,18 +45,39 @@ export const useNextNumberRangeNumber = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const current = numberRanges[resolvedNumberRange]
|
const current = numberRanges[resolvedNumberRange]
|
||||||
|
let nextNumber = Number(current.nextNumber)
|
||||||
|
|
||||||
const usedNumber =
|
if (!Number.isFinite(nextNumber)) {
|
||||||
|
throw new Error(`Number range '${resolvedNumberRange}' has an invalid nextNumber`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let usedNumber = ""
|
||||||
|
const maxAttempts = 10000
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
const candidate =
|
||||||
(current.prefix || "") +
|
(current.prefix || "") +
|
||||||
current.nextNumber +
|
nextNumber +
|
||||||
(current.suffix || "")
|
(current.suffix || "")
|
||||||
|
|
||||||
|
if (await isAvailable(candidate, tx)) {
|
||||||
|
usedNumber = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
nextNumber++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!usedNumber) {
|
||||||
|
throw new Error(`Could not find an available number in range '${resolvedNumberRange}'`)
|
||||||
|
}
|
||||||
|
|
||||||
const updatedRanges = {
|
const updatedRanges = {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
...numberRanges,
|
...numberRanges,
|
||||||
[resolvedNumberRange]: {
|
[resolvedNumberRange]: {
|
||||||
...current,
|
...current,
|
||||||
nextNumber: current.nextNumber + 1,
|
nextNumber: nextNumber + 1,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { historyitems } from "../../db/schema";
|
import { historyitems } from "../../db/schema";
|
||||||
|
import { sanitizeHistoryText, sanitizeHistoryValue } from "./historySanitization";
|
||||||
|
|
||||||
const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
||||||
customers: "Kunden",
|
customers: "Kunden",
|
||||||
@@ -35,6 +36,7 @@ const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
|||||||
memberrelations: "Mitgliedsverhältnisse",
|
memberrelations: "Mitgliedsverhältnisse",
|
||||||
teams: "Teams",
|
teams: "Teams",
|
||||||
outgoingsepamandates: "Ausgehende SEPA-Mandate",
|
outgoingsepamandates: "Ausgehende SEPA-Mandate",
|
||||||
|
projecttypes: "Projekttypen",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHistoryEntityLabel(entity: string) {
|
export function getHistoryEntityLabel(entity: string) {
|
||||||
@@ -96,6 +98,7 @@ export async function insertHistoryItem(
|
|||||||
files: "file",
|
files: "file",
|
||||||
memberrelations: "memberrelation",
|
memberrelations: "memberrelation",
|
||||||
outgoingsepamandates: "outgoingsepamandate",
|
outgoingsepamandates: "outgoingsepamandate",
|
||||||
|
projecttypes: "projecttype",
|
||||||
}
|
}
|
||||||
|
|
||||||
const fkColumn = columnMap[params.entity]
|
const fkColumn = columnMap[params.entity]
|
||||||
@@ -112,11 +115,11 @@ export async function insertHistoryItem(
|
|||||||
const entry = {
|
const entry = {
|
||||||
tenant: params.tenant_id,
|
tenant: params.tenant_id,
|
||||||
createdBy: params.created_by,
|
createdBy: params.created_by,
|
||||||
text: params.text || textMap[params.action],
|
text: sanitizeHistoryText(params.text || textMap[params.action]),
|
||||||
action: params.action,
|
action: params.action,
|
||||||
[fkColumn]: params.entityId,
|
[fkColumn]: params.entityId,
|
||||||
oldVal: stringifyHistoryValue(params.oldVal),
|
oldVal: stringifyHistoryValue(sanitizeHistoryValue(params.oldVal)),
|
||||||
newVal: stringifyHistoryValue(params.newVal)
|
newVal: stringifyHistoryValue(sanitizeHistoryValue(params.newVal))
|
||||||
}
|
}
|
||||||
|
|
||||||
await server.db.insert(historyitems).values(entry as any)
|
await server.db.insert(historyitems).values(entry as any)
|
||||||
|
|||||||
45
backend/src/utils/historySanitization.ts
Normal file
45
backend/src/utils/historySanitization.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
const IBAN_IN_TEXT_PATTERN = /\b([A-Z]{2}\d{2}(?:[\s-]?[A-Z0-9]){11,30})(?=["'\]},.;:!?)]|$)/gi
|
||||||
|
|
||||||
|
export function maskIban(iban: string): string {
|
||||||
|
const normalized = iban.replace(/[\s-]+/g, "").toUpperCase()
|
||||||
|
if (normalized.length <= 8) return normalized
|
||||||
|
return `${normalized.slice(0, 4)} **** **** ${normalized.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeHistoryText(text: string): string {
|
||||||
|
return text.replace(IBAN_IN_TEXT_PATTERN, (candidate) => maskIban(candidate))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeIbanField(value: any): any {
|
||||||
|
if (typeof value === "string") return maskIban(value)
|
||||||
|
if (Array.isArray(value)) return value.map(sanitizeIbanField)
|
||||||
|
return sanitizeHistoryValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeHistoryValue(value: any): any {
|
||||||
|
if (typeof value === "string") return sanitizeHistoryText(value)
|
||||||
|
if (Array.isArray(value)) return value.map(sanitizeHistoryValue)
|
||||||
|
if (!value || typeof value !== "object" || value instanceof Date) return value
|
||||||
|
|
||||||
|
const prototype = Object.getPrototypeOf(value)
|
||||||
|
if (prototype !== Object.prototype && prototype !== null) return value
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, nestedValue]) => [
|
||||||
|
key,
|
||||||
|
key.toLowerCase().includes("iban")
|
||||||
|
? sanitizeIbanField(nestedValue)
|
||||||
|
: sanitizeHistoryValue(nestedValue),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeHistoryItem<T extends Record<string, any>>(item: T): T {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
text: typeof item.text === "string" ? sanitizeHistoryText(item.text) : item.text,
|
||||||
|
oldVal: sanitizeHistoryValue(item.oldVal),
|
||||||
|
newVal: sanitizeHistoryValue(item.newVal),
|
||||||
|
config: sanitizeHistoryValue(item.config),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -147,6 +147,7 @@ export const resourceConfig = {
|
|||||||
},
|
},
|
||||||
products: {
|
products: {
|
||||||
table: products,
|
table: products,
|
||||||
|
mtoLoad: ["unit"],
|
||||||
searchColumns: ["name","manufacturer","ean","barcode","description","manfacturer_number","article_number"],
|
searchColumns: ["name","manufacturer","ean","barcode","description","manfacturer_number","article_number"],
|
||||||
},
|
},
|
||||||
productcategories: {
|
productcategories: {
|
||||||
|
|||||||
55
backend/tests/historySanitization.test.ts
Normal file
55
backend/tests/historySanitization.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import test from "node:test"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
|
||||||
|
import {
|
||||||
|
maskIban,
|
||||||
|
sanitizeHistoryItem,
|
||||||
|
sanitizeHistoryText,
|
||||||
|
sanitizeHistoryValue,
|
||||||
|
} from "../src/utils/historySanitization"
|
||||||
|
|
||||||
|
const IBAN = "DE89370400440532013000"
|
||||||
|
|
||||||
|
test("masks all but the first and last four IBAN characters", () => {
|
||||||
|
assert.equal(maskIban(IBAN), "DE89 **** **** 3000")
|
||||||
|
assert.equal(maskIban("DE89 3704 0044 0532 0130 00"), "DE89 **** **** 3000")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("masks IBAN values in nested history data", () => {
|
||||||
|
const sanitized = sanitizeHistoryValue({
|
||||||
|
name: "Beispielkunde",
|
||||||
|
infoData: {
|
||||||
|
bankingIban: IBAN,
|
||||||
|
bankingIbans: [IBAN, "AT61 1904 3002 3457 3201"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(sanitized, {
|
||||||
|
name: "Beispielkunde",
|
||||||
|
infoData: {
|
||||||
|
bankingIban: "DE89 **** **** 3000",
|
||||||
|
bankingIbans: ["DE89 **** **** 3000", "AT61 **** **** 3201"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("masks IBANs embedded in generated history text", () => {
|
||||||
|
const text = `Kunden: Info Daten geändert von "{\"bankingIbans\":[\"${IBAN}\"]}"`
|
||||||
|
const sanitized = sanitizeHistoryText(text)
|
||||||
|
|
||||||
|
assert.equal(sanitized.includes(IBAN), false)
|
||||||
|
assert.equal(sanitized.includes("DE89 **** **** 3000"), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sanitizes existing history items before they are returned", () => {
|
||||||
|
const sanitized = sanitizeHistoryItem({
|
||||||
|
text: `IBAN: ${IBAN}.`,
|
||||||
|
oldVal: JSON.stringify({ bankingIban: IBAN }),
|
||||||
|
newVal: { iban: IBAN },
|
||||||
|
config: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(sanitized.text, "IBAN: DE89 **** **** 3000.")
|
||||||
|
assert.equal(sanitized.oldVal.includes(IBAN), false)
|
||||||
|
assert.deepEqual(sanitized.newVal, { iban: "DE89 **** **** 3000" })
|
||||||
|
})
|
||||||
37
backend/tests/mcpArchivedResults.test.ts
Normal file
37
backend/tests/mcpArchivedResults.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { mcpTools } from "../src/mcp/registry"
|
||||||
|
import { asToolResult } from "../src/mcp/result"
|
||||||
|
|
||||||
|
test("removes archived records from MCP text and structured output", () => {
|
||||||
|
const result = asToolResult({
|
||||||
|
rows: [
|
||||||
|
{ id: 1, archived: false, name: "Aktiv" },
|
||||||
|
{ id: 2, archived: true, name: "Archiviert" },
|
||||||
|
],
|
||||||
|
nested: {
|
||||||
|
current: { id: 3, archived: false },
|
||||||
|
previous: { id: 4, archived: true },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(result.structuredContent, {
|
||||||
|
rows: [{ id: 1, archived: false, name: "Aktiv" }],
|
||||||
|
nested: { current: { id: 3, archived: false } },
|
||||||
|
})
|
||||||
|
assert.deepEqual(JSON.parse(result.content[0].text), result.structuredContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not expose a singular archived record", () => {
|
||||||
|
const result = asToolResult({ task: { id: 1, archived: true } })
|
||||||
|
|
||||||
|
assert.deepEqual(result.structuredContent, {})
|
||||||
|
assert.deepEqual(JSON.parse(result.content[0].text), {})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not advertise includeArchived on MCP tools", () => {
|
||||||
|
for (const tool of mcpTools) {
|
||||||
|
const properties = (tool.inputSchema.properties || {}) as Record<string, unknown>
|
||||||
|
assert.equal(properties.includeArchived, undefined, tool.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
34
backend/tests/mcpSerialInvoices.test.ts
Normal file
34
backend/tests/mcpSerialInvoices.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { mcpToolMap } from "../src/mcp/registry"
|
||||||
|
|
||||||
|
test("publishes the complete serial invoice configuration on create and update", () => {
|
||||||
|
for (const name of ["accounting.outgoing_documents.create", "accounting.outgoing_documents.update"]) {
|
||||||
|
const tool = mcpToolMap.get(name)
|
||||||
|
const properties = tool?.inputSchema.properties as Record<string, any>
|
||||||
|
|
||||||
|
assert.ok(properties.serialConfig, name)
|
||||||
|
assert.deepEqual(properties.serialConfig.properties.intervall.enum, [
|
||||||
|
"wöchentlich",
|
||||||
|
"2 - wöchentlich",
|
||||||
|
"monatlich",
|
||||||
|
"vierteljährlich",
|
||||||
|
"halbjährlich",
|
||||||
|
"jährlich",
|
||||||
|
])
|
||||||
|
assert.deepEqual(properties.serialConfig.properties.dateDirection.enum, ["Rückwirkend", "Im Voraus"])
|
||||||
|
assert.ok(properties.type.enum.includes("serialInvoices"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("registers tenant-scoped serial invoice execution tools", () => {
|
||||||
|
const execute = mcpToolMap.get("accounting.serial_invoices.execute")
|
||||||
|
const list = mcpToolMap.get("accounting.serial_invoice_executions.list")
|
||||||
|
const finish = mcpToolMap.get("accounting.serial_invoice_executions.finish")
|
||||||
|
|
||||||
|
assert.deepEqual(execute?.requiredPermissions, ["accounting.outgoing_documents.write"])
|
||||||
|
assert.deepEqual(execute?.inputSchema.required, ["executionDate", "templateIds"])
|
||||||
|
assert.deepEqual(list?.requiredPermissions, ["accounting.outgoing_documents.read"])
|
||||||
|
assert.deepEqual(finish?.requiredPermissions, ["accounting.outgoing_documents.write"])
|
||||||
|
assert.deepEqual(finish?.inputSchema.required, ["id"])
|
||||||
|
})
|
||||||
50
backend/tests/numberRangeAvailability.test.ts
Normal file
50
backend/tests/numberRangeAvailability.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
|
||||||
|
import { useNextNumberRangeNumber } from "../src/utils/functions"
|
||||||
|
|
||||||
|
test("number range skips identifiers that are already in use", async () => {
|
||||||
|
const tenant = {
|
||||||
|
id: 7,
|
||||||
|
numberRanges: {
|
||||||
|
customerinventoryitems: {
|
||||||
|
prefix: "KIA-",
|
||||||
|
suffix: "",
|
||||||
|
nextNumber: 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
let savedNumberRanges: any = null
|
||||||
|
|
||||||
|
const tx = {
|
||||||
|
execute: async () => undefined,
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: async () => [tenant],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
update: () => ({
|
||||||
|
set: (data: any) => ({
|
||||||
|
where: async () => {
|
||||||
|
savedNumberRanges = data.numberRanges
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const server = {
|
||||||
|
db: {
|
||||||
|
transaction: async (callback: (transaction: typeof tx) => Promise<any>) => callback(tx),
|
||||||
|
},
|
||||||
|
} as any
|
||||||
|
const occupied = new Set(["KIA-1000", "KIA-1001"])
|
||||||
|
|
||||||
|
const result = await useNextNumberRangeNumber(
|
||||||
|
server,
|
||||||
|
tenant.id,
|
||||||
|
"customerinventoryitems",
|
||||||
|
async (candidate) => !occupied.has(candidate)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(result.usedNumber, "KIA-1002")
|
||||||
|
assert.equal(savedNumberRanges.customerinventoryitems.nextNumber, 1003)
|
||||||
|
})
|
||||||
33
backend/tests/outgoingDocumentCostCentres.test.ts
Normal file
33
backend/tests/outgoingDocumentCostCentres.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { applyRowCostCentreAssignments } from "../src/modules/outgoing-document-cost-centres.service"
|
||||||
|
import { mcpToolMap } from "../src/mcp/registry"
|
||||||
|
|
||||||
|
test("changes only requested position cost centres", () => {
|
||||||
|
const rows = [
|
||||||
|
{ id: "row-1", description: "Montage", price: 100, costCentre: "old-1" },
|
||||||
|
{ id: "row-2", description: "Material", price: 50, costCentre: "old-2" },
|
||||||
|
]
|
||||||
|
|
||||||
|
assert.deepEqual(applyRowCostCentreAssignments(rows, [
|
||||||
|
{ rowId: "row-1", costcentre: "new-1" },
|
||||||
|
]), [
|
||||||
|
{ id: "row-1", description: "Montage", price: 100, costCentre: "new-1" },
|
||||||
|
rows[1],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects assignments for unknown document positions", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => applyRowCostCentreAssignments([{ id: "row-1" }], [{ rowId: "missing", costcentre: null }]),
|
||||||
|
/Position missing.*nicht gefunden/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("registers a dedicated finalized-document cost centre tool", () => {
|
||||||
|
const tool = mcpToolMap.get("accounting.outgoing_documents.cost_centres.update")
|
||||||
|
|
||||||
|
assert.deepEqual(tool?.requiredPermissions, ["accounting.outgoing_documents.write"])
|
||||||
|
assert.deepEqual(tool?.inputSchema.required, ["id"])
|
||||||
|
assert.ok(tool?.inputSchema.anyOf)
|
||||||
|
})
|
||||||
8
backend/tests/productUnitRelation.test.ts
Normal file
8
backend/tests/productUnitRelation.test.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
|
||||||
|
import { resourceConfig } from "../src/utils/resource.config"
|
||||||
|
|
||||||
|
test("product details load their configured unit", () => {
|
||||||
|
assert.ok(resourceConfig.products.mtoLoad.includes("unit"))
|
||||||
|
})
|
||||||
@@ -22,6 +22,10 @@ const props = defineProps({
|
|||||||
inModal: {
|
inModal: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
},
|
},
|
||||||
|
floatingWindow: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
platform: {
|
platform: {
|
||||||
type: String,
|
type: String,
|
||||||
}
|
}
|
||||||
@@ -527,7 +531,7 @@ const updateItem = async () => {
|
|||||||
>
|
>
|
||||||
<template #center>
|
<template #center>
|
||||||
<h1
|
<h1
|
||||||
v-if="item"
|
v-if="item && !props.floatingWindow"
|
||||||
:class="['text-xl', 'font-medium']"
|
:class="['text-xl', 'font-medium']"
|
||||||
>{{ item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
|
>{{ item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
|
||||||
</template>
|
</template>
|
||||||
@@ -547,6 +551,7 @@ const updateItem = async () => {
|
|||||||
Erstellen
|
Erstellen
|
||||||
</UButton>
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
|
v-if="!props.floatingWindow"
|
||||||
@click="modal.close()"
|
@click="modal.close()"
|
||||||
color="red"
|
color="red"
|
||||||
class="ml-2"
|
class="ml-2"
|
||||||
|
|||||||
396
frontend/components/HeroiconPicker.vue
Normal file
396
frontend/components/HeroiconPicker.vue
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const heroiconNames = [
|
||||||
|
'academic-cap',
|
||||||
|
'adjustments-horizontal',
|
||||||
|
'adjustments-vertical',
|
||||||
|
'archive-box',
|
||||||
|
'archive-box-arrow-down',
|
||||||
|
'archive-box-x-mark',
|
||||||
|
'arrow-down',
|
||||||
|
'arrow-down-circle',
|
||||||
|
'arrow-down-left',
|
||||||
|
'arrow-down-on-square',
|
||||||
|
'arrow-down-on-square-stack',
|
||||||
|
'arrow-down-right',
|
||||||
|
'arrow-down-tray',
|
||||||
|
'arrow-left',
|
||||||
|
'arrow-left-circle',
|
||||||
|
'arrow-left-end-on-rectangle',
|
||||||
|
'arrow-left-on-rectangle',
|
||||||
|
'arrow-left-start-on-rectangle',
|
||||||
|
'arrow-long-down',
|
||||||
|
'arrow-long-left',
|
||||||
|
'arrow-long-right',
|
||||||
|
'arrow-long-up',
|
||||||
|
'arrow-path',
|
||||||
|
'arrow-path-rounded-square',
|
||||||
|
'arrow-right',
|
||||||
|
'arrow-right-circle',
|
||||||
|
'arrow-right-end-on-rectangle',
|
||||||
|
'arrow-right-on-rectangle',
|
||||||
|
'arrow-right-start-on-rectangle',
|
||||||
|
'arrow-small-down',
|
||||||
|
'arrow-small-left',
|
||||||
|
'arrow-small-right',
|
||||||
|
'arrow-small-up',
|
||||||
|
'arrow-top-right-on-square',
|
||||||
|
'arrow-trending-down',
|
||||||
|
'arrow-trending-up',
|
||||||
|
'arrow-turn-down-left',
|
||||||
|
'arrow-turn-down-right',
|
||||||
|
'arrow-turn-left-down',
|
||||||
|
'arrow-turn-left-up',
|
||||||
|
'arrow-turn-right-down',
|
||||||
|
'arrow-turn-right-up',
|
||||||
|
'arrow-turn-up-left',
|
||||||
|
'arrow-turn-up-right',
|
||||||
|
'arrow-up',
|
||||||
|
'arrow-up-circle',
|
||||||
|
'arrow-up-left',
|
||||||
|
'arrow-up-on-square',
|
||||||
|
'arrow-up-on-square-stack',
|
||||||
|
'arrow-up-right',
|
||||||
|
'arrow-up-tray',
|
||||||
|
'arrow-uturn-down',
|
||||||
|
'arrow-uturn-left',
|
||||||
|
'arrow-uturn-right',
|
||||||
|
'arrow-uturn-up',
|
||||||
|
'arrows-pointing-in',
|
||||||
|
'arrows-pointing-out',
|
||||||
|
'arrows-right-left',
|
||||||
|
'arrows-up-down',
|
||||||
|
'at-symbol',
|
||||||
|
'backspace',
|
||||||
|
'backward',
|
||||||
|
'banknotes',
|
||||||
|
'bars-2',
|
||||||
|
'bars-3',
|
||||||
|
'bars-3-bottom-left',
|
||||||
|
'bars-3-bottom-right',
|
||||||
|
'bars-3-center-left',
|
||||||
|
'bars-4',
|
||||||
|
'bars-arrow-down',
|
||||||
|
'bars-arrow-up',
|
||||||
|
'battery-0',
|
||||||
|
'battery-100',
|
||||||
|
'battery-50',
|
||||||
|
'beaker',
|
||||||
|
'bell',
|
||||||
|
'bell-alert',
|
||||||
|
'bell-slash',
|
||||||
|
'bell-snooze',
|
||||||
|
'bold',
|
||||||
|
'bolt',
|
||||||
|
'bolt-slash',
|
||||||
|
'book-open',
|
||||||
|
'bookmark',
|
||||||
|
'bookmark-slash',
|
||||||
|
'bookmark-square',
|
||||||
|
'briefcase',
|
||||||
|
'bug-ant',
|
||||||
|
'building-library',
|
||||||
|
'building-office',
|
||||||
|
'building-office-2',
|
||||||
|
'building-storefront',
|
||||||
|
'cake',
|
||||||
|
'calculator',
|
||||||
|
'calendar',
|
||||||
|
'calendar-date-range',
|
||||||
|
'calendar-days',
|
||||||
|
'camera',
|
||||||
|
'chart-bar',
|
||||||
|
'chart-bar-square',
|
||||||
|
'chart-pie',
|
||||||
|
'chat-bubble-bottom-center',
|
||||||
|
'chat-bubble-bottom-center-text',
|
||||||
|
'chat-bubble-left',
|
||||||
|
'chat-bubble-left-ellipsis',
|
||||||
|
'chat-bubble-left-right',
|
||||||
|
'chat-bubble-oval-left',
|
||||||
|
'chat-bubble-oval-left-ellipsis',
|
||||||
|
'check',
|
||||||
|
'check-badge',
|
||||||
|
'check-circle',
|
||||||
|
'chevron-double-down',
|
||||||
|
'chevron-double-left',
|
||||||
|
'chevron-double-right',
|
||||||
|
'chevron-double-up',
|
||||||
|
'chevron-down',
|
||||||
|
'chevron-left',
|
||||||
|
'chevron-right',
|
||||||
|
'chevron-up',
|
||||||
|
'chevron-up-down',
|
||||||
|
'circle-stack',
|
||||||
|
'clipboard',
|
||||||
|
'clipboard-document',
|
||||||
|
'clipboard-document-check',
|
||||||
|
'clipboard-document-list',
|
||||||
|
'clock',
|
||||||
|
'cloud',
|
||||||
|
'cloud-arrow-down',
|
||||||
|
'cloud-arrow-up',
|
||||||
|
'code-bracket',
|
||||||
|
'code-bracket-square',
|
||||||
|
'cog',
|
||||||
|
'cog-6-tooth',
|
||||||
|
'cog-8-tooth',
|
||||||
|
'command-line',
|
||||||
|
'computer-desktop',
|
||||||
|
'cpu-chip',
|
||||||
|
'credit-card',
|
||||||
|
'cube',
|
||||||
|
'cube-transparent',
|
||||||
|
'currency-bangladeshi',
|
||||||
|
'currency-dollar',
|
||||||
|
'currency-euro',
|
||||||
|
'currency-pound',
|
||||||
|
'currency-rupee',
|
||||||
|
'currency-yen',
|
||||||
|
'cursor-arrow-rays',
|
||||||
|
'cursor-arrow-ripple',
|
||||||
|
'device-phone-mobile',
|
||||||
|
'device-tablet',
|
||||||
|
'divide',
|
||||||
|
'document',
|
||||||
|
'document-arrow-down',
|
||||||
|
'document-arrow-up',
|
||||||
|
'document-chart-bar',
|
||||||
|
'document-check',
|
||||||
|
'document-currency-bangladeshi',
|
||||||
|
'document-currency-dollar',
|
||||||
|
'document-currency-euro',
|
||||||
|
'document-currency-pound',
|
||||||
|
'document-currency-rupee',
|
||||||
|
'document-currency-yen',
|
||||||
|
'document-duplicate',
|
||||||
|
'document-magnifying-glass',
|
||||||
|
'document-minus',
|
||||||
|
'document-plus',
|
||||||
|
'document-text',
|
||||||
|
'ellipsis-horizontal',
|
||||||
|
'ellipsis-horizontal-circle',
|
||||||
|
'ellipsis-vertical',
|
||||||
|
'envelope',
|
||||||
|
'envelope-open',
|
||||||
|
'equals',
|
||||||
|
'exclamation-circle',
|
||||||
|
'exclamation-triangle',
|
||||||
|
'eye',
|
||||||
|
'eye-dropper',
|
||||||
|
'eye-slash',
|
||||||
|
'face-frown',
|
||||||
|
'face-smile',
|
||||||
|
'film',
|
||||||
|
'finger-print',
|
||||||
|
'fire',
|
||||||
|
'flag',
|
||||||
|
'folder',
|
||||||
|
'folder-arrow-down',
|
||||||
|
'folder-minus',
|
||||||
|
'folder-open',
|
||||||
|
'folder-plus',
|
||||||
|
'forward',
|
||||||
|
'funnel',
|
||||||
|
'gif',
|
||||||
|
'gift',
|
||||||
|
'gift-top',
|
||||||
|
'globe-alt',
|
||||||
|
'globe-americas',
|
||||||
|
'globe-asia-australia',
|
||||||
|
'globe-europe-africa',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'hand-raised',
|
||||||
|
'hand-thumb-down',
|
||||||
|
'hand-thumb-up',
|
||||||
|
'hashtag',
|
||||||
|
'heart',
|
||||||
|
'home',
|
||||||
|
'home-modern',
|
||||||
|
'identification',
|
||||||
|
'inbox',
|
||||||
|
'inbox-arrow-down',
|
||||||
|
'inbox-stack',
|
||||||
|
'information-circle',
|
||||||
|
'italic',
|
||||||
|
'key',
|
||||||
|
'language',
|
||||||
|
'lifebuoy',
|
||||||
|
'light-bulb',
|
||||||
|
'link',
|
||||||
|
'link-slash',
|
||||||
|
'list-bullet',
|
||||||
|
'lock-closed',
|
||||||
|
'lock-open',
|
||||||
|
'magnifying-glass',
|
||||||
|
'magnifying-glass-circle',
|
||||||
|
'magnifying-glass-minus',
|
||||||
|
'magnifying-glass-plus',
|
||||||
|
'map',
|
||||||
|
'map-pin',
|
||||||
|
'megaphone',
|
||||||
|
'microphone',
|
||||||
|
'minus',
|
||||||
|
'minus-circle',
|
||||||
|
'minus-small',
|
||||||
|
'moon',
|
||||||
|
'musical-note',
|
||||||
|
'newspaper',
|
||||||
|
'no-symbol',
|
||||||
|
'numbered-list',
|
||||||
|
'paint-brush',
|
||||||
|
'paper-airplane',
|
||||||
|
'paper-clip',
|
||||||
|
'pause',
|
||||||
|
'pause-circle',
|
||||||
|
'pencil',
|
||||||
|
'pencil-square',
|
||||||
|
'percent-badge',
|
||||||
|
'phone',
|
||||||
|
'phone-arrow-down-left',
|
||||||
|
'phone-arrow-up-right',
|
||||||
|
'phone-x-mark',
|
||||||
|
'photo',
|
||||||
|
'play',
|
||||||
|
'play-circle',
|
||||||
|
'play-pause',
|
||||||
|
'plus',
|
||||||
|
'plus-circle',
|
||||||
|
'plus-small',
|
||||||
|
'power',
|
||||||
|
'presentation-chart-bar',
|
||||||
|
'presentation-chart-line',
|
||||||
|
'printer',
|
||||||
|
'puzzle-piece',
|
||||||
|
'qr-code',
|
||||||
|
'question-mark-circle',
|
||||||
|
'queue-list',
|
||||||
|
'radio',
|
||||||
|
'receipt-percent',
|
||||||
|
'receipt-refund',
|
||||||
|
'rectangle-group',
|
||||||
|
'rectangle-stack',
|
||||||
|
'rocket-launch',
|
||||||
|
'rss',
|
||||||
|
'scale',
|
||||||
|
'scissors',
|
||||||
|
'server',
|
||||||
|
'server-stack',
|
||||||
|
'share',
|
||||||
|
'shield-check',
|
||||||
|
'shield-exclamation',
|
||||||
|
'shopping-bag',
|
||||||
|
'shopping-cart',
|
||||||
|
'signal',
|
||||||
|
'signal-slash',
|
||||||
|
'slash',
|
||||||
|
'sparkles',
|
||||||
|
'speaker-wave',
|
||||||
|
'speaker-x-mark',
|
||||||
|
'square-2-stack',
|
||||||
|
'square-3-stack-3d',
|
||||||
|
'squares-2x2',
|
||||||
|
'squares-plus',
|
||||||
|
'star',
|
||||||
|
'stop',
|
||||||
|
'stop-circle',
|
||||||
|
'strikethrough',
|
||||||
|
'sun',
|
||||||
|
'swatch',
|
||||||
|
'table-cells',
|
||||||
|
'tag',
|
||||||
|
'ticket',
|
||||||
|
'trash',
|
||||||
|
'trophy',
|
||||||
|
'truck',
|
||||||
|
'tv',
|
||||||
|
'underline',
|
||||||
|
'user',
|
||||||
|
'user-circle',
|
||||||
|
'user-group',
|
||||||
|
'user-minus',
|
||||||
|
'user-plus',
|
||||||
|
'users',
|
||||||
|
'variable',
|
||||||
|
'video-camera',
|
||||||
|
'video-camera-slash',
|
||||||
|
'view-columns',
|
||||||
|
'viewfinder-circle',
|
||||||
|
'wallet',
|
||||||
|
'wifi',
|
||||||
|
'window',
|
||||||
|
'wrench',
|
||||||
|
'wrench-screwdriver',
|
||||||
|
'x-circle',
|
||||||
|
'x-mark',
|
||||||
|
]
|
||||||
|
|
||||||
|
const iconOptions = heroiconNames.map(name => ({
|
||||||
|
label: name,
|
||||||
|
value: `i-heroicons-${name}`
|
||||||
|
}))
|
||||||
|
|
||||||
|
const selectedIcon = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: value => emit('update:modelValue', value || '')
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedName = computed(() => {
|
||||||
|
return iconOptions.find(option => option.value === selectedIcon.value)?.label
|
||||||
|
|| selectedIcon.value.replace(/^i-heroicons-/, '')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex size-9 shrink-0 items-center justify-center rounded-md border border-default">
|
||||||
|
<UIcon
|
||||||
|
:name="selectedIcon || 'i-heroicons-question-mark-circle'"
|
||||||
|
class="size-5"
|
||||||
|
:class="{ 'text-muted': !selectedIcon }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<USelectMenu
|
||||||
|
v-model="selectedIcon"
|
||||||
|
:items="iconOptions"
|
||||||
|
value-key="value"
|
||||||
|
label-key="label"
|
||||||
|
:search-input="{ placeholder: 'Heroicon suchen …' }"
|
||||||
|
:filter-fields="['label', 'value']"
|
||||||
|
placeholder="Heroicon auswählen"
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
clear-search-on-close
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<span class="truncate">{{ selectedName || 'Heroicon auswählen' }}</span>
|
||||||
|
</template>
|
||||||
|
<template #item="{ item }">
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<UIcon :name="item.value" class="size-5 shrink-0" />
|
||||||
|
<span class="truncate">{{ item.label }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</USelectMenu>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="selectedIcon"
|
||||||
|
icon="i-heroicons-x-mark"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="Icon entfernen"
|
||||||
|
@click="selectedIcon = ''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { useDraggable } from '@vueuse/core'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const dataStore = useDataStore()
|
const dataStore = useDataStore()
|
||||||
@@ -31,6 +32,27 @@ const loaded = ref(false)
|
|||||||
const items = ref([])
|
const items = ref([])
|
||||||
const item = ref({})
|
const item = ref({})
|
||||||
|
|
||||||
|
const isDraggableVendorCreate = computed(() => props.type === "vendors" && props.mode === "create")
|
||||||
|
const isDraggableWindowOpen = ref(true)
|
||||||
|
const draggableWindow = ref(null)
|
||||||
|
const draggableWindowHandle = ref(null)
|
||||||
|
const initialWindowPosition = import.meta.client
|
||||||
|
? {
|
||||||
|
x: Math.max(16, (window.innerWidth - Math.min(1024, window.innerWidth - 32)) / 2),
|
||||||
|
y: Math.max(16, Math.min(96, window.innerHeight * 0.1)),
|
||||||
|
}
|
||||||
|
: { x: 16, y: 16 }
|
||||||
|
|
||||||
|
const { style: draggableWindowStyle } = useDraggable(draggableWindow, {
|
||||||
|
initialValue: initialWindowPosition,
|
||||||
|
handle: draggableWindowHandle,
|
||||||
|
})
|
||||||
|
|
||||||
|
const closeDraggableWindow = () => {
|
||||||
|
isDraggableWindowOpen.value = false
|
||||||
|
modal.close()
|
||||||
|
}
|
||||||
|
|
||||||
const setupPage = async () => {
|
const setupPage = async () => {
|
||||||
if(props.mode === "show") {
|
if(props.mode === "show") {
|
||||||
//Load Data for Show
|
//Load Data for Show
|
||||||
@@ -60,7 +82,49 @@ setupPage()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UModal :fullscreen="props.mode === 'show'">
|
<div
|
||||||
|
v-if="isDraggableVendorCreate && isDraggableWindowOpen"
|
||||||
|
ref="draggableWindow"
|
||||||
|
:style="draggableWindowStyle"
|
||||||
|
class="fixed z-[999] flex h-[80vh] max-h-[calc(100vh-2rem)] w-[calc(100vw-2rem)] max-w-5xl flex-col overflow-hidden resize rounded-xl border border-gray-200 bg-white shadow-2xl dark:border-gray-800 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref="draggableWindowHandle"
|
||||||
|
class="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3 select-none dark:border-gray-800 dark:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 text-gray-500">
|
||||||
|
<UIcon name="i-heroicons-building-storefront" />
|
||||||
|
<span class="text-xs font-bold uppercase tracking-wider">Lieferant erstellen</span>
|
||||||
|
</div>
|
||||||
|
<UTooltip text="Schließen">
|
||||||
|
<UButton
|
||||||
|
color="gray"
|
||||||
|
variant="ghost"
|
||||||
|
icon="i-heroicons-x-mark"
|
||||||
|
size="xs"
|
||||||
|
@pointerdown.stop
|
||||||
|
@click.stop="closeDraggableWindow"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
</div>
|
||||||
|
<EntityEdit
|
||||||
|
v-if="loaded"
|
||||||
|
:type="props.type"
|
||||||
|
:item="item"
|
||||||
|
:inModal="true"
|
||||||
|
:floating-window="true"
|
||||||
|
@return-data="(data) => emit('returnData', data)"
|
||||||
|
:createQuery="props.createQuery"
|
||||||
|
:mode="props.mode"
|
||||||
|
/>
|
||||||
|
<UProgress
|
||||||
|
v-else
|
||||||
|
animation="carousel"
|
||||||
|
class="m-5 mt-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UModal v-else-if="!isDraggableVendorCreate" :fullscreen="props.mode === 'show'">
|
||||||
<template #content>
|
<template #content>
|
||||||
<EntityShow
|
<EntityShow
|
||||||
v-if="loaded && props.mode === 'show'"
|
v-if="loaded && props.mode === 'show'"
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ export const useDesktopPush = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendTestPushToProfile = async (profileId) => {
|
||||||
|
return await $api(`/api/notifications/test-push/profile/${encodeURIComponent(profileId)}`, {
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
supported,
|
supported,
|
||||||
permission,
|
permission,
|
||||||
@@ -85,5 +91,6 @@ export const useDesktopPush = () => {
|
|||||||
loadConfig,
|
loadConfig,
|
||||||
subscribe,
|
subscribe,
|
||||||
sendTestPush,
|
sendTestPush,
|
||||||
|
sendTestPushToProfile,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,9 +119,10 @@ export default defineNuxtConfig({
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
/* WICHTIG FÜR SAFARI / iOS */
|
// FEDEO wird serverseitig gerendert; es gibt daher keine vorab gecachte
|
||||||
|
// index.html, die Workbox als Navigations-Fallback verwenden könnte.
|
||||||
workbox: {
|
workbox: {
|
||||||
navigateFallback: '/',
|
navigateFallback: null,
|
||||||
},
|
},
|
||||||
|
|
||||||
devOptions: {
|
devOptions: {
|
||||||
|
|||||||
@@ -1896,6 +1896,31 @@ const saveDocument = async (state, resetup = false) => {
|
|||||||
if (resetup) await setupPage()
|
if (resetup) await setupPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saveFinalizedCostCentres = async () => {
|
||||||
|
const rowCostCentres = itemInfo.value.rows
|
||||||
|
.filter(row => row?.id)
|
||||||
|
.map(row => ({
|
||||||
|
rowId: String(row.id),
|
||||||
|
costcentre: row.costCentre || null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await $api(`/api/functions/outgoing-documents/${itemInfo.value.id}/cost-centres`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: {
|
||||||
|
costcentre: itemInfo.value.costcentre || null,
|
||||||
|
rowCostCentres,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
itemInfo.value.costcentre = result.document.costcentre
|
||||||
|
itemInfo.value.rows = normalizeCreatedDocumentRows(result.document.rows)
|
||||||
|
toast.add({
|
||||||
|
title: "Kostenstellen gespeichert",
|
||||||
|
description: "Die Kostenstellen des fertiggestellten Belegs wurden aktualisiert.",
|
||||||
|
color: "success",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const selectedTab = ref("0")
|
const selectedTab = ref("0")
|
||||||
|
|
||||||
const closeDocument = async () => {
|
const closeDocument = async () => {
|
||||||
@@ -2077,7 +2102,7 @@ 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' && !isTemplateMode"
|
v-if="itemInfo.type !== 'serialInvoices' && !isTemplateMode && itemInfo.state === 'Entwurf'"
|
||||||
:disabled="!itemInfo.customer"
|
:disabled="!itemInfo.customer"
|
||||||
>
|
>
|
||||||
Speichern
|
Speichern
|
||||||
@@ -2092,10 +2117,17 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
|
|||||||
</UButton>
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
@click="closeDocument"
|
@click="closeDocument"
|
||||||
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices' && !isTemplateMode"
|
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices' && !isTemplateMode && itemInfo.state === 'Entwurf'"
|
||||||
>
|
>
|
||||||
{{selectedTab === '0' ? "Vorschau zeigen" : "Fertigstellen"}}
|
{{selectedTab === '0' ? "Vorschau zeigen" : "Fertigstellen"}}
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-tag"
|
||||||
|
@click="saveFinalizedCostCentres"
|
||||||
|
v-if="itemInfo.id && itemInfo.type !== 'serialInvoices' && !isTemplateMode && itemInfo.state === 'Gebucht'"
|
||||||
|
>
|
||||||
|
Kostenstellen speichern
|
||||||
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-mdi-content-save"
|
icon="i-mdi-content-save"
|
||||||
@click="saveSerialInvoice"
|
@click="saveSerialInvoice"
|
||||||
|
|||||||
@@ -652,7 +652,7 @@ const executeSerialInvoices = async () => {
|
|||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
title: 'Ausführung gestartet',
|
title: 'Ausführung gestartet',
|
||||||
description: `${res.length} Rechnungen werden im Hintergrund generiert.`,
|
description: `${res.results.length} Rechnungen wurden für den Lauf vorbereitet.`,
|
||||||
icon: 'i-heroicons-check-circle',
|
icon: 'i-heroicons-check-circle',
|
||||||
color: 'green'
|
color: 'green'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -184,12 +184,16 @@ const resolvedPlanningBoardConfig = computed(() => {
|
|||||||
const normalizedStartTime = /^\d{2}:\d{2}$/.test(String(config.startTime || "")) ? String(config.startTime) : "06:00"
|
const normalizedStartTime = /^\d{2}:\d{2}$/.test(String(config.startTime || "")) ? String(config.startTime) : "06:00"
|
||||||
const normalizedEndTime = /^\d{2}:\d{2}$/.test(String(config.endTime || "")) ? String(config.endTime) : "21:00"
|
const normalizedEndTime = /^\d{2}:\d{2}$/.test(String(config.endTime || "")) ? String(config.endTime) : "21:00"
|
||||||
const normalizedSlotMinutes = Number(config.slotMinutes)
|
const normalizedSlotMinutes = Number(config.slotMinutes)
|
||||||
|
const normalizedWeekStartsOn = Number(config.weekStartsOn)
|
||||||
const allowedSlotMinutes = [15, 30, 60, 120, 180]
|
const allowedSlotMinutes = [15, 30, 60, 120, 180]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startTime: normalizedStartTime,
|
startTime: normalizedStartTime,
|
||||||
endTime: normalizedEndTime,
|
endTime: normalizedEndTime,
|
||||||
slotMinutes: allowedSlotMinutes.includes(normalizedSlotMinutes) ? normalizedSlotMinutes : 180
|
slotMinutes: allowedSlotMinutes.includes(normalizedSlotMinutes) ? normalizedSlotMinutes : 180,
|
||||||
|
weekStartsOn: Number.isInteger(normalizedWeekStartsOn) && normalizedWeekStartsOn >= 1 && normalizedWeekStartsOn <= 5
|
||||||
|
? normalizedWeekStartsOn
|
||||||
|
: 1
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -295,6 +299,7 @@ const visibleDraftEventIds = computed(() =>
|
|||||||
const calendarOptions = computed(() => ({
|
const calendarOptions = computed(() => ({
|
||||||
schedulerLicenseKey: "CC-Attribution-NonCommercial-NoDerivatives",
|
schedulerLicenseKey: "CC-Attribution-NonCommercial-NoDerivatives",
|
||||||
locale: deLocale,
|
locale: deLocale,
|
||||||
|
firstDay: resolvedPlanningBoardConfig.value.weekStartsOn,
|
||||||
plugins: [resourceTimelinePlugin, interactionPlugin],
|
plugins: [resourceTimelinePlugin, interactionPlugin],
|
||||||
initialView: calendarView.value,
|
initialView: calendarView.value,
|
||||||
initialDate: calendarCurrentDate.value,
|
initialDate: calendarCurrentDate.value,
|
||||||
@@ -349,6 +354,7 @@ const calendarOptions = computed(() => ({
|
|||||||
resourceTimelineWeek: {
|
resourceTimelineWeek: {
|
||||||
type: "resourceTimeline",
|
type: "resourceTimeline",
|
||||||
duration: { days: 7 },
|
duration: { days: 7 },
|
||||||
|
dateAlignment: "week",
|
||||||
buttonText: "Woche",
|
buttonText: "Woche",
|
||||||
slotDuration: { minutes: resolvedPlanningBoardConfig.value.slotMinutes },
|
slotDuration: { minutes: resolvedPlanningBoardConfig.value.slotMinutes },
|
||||||
snapDuration: { minutes: resolvedPlanningBoardConfig.value.slotMinutes },
|
snapDuration: { minutes: resolvedPlanningBoardConfig.value.slotMinutes },
|
||||||
|
|||||||
@@ -1,314 +1,392 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
const defaultPhases = [
|
||||||
|
{ icon: 'i-heroicons-clipboard-document', label: 'Erstkontakt' },
|
||||||
|
{ icon: 'i-heroicons-wrench-screwdriver', label: 'Umsetzung' },
|
||||||
|
{ icon: 'i-heroicons-document-text', label: 'Rechnungsstellung' },
|
||||||
|
{ icon: 'i-heroicons-check', label: 'Abgeschlossen' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const quickActionOptions = [
|
||||||
|
{ label: '+ Angebot', title: 'Angebot erstellen', link: '/createDocument/edit/?type=quotes' },
|
||||||
|
{ label: '+ Kostenschätzung', title: 'Kostenschätzung erstellen', link: '/createDocument/edit/?type=costEstimates' },
|
||||||
|
{ label: '+ Auftrag', title: 'Auftrag erstellen', link: '/createDocument/edit/?type=confirmationOrders' },
|
||||||
|
{ label: '+ Lieferschein', title: 'Lieferschein erstellen', link: '/createDocument/edit/?type=deliveryNotes' },
|
||||||
|
{ label: '+ Packschein', title: 'Packschein erstellen', link: '/createDocument/edit/?type=packingSlips' },
|
||||||
|
{ label: '+ Rechnung', title: 'Rechnung erstellen', link: '/createDocument/edit/?type=invoices' },
|
||||||
|
{ label: '+ Aufgabe', title: 'Aufgabe erstellen', link: '/tasks?mode=create' },
|
||||||
|
{ label: '+ Termin', title: 'Termin erstellen', link: '/standardEntity/events/create' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const createPhase = (phase = {}, index = -1) => ({
|
||||||
defineShortcuts({
|
key: phase.key || uuidv4(),
|
||||||
'backspace': () => {
|
icon: phase.icon || '',
|
||||||
router.push("/projecttypes")
|
label: phase.label || '',
|
||||||
},
|
optional: Boolean(phase.optional),
|
||||||
'arrowleft': () => {
|
description: phase.description || '',
|
||||||
const currentIndex = Number(openTab.value)
|
quickactions: Array.isArray(phase.quickactions)
|
||||||
if(currentIndex > 0){
|
? phase.quickactions.map(action => ({ ...action }))
|
||||||
openTab.value = String(currentIndex - 1)
|
: [],
|
||||||
}
|
active: index === 0
|
||||||
},
|
})
|
||||||
'arrowright': () => {
|
|
||||||
const currentIndex = Number(openTab.value)
|
const createEmptyItem = () => ({
|
||||||
if(currentIndex < 3) {
|
name: '',
|
||||||
openTab.value = String(currentIndex + 1)
|
initialPhases: defaultPhases.map(createPhase)
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const openTab = ref("0")
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const entities = useEntities('projecttypes')
|
||||||
|
|
||||||
const mode = ref(route.params.mode || "show")
|
const mode = computed(() => String(route.params.mode || 'show'))
|
||||||
const itemInfo = ref({
|
const itemInfo = ref(createEmptyItem())
|
||||||
name: "",
|
const loading = ref(false)
|
||||||
initialPhases: [{ "key": "f31f6fcb-34d5-41a0-9b8f-6c85062f19be", "icon": "i-heroicons-clipboard-document", "label": "Erstkontakt", "active": true, "quickactions": [] }, { "key": "41995d1f-78fa-448b-b6ea-e206645ffb89", "icon": "i-heroicons-wrench-screwdriver", "label": "Umsetzung", "quickactions": [] }, { "key": "267e78ac-9eab-4736-b9c8-4b94c1724494", "icon": "i-heroicons-document-text", "label": "Rechnungsstellung", "quickactions": [] }, { "key": "038df888-53f2-4985-b08e-776ab82df5d3", "icon": "i-heroicons-check", "label": "Abgeschlossen", "quickactions": [] } ]
|
const saving = ref(false)
|
||||||
})
|
const loadError = ref('')
|
||||||
const oldItemInfo = ref({})
|
|
||||||
|
|
||||||
const openQuickActionModal = ref(false)
|
const openQuickActionModal = ref(false)
|
||||||
const selectedKeyForQuickAction = ref("")
|
const selectedPhaseKey = ref(null)
|
||||||
|
const openArchiveModal = ref(false)
|
||||||
|
|
||||||
const setKeys = () => {
|
const isEditMode = computed(() => mode.value === 'edit' || mode.value === 'create')
|
||||||
itemInfo.value.initialPhases = itemInfo.value.initialPhases.map(i => {
|
const canSave = computed(() => {
|
||||||
return {
|
return itemInfo.value.name.trim().length > 0
|
||||||
...i,
|
&& itemInfo.value.initialPhases.length > 0
|
||||||
key: uuidv4(),
|
&& itemInfo.value.initialPhases.every(phase => phase.label.trim().length > 0)
|
||||||
quickactions: i.quickactions || []
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
itemInfo.value.initialPhases[0].active = true
|
const normalizeItem = (item) => ({
|
||||||
|
...item,
|
||||||
|
name: item?.name || '',
|
||||||
|
initialPhases: Array.isArray(item?.initialPhases)
|
||||||
|
? item.initialPhases.map(createPhase)
|
||||||
|
: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadItem = async () => {
|
||||||
|
loadError.value = ''
|
||||||
|
openQuickActionModal.value = false
|
||||||
|
openArchiveModal.value = false
|
||||||
|
|
||||||
|
if (mode.value === 'create') {
|
||||||
|
itemInfo.value = createEmptyItem()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const setupPage = async() => {
|
if (!route.params.id) {
|
||||||
|
loadError.value = 'Es wurde kein Projekttyp ausgewählt.'
|
||||||
if(mode.value === "show" ){
|
return
|
||||||
itemInfo.value = await useEntities("projecttypes").selectSingle(route.params.id,"*")
|
|
||||||
} else if (mode.value === "edit") {
|
|
||||||
itemInfo.value = await useEntities("projecttypes").selectSingle(route.params.id,"*")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if(mode.value === "create") {
|
loading.value = true
|
||||||
let query = route.query
|
try {
|
||||||
|
const item = await entities.selectSingle(route.params.id, '*')
|
||||||
|
if (!item) {
|
||||||
|
loadError.value = 'Der Projekttyp wurde nicht gefunden.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
itemInfo.value = normalizeItem(item)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Projekttyp konnte nicht geladen werden', error)
|
||||||
|
loadError.value = 'Der Projekttyp konnte nicht geladen werden.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
if(itemInfo.value) oldItemInfo.value = JSON.parse(JSON.stringify(itemInfo.value))
|
|
||||||
|
|
||||||
setKeys()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setupPage()
|
watch(
|
||||||
|
() => [route.params.mode, route.params.id],
|
||||||
|
loadItem,
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
defineShortcuts({
|
||||||
|
backspace: () => router.push('/projecttypes')
|
||||||
|
})
|
||||||
|
|
||||||
const addPhase = () => {
|
const addPhase = () => {
|
||||||
itemInfo.value.initialPhases.push({label: '', icon: ''}),
|
itemInfo.value.initialPhases.push(createPhase())
|
||||||
setKeys
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removePhase = (phaseKey) => {
|
||||||
|
itemInfo.value.initialPhases = itemInfo.value.initialPhases
|
||||||
|
.filter(phase => phase.key !== phaseKey)
|
||||||
|
.map(createPhase)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openQuickActions = (phaseKey) => {
|
||||||
|
selectedPhaseKey.value = phaseKey
|
||||||
|
openQuickActionModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const addQuickAction = (action) => {
|
||||||
|
const phase = itemInfo.value.initialPhases.find(item => item.key === selectedPhaseKey.value)
|
||||||
|
if (!phase) return
|
||||||
|
|
||||||
|
if (!phase.quickactions.some(item => item.link === action.link)) {
|
||||||
|
phase.quickactions.push({ label: action.label, link: action.link })
|
||||||
|
}
|
||||||
|
openQuickActionModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeQuickAction = (phase, actionIndex) => {
|
||||||
|
phase.quickactions.splice(actionIndex, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = () => ({
|
||||||
|
name: itemInfo.value.name.trim(),
|
||||||
|
initialPhases: itemInfo.value.initialPhases.map((phase, index) => ({
|
||||||
|
...createPhase(phase, index),
|
||||||
|
label: phase.label.trim(),
|
||||||
|
description: phase.description.trim()
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!canSave.value || saving.value) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (mode.value === 'create') {
|
||||||
|
await entities.create(payload())
|
||||||
|
} else {
|
||||||
|
await entities.update(itemInfo.value.id, payload())
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Projekttyp konnte nicht gespeichert werden', error)
|
||||||
|
toast.add({ title: 'Projekttyp konnte nicht gespeichert werden', color: 'error' })
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveItem = async () => {
|
||||||
|
if (!itemInfo.value.id || saving.value) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await entities.archive(itemInfo.value.id)
|
||||||
|
toast.add({ title: 'Projekttyp archiviert' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Projekttyp konnte nicht archiviert werden', error)
|
||||||
|
toast.add({ title: 'Projekttyp konnte nicht archiviert werden', color: 'error' })
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
openArchiveModal.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UDashboardNavbar :title="itemInfo ? itemInfo.name : (mode === 'create' ? 'Projekt erstellen' : 'Projekt bearbeiten')">
|
<UDashboardNavbar :title="itemInfo.name || (mode === 'create' ? 'Projekttyp erstellen' : 'Projekttyp')">
|
||||||
<template #left>
|
<template #left>
|
||||||
<UButton
|
<UButton icon="i-heroicons-chevron-left" variant="outline" @click="router.push('/projecttypes')">
|
||||||
icon="i-heroicons-chevron-left"
|
|
||||||
variant="outline"
|
|
||||||
@click="router.push(`/projecttypes`)"
|
|
||||||
>
|
|
||||||
Projekttypen
|
Projekttypen
|
||||||
</UButton>
|
</UButton>
|
||||||
</template>
|
</template>
|
||||||
<template #center>
|
|
||||||
<h1
|
|
||||||
v-if="itemInfo"
|
|
||||||
class="text-xl font-medium"
|
|
||||||
>{{itemInfo.name ? `Projekttyp: ${itemInfo.name}` : (mode === 'create' ? 'Projekttyp erstellen' : 'Projekttyp bearbeiten')}}</h1>
|
|
||||||
</template>
|
|
||||||
<template #right>
|
<template #right>
|
||||||
<UButton
|
<UButton v-if="isEditMode" :loading="saving" :disabled="!canSave" @click="save">
|
||||||
v-if="mode === 'edit'"
|
{{ mode === 'create' ? 'Erstellen' : 'Speichern' }}
|
||||||
@click="useEntities('projecttypes').update(itemInfo.id, itemInfo)"
|
|
||||||
>
|
|
||||||
Speichern
|
|
||||||
</UButton>
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
v-else-if="mode === 'create'"
|
v-if="isEditMode"
|
||||||
@click="useEntities('projecttypes').create( itemInfo)"
|
color="neutral"
|
||||||
>
|
variant="outline"
|
||||||
Erstellen
|
@click="router.push(itemInfo.id ? `/projecttypes/show/${itemInfo.id}` : '/projecttypes')"
|
||||||
</UButton>
|
|
||||||
<UButton
|
|
||||||
@click="router.push(itemInfo.id ? `/projecttypes/show/${itemInfo.id}` : `/projecttypes`)"
|
|
||||||
color="red"
|
|
||||||
class="ml-2"
|
|
||||||
v-if="mode === 'edit' || mode === 'create'"
|
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</UButton>
|
</UButton>
|
||||||
<UButton
|
<template v-if="mode === 'show' && itemInfo.id">
|
||||||
v-if="mode === 'show'"
|
<UButton variant="outline" @click="router.push(`/projecttypes/edit/${itemInfo.id}`)">
|
||||||
@click="router.push(`/projecttypes/edit/${itemInfo.id}`)"
|
|
||||||
>
|
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton color="error" variant="outline" @click="openArchiveModal = true">
|
||||||
|
Archivieren
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
|
|
||||||
<UDashboardPanelContent>
|
<UDashboardPanelContent>
|
||||||
<UTabs
|
<div v-if="loading" class="flex justify-center py-12">
|
||||||
:items="[{label: 'Informationen'}]"
|
<UIcon name="i-heroicons-arrow-path" class="size-7 animate-spin" />
|
||||||
v-if="itemInfo.id && mode == 'show'"
|
|
||||||
v-model="openTab"
|
|
||||||
>
|
|
||||||
<template #content="{ item }">
|
|
||||||
<div v-if="item.label === 'Informationen'" class="flex flex-row">
|
|
||||||
<div class="w-1/2 mr-3">
|
|
||||||
<UCard class="mt-5">
|
|
||||||
{{itemInfo}}
|
|
||||||
</UCard>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2">
|
|
||||||
<UCard class="mt-5">
|
<UAlert
|
||||||
|
v-else-if="loadError"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-heroicons-exclamation-triangle"
|
||||||
|
title="Fehler beim Laden"
|
||||||
|
:description="loadError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else-if="mode === 'show'" class="grid gap-5 lg:grid-cols-2">
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-lg font-medium">Phasenvorlage</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="itemInfo.initialPhases.length" class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(phase, index) in itemInfo.initialPhases"
|
||||||
|
:key="phase.key"
|
||||||
|
class="rounded-lg border border-default p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<UIcon :name="phase.icon || 'i-heroicons-clipboard-document'" class="size-5" />
|
||||||
|
<span class="font-medium">{{ phase.label }}</span>
|
||||||
|
<UBadge v-if="index === 0" variant="soft">Startphase</UBadge>
|
||||||
|
<UBadge v-if="phase.optional" color="neutral" variant="soft">Optional</UBadge>
|
||||||
|
</div>
|
||||||
|
<p v-if="phase.description" class="mt-2 text-sm text-muted">{{ phase.description }}</p>
|
||||||
|
<div v-if="phase.quickactions.length" class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<UBadge v-for="action in phase.quickactions" :key="action.link" color="neutral" variant="outline">
|
||||||
|
{{ action.label }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TableEmptyState v-else label="Keine Phasen hinterlegt" />
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
<HistoryDisplay
|
<HistoryDisplay
|
||||||
type="project"
|
v-if="itemInfo.id"
|
||||||
v-if="itemInfo"
|
type="projecttypes"
|
||||||
:element-id="itemInfo.id"
|
:element-id="String(itemInfo.id)"
|
||||||
render-headline
|
render-headline
|
||||||
/>
|
/>
|
||||||
</UCard>
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="isEditMode" class="space-y-5">
|
||||||
|
<UAlert
|
||||||
|
v-if="mode === 'edit'"
|
||||||
|
color="warning"
|
||||||
|
variant="outline"
|
||||||
|
description="Änderungen an diesem Projekttyp gelten nur für neu erstellte Projekte. Bestehende Projekte bleiben unverändert."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<UFormField label="Name" required>
|
||||||
|
<UInput v-model="itemInfo.name" class="w-full" placeholder="Name des Projekttyps" />
|
||||||
|
</UFormField>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-medium">Initiale Phasen</h2>
|
||||||
|
<p class="text-sm text-muted">Diese Phasen werden in neue Projekte übernommen.</p>
|
||||||
|
</div>
|
||||||
|
<UButton icon="i-heroicons-plus" @click="addPhase">Phase hinzufügen</UButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UTabs>
|
|
||||||
|
|
||||||
<UForm v-else-if="mode === 'edit' || mode === 'create'">
|
|
||||||
<UAlert
|
|
||||||
color="error"
|
|
||||||
variant="outline"
|
|
||||||
class="mb-5"
|
|
||||||
v-if="mode === 'edit'"
|
|
||||||
description="Achtung Änderungen an diesem Projekttypen betreffen nur Projekte die damit neu erstellt werden. Bestehende Projekte bleiben unverändert."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UFormField
|
|
||||||
label="Name:"
|
|
||||||
>
|
|
||||||
<UInput
|
|
||||||
v-model="itemInfo.name"
|
|
||||||
/>
|
|
||||||
</UFormField>
|
|
||||||
|
|
||||||
<USeparator class="mt-5">
|
|
||||||
Initiale Phasen
|
|
||||||
</USeparator>
|
|
||||||
<UButton
|
|
||||||
class="mt-3"
|
|
||||||
@click="addPhase"
|
|
||||||
>
|
|
||||||
+ Phase
|
|
||||||
</UButton>
|
|
||||||
|
|
||||||
<table class="mt-3">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th></th>
|
|
||||||
<th class="text-left"><span class="ml-2">Name</span></th>
|
|
||||||
<th class="text-left"><span class="ml-2">Icon</span></th>
|
|
||||||
<th class="text-left"><span class="ml-2">Optional</span></th>
|
|
||||||
<th class="text-left"><span class="ml-2">Beschreibung</span></th>
|
|
||||||
<th class="text-left"><span class="ml-2">Schnellaktionen</span></th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<draggable
|
<draggable
|
||||||
v-model="itemInfo.initialPhases"
|
v-model="itemInfo.initialPhases"
|
||||||
handle=".handle"
|
handle=".phase-handle"
|
||||||
tag="tbody"
|
item-key="key"
|
||||||
itemKey="pos"
|
class="space-y-3"
|
||||||
@end="setKeys"
|
|
||||||
>
|
>
|
||||||
|
<template #item="{ element: phase, index }">
|
||||||
|
<div class="rounded-lg border border-default p-4">
|
||||||
|
<div class="mb-4 flex items-center gap-2">
|
||||||
|
<UIcon name="i-mdi-menu" class="phase-handle size-5 cursor-grab text-muted" />
|
||||||
|
<span class="font-medium">Phase {{ index + 1 }}</span>
|
||||||
|
<UBadge v-if="index === 0" variant="soft">Startphase</UBadge>
|
||||||
|
<UButton
|
||||||
|
class="ml-auto"
|
||||||
|
icon="i-heroicons-trash"
|
||||||
|
color="error"
|
||||||
|
variant="ghost"
|
||||||
|
:disabled="itemInfo.initialPhases.length === 1"
|
||||||
|
aria-label="Phase entfernen"
|
||||||
|
@click="removePhase(phase.key)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template #item="{element: phase}">
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<tr>
|
<UFormField label="Name" required>
|
||||||
<td>
|
<UInput v-model="phase.label" class="w-full" placeholder="Phasenname" />
|
||||||
<UIcon
|
</UFormField>
|
||||||
class="handle"
|
<UFormField label="Icon">
|
||||||
name="i-mdi-menu"
|
<HeroiconPicker v-model="phase.icon" />
|
||||||
/>
|
</UFormField>
|
||||||
</td>
|
<UFormField label="Beschreibung" class="xl:col-span-2">
|
||||||
<td>
|
<UInput v-model="phase.description" class="w-full" placeholder="Optionale Beschreibung" />
|
||||||
<UInput
|
</UFormField>
|
||||||
class="my-2 ml-2"
|
</div>
|
||||||
v-model="phase.label"
|
|
||||||
placeholder="Name"
|
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||||
/>
|
<UCheckbox v-model="phase.optional" label="Optionale Phase" :disabled="index === 0" />
|
||||||
</td>
|
<UButton class="ml-auto" variant="outline" icon="i-heroicons-bolt" @click="openQuickActions(phase.key)">
|
||||||
<td>
|
Schnellaktion hinzufügen
|
||||||
<UInput
|
|
||||||
class="my-2 ml-2"
|
|
||||||
v-model="phase.icon"
|
|
||||||
placeholder="Icon"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<UCheckbox
|
|
||||||
class="my-2 ml-2"
|
|
||||||
v-model="phase.optional"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<UInput
|
|
||||||
class="my-2 ml-2"
|
|
||||||
v-model="phase.description"
|
|
||||||
placeholder="Beschreibung"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<UButton
|
|
||||||
class="my-2 ml-2"
|
|
||||||
variant="outline"
|
|
||||||
@click="openQuickActionModal = true,
|
|
||||||
selectedKeyForQuickAction = phase.key"
|
|
||||||
>+ Schnellaktion</UButton>
|
|
||||||
<UButton
|
|
||||||
@click="phase.quickactions = phase.quickactions.filter(i => i.label !== button.label)"
|
|
||||||
v-for="button in phase.quickactions"
|
|
||||||
class="ml-1"
|
|
||||||
>
|
|
||||||
{{ button.label }}
|
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-for="(action, actionIndex) in phase.quickactions"
|
||||||
|
:key="`${action.link}-${actionIndex}`"
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
trailing-icon="i-heroicons-x-mark"
|
||||||
|
@click="removeQuickAction(phase, actionIndex)"
|
||||||
|
>
|
||||||
|
{{ action.label }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
|
||||||
<UModal v-model:open="openQuickActionModal">
|
<p v-if="!canSave" class="mt-4 text-sm text-error">
|
||||||
|
Name und Phasennamen müssen ausgefüllt sein; mindestens eine Phase ist erforderlich.
|
||||||
|
</p>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</UDashboardPanelContent>
|
||||||
|
|
||||||
|
<UModal v-model:open="openQuickActionModal" title="Schnellaktion hinzufügen">
|
||||||
<template #content>
|
<template #content>
|
||||||
<UCard>
|
<UCard>
|
||||||
|
<template #header>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
|
<h3 class="font-semibold">Schnellaktion hinzufügen</h3>
|
||||||
Schnellaktion hinzufügen
|
<UButton icon="i-heroicons-x-mark" color="neutral" variant="ghost" @click="openQuickActionModal = false" />
|
||||||
</h3>
|
|
||||||
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="openQuickActionModal = false" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
</template>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
<UButton
|
<UButton
|
||||||
class="my-1"
|
v-for="action in quickActionOptions"
|
||||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Angebot',link:'/createDocument/edit/?type=quotes'})">Angebot Erstellen</UButton>
|
:key="action.link"
|
||||||
<UButton
|
variant="outline"
|
||||||
class="my-1"
|
@click="addQuickAction(action)"
|
||||||
@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
|
{{ action.title }}
|
||||||
class="my-1"
|
</UButton>
|
||||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Auftrag',link:'/createDocument/edit/?type=confirmationOrders'})">Auftrag Erstellen</UButton>
|
|
||||||
<UButton
|
|
||||||
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>
|
|
||||||
<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
|
|
||||||
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>
|
|
||||||
<UButton
|
|
||||||
class="my-1"
|
|
||||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Aufgabe',link:'/tasks?mode=create'})">Aufgabe Erstellen</UButton>
|
|
||||||
<UButton
|
|
||||||
class="my-1"
|
|
||||||
@click="itemInfo.initialPhases[itemInfo.initialPhases.findIndex(i=> i.key === selectedKeyForQuickAction)].quickactions.push({label:'+ Termin',link:'/standardEntity/events/create'})">Termin Erstellen</UButton>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</UCard>
|
</UCard>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</UModal>
|
||||||
</td>
|
|
||||||
<td>
|
<UModal v-model:open="openArchiveModal" title="Projekttyp archivieren">
|
||||||
<UButton
|
<template #content>
|
||||||
class="my-2 ml-2"
|
<UCard>
|
||||||
variant="outline"
|
<template #header>
|
||||||
color="error"
|
<h3 class="font-semibold">Projekttyp archivieren?</h3>
|
||||||
@click="itemInfo.initialPhases = itemInfo.initialPhases.filter(i => i !== phase)"
|
|
||||||
>X</UButton>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
<p>„{{ itemInfo.name }}“ wird aus der Projekttyp-Auswahl ausgeblendet.</p>
|
||||||
</table>
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
</UForm>
|
<UButton color="neutral" variant="outline" @click="openArchiveModal = false">Abbrechen</UButton>
|
||||||
</UDashboardPanelContent>
|
<UButton color="error" :loading="saving" @click="archiveItem">Archivieren</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -3,17 +3,16 @@
|
|||||||
|
|
||||||
defineShortcuts({
|
defineShortcuts({
|
||||||
'/': () => {
|
'/': () => {
|
||||||
//console.log(searchinput)
|
document.getElementById("searchinput")?.focus()
|
||||||
//searchinput.value.focus()
|
|
||||||
document.getElementById("searchinput").focus()
|
|
||||||
},
|
},
|
||||||
'+': () => {
|
'+': () => {
|
||||||
router.push("/projects/create")
|
router.push("/projecttypes/create")
|
||||||
},
|
},
|
||||||
'Enter': {
|
'Enter': {
|
||||||
usingInput: true,
|
usingInput: true,
|
||||||
handler: () => {
|
handler: () => {
|
||||||
router.push(`/projecttypes/show/${filteredRows.value[selectedItem.value].id}`)
|
const selected = filteredRows.value[selectedItem.value]
|
||||||
|
if (selected) router.push(`/projecttypes/show/${selected.id}`)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'arrowdown': () => {
|
'arrowdown': () => {
|
||||||
@@ -38,10 +37,21 @@ const tempStore = useTempStore()
|
|||||||
|
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const selectedItem = ref(0)
|
const selectedItem = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
items.value = await useEntities("projecttypes").select()
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
try {
|
||||||
|
items.value = await useEntities("projecttypes").select('*', 'name', true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Projekttypen konnten nicht geladen werden', error)
|
||||||
|
loadError.value = 'Die Projekttypen konnten nicht geladen werden.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setup()
|
setup()
|
||||||
@@ -62,6 +72,11 @@ const filteredRows = computed(() => {
|
|||||||
return useListFilter(searchString.value, items.value)
|
return useListFilter(searchString.value, items.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const openProjecttype = (row) => {
|
||||||
|
const item = row?.original || row
|
||||||
|
if (item?.id) router.push(`/projecttypes/show/${item.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -87,11 +102,13 @@ const filteredRows = computed(() => {
|
|||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
|
|
||||||
<UTable
|
<UTable
|
||||||
|
v-if="!loadError"
|
||||||
:data="filteredRows"
|
:data="filteredRows"
|
||||||
: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(`/projecttypes/show/${i.id}`) "
|
:loading="loading"
|
||||||
|
:on-select="openProjecttype"
|
||||||
>
|
>
|
||||||
<template #name-cell="{row}">
|
<template #name-cell="{row}">
|
||||||
<span class="text-primary-500 font-bold" v-if="row.original === filteredRows[selectedItem]">{{ row.original.name }}</span>
|
<span class="text-primary-500 font-bold" v-if="row.original === filteredRows[selectedItem]">{{ row.original.name }}</span>
|
||||||
@@ -101,6 +118,15 @@ const filteredRows = computed(() => {
|
|||||||
<TableEmptyState label="Keine Projekttypen anzuzeigen" />
|
<TableEmptyState label="Keine Projekttypen anzuzeigen" />
|
||||||
</template>
|
</template>
|
||||||
</UTable>
|
</UTable>
|
||||||
|
<UAlert
|
||||||
|
v-else
|
||||||
|
class="m-5"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-heroicons-exclamation-triangle"
|
||||||
|
title="Fehler beim Laden"
|
||||||
|
:description="loadError"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -49,11 +49,12 @@ const resources = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const numberRanges = ref(auth.activeTenantData.numberRanges || {})
|
const createNumberRanges = (ranges = {}) => {
|
||||||
|
const freshRanges = structuredClone(ranges)
|
||||||
|
|
||||||
Object.keys(resources).forEach((key) => {
|
Object.keys(resources).forEach((key) => {
|
||||||
if (!numberRanges.value[key]) {
|
if (!freshRanges[key]) {
|
||||||
numberRanges.value[key] = {
|
freshRanges[key] = {
|
||||||
prefix: "",
|
prefix: "",
|
||||||
suffix: "",
|
suffix: "",
|
||||||
nextNumber: 1000
|
nextNumber: 1000
|
||||||
@@ -61,17 +62,41 @@ Object.keys(resources).forEach((key) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateNumberRanges = async (range) => {
|
return freshRanges
|
||||||
|
}
|
||||||
|
|
||||||
const res = await useNuxtApp().$api(`/api/tenant/numberrange/${range}`,{
|
const numberRanges = ref({})
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const loadNumberRanges = async () => {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tenant = await useNuxtApp().$api("/api/tenant")
|
||||||
|
numberRanges.value = createNumberRanges(tenant?.numberRanges || {})
|
||||||
|
|
||||||
|
if (tenant?.id) {
|
||||||
|
auth.activeTenantData = tenant
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadNumberRanges)
|
||||||
|
|
||||||
|
const updateNumberRanges = async (range) => {
|
||||||
|
const tenant = await useNuxtApp().$api(`/api/tenant/numberrange/${range}`,{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: {
|
body: {
|
||||||
numberRange: numberRanges.value[range]
|
numberRange: numberRanges.value[range]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(res)
|
if (tenant?.id) {
|
||||||
|
auth.activeTenantData = tenant
|
||||||
|
numberRanges.value = createNumberRanges(tenant.numberRanges || {})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -92,7 +117,13 @@ const updateNumberRanges = async (range) => {
|
|||||||
/>
|
/>
|
||||||
</UDashboardToolbar>
|
</UDashboardToolbar>
|
||||||
|
|
||||||
|
<UProgress
|
||||||
|
v-if="loading"
|
||||||
|
animation="carousel"
|
||||||
|
class="m-5 w-1/2"
|
||||||
|
/>
|
||||||
<table
|
<table
|
||||||
|
v-else
|
||||||
class="m-3"
|
class="m-3"
|
||||||
>
|
>
|
||||||
<tr class="text-left">
|
<tr class="text-left">
|
||||||
@@ -103,6 +134,7 @@ const updateNumberRanges = async (range) => {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr
|
<tr
|
||||||
v-for="key in Object.keys(resources)"
|
v-for="key in Object.keys(resources)"
|
||||||
|
:key="key"
|
||||||
>
|
>
|
||||||
<td>{{resources[key].label}}</td>
|
<td>{{resources[key].label}}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -130,10 +130,19 @@ const planningBoardTimeGridOptions = [
|
|||||||
{ label: "180 Minuten", value: 180 }
|
{ label: "180 Minuten", value: 180 }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const planningBoardWeekStartOptions = [
|
||||||
|
{ label: "Montag", value: 1 },
|
||||||
|
{ label: "Dienstag", value: 2 },
|
||||||
|
{ label: "Mittwoch", value: 3 },
|
||||||
|
{ label: "Donnerstag", value: 4 },
|
||||||
|
{ label: "Freitag", value: 5 }
|
||||||
|
]
|
||||||
|
|
||||||
const planningBoardConfig = reactive({
|
const planningBoardConfig = reactive({
|
||||||
startTime: auth.activeTenantData?.calendarConfig?.planningBoard?.startTime || "06:00",
|
startTime: auth.activeTenantData?.calendarConfig?.planningBoard?.startTime || "06:00",
|
||||||
endTime: auth.activeTenantData?.calendarConfig?.planningBoard?.endTime || "21:00",
|
endTime: auth.activeTenantData?.calendarConfig?.planningBoard?.endTime || "21:00",
|
||||||
slotMinutes: auth.activeTenantData?.calendarConfig?.planningBoard?.slotMinutes || 180
|
slotMinutes: auth.activeTenantData?.calendarConfig?.planningBoard?.slotMinutes || 180,
|
||||||
|
weekStartsOn: auth.activeTenantData?.calendarConfig?.planningBoard?.weekStartsOn ?? 1
|
||||||
})
|
})
|
||||||
|
|
||||||
const canManageMcpTokens = computed(() => Boolean(auth.user?.is_admin || auth.hasPermission("mcp.tokens.write")))
|
const canManageMcpTokens = computed(() => Boolean(auth.user?.is_admin || auth.hasPermission("mcp.tokens.write")))
|
||||||
@@ -238,6 +247,7 @@ const setupPage = async () => {
|
|||||||
planningBoardConfig.startTime = auth.activeTenantData?.calendarConfig?.planningBoard?.startTime || "06:00"
|
planningBoardConfig.startTime = auth.activeTenantData?.calendarConfig?.planningBoard?.startTime || "06:00"
|
||||||
planningBoardConfig.endTime = auth.activeTenantData?.calendarConfig?.planningBoard?.endTime || "21:00"
|
planningBoardConfig.endTime = auth.activeTenantData?.calendarConfig?.planningBoard?.endTime || "21:00"
|
||||||
planningBoardConfig.slotMinutes = auth.activeTenantData?.calendarConfig?.planningBoard?.slotMinutes || 180
|
planningBoardConfig.slotMinutes = auth.activeTenantData?.calendarConfig?.planningBoard?.slotMinutes || 180
|
||||||
|
planningBoardConfig.weekStartsOn = auth.activeTenantData?.calendarConfig?.planningBoard?.weekStartsOn ?? 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) })
|
const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) })
|
||||||
@@ -571,7 +581,8 @@ const savePlanningBoardConfig = async () => {
|
|||||||
planningBoard: {
|
planningBoard: {
|
||||||
startTime: planningBoardConfig.startTime || "06:00",
|
startTime: planningBoardConfig.startTime || "06:00",
|
||||||
endTime: planningBoardConfig.endTime || "21:00",
|
endTime: planningBoardConfig.endTime || "21:00",
|
||||||
slotMinutes: Number(planningBoardConfig.slotMinutes) || 180
|
slotMinutes: Number(planningBoardConfig.slotMinutes) || 180,
|
||||||
|
weekStartsOn: Number(planningBoardConfig.weekStartsOn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -741,7 +752,7 @@ onMounted(() => {
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-base font-semibold text-highlighted">Plantafel</h3>
|
<h3 class="text-base font-semibold text-highlighted">Plantafel</h3>
|
||||||
<p class="text-sm text-muted">Lege hier die Rahmenzeiten und den Rasterabstand für die Plantafel fest.</p>
|
<p class="text-sm text-muted">Lege hier Rahmenzeiten, Rasterabstand und Wochenbeginn für die Plantafel fest.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UFormField
|
<UFormField
|
||||||
@@ -767,6 +778,17 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField
|
||||||
|
label="Wochenbeginn:"
|
||||||
|
>
|
||||||
|
<USelectMenu
|
||||||
|
v-model="planningBoardConfig.weekStartsOn"
|
||||||
|
:items="planningBoardWeekStartOptions"
|
||||||
|
label-key="label"
|
||||||
|
value-key="value"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
<UButton
|
<UButton
|
||||||
class="mt-1"
|
class="mt-1"
|
||||||
@click="savePlanningBoardConfig"
|
@click="savePlanningBoardConfig"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const route = useRoute()
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const admin = useAdmin()
|
const admin = useAdmin()
|
||||||
|
const desktopPush = useDesktopPush()
|
||||||
const { $api } = useNuxtApp()
|
const { $api } = useNuxtApp()
|
||||||
const runtimeConfig = useRuntimeConfig()
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ const creatingLinkedUser = ref(false)
|
|||||||
const createLinkedUserModalOpen = ref(false)
|
const createLinkedUserModalOpen = ref(false)
|
||||||
const createdLinkedUserPassword = ref("")
|
const createdLinkedUserPassword = ref("")
|
||||||
const generatingCalendarSubscription = ref(false)
|
const generatingCalendarSubscription = ref(false)
|
||||||
|
const sendingTestPush = ref(false)
|
||||||
const createLinkedUserForm = reactive({
|
const createLinkedUserForm = reactive({
|
||||||
email: "",
|
email: "",
|
||||||
})
|
})
|
||||||
@@ -270,6 +272,30 @@ async function copyCalendarSubscriptionUrl(value: string, successTitle: string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendTestPush() {
|
||||||
|
if (!profile.value?.user_id || sendingTestPush.value) return
|
||||||
|
|
||||||
|
sendingTestPush.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await desktopPush.sendTestPushToProfile(profile.value.id)
|
||||||
|
toast.add({
|
||||||
|
title: "Test-Push gesendet",
|
||||||
|
description: `Die Nachricht wurde einmalig an ${profile.value.full_name || "den Mitarbeiter"} gesendet.`,
|
||||||
|
color: "green"
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[sendTestPush]", err)
|
||||||
|
toast.add({
|
||||||
|
title: "Test-Push fehlgeschlagen",
|
||||||
|
description: err?.data?.error || err?.message || "Die Testnachricht konnte nicht zugestellt werden.",
|
||||||
|
color: "red"
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
sendingTestPush.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const weekdays = [
|
const weekdays = [
|
||||||
{ key: '1', label: 'Montag' },
|
{ key: '1', label: 'Montag' },
|
||||||
{ key: '2', label: 'Dienstag' },
|
{ key: '2', label: 'Dienstag' },
|
||||||
@@ -456,6 +482,35 @@ onMounted(async () => {
|
|||||||
description="Lege fest, unter welcher Nebenstelle dieser Benutzer erreichbar ist."
|
description="Lege fest, unter welcher Nebenstelle dieser Benutzer erreichbar ist."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<UCard v-if="!pending && profile" class="mt-3">
|
||||||
|
<USeparator label="Push-Benachrichtigung" />
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Push-Zustellung testen</p>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
<template v-if="profile.user_id">
|
||||||
|
Sendet einmalig eine Testnachricht an alle aktiven mobilen Push-Geräte dieses Mitarbeiters.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Für einen Push-Test muss der Mitarbeiter zuerst mit einem Benutzerkonto verknüpft werden.
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-paper-airplane"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
:loading="sendingTestPush"
|
||||||
|
:disabled="!profile.user_id"
|
||||||
|
@click="sendTestPush"
|
||||||
|
>
|
||||||
|
Test-Push senden
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
<UCard v-if="!pending && profile" class="mt-3">
|
<UCard v-if="!pending && profile" class="mt-3">
|
||||||
<USeparator label="Vertragsinformationen" />
|
<USeparator label="Vertragsinformationen" />
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user