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>
1079 lines
39 KiB
Vue
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>
|