Compare commits
2 Commits
4e49dd18a1
...
9ba5f26efc
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ba5f26efc | |||
| 82f2143dd1 |
@@ -3,11 +3,14 @@ import {
|
|||||||
accounts,
|
accounts,
|
||||||
bankstatements,
|
bankstatements,
|
||||||
createddocuments,
|
createddocuments,
|
||||||
|
filetags,
|
||||||
files,
|
files,
|
||||||
|
folders,
|
||||||
incominginvoices,
|
incominginvoices,
|
||||||
statementallocations,
|
statementallocations,
|
||||||
} from "../../../db/schema"
|
} from "../../../db/schema"
|
||||||
import { useNextNumberRangeNumber } from "../../utils/functions"
|
import { useNextNumberRangeNumber } from "../../utils/functions"
|
||||||
|
import { saveFile } from "../../utils/files"
|
||||||
import { McpTool } from "../types"
|
import { McpTool } from "../types"
|
||||||
|
|
||||||
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
const limitFromArgs = (args: Record<string, unknown>, fallback = 25) => {
|
||||||
@@ -28,6 +31,7 @@ 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 allowedOutgoingDocumentTypes = new Set([
|
const allowedOutgoingDocumentTypes = new Set([
|
||||||
"quotes",
|
"quotes",
|
||||||
@@ -193,6 +197,49 @@ const incomingInvoiceAccountsArg = (args: Record<string, unknown>) => {
|
|||||||
return args.accounts
|
return args.accounts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const base64BufferArg = (args: Record<string, unknown>, key: string) => {
|
||||||
|
const value = stringArg(args, key)
|
||||||
|
if (!value) throw new Error(`${key} ist erforderlich`)
|
||||||
|
|
||||||
|
const base64 = value.includes(",") ? value.split(",").pop() || "" : value
|
||||||
|
const buffer = Buffer.from(base64, "base64")
|
||||||
|
|
||||||
|
if (!buffer.length) throw new Error(`${key} enthält keine Datei`)
|
||||||
|
if (buffer.length > MAX_MCP_UPLOAD_BYTES) throw new Error("Datei ist größer als 20 MB")
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadIncomingInvoiceFileDefaults = async (context: { server: any; tenantId: number }) => {
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
|
||||||
|
const [tag] = await context.server.db
|
||||||
|
.select({ id: filetags.id })
|
||||||
|
.from(filetags)
|
||||||
|
.where(and(
|
||||||
|
eq(filetags.tenant, context.tenantId),
|
||||||
|
eq(filetags.incomingDocumentType, "invoices"),
|
||||||
|
eq(filetags.archived, false)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const [folder] = await context.server.db
|
||||||
|
.select({ id: folders.id })
|
||||||
|
.from(folders)
|
||||||
|
.where(and(
|
||||||
|
eq(folders.tenant, context.tenantId),
|
||||||
|
eq(folders.function, "incomingInvoices"),
|
||||||
|
eq(folders.year, currentYear),
|
||||||
|
eq(folders.archived, false)
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
folderId: folder?.id || null,
|
||||||
|
fileTagId: tag?.id || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const buildIncomingInvoicePayload = (
|
const buildIncomingInvoicePayload = (
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -694,6 +741,124 @@ export const accountingTools: McpTool[] = [
|
|||||||
return { rows }
|
return { rows }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "accounting.incoming_invoices.files.upload",
|
||||||
|
title: "Datei für Eingangsbeleg hochladen",
|
||||||
|
description: "Lädt eine PDF- oder Bilddatei als Base64 hoch, verknüpft sie optional mit einem Eingangsbeleg oder stößt die automatische Vorbereitung an.",
|
||||||
|
requiredPermissions: ["accounting.incoming_invoices.write"],
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["filename", "contentBase64"],
|
||||||
|
properties: {
|
||||||
|
filename: { type: "string", description: "Dateiname, z. B. rechnung.pdf." },
|
||||||
|
contentBase64: { type: "string", description: "Dateiinhalt als Base64 oder Data-URL." },
|
||||||
|
mimeType: { type: "string", description: "MIME-Type, z. B. application/pdf oder image/jpeg." },
|
||||||
|
invoiceId: { type: "number", description: "Optional: vorhandener Eingangsbeleg, mit dem die Datei verknüpft wird." },
|
||||||
|
prepare: {
|
||||||
|
type: "boolean",
|
||||||
|
default: true,
|
||||||
|
description: "Wenn kein invoiceId und keine invoice-Daten übergeben werden, wird nach dem Upload die automatische Eingangsbeleg-Vorbereitung ausgeführt.",
|
||||||
|
},
|
||||||
|
invoice: {
|
||||||
|
type: "object",
|
||||||
|
description: "Optional: Daten für einen neuen Eingangsbeleg-Entwurf, der direkt mit der Datei verknüpft wird.",
|
||||||
|
properties: {
|
||||||
|
vendor: { type: "number" },
|
||||||
|
reference: { type: "string" },
|
||||||
|
date: { type: "string" },
|
||||||
|
dueDate: { type: "string" },
|
||||||
|
document: { type: "number" },
|
||||||
|
description: { type: "string" },
|
||||||
|
paymentType: { type: "string" },
|
||||||
|
accounts: { type: "array" },
|
||||||
|
expense: { type: "boolean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async handler(context, args) {
|
||||||
|
const filename = stringArg(args, "filename")
|
||||||
|
if (!filename) throw new Error("filename ist erforderlich")
|
||||||
|
|
||||||
|
const buffer = base64BufferArg(args, "contentBase64")
|
||||||
|
const mimeType = stringArg(args, "mimeType") || "application/octet-stream"
|
||||||
|
const invoiceId = numberArg(args, "invoiceId")
|
||||||
|
const invoiceArgs = optionalObjectArg(args, "invoice") as Record<string, unknown> | null
|
||||||
|
const shouldPrepare = args.prepare !== false && !invoiceId && !invoiceArgs
|
||||||
|
|
||||||
|
let linkedInvoice: any = null
|
||||||
|
|
||||||
|
if (invoiceId) {
|
||||||
|
const [existing] = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(incominginvoices)
|
||||||
|
.where(and(eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
|
||||||
|
linkedInvoice = existing
|
||||||
|
} else if (invoiceArgs) {
|
||||||
|
const payload = buildIncomingInvoicePayload(invoiceArgs, context.userId, context.tenantId, true)
|
||||||
|
payload.state = "Entwurf"
|
||||||
|
|
||||||
|
const [createdInvoice] = await context.server.db
|
||||||
|
.insert(incominginvoices)
|
||||||
|
.values(payload as any)
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
linkedInvoice = createdInvoice
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = await loadIncomingInvoiceFileDefaults(context)
|
||||||
|
const saved = await saveFile(
|
||||||
|
context.server,
|
||||||
|
context.tenantId,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
filename,
|
||||||
|
content: buffer,
|
||||||
|
contentType: mimeType,
|
||||||
|
},
|
||||||
|
defaults.folderId,
|
||||||
|
defaults.fileTagId,
|
||||||
|
{
|
||||||
|
incominginvoice: linkedInvoice?.id || null,
|
||||||
|
createdBy: context.userId,
|
||||||
|
updatedBy: context.userId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!saved) throw new Error("Datei konnte nicht gespeichert werden")
|
||||||
|
|
||||||
|
if (shouldPrepare) {
|
||||||
|
await context.server.services.prepareIncomingInvoices.run(context.tenantId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [file] = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(files)
|
||||||
|
.where(and(eq(files.id, saved.id), eq(files.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (file?.incominginvoice && !linkedInvoice) {
|
||||||
|
const [preparedInvoice] = await context.server.db
|
||||||
|
.select()
|
||||||
|
.from(incominginvoices)
|
||||||
|
.where(and(eq(incominginvoices.id, file.incominginvoice), eq(incominginvoices.tenant, context.tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
linkedInvoice = preparedInvoice || null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
invoice: linkedInvoice,
|
||||||
|
validation: linkedInvoice ? validateIncomingInvoiceData(linkedInvoice as Record<string, any>) : null,
|
||||||
|
prepared: Boolean(shouldPrepare && linkedInvoice),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "accounting.incoming_invoices.validate",
|
name: "accounting.incoming_invoices.validate",
|
||||||
title: "Eingangsbeleg validieren",
|
title: "Eingangsbeleg validieren",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "~/composables/useTaxEvaluation"
|
} from "~/composables/useTaxEvaluation"
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const toast = useToast()
|
||||||
const defaultFeatures = {
|
const defaultFeatures = {
|
||||||
objects: true,
|
objects: true,
|
||||||
calendar: true,
|
calendar: true,
|
||||||
@@ -119,6 +120,17 @@ const itemInfo = ref({
|
|||||||
projectTypes: []
|
projectTypes: []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const canManageMcpTokens = computed(() => Boolean(auth.user?.is_admin || auth.hasPermission("mcp.tokens.write")))
|
||||||
|
const mcpTokens = ref([])
|
||||||
|
const mcpTokensLoading = ref(false)
|
||||||
|
const mcpTokenCreating = ref(false)
|
||||||
|
const mcpTokenDeletingId = ref(null)
|
||||||
|
const createdMcpToken = ref("")
|
||||||
|
const mcpTokenForm = reactive({
|
||||||
|
name: "Codex MCP Token",
|
||||||
|
expiresAt: ""
|
||||||
|
})
|
||||||
|
|
||||||
const setupPage = async () => {
|
const setupPage = async () => {
|
||||||
itemInfo.value = auth.activeTenantData
|
itemInfo.value = auth.activeTenantData
|
||||||
console.log(itemInfo.value)
|
console.log(itemInfo.value)
|
||||||
@@ -153,7 +165,86 @@ const saveFeatures = async () => {
|
|||||||
await updateTenant({features: features.value})
|
await updateTenant({features: features.value})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatMcpTokenDate = (value) => {
|
||||||
|
if (!value) return "Nie"
|
||||||
|
|
||||||
|
return new Date(value).toLocaleString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMcpTokens = async () => {
|
||||||
|
if (!canManageMcpTokens.value) return
|
||||||
|
|
||||||
|
mcpTokensLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await useNuxtApp().$api("/api/mcp/tokens")
|
||||||
|
mcpTokens.value = res?.rows || []
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({ title: "MCP Tokens konnten nicht geladen werden", color: "error" })
|
||||||
|
} finally {
|
||||||
|
mcpTokensLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createMcpToken = async () => {
|
||||||
|
if (!mcpTokenForm.name?.trim()) {
|
||||||
|
toast.add({ title: "Name fehlt", description: "Bitte gib einen Namen für den Token an.", color: "orange" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpTokenCreating.value = true
|
||||||
|
createdMcpToken.value = ""
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await useNuxtApp().$api("/api/mcp/tokens", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
name: mcpTokenForm.name.trim(),
|
||||||
|
expiresAt: mcpTokenForm.expiresAt || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createdMcpToken.value = res?.token || ""
|
||||||
|
toast.add({ title: "MCP Token erstellt", color: "success" })
|
||||||
|
await loadMcpTokens()
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({ title: "MCP Token konnte nicht erstellt werden", color: "error" })
|
||||||
|
} finally {
|
||||||
|
mcpTokenCreating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyCreatedMcpToken = async () => {
|
||||||
|
if (!createdMcpToken.value) return
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(createdMcpToken.value)
|
||||||
|
toast.add({ title: "Token kopiert", color: "success" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const deactivateMcpToken = async (token) => {
|
||||||
|
if (!token?.id || !confirm(`MCP Token "${token.name}" deaktivieren?`)) return
|
||||||
|
|
||||||
|
mcpTokenDeletingId.value = token.id
|
||||||
|
try {
|
||||||
|
await useNuxtApp().$api(`/api/mcp/tokens/${token.id}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
})
|
||||||
|
toast.add({ title: "MCP Token deaktiviert", color: "success" })
|
||||||
|
await loadMcpTokens()
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({ title: "MCP Token konnte nicht deaktiviert werden", color: "error" })
|
||||||
|
} finally {
|
||||||
|
mcpTokenDeletingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setupPage()
|
setupPage()
|
||||||
|
onMounted(loadMcpTokens)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -167,6 +258,8 @@ setupPage()
|
|||||||
label: 'Dokubox'
|
label: 'Dokubox'
|
||||||
},{
|
},{
|
||||||
label: 'Rechnung & Kontakt'
|
label: 'Rechnung & Kontakt'
|
||||||
|
},{
|
||||||
|
label: 'Integrationen'
|
||||||
},{
|
},{
|
||||||
label: 'Funktionen'
|
label: 'Funktionen'
|
||||||
}
|
}
|
||||||
@@ -259,6 +352,124 @@ setupPage()
|
|||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="item.label === 'Integrationen'">
|
||||||
|
<UCard class="mt-5">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-highlighted">MCP Tokens</h3>
|
||||||
|
<p class="text-sm text-muted">Verwalte dauerhafte Tokens für Codex und andere MCP Clients.</p>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-arrow-path"
|
||||||
|
variant="outline"
|
||||||
|
:loading="mcpTokensLoading"
|
||||||
|
:disabled="!canManageMcpTokens"
|
||||||
|
@click="loadMcpTokens"
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="!canManageMcpTokens"
|
||||||
|
title="Keine Berechtigung"
|
||||||
|
description="Du benötigst Adminrechte oder die Berechtigung mcp.tokens.write."
|
||||||
|
color="orange"
|
||||||
|
variant="outline"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<UFormField label="Name" class="md:col-span-2">
|
||||||
|
<UInput v-model="mcpTokenForm.name" placeholder="z.B. Codex MCP Token" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Ablaufdatum">
|
||||||
|
<UInput v-model="mcpTokenForm.expiresAt" type="date" />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-plus"
|
||||||
|
:loading="mcpTokenCreating"
|
||||||
|
@click="createMcpToken"
|
||||||
|
>
|
||||||
|
Token erstellen
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="createdMcpToken"
|
||||||
|
title="Token wurde erstellt"
|
||||||
|
description="Der Token wird nur jetzt vollständig angezeigt."
|
||||||
|
color="success"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<div class="mt-3 space-y-3">
|
||||||
|
<UTextarea
|
||||||
|
:model-value="createdMcpToken"
|
||||||
|
readonly
|
||||||
|
autoresize
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-clipboard-document"
|
||||||
|
variant="outline"
|
||||||
|
@click="copyCreatedMcpToken"
|
||||||
|
>
|
||||||
|
Token kopieren
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UAlert>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto rounded-md border border-default">
|
||||||
|
<table class="min-w-full divide-y divide-default text-sm">
|
||||||
|
<thead class="bg-muted">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Name</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Prefix</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Erstellt</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Zuletzt genutzt</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Läuft ab</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Status</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-default">
|
||||||
|
<tr v-if="!mcpTokensLoading && !mcpTokens.length">
|
||||||
|
<td colspan="7" class="px-4 py-6 text-center text-muted">Noch keine MCP Tokens vorhanden.</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="token in mcpTokens" :key="token.id">
|
||||||
|
<td class="px-4 py-3">{{ token.name }}</td>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs">{{ token.keyPrefix }}</td>
|
||||||
|
<td class="px-4 py-3">{{ formatMcpTokenDate(token.createdAt) }}</td>
|
||||||
|
<td class="px-4 py-3">{{ formatMcpTokenDate(token.lastUsedAt) }}</td>
|
||||||
|
<td class="px-4 py-3">{{ formatMcpTokenDate(token.expiresAt) }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<UBadge :color="token.active ? 'success' : 'neutral'" variant="soft">
|
||||||
|
{{ token.active ? "Aktiv" : "Inaktiv" }}
|
||||||
|
</UBadge>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<UButton
|
||||||
|
v-if="token.active"
|
||||||
|
icon="i-heroicons-archive-box"
|
||||||
|
color="error"
|
||||||
|
variant="ghost"
|
||||||
|
:loading="mcpTokenDeletingId === token.id"
|
||||||
|
@click="deactivateMcpToken(token)"
|
||||||
|
>
|
||||||
|
Deaktivieren
|
||||||
|
</UButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
<div v-else-if="item.label === 'Funktionen'">
|
<div v-else-if="item.label === 'Funktionen'">
|
||||||
<UCard class="mt-5">
|
<UCard class="mt-5">
|
||||||
<UAlert
|
<UAlert
|
||||||
|
|||||||
Reference in New Issue
Block a user