Files
FEDEO/frontend/pages/settings/tenant.vue
florianfederspiel af4a283eb5 feat(accounting): Skonto bei Zahlungen auf Ausgangs- und Eingangsbelege buchen
Skonto wird an der Bankzuordnung gespeichert und gleicht den Beleg zusammen
mit der Zahlung aus. Der Betrag wird anteilig nach Steuersätzen auf die
Skontokonten verteilt (Standard SKR03, je Mandant einstellbar), im
DATEV-Export als eigene Buchung je Steuersatz ausgegeben und in der
USt-Auswertung im Zeitraum der Zahlung berücksichtigt. Schlussrechnungen
verteilen den Skonto auf die Restforderung nach Abzug der Abschläge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:31:47 +01:00

1079 lines
39 KiB
Vue

<script setup>
import { resolveCashDiscountAccounts } from "~/utils/cashDiscount"
import {
TAX_EVALUATION_PERIOD_OPTIONS,
normalizeTaxEvaluationPeriod
} from "~/composables/useTaxEvaluation"
import { DEFAULT_PORTAL_COLOR, getPortalColor, normalizePortalColor, portalThemeStyle } from "~/utils/portalTheme"
const auth = useAuthStore()
const toast = useToast()
const defaultFeatures = {
objects: true,
calendar: true,
contacts: true,
projects: true,
vehicles: true,
contracts: true,
inventory: true,
accounting: true,
timeTracking: true,
planningBoard: true,
workingTimeTracking: true,
dashboard: true,
historyitems: true,
tasks: true,
wiki: true,
files: true,
createdletters: true,
documentboxes: true,
helpdesk: true,
email: true,
members: true,
customers: true,
vendors: true,
contactsList: true,
staffTime: true,
createDocument: true,
serialInvoice: true,
incomingInvoices: true,
outgoingsepamandates: true,
costcentres: true,
branches: true,
teams: true,
accounts: true,
ownaccounts: true,
banking: true,
spaces: true,
customerspaces: true,
customerinventoryitems: true,
inventoryitems: true,
inventoryitemgroups: true,
products: true,
productcategories: true,
services: true,
servicecategories: true,
memberrelations: true,
staffProfiles: true,
hourrates: true,
projecttypes: true,
contracttypes: true,
plants: true,
settingsNumberRanges: true,
settingsEmailAccounts: true,
settingsBanking: true,
settingsTexttemplates: true,
settingsTenant: true,
export: true,
}
const featureOptions = [
{ key: "dashboard", label: "Dashboard" },
{ key: "historyitems", label: "Logbuch" },
{ key: "tasks", label: "Aufgaben" },
{ key: "planningBoard", label: "Plantafel" },
{ key: "wiki", label: "Wiki" },
{ key: "files", label: "Dateien" },
{ key: "createdletters", label: "Anschreiben" },
{ key: "documentboxes", label: "Boxen" },
{ key: "helpdesk", label: "Helpdesk" },
{ key: "email", label: "E-Mail" },
{ key: "members", label: "Mitglieder" },
{ key: "customers", label: "Kunden" },
{ key: "vendors", label: "Lieferanten" },
{ key: "contactsList", label: "Ansprechpartner" },
{ key: "staffTime", label: "Mitarbeiter: Zeiten" },
{ key: "createDocument", label: "Buchhaltung: Ausgangsbelege" },
{ key: "serialInvoice", label: "Buchhaltung: Serienvorlagen" },
{ key: "incomingInvoices", label: "Buchhaltung: Eingangsbelege" },
{ key: "outgoingsepamandates", label: "Buchhaltung: Ausgehende SEPA-Mandate" },
{ key: "costcentres", label: "Buchhaltung: Kostenstellen" },
{ key: "branches", label: "Stammdaten: Niederlassungen" },
{ key: "teams", label: "Mitarbeiter: Teams" },
{ key: "accounts", label: "Buchhaltung: Buchungskonten" },
{ key: "ownaccounts", label: "Buchhaltung: Zusätzliche Buchungskonten" },
{ key: "banking", label: "Buchhaltung: Bank" },
{ key: "spaces", label: "Lagerplätze" },
{ key: "customerspaces", label: "Kundenlagerplätze" },
{ key: "customerinventoryitems", label: "Kundeninventar" },
{ key: "inventoryitems", label: "Inventar" },
{ key: "inventoryitemgroups", label: "Inventargruppen" },
{ key: "products", label: "Stammdaten: Artikel" },
{ key: "productcategories", label: "Stammdaten: Artikelkategorien" },
{ key: "services", label: "Stammdaten: Leistungen" },
{ key: "servicecategories", label: "Stammdaten: Leistungskategorien" },
{ key: "memberrelations", label: "Stammdaten: Mitgliedsverhältnisse" },
{ key: "staffProfiles", label: "Mitarbeiter: Mitarbeiterprofile" },
{ key: "hourrates", label: "Stammdaten: Stundensätze" },
{ key: "projecttypes", label: "Stammdaten: Projekttypen" },
{ key: "contracttypes", label: "Stammdaten: Vertragstypen" },
{ key: "vehicles", label: "Stammdaten: Fahrzeuge" },
{ key: "projects", label: "Projekte" },
{ key: "contracts", label: "Verträge" },
{ key: "plants", label: "Objekte" },
{ key: "settingsNumberRanges", label: "Einstellungen: Nummernkreise" },
{ key: "settingsEmailAccounts", label: "Einstellungen: E-Mail Konten" },
{ key: "settingsBanking", label: "Einstellungen: Bankkonten" },
{ key: "settingsTexttemplates", label: "Einstellungen: Textvorlagen" },
{ key: "settingsTenant", label: "Einstellungen: Firmeneinstellungen" },
{ key: "export", label: "Einstellungen: Export" },
]
const itemInfo = ref({
features: {},
businessInfo: {},
projectTypes: []
})
const planningBoardTimeGridOptions = [
{ label: "15 Minuten", value: 15 },
{ label: "30 Minuten", value: 30 },
{ label: "60 Minuten", value: 60 },
{ label: "120 Minuten", value: 120 },
{ 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 planningBoardWeekEndOptions = [
{ label: "Freitag", value: 5 },
{ label: "Samstag", value: 6 },
{ label: "Sonntag", value: 0 }
]
const planningBoardConfig = reactive({
startTime: auth.activeTenantData?.calendarConfig?.planningBoard?.startTime || "06:00",
endTime: auth.activeTenantData?.calendarConfig?.planningBoard?.endTime || "21:00",
slotMinutes: auth.activeTenantData?.calendarConfig?.planningBoard?.slotMinutes || 180,
weekStartsOn: auth.activeTenantData?.calendarConfig?.planningBoard?.weekStartsOn ?? 1,
weekEndsOn: auth.activeTenantData?.calendarConfig?.planningBoard?.weekEndsOn ?? 5
})
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 telephonyTrunkLoading = ref(false)
const telephonyTrunkSaving = ref(false)
const telephonyTrunkApplying = ref(false)
const telephonyExtensionsLoading = ref(false)
const telephonyExtensionSaving = ref(false)
const telephonyExtensionDeletingId = ref(null)
const telephonyProviderOptions = [
{ label: "Easybell", value: "easybell" },
{ label: "Telekom", value: "telekom" }
]
const telephonyExtensionTargetTypes = [
{ label: "Benutzer", value: "user" },
{ label: "Team", value: "team" },
{ label: "Niederlassung", value: "branch" }
]
const telephonyProviderDefaults = {
easybell: {
registrar: "voip.easybell.de",
title: "Easybell SIP-Trunk",
description: "Nutze SIP-Benutzername und SIP-Passwort aus dem Easybell-Kundenportal. Falls dort eine Kennung wie K... angezeigt wird, trage sie als Auth-User ein. Die Absendernummer ist die Stammnummer im internationalen Format ohne führende 00."
},
telekom: {
registrar: "tel.t-online.de",
title: "Telekom Zugangsdaten",
description: "Die SIP-ID ist meistens deine Rufnummer mit Vorwahl ohne Leerzeichen und Sonderzeichen. Falls dein Anschluss die klassischen Zugangsdaten nutzt, kannst du den Auth-User aus Anschlusskennung, Zugangsnummer, #, Mitbenutzernummer und @t-online.de bilden."
}
}
const telephonyTrunkForm = reactive({
provider: "easybell",
enabled: false,
registrar: "voip.easybell.de",
sipUser: "",
authUser: "",
password: "",
passwordConfigured: false,
clearPassword: false,
callerId: "",
inboundExtension: "1001",
defaultRouteExtensionId: null,
outboundPrefix: "0",
externalSignalingAddress: "",
externalMediaAddress: "",
localNetworks: "172.16.0.0/12,192.168.0.0/16,10.0.0.0/8"
})
const telephonyExtensions = ref([])
const telephonyExtensionOptions = ref({ users: [], teams: [], branches: [] })
const telephonyExtensionForm = reactive({
id: null,
targetType: "user",
targetUserId: null,
targetTeamId: null,
targetBranchId: null,
extension: "",
displayName: "",
sipUsername: "",
sipPassword: "",
enabled: true
})
const activeTelephonyProvider = computed(() => telephonyProviderDefaults[telephonyTrunkForm.provider] || telephonyProviderDefaults.easybell)
const telephonyRouteOptions = computed(() => telephonyExtensions.value.map((extension) => ({
label: `${extension.extension} - ${extension.displayName || extension.targetType}`,
value: extension.id
})))
const activeTelephonyTargetOptions = computed(() => {
if (telephonyExtensionForm.targetType === "team") return telephonyExtensionOptions.value.teams || []
if (telephonyExtensionForm.targetType === "branch") return telephonyExtensionOptions.value.branches || []
return telephonyExtensionOptions.value.users || []
})
const defaultBusinessInfo = {
name: "",
street: "",
zip: "",
city: "",
email: "",
phone: "",
website: "",
taxNumber: "",
vatId: "",
bankName: "",
bankAccountOwner: "",
iban: "",
bic: "",
}
const normalizeBusinessInfo = (value) => ({ ...defaultBusinessInfo, ...(value || {}) })
const setupPage = async () => {
itemInfo.value = auth.activeTenantData
console.log(itemInfo.value)
businessInfo.value = normalizeBusinessInfo(auth.activeTenantData?.businessInfo)
planningBoardConfig.startTime = auth.activeTenantData?.calendarConfig?.planningBoard?.startTime || "06:00"
planningBoardConfig.endTime = auth.activeTenantData?.calendarConfig?.planningBoard?.endTime || "21:00"
planningBoardConfig.slotMinutes = auth.activeTenantData?.calendarConfig?.planningBoard?.slotMinutes || 180
planningBoardConfig.weekStartsOn = auth.activeTenantData?.calendarConfig?.planningBoard?.weekStartsOn ?? 1
planningBoardConfig.weekEndsOn = auth.activeTenantData?.calendarConfig?.planningBoard?.weekEndsOn ?? 5
}
const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) })
const businessInfo = ref(normalizeBusinessInfo(auth.activeTenantData?.businessInfo))
const portalColor = ref(getPortalColor(auth.activeTenantData?.portalConfig))
const portalColorSaving = ref(false)
const validPortalColor = computed(() => normalizePortalColor(portalColor.value))
const portalPreviewStyle = computed(() => portalThemeStyle(portalColor.value))
watch(() => auth.activeTenant, () => {
portalColor.value = getPortalColor(auth.activeTenantData?.portalConfig)
})
watch(() => auth.activeTenantData?.portalConfig, (config) => {
portalColor.value = getPortalColor(config)
})
const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
const taxEvaluationPeriod = ref(normalizeTaxEvaluationPeriod(auth.activeTenantData.taxEvaluationPeriod))
const buildCashDiscountForm = (config) => {
const accounts = resolveCashDiscountAccounts(config)
return { received: { ...accounts.received }, granted: { ...accounts.granted } }
}
const cashDiscountAccounts = ref(buildCashDiscountForm(auth.activeTenantData?.cashDiscountAccounts))
const cashDiscountRateLabels = { "19": "19 %", "7": "7 %", "0": "ohne USt" }
const saveCashDiscountAccounts = async () => {
try {
await updateTenant({ cashDiscountAccounts: cashDiscountAccounts.value })
toast.add({ title: "Skontokonten gespeichert", color: "success" })
} catch (err) {
toast.add({ title: "Skontokonten konnten nicht gespeichert werden", description: err?.data?.error, color: "error" })
}
}
const accountChartOptions = [
{ label: "SKR 03", value: "skr03" },
{ label: "Verein", value: "verein" }
]
const updateTenant = async (newData) => {
const res = await useNuxtApp().$api(`/api/tenant/other/${auth.activeTenant}`, {
method: "PUT",
body: {
data: newData,
}
})
if (res) {
itemInfo.value = res
auth.activeTenantData = res
features.value = { ...defaultFeatures, ...(res?.features || {}) }
businessInfo.value = normalizeBusinessInfo(res?.businessInfo)
taxEvaluationPeriod.value = normalizeTaxEvaluationPeriod(res?.taxEvaluationPeriod)
cashDiscountAccounts.value = buildCashDiscountForm(res?.cashDiscountAccounts)
}
}
const saveFeatures = async () => {
await updateTenant({features: features.value})
}
const savePortalColor = async () => {
if (!validPortalColor.value || portalColorSaving.value) return
portalColorSaving.value = true
try {
const { primayColor, ...portalConfig } = auth.activeTenantData?.portalConfig || {}
await updateTenant({ portalConfig: { ...portalConfig, primaryColor: validPortalColor.value } })
toast.add({ title: "Akzentfarbe für das Kundenportal gespeichert", color: "success" })
} finally {
portalColorSaving.value = false
}
}
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 loadTelephonyTrunk = async () => {
telephonyTrunkLoading.value = true
try {
const res = await useNuxtApp().$api(`/api/telephony/trunk-config?provider=${telephonyTrunkForm.provider}`)
telephonyTrunkForm.provider = res?.provider || telephonyTrunkForm.provider || "easybell"
telephonyTrunkForm.enabled = Boolean(res?.enabled)
telephonyTrunkForm.registrar = res?.registrar || activeTelephonyProvider.value.registrar
telephonyTrunkForm.sipUser = res?.sipUser || ""
telephonyTrunkForm.authUser = res?.authUser || ""
telephonyTrunkForm.password = ""
telephonyTrunkForm.passwordConfigured = Boolean(res?.passwordConfigured)
telephonyTrunkForm.clearPassword = false
telephonyTrunkForm.callerId = res?.callerId || ""
telephonyTrunkForm.inboundExtension = res?.inboundExtension || "1001"
telephonyTrunkForm.defaultRouteExtensionId = res?.defaultRouteExtensionId || null
telephonyTrunkForm.outboundPrefix = res?.outboundPrefix || "0"
telephonyTrunkForm.externalSignalingAddress = res?.externalSignalingAddress || ""
telephonyTrunkForm.externalMediaAddress = res?.externalMediaAddress || ""
telephonyTrunkForm.localNetworks = res?.localNetworks || "172.16.0.0/12,192.168.0.0/16,10.0.0.0/8"
} catch (error) {
toast.add({ title: "Telefonie-Trunk konnte nicht geladen werden", color: "error" })
} finally {
telephonyTrunkLoading.value = false
}
}
const saveTelephonyTrunk = async () => {
if (telephonyTrunkForm.enabled && (!telephonyTrunkForm.sipUser?.trim() || (!telephonyTrunkForm.password?.trim() && !telephonyTrunkForm.passwordConfigured))) {
toast.add({
title: "Trunk-Zugang unvollständig",
description: "Bitte gib mindestens SIP-ID und Kennwort an.",
color: "orange"
})
return
}
telephonyTrunkSaving.value = true
try {
const res = await useNuxtApp().$api("/api/telephony/trunk-config", {
method: "PUT",
body: {
provider: telephonyTrunkForm.provider,
enabled: telephonyTrunkForm.enabled,
registrar: telephonyTrunkForm.registrar,
sipUser: telephonyTrunkForm.sipUser,
authUser: telephonyTrunkForm.authUser,
password: telephonyTrunkForm.password,
clearPassword: telephonyTrunkForm.clearPassword,
callerId: telephonyTrunkForm.callerId,
inboundExtension: telephonyTrunkForm.inboundExtension,
defaultRouteExtensionId: telephonyTrunkForm.defaultRouteExtensionId,
outboundPrefix: telephonyTrunkForm.outboundPrefix,
externalSignalingAddress: telephonyTrunkForm.externalSignalingAddress,
externalMediaAddress: telephonyTrunkForm.externalMediaAddress,
localNetworks: telephonyTrunkForm.localNetworks
}
})
telephonyTrunkForm.password = ""
telephonyTrunkForm.passwordConfigured = Boolean(res?.passwordConfigured)
telephonyTrunkForm.clearPassword = false
toast.add({ title: "Telefonie-Trunk gespeichert", color: "success" })
} catch (error) {
toast.add({
title: "Telefonie-Trunk konnte nicht gespeichert werden",
description: error?.data?.error || error?.message,
color: "error"
})
} finally {
telephonyTrunkSaving.value = false
}
}
const loadTelephonyExtensionOptions = async () => {
try {
telephonyExtensionOptions.value = await useNuxtApp().$api("/api/telephony/extensions/options")
} catch (error) {
toast.add({ title: "Nebenstellen-Ziele konnten nicht geladen werden", color: "error" })
}
}
const loadTelephonyExtensions = async () => {
telephonyExtensionsLoading.value = true
try {
const res = await useNuxtApp().$api("/api/telephony/extensions")
telephonyExtensions.value = res?.rows || []
} catch (error) {
toast.add({ title: "Nebenstellen konnten nicht geladen werden", color: "error" })
} finally {
telephonyExtensionsLoading.value = false
}
}
const resetTelephonyExtensionForm = () => {
telephonyExtensionForm.id = null
telephonyExtensionForm.targetType = "user"
telephonyExtensionForm.targetUserId = null
telephonyExtensionForm.targetTeamId = null
telephonyExtensionForm.targetBranchId = null
telephonyExtensionForm.extension = ""
telephonyExtensionForm.displayName = ""
telephonyExtensionForm.sipUsername = ""
telephonyExtensionForm.sipPassword = ""
telephonyExtensionForm.enabled = true
}
const editTelephonyExtension = (extension) => {
telephonyExtensionForm.id = extension.id
telephonyExtensionForm.targetType = extension.targetType || "user"
telephonyExtensionForm.targetUserId = extension.targetUserId || null
telephonyExtensionForm.targetTeamId = extension.targetTeamId || null
telephonyExtensionForm.targetBranchId = extension.targetBranchId || null
telephonyExtensionForm.extension = extension.extension || ""
telephonyExtensionForm.displayName = extension.displayName || ""
telephonyExtensionForm.sipUsername = extension.sipUsername || extension.extension || ""
telephonyExtensionForm.sipPassword = ""
telephonyExtensionForm.enabled = extension.enabled !== false
}
const telephonyExtensionPayload = () => ({
targetType: telephonyExtensionForm.targetType,
targetUserId: telephonyExtensionForm.targetType === "user" ? telephonyExtensionForm.targetUserId : null,
targetTeamId: telephonyExtensionForm.targetType === "team" ? telephonyExtensionForm.targetTeamId : null,
targetBranchId: telephonyExtensionForm.targetType === "branch" ? telephonyExtensionForm.targetBranchId : null,
extension: telephonyExtensionForm.extension,
displayName: telephonyExtensionForm.displayName,
sipUsername: telephonyExtensionForm.sipUsername,
sipPassword: telephonyExtensionForm.sipPassword,
enabled: telephonyExtensionForm.enabled
})
const saveTelephonyExtension = async () => {
if (!telephonyExtensionForm.extension?.trim()) {
toast.add({ title: "Nebenstelle fehlt", color: "orange" })
return
}
telephonyExtensionSaving.value = true
try {
await useNuxtApp().$api(
telephonyExtensionForm.id ? `/api/telephony/extensions/${telephonyExtensionForm.id}` : "/api/telephony/extensions",
{
method: telephonyExtensionForm.id ? "PUT" : "POST",
body: telephonyExtensionPayload()
}
)
toast.add({ title: "Nebenstelle gespeichert", color: "success" })
resetTelephonyExtensionForm()
await loadTelephonyExtensions()
} catch (error) {
toast.add({
title: "Nebenstelle konnte nicht gespeichert werden",
description: error?.data?.error || error?.message,
color: "error"
})
} finally {
telephonyExtensionSaving.value = false
}
}
const deleteTelephonyExtension = async (extension) => {
if (!extension?.id || !confirm(`Nebenstelle ${extension.extension} löschen?`)) return
telephonyExtensionDeletingId.value = extension.id
try {
await useNuxtApp().$api(`/api/telephony/extensions/${extension.id}`, { method: "DELETE" })
toast.add({ title: "Nebenstelle gelöscht", color: "success" })
await loadTelephonyExtensions()
} catch (error) {
toast.add({ title: "Nebenstelle konnte nicht gelöscht werden", color: "error" })
} finally {
telephonyExtensionDeletingId.value = null
}
}
const applyTelephonyTrunk = async () => {
telephonyTrunkApplying.value = true
try {
const res = await useNuxtApp().$api("/api/telephony/trunk-config/apply", {
method: "POST"
})
toast.add({
title: res?.warning ? "Trunk-Konfiguration geschrieben" : "Telefonie-Trunk angewendet",
description: res?.warning || (res?.status?.registered ? "Trunk-Registration ist aktiv." : "Asterisk wurde neu geladen."),
color: res?.warning ? "orange" : "success"
})
} catch (error) {
toast.add({
title: "Telefonie-Trunk konnte nicht angewendet werden",
description: error?.data?.error || error?.message,
color: "error"
})
} finally {
telephonyTrunkApplying.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
}
}
const savePlanningBoardConfig = async () => {
if (!planningBoardConfig.startTime || !planningBoardConfig.endTime) {
toast.add({ title: "Rahmenzeiten fehlen", description: "Bitte Start- und Endzeit angeben.", color: "orange" })
return
}
if (planningBoardConfig.startTime >= planningBoardConfig.endTime) {
toast.add({ title: "Rahmenzeiten ungültig", description: "Die Endzeit muss nach der Startzeit liegen.", color: "orange" })
return
}
const currentCalendarConfig = auth.activeTenantData?.calendarConfig || {}
await updateTenant({
calendarConfig: {
...currentCalendarConfig,
planningBoard: {
startTime: planningBoardConfig.startTime || "06:00",
endTime: planningBoardConfig.endTime || "21:00",
slotMinutes: Number(planningBoardConfig.slotMinutes) || 180,
weekStartsOn: Number(planningBoardConfig.weekStartsOn),
weekEndsOn: Number(planningBoardConfig.weekEndsOn)
}
}
})
}
setupPage()
onMounted(() => {
loadMcpTokens()
})
</script>
<template>
<UDashboardNavbar title="Firmeneinstellungen">
</UDashboardNavbar>
<UDashboardPanelContent>
<UTabs
:items="[
{
label: 'Dokubox'
},{
label: 'Rechnung & Kontakt'
},{
label: 'Kundenportal'
},{
label: 'Plantafel'
},{
label: 'Integrationen'
},{
label: 'Funktionen'
}
]"
>
<template #content="{item}">
<div v-if="item.label === 'Dokubox'">
<UAlert
class="mt-5"
title="DOKUBOX"
color="neutral"
variant="outline"
>
<template #description>
<p>Die Dokubox ist eine E-Mail Inbox um deine Anhänge direkt als Dokumente zu importieren. Leite Deine E-Mails einfach an die folgende E-Mail Adresse weiter, diese ist für dein Unternehmen einzigartig. Die E-Mails werden dann alle 5 min abgerufen.</p>
<br><br>
<a
v-if="itemInfo.id"
:href="`mailto:${itemInfo.dokuboxkey}@fedeo-dokubox.de`"
>
{{itemInfo.dokuboxkey}}@fedeo-dokubox.de
</a>
</template>
</UAlert>
</div>
<div v-if="item.label === 'Rechnung & Kontakt'">
<UCard class="mt-5">
<UForm class="w-1/2">
<UFormField
label="Firmenname:"
>
<UInput v-model="businessInfo.name"/>
</UFormField>
<UFormField
label="Straße + Hausnummer:"
>
<UInput v-model="businessInfo.street"/>
</UFormField>
<UFormField
label="PLZ + Ort"
class="w-full"
>
<InputGroup class="w-full">
<UInput v-model="businessInfo.zip"/>
<UInput v-model="businessInfo.city" class="flex-auto"/>
</InputGroup>
</UFormField>
<UFormField
label="E-Mail:"
>
<UInput v-model="businessInfo.email"/>
</UFormField>
<UFormField
label="Telefon:"
>
<UInput v-model="businessInfo.phone"/>
</UFormField>
<UFormField
label="Website:"
>
<UInput v-model="businessInfo.website"/>
</UFormField>
<UFormField
label="Steuernummer:"
>
<UInput v-model="businessInfo.taxNumber"/>
</UFormField>
<UFormField
label="USt-IdNr.:"
>
<UInput v-model="businessInfo.vatId"/>
</UFormField>
<UFormField
label="Bank:"
>
<UInput v-model="businessInfo.bankName"/>
</UFormField>
<UFormField
label="Kontoinhaber:"
>
<UInput v-model="businessInfo.bankAccountOwner"/>
</UFormField>
<UFormField
label="IBAN:"
>
<UInput v-model="businessInfo.iban"/>
</UFormField>
<UFormField
label="BIC:"
>
<UInput v-model="businessInfo.bic"/>
</UFormField>
<UButton
class="mt-3"
@click="updateTenant({businessInfo: businessInfo})"
>
Speichern
</UButton>
<UFormField
label="Kontenrahmen:"
class="mt-6"
>
<USelectMenu
v-model="accountChart"
:items="accountChartOptions"
label-key="label"
value-key="value"
/>
</UFormField>
<UButton
class="mt-3"
@click="updateTenant({accountChart: accountChart})"
>
Kontenrahmen speichern
</UButton>
<UFormField
label="USt-Auswertung:"
class="mt-6"
>
<USelectMenu
v-model="taxEvaluationPeriod"
:items="TAX_EVALUATION_PERIOD_OPTIONS"
label-key="label"
value-key="value"
/>
</UFormField>
<UButton
class="mt-3"
@click="updateTenant({taxEvaluationPeriod: taxEvaluationPeriod})"
>
Zeitraum speichern
</UButton>
<div class="mt-6">
<div class="text-sm font-medium">Skontokonten</div>
<p class="text-xs text-muted mt-1">
DATEV-Automatikkonten für Skonto bei Zahlungen. Standard ist SKR 03.
</p>
<div class="mt-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<div v-for="side in ['received', 'granted']" :key="side" class="space-y-2">
<div class="text-xs font-semibold uppercase text-muted">
{{ side === 'received' ? 'Erhaltene Skonti (Eingangsbelege)' : 'Gewährte Skonti (Ausgangsbelege)' }}
</div>
<UFormField
v-for="rate in ['19', '7', '0']"
:key="`${side}-${rate}`"
:label="cashDiscountRateLabels[rate]"
>
<UInput v-model="cashDiscountAccounts[side][rate]" inputmode="numeric"/>
</UFormField>
</div>
</div>
</div>
<UButton
class="mt-3"
@click="saveCashDiscountAccounts"
>
Skontokonten speichern
</UButton>
</UForm>
</UCard>
</div>
<div v-else-if="item.label === 'Kundenportal'">
<UCard class="mt-5">
<div class="max-w-xl space-y-6">
<div>
<h2 class="text-lg font-semibold">Akzentfarbe im Kundenportal</h2>
<p class="mt-1 text-sm text-muted">Wähle die Farbe für Schaltflächen, Reiter und Hervorhebungen im Kundenportal deiner Firma.</p>
</div>
<UFormField
label="Akzentfarbe"
help="Hex-Farbwert mit sechs Zeichen, zum Beispiel #69c350."
:error="!validPortalColor ? 'Bitte einen gültigen Hex-Farbwert eingeben.' : undefined"
>
<div class="flex items-center gap-3">
<input
type="color"
:value="validPortalColor || DEFAULT_PORTAL_COLOR"
aria-label="Akzentfarbe auswählen"
class="h-10 w-14 shrink-0 cursor-pointer rounded border border-default bg-default p-1"
@input="portalColor = $event.target.value"
>
<UInput v-model="portalColor" aria-label="Akzentfarbe als Hex-Wert" placeholder="#69c350" class="w-40" maxlength="7" />
</div>
</UFormField>
<div :style="portalPreviewStyle" class="rounded-xl border border-default p-5" aria-label="Vorschau der Portal-Akzentfarbe">
<p class="mb-3 text-sm font-medium text-primary-600">Vorschau Kundenportal</p>
<div class="flex flex-wrap items-center gap-3">
<UButton class="text-[var(--portal-accent-contrast)]" tabindex="-1">Kundendaten speichern</UButton>
<UBadge color="primary" variant="subtle">Rechnung</UBadge>
</div>
</div>
<div class="flex flex-wrap gap-3">
<UButton :loading="portalColorSaving" :disabled="!validPortalColor" @click="savePortalColor">Akzentfarbe speichern</UButton>
<UButton color="neutral" variant="outline" :disabled="portalColorSaving" @click="portalColor = DEFAULT_PORTAL_COLOR">FEDEO-Grün verwenden</UButton>
</div>
</div>
</UCard>
</div>
<div v-else-if="item.label === 'Plantafel'">
<UCard class="mt-5">
<UForm class="w-1/2">
<div class="space-y-4">
<div>
<h3 class="text-base font-semibold text-highlighted">Plantafel</h3>
<p class="text-sm text-muted">Lege hier Rahmenzeiten, Rasterabstand und Wochenbeginn für die Plantafel fest.</p>
</div>
<UFormField
label="Rahmenzeit von:"
>
<UInput type="time" v-model="planningBoardConfig.startTime" />
</UFormField>
<UFormField
label="Rahmenzeit bis:"
>
<UInput type="time" v-model="planningBoardConfig.endTime" />
</UFormField>
<UFormField
label="Rasterabstand:"
>
<USelectMenu
v-model="planningBoardConfig.slotMinutes"
:items="planningBoardTimeGridOptions"
label-key="label"
value-key="value"
/>
</UFormField>
<UFormField
label="Wochenbeginn:"
>
<USelectMenu
v-model="planningBoardConfig.weekStartsOn"
:items="planningBoardWeekStartOptions"
label-key="label"
value-key="value"
/>
</UFormField>
<UFormField
label="Letzter angezeigter Wochentag:"
>
<USelectMenu
v-model="planningBoardConfig.weekEndsOn"
:items="planningBoardWeekEndOptions"
label-key="label"
value-key="value"
/>
</UFormField>
<UButton
class="mt-1"
@click="savePlanningBoardConfig"
>
Plantafel speichern
</UButton>
</div>
</UForm>
</UCard>
</div>
<div v-else-if="item.label === 'Integrationen'">
<UCard class="mt-5">
<UAlert
class="mb-8"
color="neutral"
icon="i-heroicons-phone"
title="Telefonie ist jetzt eine eigene Einstellungsseite"
description="SIP-Trunk, Standardroute und Nebenstellenübersicht findest du unter Einstellungen > Telefonie."
:actions="[{ label: 'Telefonie öffnen', to: '/settings/telephony', icon: 'i-heroicons-arrow-top-right-on-square' }]"
/>
<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'">
<UCard class="mt-5">
<UAlert
title="Funktionen ausblenden"
description="Nur Funktionen mit gesetztem Haken sind im Unternehmen verfügbar. Diese Einstellungen gelten für alle Mitarbeiter und sind unabhängig von Berechtigungen."
color="error"
variant="outline"
class="mb-5"
/>
<UCheckbox
v-for="option in featureOptions"
:key="option.key"
:label="option.label"
v-model="features[option.key]"
@change="saveFeatures"
/>
</UCard>
</div>
</template>
</UTabs>
</UDashboardPanelContent>
</template>
<style scoped>
</style>