Compare commits

...

10 Commits

Author SHA1 Message Date
8dfcffc92b Added Repository Changelog
Some checks failed
Build and Push Docker Images / build-backend (push) Successful in 27s
Build and Push Docker Images / build-frontend (push) Failing after 28s
2026-03-21 17:52:01 +01:00
9ecacdab50 Handlebars Util 2026-03-21 17:44:44 +01:00
44fb50b11e Removed non available Entries 2026-03-21 17:44:37 +01:00
23c4d21f44 Added UST Auswertung 2026-03-21 17:44:25 +01:00
6f77bccd85 DB Changes 2026-03-21 17:42:59 +01:00
be336a51ab Changes on Admin Interface 2026-03-21 17:10:03 +01:00
ac2e2fcfe9 Fix for no Files present in tenant 2026-03-21 17:09:38 +01:00
9dbb194c8a Fix False Open State for cancelled Invoices 2026-03-21 17:08:57 +01:00
0aacb18aaa Fix False Showing Card 2026-03-21 17:07:47 +01:00
e3a1636018 Fix #44 with Handlebars Templates 2026-03-21 17:05:04 +01:00
27 changed files with 1713 additions and 93 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE "auth_users"
ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "tasks"
ADD COLUMN "dependency_ids" jsonb NOT NULL DEFAULT '[]'::jsonb;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "tenants"
ADD COLUMN "taxEvaluationPeriod" text DEFAULT 'monthly' NOT NULL;

View File

@@ -148,6 +148,20 @@
"when": 1773835200000,
"tag": "0021_admin_user_flag",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1773925200000,
"tag": "0022_task_dependencies",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1774080000000,
"tag": "0023_tax_evaluation_period",
"breakpoints": true
}
]
}

View File

@@ -161,6 +161,10 @@ export const tenants = pgTable(
.notNull()
.default(14),
taxEvaluationPeriod: text("taxEvaluationPeriod")
.notNull()
.default("monthly"),
dokuboxEmailAddresses: jsonb("dokuboxEmailAddresses").default([]),
dokuboxkey: uuid("dokuboxkey").notNull().defaultRandom(),

View File

@@ -1,6 +1,5 @@
import dayjs from "dayjs";
import quarterOfYear from "dayjs/plugin/quarterOfYear";
import Handlebars from "handlebars";
import axios from "axios";
import { eq, inArray, and } from "drizzle-orm"; // Drizzle Operatoren
@@ -10,6 +9,7 @@ import { saveFile } from "../utils/files";
import {FastifyInstance} from "fastify";
import {useNextNumberRangeNumber} from "../utils/functions";
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
import { documentTemplateHandlebars } from "../utils/handlebars";
dayjs.extend(quarterOfYear);
@@ -609,8 +609,8 @@ export function getDocumentDataBackend(
};
};
const templateStartText = Handlebars.compile(itemInfo.startText || "");
const templateEndText = Handlebars.compile(itemInfo.endText || "");
const templateStartText = documentTemplateHandlebars.compile(itemInfo.startText || "");
const templateEndText = documentTemplateHandlebars.compile(itemInfo.endText || "");
// --- 6. Title Sums Formatting ---
let returnTitleSums: Record<string, string> = {};

View File

@@ -30,6 +30,7 @@ export default async function adminRoutes(server: FastifyInstance) {
const createTenantSeeds = async (tenantId: number, createdBy: string) => {
const currentYear = new Date().getFullYear();
const timestamp = new Date();
const insertedTags = await server.db
.insert(filetags)
@@ -84,77 +85,148 @@ export default async function adminRoutes(server: FastifyInstance) {
const deliveryTag = insertedTags.find((tag) => tag.createdDocumentType === "deliveryNotes");
const incomingInvoiceTag = insertedTags.find((tag) => tag.incomingDocumentType === "invoices");
await server.db
const insertedFolders = await server.db
.insert(folders)
.values([
{
tenant: tenantId,
name: "Ausgangsrechnungen",
function: "yearSubCategory",
icon: "i-heroicons-document-text",
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: "Angebote",
function: "yearSubCategory",
icon: "i-heroicons-document-duplicate",
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: "Auftragsbestätigungen",
function: "yearSubCategory",
icon: "i-heroicons-clipboard-document-check",
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: "Lieferscheine",
function: "yearSubCategory",
icon: "i-heroicons-truck",
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: "Eingangsrechnungen",
function: "yearSubCategory",
icon: "i-heroicons-inbox-arrow-down",
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: "Belege Bankeinzahlung",
function: "yearSubCategory",
icon: "i-heroicons-banknotes",
isSystemUsed: true,
updatedAt: timestamp,
updatedBy: createdBy,
},
])
.returning({
id: folders.id,
name: folders.name,
});
const folderByName = new Map(insertedFolders.map((folder) => [folder.name, folder.id]));
await server.db
.insert(folders)
.values([
{
tenant: tenantId,
name: String(currentYear),
parent: folderByName.get("Ausgangsrechnungen"),
function: "invoices",
year: currentYear,
icon: "i-heroicons-document-text",
standardFiletype: invoiceTag?.id,
standardFiletypeIsOptional: false,
isSystemUsed: true,
updatedAt: new Date(),
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: "Angebote",
name: String(currentYear),
parent: folderByName.get("Angebote"),
function: "quotes",
year: currentYear,
icon: "i-heroicons-document-duplicate",
standardFiletype: quoteTag?.id,
standardFiletypeIsOptional: false,
isSystemUsed: true,
updatedAt: new Date(),
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: "Auftragsbestätigungen",
name: String(currentYear),
parent: folderByName.get("Auftragsbestätigungen"),
function: "confirmationOrders",
year: currentYear,
icon: "i-heroicons-clipboard-document-check",
standardFiletype: confirmationTag?.id,
standardFiletypeIsOptional: false,
isSystemUsed: true,
updatedAt: new Date(),
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: "Lieferscheine",
name: String(currentYear),
parent: folderByName.get("Lieferscheine"),
function: "deliveryNotes",
year: currentYear,
icon: "i-heroicons-truck",
standardFiletype: deliveryTag?.id,
standardFiletypeIsOptional: false,
isSystemUsed: true,
updatedAt: new Date(),
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: "Eingangsrechnungen",
name: String(currentYear),
parent: folderByName.get("Eingangsrechnungen"),
function: "incomingInvoices",
year: currentYear,
icon: "i-heroicons-inbox-arrow-down",
standardFiletype: incomingInvoiceTag?.id,
standardFiletypeIsOptional: false,
isSystemUsed: true,
updatedAt: new Date(),
updatedAt: timestamp,
updatedBy: createdBy,
},
{
tenant: tenantId,
name: "Belege Bankeinzahlung",
name: String(currentYear),
parent: folderByName.get("Belege Bankeinzahlung"),
function: "deposit",
year: currentYear,
icon: "i-heroicons-banknotes",
isSystemUsed: true,
updatedAt: new Date(),
updatedAt: timestamp,
updatedBy: createdBy,
},
]);

View File

@@ -57,6 +57,7 @@ export default async function meRoutes(server: FastifyInstance) {
businessInfo: tenants.businessInfo,
numberRanges: tenants.numberRanges,
accountChart: tenants.accountChart,
taxEvaluationPeriod: tenants.taxEvaluationPeriod,
dokuboxkey: tenants.dokuboxkey,
standardEmailForInvoices: tenants.standardEmailForInvoices,
standardPaymentDays: tenants.standardPaymentDays,

View File

@@ -237,7 +237,7 @@ export default async function fileRoutes(server: FastifyInstance) {
// MULTIPLE PRESIGNED URLs
// -------------------------------------------------
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return reply.code(400).send({ error: "No ids provided" })
return { files: [] }
}
const rows = await server.db

View File

@@ -2,6 +2,10 @@ import { FastifyInstance } from "fastify";
import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import dayjs from "dayjs";
//import { ready as zplReady } from 'zpl-renderer-js'
//import { renderZPL } from "zpl-image";
@@ -28,6 +32,31 @@ dayjs.extend(isSameOrBefore)
dayjs.extend(duration)
dayjs.extend(timezone)
const execFileAsync = promisify(execFile)
function resolveGitRoot() {
const searchRoots = [
process.cwd(),
path.resolve(process.cwd(), ".."),
path.resolve(__dirname, "../../.."),
path.resolve(__dirname, "../../../.."),
]
for (const startDir of searchRoots) {
let currentDir = startDir
while (currentDir && currentDir !== path.dirname(currentDir)) {
if (existsSync(path.join(currentDir, ".git"))) {
return currentDir
}
currentDir = path.dirname(currentDir)
}
}
return null
}
export default async function functionRoutes(server: FastifyInstance) {
const streamToBuffer = async (stream: any): Promise<Buffer> =>
new Promise((resolve, reject) => {
@@ -162,6 +191,55 @@ export default async function functionRoutes(server: FastifyInstance) {
}
})
server.get('/functions/changelog', async (req, reply) => {
const { limit } = req.query as { limit?: string | number }
const parsedLimit = Number(limit)
const safeLimit = Number.isFinite(parsedLimit)
? Math.min(Math.max(parsedLimit, 1), 50)
: 15
const gitRoot = resolveGitRoot()
if (!gitRoot) {
return reply.code(500).send({ error: 'Git repository not found' })
}
try {
const { stdout } = await execFileAsync('git', [
'-C',
gitRoot,
'log',
`--max-count=${safeLimit}`,
'--date=iso-strict',
'--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1e'
])
const entries = stdout
.split('\x1e')
.map(entry => entry.trim())
.filter(Boolean)
.map(entry => {
const [hash, shortHash, subject, authorName, committedAt] = entry.split('\x1f')
return {
hash,
shortHash,
subject,
authorName,
committedAt
}
})
return reply.send({
repositoryRoot: gitRoot,
entries
})
} catch (err) {
req.log.error(err)
return reply.code(500).send({ error: 'Failed to load changelog' })
}
})
server.post('/functions/serial/start', async (req, reply) => {
console.log(req.body)
const {executionDate,templateIds,tenantId} = req.body as {executionDate:string,templateIds:Number[],tenantId:Number}

View File

@@ -0,0 +1,26 @@
import Handlebars from "handlebars";
const createDocumentTemplateHandlebars = () => {
const instance = Handlebars.create();
instance.registerHelper("eq", (left, right) => left === right);
instance.registerHelper("ne", (left, right) => left !== right);
instance.registerHelper("gt", (left, right) => left > right);
instance.registerHelper("gte", (left, right) => left >= right);
instance.registerHelper("lt", (left, right) => left < right);
instance.registerHelper("lte", (left, right) => left <= right);
instance.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
instance.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
instance.registerHelper("not", (value) => !value);
instance.registerHelper("includes", (collection, value) => {
if (Array.isArray(collection) || typeof collection === "string") {
return collection.includes(value);
}
return false;
});
return instance;
};
export const documentTemplateHandlebars = createDocumentTemplateHandlebars();

View File

@@ -1,6 +1,9 @@
<script setup>
import dayjs from 'dayjs'
const { isHelpSlideoverOpen } = useDashboard()
const { metaSymbol } = useShortcuts()
const { entries, pending, error, seenState, refresh, markAsSeen } = useChangelog()
const shortcuts = ref(false)
const query = ref('')
@@ -133,6 +136,21 @@ const resetContactRequest = () => {
title: "",
}
}
const lastOpenedLabel = computed(() => {
if (!seenState.value.lastOpenedAt) return 'Noch nicht geöffnet'
return dayjs(seenState.value.lastOpenedAt).format('DD.MM.YYYY HH:mm')
})
const changelogEntries = computed(() => entries.value.slice(0, 12))
watch(isHelpSlideoverOpen, async (isOpen) => {
if (!isOpen || shortcuts.value) return
await refresh(true)
markAsSeen()
})
</script>
<template>
@@ -171,8 +189,71 @@ const resetContactRequest = () => {
</div>
</div>
</div>
<div v-else class="flex flex-col gap-y-3">
<UButton v-for="(link, index) in links" :key="index" color="white" v-bind="link" />
<div v-else class="flex flex-col gap-y-6">
<div class="flex flex-col gap-y-3">
<UButton v-for="(link, index) in links" :key="index" color="white" v-bind="link" />
</div>
<UCard>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-base font-semibold text-gray-900 dark:text-white">
Changelog
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
Zuletzt geöffnet: {{ lastOpenedLabel }}
</p>
</div>
<UButton
icon="i-heroicons-arrow-path"
color="gray"
variant="ghost"
:loading="pending"
@click="refresh(true)"
/>
</div>
<UAlert
v-if="error"
class="mt-4"
color="red"
variant="soft"
title="Changelog konnte nicht geladen werden"
:description="error"
/>
<div v-else-if="pending && !changelogEntries.length" class="mt-4">
<UProgress animation="carousel"/>
</div>
<div v-else-if="changelogEntries.length" class="mt-4 flex flex-col gap-3">
<div
v-for="entry in changelogEntries"
:key="entry.hash"
class="rounded-lg border border-gray-200 dark:border-gray-800 p-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="font-medium text-gray-900 dark:text-white break-words">
{{ entry.subject }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{{ entry.authorName }} · {{ dayjs(entry.committedAt).format('DD.MM.YYYY HH:mm') }}
</p>
</div>
<UBadge color="gray" variant="subtle">
{{ entry.shortHash }}
</UBadge>
</div>
</div>
</div>
<p v-else class="mt-4 text-sm text-gray-500 dark:text-gray-400">
Es sind noch keine Changelog-Einträge verfügbar.
</p>
</UCard>
</div>
<!-- <div class="mt-5" v-if="!loadingContactRequest">
<h1 class="font-semibold">Kontaktanfrage:</h1>

View File

@@ -18,6 +18,7 @@ const showMemberRelationsNav = computed(() => {
const isAdmin = computed(() => Boolean(auth.user?.is_admin))
const tenantFeatures = computed(() => auth.activeTenantData?.features || {})
const featureEnabled = (key) => tenantFeatures.value?.[key] !== false
const visibleItems = (items) => items.filter(item => item && !item.disabled)
const links = computed(() => {
const organisationChildren = [
@@ -36,7 +37,7 @@ const links = computed(() => {
to: "/wiki",
icon: "i-heroicons-book-open"
} : null,
].filter(Boolean)
]
const documentChildren = [
featureEnabled("files") ? {
@@ -56,7 +57,7 @@ const links = computed(() => {
icon: "i-heroicons-archive-box",
disabled: true
} : null,
].filter(Boolean)
]
const communicationChildren = [
featureEnabled("helpdesk") ? {
@@ -71,7 +72,7 @@ const links = computed(() => {
icon: "i-heroicons-envelope",
disabled: true
} : null,
].filter(Boolean)
]
const contactsChildren = [
showMembersNav.value && featureEnabled("members") ? {
@@ -94,7 +95,7 @@ const links = computed(() => {
to: "/standardEntity/contacts",
icon: "i-heroicons-user-group"
} : null,
].filter(Boolean)
]
const staffChildren = [
featureEnabled("staffTime") ? {
@@ -102,7 +103,7 @@ const links = computed(() => {
to: "/staff/time",
icon: "i-heroicons-clock",
} : null,
].filter(Boolean)
]
const accountingChildren = [
featureEnabled("createDocument") ? {
@@ -120,6 +121,11 @@ const links = computed(() => {
to: "/incomingInvoices",
icon: "i-heroicons-document-text",
} : null,
(featureEnabled("createDocument") || featureEnabled("incomingInvoices")) ? {
label: "USt-Auswertung",
to: "/accounting/tax",
icon: "i-heroicons-calculator",
} : null,
featureEnabled("costcentres") ? {
label: "Kostenstellen",
to: "/standardEntity/costcentres",
@@ -140,7 +146,7 @@ const links = computed(() => {
to: "/banking",
icon: "i-heroicons-document-text",
} : null,
].filter(Boolean)
]
const inventoryChildren = [
has("spaces") && featureEnabled("spaces") ? {
@@ -168,7 +174,7 @@ const links = computed(() => {
to: "/standardEntity/inventoryitemgroups",
icon: "i-heroicons-puzzle-piece"
} : null,
].filter(Boolean)
]
const masterDataChildren = [
has("products") && featureEnabled("products") ? {
@@ -221,7 +227,7 @@ const links = computed(() => {
to: "/standardEntity/vehicles",
icon: "i-heroicons-truck"
} : null,
].filter(Boolean)
]
const settingsChildren = [
featureEnabled("settingsNumberRanges") ? {
@@ -259,9 +265,19 @@ const links = computed(() => {
to: "/export",
icon: "i-heroicons-clipboard-document-list"
} : null,
].filter(Boolean)
]
return [
const visibleOrganisationChildren = visibleItems(organisationChildren)
const visibleDocumentChildren = visibleItems(documentChildren)
const visibleCommunicationChildren = visibleItems(communicationChildren)
const visibleContactsChildren = visibleItems(contactsChildren)
const visibleStaffChildren = visibleItems(staffChildren)
const visibleAccountingChildren = visibleItems(accountingChildren)
const visibleInventoryChildren = visibleItems(inventoryChildren)
const visibleMasterDataChildren = visibleItems(masterDataChildren)
const visibleSettingsChildren = visibleItems(settingsChildren)
return visibleItems([
...(auth.profile?.pinned_on_navigation || []).map(pin => {
if (pin.type === "external") {
return {
@@ -293,53 +309,53 @@ const links = computed(() => {
to: "/historyitems",
icon: "i-heroicons-book-open"
} : null,
...(organisationChildren.length > 0 ? [{
...(visibleOrganisationChildren.length > 0 ? [{
label: "Organisation",
icon: "i-heroicons-rectangle-stack",
defaultOpen: false,
children: organisationChildren
children: visibleOrganisationChildren
}] : []),
...(documentChildren.length > 0 ? [{
...(visibleDocumentChildren.length > 0 ? [{
label: "Dokumente",
icon: "i-heroicons-rectangle-stack",
defaultOpen: false,
children: documentChildren
children: visibleDocumentChildren
}] : []),
...(communicationChildren.length > 0 ? [{
...(visibleCommunicationChildren.length > 0 ? [{
label: "Kommunikation",
icon: "i-heroicons-megaphone",
defaultOpen: false,
children: communicationChildren
children: visibleCommunicationChildren
}] : []),
...(contactsChildren.length > 0 ? [{
...(visibleContactsChildren.length > 0 ? [{
label: "Kontakte",
defaultOpen: false,
icon: "i-heroicons-user-group",
children: contactsChildren
children: visibleContactsChildren
}] : []),
...(staffChildren.length > 0 ? [{
...(visibleStaffChildren.length > 0 ? [{
label: "Mitarbeiter",
defaultOpen: false,
icon: "i-heroicons-user-group",
children: staffChildren
children: visibleStaffChildren
}] : []),
...(accountingChildren.length > 0 ? [{
...(visibleAccountingChildren.length > 0 ? [{
label: "Buchhaltung",
defaultOpen: false,
icon: "i-heroicons-chart-bar-square",
children: accountingChildren
children: visibleAccountingChildren
}] : []),
...(inventoryChildren.length > 0 ? [{
...(visibleInventoryChildren.length > 0 ? [{
label: "Lager",
icon: "i-heroicons-puzzle-piece",
defaultOpen: false,
children: inventoryChildren
children: visibleInventoryChildren
}] : []),
...(masterDataChildren.length > 0 ? [{
...(visibleMasterDataChildren.length > 0 ? [{
label: "Stammdaten",
defaultOpen: false,
icon: "i-heroicons-clipboard-document",
children: masterDataChildren
children: visibleMasterDataChildren
}] : []),
...(has("projects") && featureEnabled("projects")) ? [{
@@ -357,13 +373,13 @@ const links = computed(() => {
to: "/standardEntity/plants",
icon: "i-heroicons-clipboard-document"
}] : [],
...(settingsChildren.length > 0 ? [{
...(visibleSettingsChildren.length > 0 ? [{
label: "Einstellungen",
defaultOpen: false,
icon: "i-heroicons-cog-8-tooth",
children: settingsChildren
children: visibleSettingsChildren
}] : []),
].filter(Boolean)
])
})
const accordionItems = computed(() =>

View File

@@ -16,27 +16,23 @@ const setupPage = async () => {
let documents = items.filter(i => i.type === "invoices" ||i.type === "advanceInvoices")
let draftDocuments = documents.filter(i => i.state === "Entwurf")
let finalizedDocuments = documents.filter(i => i.state === "Gebucht")
finalizedDocuments = finalizedDocuments.filter(i => i.statementallocations.reduce((n,{amount}) => n + amount, 0).toFixed(2) !== useSum().getCreatedDocumentSum(i, documents).toFixed(2))
finalizedDocuments = finalizedDocuments.filter(x => (x.type === 'invoices' || x.type === 'advanceInvoices') && x.state === 'Gebucht' && !items.find(i => i.createddocument && i.createddocument.id === x.id))
let finalizedDocuments = documents.filter(i => useSum().isOpenCreatedDocument(i, items))
finalizedDocuments.forEach(i => {
if(dayjs().subtract(i.paymentDays,"days").isAfter(i.documentDate)) {
unpaidOverdueInvoicesSum.value += useSum().getCreatedDocumentSum(i, documents) - i.statementallocations.reduce((n,{amount}) => n + amount, 0)
unpaidOverdueInvoicesSum.value += useSum().getCreatedDocumentOpenAmount(i, items)
unpaidOverdueInvoicesCount.value += 1
} else {
unpaidInvoicesSum.value += useSum().getCreatedDocumentSum(i, items) - i.statementallocations.reduce((n,{amount}) => n + amount, 0)
unpaidInvoicesSum.value += useSum().getCreatedDocumentOpenAmount(i, items)
unpaidInvoicesCount.value += 1
}
})
//unpaidInvoicesCount.value = finalizedDocuments.length
draftDocuments.forEach(i => {
draftInvoicesSum.value += useSum().getCreatedDocumentSum(i, documents) - i.statementallocations.reduce((n,{amount}) => n + amount, 0)
draftInvoicesSum.value += useSum().getCreatedDocumentOpenAmount(i, items)
})
draftInvoicesCount.value = draftDocuments.length

View File

@@ -36,6 +36,9 @@ export const useFiles = () => {
let data = []
data = await useEntities("files").select("*, incominginvoice(*), project(*), vendor(*), customer(*), contract(*), plant(*), createddocument(*), vehicle(*), product(*), profile(*), check(*), inventoryitem(*)")
if (!Array.isArray(data) || data.length === 0) {
return []
}
const res = await useNuxtApp().$api("/api/files/presigned",{
@@ -138,4 +141,4 @@ export const useFiles = () => {
return {uploadFiles, selectDocuments, selectSomeDocuments, selectDocument, downloadFile, dataURLtoFile}
}
}

View File

@@ -1,5 +1,16 @@
export const useSum = () => {
const unwrapCreatedDocuments = (createddocuments = []) => {
if (Array.isArray(createddocuments)) return createddocuments
if (Array.isArray(createddocuments?.value)) return createddocuments.value
return []
}
const getCreatedDocumentLinkId = (value) => {
if (value && typeof value === "object") return value.id
return value
}
const getIncomingInvoiceSum = (invoice) => {
let sum = 0
invoice.accounts.forEach(account => {
@@ -15,6 +26,7 @@ export const useSum = () => {
}
const getCreatedDocumentSum = (createddocument,createddocuments = []) => {
const availableCreatedDocuments = unwrapCreatedDocuments(createddocuments)
let totalNet = 0
let total19 = 0
let total7 = 0
@@ -44,7 +56,9 @@ export const useSum = () => {
createddocument.usedAdvanceInvoices.forEach(advanceInvoiceId => {
let advanceInvoice = createddocuments.find(i => i.id === advanceInvoiceId)
let advanceInvoice = availableCreatedDocuments.find(i => i.id === advanceInvoiceId)
if (!advanceInvoice) return
let priceNet = advanceInvoice.rows.find(i => i.advanceInvoiceData).price
@@ -59,6 +73,24 @@ export const useSum = () => {
return Number(sumToPay.toFixed(2))
}
const hasCancellationInvoice = (createddocument, createddocuments = []) => {
const availableCreatedDocuments = unwrapCreatedDocuments(createddocuments)
return availableCreatedDocuments.some((document) => {
return document.type === "cancellationInvoices"
&& document.state !== "Entwurf"
&& !document.archived
&& getCreatedDocumentLinkId(document.createddocument) === createddocument.id
})
}
const getCreatedDocumentOpenAmount = (createddocument, createddocuments = []) => {
let amountPaid = 0
createddocument.statementallocations.forEach(allocation => amountPaid += allocation.amount)
return Number((getCreatedDocumentSum(createddocument, createddocuments) - amountPaid).toFixed(2))
}
const getCreatedDocumentSumDetailed = (createddocument) => {
let totalNet = 0
let total19 = 0
@@ -124,12 +156,24 @@ export const useSum = () => {
}
const getIsPaid = (createddocument,createddocuments) => {
let amountPaid = 0
createddocument.statementallocations.forEach(allocation => amountPaid += allocation.amount)
return Number(amountPaid.toFixed(2)) === getCreatedDocumentSum(createddocument,createddocuments)
return getCreatedDocumentOpenAmount(createddocument, createddocuments) === 0
}
return {getIncomingInvoiceSum, getCreatedDocumentSum, getCreatedDocumentSumDetailed, getIsPaid}
const isOpenCreatedDocument = (createddocument, createddocuments = []) => {
return ['invoices', 'advanceInvoices'].includes(createddocument.type)
&& createddocument.state === "Gebucht"
&& !hasCancellationInvoice(createddocument, createddocuments)
&& !getIsPaid(createddocument, createddocuments)
}
return {
getIncomingInvoiceSum,
getCreatedDocumentSum,
getCreatedDocumentSumDetailed,
getCreatedDocumentOpenAmount,
getIsPaid,
hasCancellationInvoice,
isOpenCreatedDocument
}
}

View File

@@ -16,6 +16,7 @@ const route = useRoute()
const auth = useAuthStore()
const labelPrinter = useLabelPrinterStore()
const calculatorStore = useCalculatorStore()
const { hasUnread, refresh: refreshChangelog } = useChangelog()
const month = dayjs().format("MM")
@@ -114,7 +115,7 @@ const groups = computed(() => [
].filter(Boolean))
// --- Footer Links nutzen jetzt den zentralen Calculator Store ---
const footerLinks = computed(() => [
const footerItems = computed(() => [
{
label: 'Taschenrechner',
icon: 'i-heroicons-calculator',
@@ -123,10 +124,15 @@ const footerLinks = computed(() => [
{
label: 'Hilfe & Info',
icon: 'i-heroicons-question-mark-circle',
badge: hasUnread.value ? 'Neu' : null,
click: () => isHelpSlideoverOpen.value = true
}
])
onMounted(() => {
void refreshChangelog()
})
</script>
<template>
@@ -256,7 +262,25 @@ const footerLinks = computed(() => [
<UColorModeToggle class="ml-3"/>
<LabelPrinterButton class="w-full"/>
<UDashboardSidebarLinks :links="footerLinks" class="w-full"/>
<div class="flex flex-col gap-1">
<UButton
v-for="item in footerItems"
:key="item.label"
color="gray"
variant="ghost"
class="w-full"
:icon="item.icon"
@click="item.click ? item.click() : null"
>
{{ item.label }}
<template #trailing>
<UBadge v-if="item.badge" color="primary" variant="solid" size="xs">
{{ item.badge }}
</UBadge>
</template>
</UButton>
</div>
<UDivider class="sticky bottom-0 w-full"/>
<UserDropdown style="margin-bottom: env(safe-area-inset-bottom, 10px) !important;" class="w-full"/>

View File

@@ -0,0 +1,289 @@
<script setup lang="ts">
import dayjs from "dayjs"
import customParseFormat from "dayjs/plugin/customParseFormat"
import {
formatTaxEvaluationPeriodLabel,
formatTaxEvaluationPeriodRange,
getCreatedDocumentTaxBreakdown,
getIncomingInvoiceTaxBreakdown,
getTaxEvaluationPeriodBounds,
normalizeTaxEvaluationPeriod,
shiftTaxEvaluationPeriodStart
} from "~/composables/useTaxEvaluation"
dayjs.extend(customParseFormat)
const auth = useAuthStore()
const loading = ref(true)
const createdDocuments = ref<any[]>([])
const incomingInvoices = ref<any[]>([])
const periodType = computed(() => normalizeTaxEvaluationPeriod(auth.activeTenantData?.taxEvaluationPeriod))
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR"
}).format(Number(value || 0))
}
const isRelevantOutputDocument = (doc: any) => {
return doc?.state === "Gebucht" && ["invoices", "advanceInvoices", "cancellationInvoices"].includes(doc?.type)
}
const isRelevantInputInvoice = (invoice: any) => {
return invoice?.state === "Gebucht" && !!invoice?.date
}
const loadData = async () => {
loading.value = true
try {
const [docs, incoming] = await Promise.all([
useEntities("createddocuments").select(),
useEntities("incominginvoices").select()
])
createdDocuments.value = (docs || []).filter(isRelevantOutputDocument)
incomingInvoices.value = (incoming || []).filter(isRelevantInputInvoice)
} finally {
loading.value = false
}
}
const periods = computed(() => {
const currentBounds = getTaxEvaluationPeriodBounds(dayjs(), periodType.value)
return Array.from({ length: 8 }, (_, index) => {
const start = shiftTaxEvaluationPeriodStart(currentBounds.start, periodType.value, -index)
const bounds = getTaxEvaluationPeriodBounds(start, periodType.value)
const outputDocs = createdDocuments.value.filter((doc) => {
const date = dayjs(doc.documentDate)
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day")
})
const inputDocs = incomingInvoices.value.filter((invoice) => {
const date = dayjs(invoice.date)
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day")
})
const output = outputDocs.reduce((sum, doc) => {
const breakdown = getCreatedDocumentTaxBreakdown(doc)
return {
net19: sum.net19 + breakdown.net19,
tax19: sum.tax19 + breakdown.tax19,
net7: sum.net7 + breakdown.net7,
tax7: sum.tax7 + breakdown.tax7,
net0: sum.net0 + breakdown.net0,
}
}, { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 })
const input = inputDocs.reduce((sum, invoice) => {
const breakdown = getIncomingInvoiceTaxBreakdown(invoice)
return {
net19: sum.net19 + breakdown.net19,
tax19: sum.tax19 + breakdown.tax19,
net7: sum.net7 + breakdown.net7,
tax7: sum.tax7 + breakdown.tax7,
net0: sum.net0 + breakdown.net0,
}
}, { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 })
const outputTax = Number((output.tax19 + output.tax7).toFixed(2))
const inputTax = Number((input.tax19 + input.tax7).toFixed(2))
const balance = Number((outputTax - inputTax).toFixed(2))
return {
key: bounds.start.format("YYYY-MM-DD"),
label: formatTaxEvaluationPeriodLabel(bounds.start, periodType.value),
range: formatTaxEvaluationPeriodRange(bounds.start, periodType.value),
isCurrent: index === 0,
outputTax,
inputTax,
balance,
output,
input,
outputCount: outputDocs.length,
inputCount: inputDocs.length,
}
})
})
const currentPeriod = computed(() => periods.value[0] || null)
const columns = [
{ key: "label", label: "Zeitraum" },
{ key: "range", label: "Datumsbereich" },
{ key: "outputTax", label: "USt Rechnungen" },
{ key: "inputTax", label: "Vorsteuer" },
{ key: "balance", label: "Ergebnis" },
{ key: "documents", label: "Belege" },
]
onMounted(loadData)
</script>
<template>
<div>
<UDashboardNavbar title="USt-Auswertung">
<template #right>
<UButton
icon="i-heroicons-arrow-path"
variant="outline"
@click="loadData"
:loading="loading"
>
Aktualisieren
</UButton>
</template>
</UDashboardNavbar>
<UDashboardPanelContent class="p-4 md:p-6">
<div class="mb-6 flex flex-col gap-2">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Aktueller Zeitraum: {{ currentPeriod?.label }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Intervall: {{ periodType === "monthly" ? "monatlich" : periodType === "quarterly" ? "quartalsweise" : "jährlich" }}.
Berücksichtigt werden gebuchte Ausgangsrechnungen, Abschlags- und Stornorechnungen sowie gebuchte Eingangsbelege mit Datum.
</p>
<p v-if="currentPeriod" class="text-sm text-gray-500 dark:text-gray-400">
{{ currentPeriod.range }}
</p>
</div>
<div v-if="currentPeriod" class="grid gap-4 md:grid-cols-3">
<UCard>
<div class="text-sm text-gray-500 dark:text-gray-400">Berechnete USt</div>
<div class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
{{ formatCurrency(currentPeriod.outputTax) }}
</div>
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
19%: {{ formatCurrency(currentPeriod.output.tax19) }} | 7%: {{ formatCurrency(currentPeriod.output.tax7) }}
</div>
</UCard>
<UCard>
<div class="text-sm text-gray-500 dark:text-gray-400">Vorsteuer</div>
<div class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
{{ formatCurrency(currentPeriod.inputTax) }}
</div>
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
19%: {{ formatCurrency(currentPeriod.input.tax19) }} | 7%: {{ formatCurrency(currentPeriod.input.tax7) }}
</div>
</UCard>
<UCard>
<div class="text-sm text-gray-500 dark:text-gray-400">Verrechnetes Ergebnis</div>
<div
class="mt-2 text-2xl font-semibold"
:class="currentPeriod.balance >= 0 ? 'text-amber-600 dark:text-amber-400' : 'text-emerald-600 dark:text-emerald-400'"
>
{{ formatCurrency(currentPeriod.balance) }}
</div>
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
{{ currentPeriod.outputCount }} Ausgangsbelege | {{ currentPeriod.inputCount }} Eingangsbelege
</div>
</UCard>
</div>
<div v-if="currentPeriod" class="mt-6 grid gap-4 xl:grid-cols-2">
<UCard>
<template #header>
<div class="font-semibold">Ausgangsrechnungen</div>
</template>
<div class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<span>Netto 19%</span>
<span>{{ formatCurrency(currentPeriod.output.net19) }}</span>
</div>
<div class="flex items-center justify-between">
<span>USt 19%</span>
<span>{{ formatCurrency(currentPeriod.output.tax19) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Netto 7%</span>
<span>{{ formatCurrency(currentPeriod.output.net7) }}</span>
</div>
<div class="flex items-center justify-between">
<span>USt 7%</span>
<span>{{ formatCurrency(currentPeriod.output.tax7) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Netto 0%</span>
<span>{{ formatCurrency(currentPeriod.output.net0) }}</span>
</div>
</div>
</UCard>
<UCard>
<template #header>
<div class="font-semibold">Eingangsbelege</div>
</template>
<div class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<span>Netto 19%</span>
<span>{{ formatCurrency(currentPeriod.input.net19) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Vorsteuer 19%</span>
<span>{{ formatCurrency(currentPeriod.input.tax19) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Netto 7%</span>
<span>{{ formatCurrency(currentPeriod.input.net7) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Vorsteuer 7%</span>
<span>{{ formatCurrency(currentPeriod.input.tax7) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Netto 0%</span>
<span>{{ formatCurrency(currentPeriod.input.net0) }}</span>
</div>
</div>
</UCard>
</div>
<UCard class="mt-6">
<template #header>
<div class="font-semibold">Aktueller und vorherige Zeiträume</div>
</template>
<UTable
:columns="columns"
:rows="periods"
:loading="loading"
:empty-state="{ icon: 'i-heroicons-calculator', label: 'Keine Daten für die USt-Auswertung vorhanden' }"
>
<template #label-data="{ row }">
<div class="flex items-center gap-2">
<span>{{ row.label }}</span>
<UBadge v-if="row.isCurrent" color="primary" variant="soft">Aktuell</UBadge>
</div>
</template>
<template #outputTax-data="{ row }">
{{ formatCurrency(row.outputTax) }}
</template>
<template #inputTax-data="{ row }">
{{ formatCurrency(row.inputTax) }}
</template>
<template #balance-data="{ row }">
<span :class="row.balance >= 0 ? 'text-amber-600 dark:text-amber-400 font-medium' : 'text-emerald-600 dark:text-emerald-400 font-medium'">
{{ formatCurrency(row.balance) }}
</span>
</template>
<template #documents-data="{ row }">
{{ row.outputCount }} / {{ row.inputCount }}
</template>
</UTable>
</UCard>
</UDashboardPanelContent>
</div>
</template>

View File

@@ -67,12 +67,12 @@ const setupPage = async () => {
incominginvoices.value = invoiceItems.filter(i => i.state === "Gebucht")
const documents = createddocuments.value.filter(i => i.type === "invoices" || i.type === "advanceInvoices")
openDocuments.value = documents.filter(i => i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== useSum().getCreatedDocumentSum(i, createddocuments.value).toFixed(2))
openDocuments.value = documents.filter(i => useSum().isOpenCreatedDocument(i, createddocuments.value))
.map(i => ({
...i,
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
statementTotal: Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0)),
openSum: (useSum().getCreatedDocumentSum(i, createddocuments.value) - Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0))).toFixed(2)
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
}))
openIncomingInvoices.value = invoiceItems

View File

@@ -53,13 +53,13 @@ const setup = async () => {
customers.value = (await useEntities("customers").select())
vendors.value = (await useEntities("vendors").select())
openDocuments.value = documents.filter(i => i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== useSum().getCreatedDocumentSum(i, createddocuments.value).toFixed(2))
openDocuments.value = documents.filter(i => useSum().isOpenCreatedDocument(i, createddocuments.value))
openDocuments.value = openDocuments.value.map(i => {
return {
...i,
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
statementTotal: Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0)),
openSum: (useSum().getCreatedDocumentSum(i, createddocuments.value) - Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0))).toFixed(2)
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
}
})

View File

@@ -1,9 +1,9 @@
<script setup>
import dayjs from "dayjs"
import Handlebars from "handlebars"
import { v4 as uuidv4 } from 'uuid';
import {useFunctions} from "~/composables/useFunctions.js";
import EntityModalButtons from "~/components/EntityModalButtons.vue";
import { documentTemplateHandlebars } from "~/utils/handlebars";
const dataStore = useDataStore()
const profileStore = useProfileStore()
@@ -1098,8 +1098,8 @@ const getDocumentData = async () => {
})
//Compile Start & EndText
const templateStartText = Handlebars.compile(itemInfo.value.startText);
const templateEndText = Handlebars.compile(itemInfo.value.endText);
const templateStartText = documentTemplateHandlebars.compile(itemInfo.value.startText);
const templateEndText = documentTemplateHandlebars.compile(itemInfo.value.endText);
const generateContext = (itemInfo, contactData) => {
return {

View File

@@ -75,16 +75,16 @@
<template #state-data="{row}">
<span v-if="row.state === 'Entwurf'" class="text-rose-500">{{ row.state }}</span>
<span
v-if="row.state === 'Gebucht' && !items.find(i => i.createddocument && i.createddocument.id === row.id)"
v-if="row.state === 'Gebucht' && !hasCancellationInvoice(row)"
class="text-primary-500"
>
{{ row.state }}
</span>
<span
v-else-if="row.state === 'Gebucht' && items.find(i => i.createddocument && i.createddocument.id === row.id && i.type === 'cancellationInvoices') && ['invoices','advanceInvoices'].includes(row.type)"
v-else-if="row.state === 'Gebucht' && hasCancellationInvoice(row) && ['invoices','advanceInvoices'].includes(row.type)"
class="text-cyan-500"
>
Storniert mit {{ items.find(i => i.createddocument && i.createddocument.id === row.id).documentNumber }}
Storniert mit {{ getCancellationInvoice(row)?.documentNumber }}
</span>
<span v-else-if="row.state === 'Gebucht'" class="text-primary-500">{{ row.state }}</span>
</template>
@@ -108,7 +108,7 @@
<template #dueDate-data="{row}">
<span
v-if="row.state === 'Gebucht' && row.paymentDays && ['invoices','advanceInvoices'].includes(row.type) && !items.find(i => i.linkedDocument && i.linkedDocument.id === row.id)"
v-if="row.state === 'Gebucht' && row.paymentDays && ['invoices','advanceInvoices'].includes(row.type) && !hasCancellationInvoice(row)"
:class="dayjs(row.documentDate).add(row.paymentDays,'day').diff(dayjs()) <= 0 && !isPaid(row) ? ['text-rose-500'] : '' "
>
{{ row.documentDate ? dayjs(row.documentDate).add(row.paymentDays, 'day').format("DD.MM.YY") : '' }}
@@ -117,7 +117,7 @@
<template #paid-data="{row}">
<div
v-if="(row.type === 'invoices' ||row.type === 'advanceInvoices') && row.state === 'Gebucht' && !items.find(i => i.linkedDocument && i.linkedDocument.id === row.id)">
v-if="(row.type === 'invoices' ||row.type === 'advanceInvoices') && row.state === 'Gebucht' && !hasCancellationInvoice(row)">
<span v-if="useSum().getIsPaid(row,items)" class="text-primary-500">Bezahlt</span>
<span v-else class="text-rose-600">Offen</span>
</div>
@@ -129,8 +129,8 @@
<template #amountOpen-data="{row}">
<span
v-if="!['deliveryNotes','cancellationInvoices','quotes','confirmationOrders'].includes(row.type) && row.state !== 'Entwurf' && !useSum().getIsPaid(row,items) && !items.find(i => i.linkedDocument && i.linkedDocument.id === row.id) ">
{{ displayCurrency(useSum().getCreatedDocumentSum(row, items) - row.statementallocations.reduce((n, {amount}) => n + amount, 0)) }}
v-if="!['deliveryNotes','cancellationInvoices','quotes','confirmationOrders'].includes(row.type) && row.state !== 'Entwurf' && !hasCancellationInvoice(row) && !useSum().getIsPaid(row,items) ">
{{ displayCurrency(useSum().getCreatedDocumentOpenAmount(row, items)) }}
</span>
</template>
</UTable>
@@ -264,13 +264,22 @@ const clearSearchString = () => {
debouncedSearchString.value = ''
}
const getCancellationInvoice = (row) => {
return items.value.find((item) => {
const linkedDocumentId = item.createddocument?.id || item.createddocument
return item.type === 'cancellationInvoices'
&& item.state !== 'Entwurf'
&& !item.archived
&& linkedDocumentId === row.id
})
}
const hasCancellationInvoice = (row) => Boolean(getCancellationInvoice(row))
const openUnpaidInvoicesFilter = {
name: 'Nur offene Rechnungen',
filterFunction: (row) => {
return ['invoices', 'advanceInvoices'].includes(row.type)
&& row.state === 'Gebucht'
&& !useSum().getIsPaid(row, items.value)
&& !items.value.find(i => i.linkedDocument && i.linkedDocument.id === row.id)
return useSum().isOpenCreatedDocument(row, items.value)
}
}
@@ -319,8 +328,6 @@ const getRowsForTab = (tabKey) => {
}
const isPaid = (item) => {
let amountPaid = 0
item.statementallocations.forEach(allocation => amountPaid += allocation.amount)
return Number(amountPaid.toFixed(2)) === useSum().getCreatedDocumentSum(item, items.value)
return useSum().getIsPaid(item, items.value)
}
</script>

View File

@@ -371,9 +371,9 @@ const syncdokubox = async () => {
<UProgress animation="carousel" class="w-1/2"/>
</div>
<div v-else>
<div v-if="displayMode === 'list'">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<div v-else>
<div v-if="displayMode === 'list'">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead class="bg-gray-50 dark:bg-gray-800 sticky top-0 z-20">
<tr>
<th class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold sm:pl-6">Name</th>
@@ -412,6 +412,11 @@ const syncdokubox = async () => {
</UDropdown>
</td>
</tr>
<tr v-if="renderedFileList.length === 0">
<td colspan="3" class="px-6 py-16 text-center text-sm text-gray-500">
Keine Dateien oder Ordner vorhanden.
</td>
</tr>
</tbody>
</table>
</div>
@@ -438,6 +443,12 @@ const syncdokubox = async () => {
/>
<span class="text-xs font-medium text-center truncate w-full">{{ entry.label }}</span>
</div>
<div
v-if="renderedFileList.length === 0"
class="col-span-full rounded-xl border border-dashed border-gray-300 dark:border-gray-700 px-6 py-16 text-center text-sm text-gray-500"
>
Keine Dateien oder Ordner vorhanden.
</div>
</div>
</div>
</UDashboardPanelContent>
@@ -497,4 +508,4 @@ const syncdokubox = async () => {
</template>
</UCard>
</UModal>
</template>
</template>

View File

@@ -7,6 +7,7 @@ import DisplayOpenBalances from "~/components/displayOpenBalances.vue"
import DisplayBankaccounts from "~/components/displayBankaccounts.vue"
import DisplayProjectsInPhases from "~/components/displayProjectsInPhases.vue"
import DisplayOpenTasks from "~/components/displayOpenTasks.vue"
import DisplayTaxSummary from "~/components/displayTaxSummary.vue"
setPageLayout("default")
@@ -68,6 +69,15 @@ const DASHBOARD_WIDGETS = [
defaultLayout: { x: 0, y: 7, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "tax-summary",
title: "USt aktuell",
description: "USt, Vorsteuer und Saldo des aktuellen Zeitraums",
component: markRaw(DisplayTaxSummary),
defaultLayout: { x: 4, y: 7, w: 4, h: 3 },
minW: 3,
minH: 3
}
]
@@ -372,7 +382,8 @@ onBeforeUnmount(() => {
</template>
</UDashboardNavbar>
<div v-if="visibleWidgets.length > 0" ref="gridElement" class="grid-stack dashboard-grid overflow-y-auto h-80">
<UDashboardPanelContent>
<div v-if="visibleWidgets.length > 0" ref="gridElement" class="grid-stack dashboard-grid">
<div
v-for="widget in visibleWidgets"
:key="widget.id"
@@ -430,7 +441,7 @@ onBeforeUnmount(() => {
</div>
</div>
<div class="rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-10 text-center">
<div v-else class="rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-10 text-center">
<p class="text-sm">
Es sind aktuell keine Dashboard-Karten sichtbar.
</p>
@@ -438,6 +449,7 @@ onBeforeUnmount(() => {
Karte hinzufügen
</UButton>
</div>
</UDashboardPanelContent>
<UModal v-model="manageCardsOpen">
<UCard>

View File

@@ -0,0 +1,888 @@
<script setup lang="ts">
const auth = useAuthStore()
const toast = useToast()
const router = useRouter()
type AdminRole = {
id: string
name: string
description?: string | null
tenant_id: number | null
}
type AdminTenant = {
id: number
name: string
short: string
user_count: number
locked?: string | null
}
type AdminUserProfile = {
id: string
user_id: string
tenant_id: number
full_name: string | null
first_name: string
last_name: string
email?: string | null
active: boolean
}
type AdminUser = {
id: string
email: string
display_name: string
multiTenant: boolean
must_change_password: boolean
is_admin: boolean
profile_defaults: {
first_name: string
last_name: string
}
tenant_ids: number[]
role_assignments: { tenant_id: number; role_id: string }[]
profile_assignments?: { tenant_id: number; profile_id?: string | null }[]
profiles: AdminUserProfile[]
}
const loading = ref(true)
const savingUser = ref(false)
const savingTenant = ref(false)
const creatingUser = ref(false)
const creatingTenant = ref(false)
const activeTab = ref(0)
const createUserModalOpen = ref(false)
const createTenantModalOpen = ref(false)
const createdUserPassword = ref("")
const users = ref<AdminUser[]>([])
const tenants = ref<AdminTenant[]>([])
const roles = ref<AdminRole[]>([])
const unassignedProfiles = ref<AdminUserProfile[]>([])
const selectedUserId = ref<string | null>(null)
const selectedTenantId = ref<number | null>(null)
const userForm = ref<AdminUser | null>(null)
const tenantForm = ref<AdminTenant | null>(null)
const createUserForm = ref({
email: "",
password: "",
first_name: "",
last_name: "",
is_admin: false,
multiTenant: true,
})
const createTenantForm = ref({
name: "",
short: "",
})
const tabItems = [
{ label: "Benutzer" },
{ label: "Tenants" },
]
const sortedUsers = computed(() =>
[...users.value].sort((a, b) => a.display_name.localeCompare(b.display_name, "de"))
)
const sortedTenants = computed(() =>
[...tenants.value].sort((a, b) => a.name.localeCompare(b.name, "de"))
)
const tenantOptions = computed(() =>
sortedTenants.value.map((tenant) => ({
label: `${tenant.name} (${tenant.short})`,
value: tenant.id,
}))
)
const userTableColumns = [
{ key: "display_name", label: "Benutzer" },
{ key: "email", label: "E-Mail" },
{ key: "tenant_count", label: "Tenants" },
{ key: "is_admin", label: "Admin" },
]
const tenantTableColumns = [
{ key: "name", label: "Tenant" },
{ key: "short", label: "Kürzel" },
{ key: "user_count", label: "Benutzer" },
]
const userTableRows = computed(() =>
sortedUsers.value.map((user) => ({
...user,
tenant_count: user.tenant_ids.length,
is_admin: user.is_admin ? "Ja" : "Nein",
}))
)
const tenantTableRows = computed(() => sortedTenants.value)
const getRoleOptionsForTenant = (tenantId: number) =>
roles.value
.filter((role) => role.tenant_id === null || role.tenant_id === tenantId)
.map((role) => ({
label: role.tenant_id === null ? `${role.name} (global)` : role.name,
value: role.id,
}))
const getUsersForTenant = (tenantId: number) =>
sortedUsers.value.filter((user) => user.tenant_ids.includes(tenantId))
const getFreeProfilesForTenant = (tenantId: number) =>
unassignedProfiles.value.filter((profile) => profile.tenant_id === tenantId)
const cloneUser = (user: AdminUser): AdminUser => ({
...user,
profile_defaults: { ...user.profile_defaults },
tenant_ids: [...(user.tenant_ids || [])],
role_assignments: [...(user.role_assignments || [])],
profile_assignments: [...(user.profile_assignments || [])],
profiles: [...(user.profiles || [])],
})
const cloneTenant = (tenant: AdminTenant): AdminTenant => ({
...tenant,
})
const normalizeUserAssignments = () => {
if (!userForm.value) return
const uniqueTenantIds = Array.from(new Set((userForm.value.tenant_ids || []).map(Number))).sort((a, b) => a - b)
const assignmentsByTenant = new Map<number, string>()
const profileAssignmentByTenant = new Map<number, string | null>()
for (const assignment of userForm.value.role_assignments || []) {
if (!uniqueTenantIds.includes(Number(assignment.tenant_id))) continue
if (assignmentsByTenant.has(Number(assignment.tenant_id))) continue
assignmentsByTenant.set(Number(assignment.tenant_id), assignment.role_id)
}
for (const assignment of userForm.value.profile_assignments || []) {
if (!uniqueTenantIds.includes(Number(assignment.tenant_id))) continue
profileAssignmentByTenant.set(Number(assignment.tenant_id), assignment.profile_id || null)
}
userForm.value.tenant_ids = uniqueTenantIds
userForm.value.role_assignments = uniqueTenantIds
.map((tenantId) => {
const roleId = assignmentsByTenant.get(tenantId)
return roleId ? { tenant_id: tenantId, role_id: roleId } : null
})
.filter(Boolean) as { tenant_id: number; role_id: string }[]
userForm.value.profile_assignments = uniqueTenantIds.map((tenantId) => ({
tenant_id: tenantId,
profile_id: profileAssignmentByTenant.get(tenantId) || null,
}))
}
const updateUserTenants = (tenantIds: number[] = []) => {
if (!userForm.value) return
userForm.value.tenant_ids = tenantIds
normalizeUserAssignments()
}
const setRoleForTenant = (tenantId: number, roleId?: string | null) => {
if (!userForm.value) return
userForm.value.role_assignments = (userForm.value.role_assignments || []).filter((assignment) => assignment.tenant_id !== tenantId)
if (roleId) {
userForm.value.role_assignments.push({
tenant_id: tenantId,
role_id: roleId,
})
}
}
const getRoleForTenant = (tenantId: number) => {
return userForm.value?.role_assignments?.find((assignment) => assignment.tenant_id === tenantId)?.role_id || null
}
const setProfileAssignmentForTenant = (tenantId: number, profileId?: string | null) => {
if (!userForm.value) return
userForm.value.profile_assignments = (userForm.value.profile_assignments || []).filter((assignment) => assignment.tenant_id !== tenantId)
userForm.value.profile_assignments.push({
tenant_id: tenantId,
profile_id: profileId || null,
})
}
const getProfileAssignmentForTenant = (tenantId: number) => {
return userForm.value?.profile_assignments?.find((assignment) => assignment.tenant_id === tenantId)?.profile_id || null
}
const selectUser = (row: any) => {
const user = users.value.find((entry) => entry.id === row.id)
if (!user) return
selectedUserId.value = user.id
userForm.value = cloneUser(user)
normalizeUserAssignments()
}
const selectTenant = (row: any) => {
const tenant = tenants.value.find((entry) => entry.id === row.id)
if (!tenant) return
selectedTenantId.value = tenant.id
tenantForm.value = cloneTenant(tenant)
}
const fetchOverview = async () => {
loading.value = true
try {
const response = await useNuxtApp().$api("/api/admin/overview")
users.value = response.users || []
tenants.value = response.tenants || []
roles.value = response.roles || []
unassignedProfiles.value = response.unassignedProfiles || []
if (!selectedUserId.value && users.value.length) {
selectUser(users.value[0])
} else if (selectedUserId.value) {
const currentUser = users.value.find((user) => user.id === selectedUserId.value)
if (currentUser) selectUser(currentUser)
}
if (!selectedTenantId.value && tenants.value.length) {
selectTenant(tenants.value[0])
} else if (selectedTenantId.value) {
const currentTenant = tenants.value.find((tenant) => tenant.id === selectedTenantId.value)
if (currentTenant) selectTenant(currentTenant)
}
} catch (err: any) {
console.error("[admin/fetchOverview]", err)
toast.add({
title: "Administration konnte nicht geladen werden",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "red",
})
} finally {
loading.value = false
}
}
const saveUser = async () => {
if (!userForm.value || savingUser.value) return
savingUser.value = true
normalizeUserAssignments()
try {
await useNuxtApp().$api(`/api/admin/users/${userForm.value.id}`, {
method: "PUT",
body: {
email: userForm.value.email,
multiTenant: userForm.value.multiTenant,
must_change_password: userForm.value.must_change_password,
is_admin: userForm.value.is_admin,
},
})
await useNuxtApp().$api(`/api/admin/users/${userForm.value.id}/access`, {
method: "PUT",
body: {
tenant_ids: userForm.value.tenant_ids,
role_assignments: userForm.value.role_assignments,
profile_defaults: userForm.value.profile_defaults,
profile_assignments: userForm.value.profile_assignments,
},
})
await fetchOverview()
await auth.fetchMe()
toast.add({
title: "Benutzer gespeichert",
color: "green",
})
} catch (err: any) {
console.error("[admin/saveUser]", err)
toast.add({
title: "Benutzer konnte nicht gespeichert werden",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "red",
})
} finally {
savingUser.value = false
}
}
const createUser = async () => {
if (creatingUser.value) return
creatingUser.value = true
try {
const response = await useNuxtApp().$api("/api/admin/users", {
method: "POST",
body: createUserForm.value,
})
createdUserPassword.value = response.initialPassword || ""
createUserModalOpen.value = false
createUserForm.value = {
email: "",
password: "",
first_name: "",
last_name: "",
is_admin: false,
multiTenant: true,
}
await fetchOverview()
if (response.user?.id) {
const createdUser = users.value.find((user) => user.id === response.user.id)
if (createdUser) selectUser(createdUser)
}
toast.add({
title: "Benutzer angelegt",
description: createdUserPassword.value ? `Initialpasswort: ${createdUserPassword.value}` : undefined,
color: "green",
})
} catch (err: any) {
console.error("[admin/createUser]", err)
toast.add({
title: "Benutzer konnte nicht angelegt werden",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "red",
})
} finally {
creatingUser.value = false
}
}
const saveTenant = async () => {
if (!tenantForm.value || savingTenant.value) return
savingTenant.value = true
try {
await useNuxtApp().$api(`/api/admin/tenants/${tenantForm.value.id}`, {
method: "PUT",
body: {
name: tenantForm.value.name,
short: tenantForm.value.short,
},
})
await fetchOverview()
await auth.fetchMe()
toast.add({
title: "Tenant gespeichert",
color: "green",
})
} catch (err: any) {
console.error("[admin/saveTenant]", err)
toast.add({
title: "Tenant konnte nicht gespeichert werden",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "red",
})
} finally {
savingTenant.value = false
}
}
const createTenant = async () => {
if (creatingTenant.value) return
creatingTenant.value = true
try {
const response = await useNuxtApp().$api("/api/admin/tenants", {
method: "POST",
body: createTenantForm.value,
})
createTenantModalOpen.value = false
createTenantForm.value = {
name: "",
short: "",
}
await fetchOverview()
if (response.tenant?.id) {
const createdTenant = tenants.value.find((tenant) => tenant.id === response.tenant.id)
if (createdTenant) selectTenant(createdTenant)
}
toast.add({
title: "Tenant angelegt",
description: "Standardordner und Datei-Tags wurden erstellt.",
color: "green",
})
} catch (err: any) {
console.error("[admin/createTenant]", err)
toast.add({
title: "Tenant konnte nicht angelegt werden",
description: err?.data?.error || err?.message || "Unbekannter Fehler",
color: "red",
})
} finally {
creatingTenant.value = false
}
}
onMounted(async () => {
if (!auth.user?.is_admin) {
toast.add({
title: "Zugriff verweigert",
description: "Diese Seite ist nur für administrative Benutzer verfügbar.",
color: "red",
})
await router.push("/")
return
}
await fetchOverview()
})
</script>
<template>
<UDashboardNavbar title="Administration" />
<UDashboardPanelContent class="p-5 overflow-hidden">
<UAlert
v-if="!auth.user?.is_admin"
title="Kein Zugriff"
description="Für diese Seite wird ein administrativer Benutzer benötigt."
color="red"
variant="soft"
/>
<UTabs
v-else
v-model="activeTab"
:items="tabItems"
class="admin-tabs h-full"
>
<template #item="{ item }">
<div v-if="item.label === 'Benutzer'" class="admin-grid mt-5 grid grid-cols-1 xl:grid-cols-3 gap-5">
<UCard class="admin-card xl:col-span-1">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">Benutzer</h2>
<div class="flex items-center gap-2">
<UBadge variant="subtle">{{ users.length }}</UBadge>
<UButton
size="sm"
icon="i-heroicons-plus"
@click="createUserModalOpen = true"
>
Benutzer
</UButton>
</div>
</div>
<div class="admin-scroll">
<UTable
v-if="!loading"
:rows="userTableRows"
:columns="userTableColumns"
@select="selectUser"
/>
<USkeleton v-else class="h-80" />
</div>
</UCard>
<UCard class="admin-card xl:col-span-2">
<div v-if="userForm" class="admin-scroll space-y-6">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">{{ userForm.display_name }}</h2>
<p class="text-sm text-gray-500">{{ userForm.email }}</p>
</div>
<UButton
color="primary"
:loading="savingUser"
@click="saveUser"
>
Benutzer speichern
</UButton>
</div>
<UForm :state="userForm" class="grid grid-cols-1 md:grid-cols-2 gap-6">
<UFormGroup label="E-Mail">
<UInput v-model="userForm.email" />
</UFormGroup>
<UFormGroup label="Profil Vorname">
<UInput v-model="userForm.profile_defaults.first_name" />
</UFormGroup>
<UFormGroup label="Profil Nachname">
<UInput v-model="userForm.profile_defaults.last_name" />
</UFormGroup>
<UFormGroup label="Tenants">
<USelectMenu
:model-value="userForm.tenant_ids"
:options="tenantOptions"
value-attribute="value"
option-attribute="label"
multiple
@update:model-value="updateUserTenants"
/>
</UFormGroup>
<UFormGroup label="Administrative Freigabe">
<div class="flex items-center gap-3 h-10">
<UToggle v-model="userForm.is_admin" />
<span class="text-sm text-gray-600">Darf Administrationsseite und Admin-API nutzen</span>
</div>
</UFormGroup>
<UFormGroup label="Multi-Tenant">
<div class="flex items-center gap-3 h-10">
<UToggle v-model="userForm.multiTenant" />
<span class="text-sm text-gray-600">Benutzer darf mehreren Tenants zugeordnet sein</span>
</div>
</UFormGroup>
<UFormGroup label="Passwortwechsel erzwingen">
<div class="flex items-center gap-3 h-10">
<UToggle v-model="userForm.must_change_password" />
<span class="text-sm text-gray-600">Beim nächsten Login muss das Passwort geändert werden</span>
</div>
</UFormGroup>
</UForm>
<div>
<UDivider label="Rollen pro Tenant" class="mb-4" />
<div
v-if="userForm.tenant_ids.length"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<UCard
v-for="tenantId in userForm.tenant_ids"
:key="tenantId"
class="border border-gray-200"
>
<div class="space-y-3">
<div>
<div class="font-medium">
{{ tenants.find((tenant) => tenant.id === tenantId)?.name || `Tenant ${tenantId}` }}
</div>
<div class="text-sm text-gray-500">
{{ tenants.find((tenant) => tenant.id === tenantId)?.short || "" }}
</div>
</div>
<UFormGroup label="Rolle">
<USelectMenu
:model-value="getRoleForTenant(tenantId)"
:options="getRoleOptionsForTenant(tenantId)"
value-attribute="value"
option-attribute="label"
placeholder="Rolle auswählen"
@update:model-value="(value) => setRoleForTenant(tenantId, value)"
/>
</UFormGroup>
<UFormGroup label="Freies Profil">
<USelectMenu
:model-value="getProfileAssignmentForTenant(tenantId)"
:options="[
{ label: 'Neues Profil erzeugen', value: null },
...getFreeProfilesForTenant(tenantId).map((profile) => ({
label: profile.full_name || `${profile.first_name} ${profile.last_name}`,
value: profile.id,
}))
]"
value-attribute="value"
option-attribute="label"
placeholder="Profil auswählen"
@update:model-value="(value) => setProfileAssignmentForTenant(tenantId, value)"
/>
</UFormGroup>
</div>
</UCard>
</div>
<UAlert
v-else
title="Keine Tenant-Zuordnung"
description="Weise dem Benutzer zuerst mindestens einen Tenant zu."
color="amber"
variant="soft"
/>
</div>
<div>
<UDivider label="Profile im System" class="mb-4" />
<div class="flex flex-wrap gap-2">
<UBadge
v-for="profile in userForm.profiles"
:key="profile.id"
variant="subtle"
color="gray"
>
{{ profile.full_name || `${profile.first_name} ${profile.last_name}` }} · Tenant {{ profile.tenant_id }}
</UBadge>
</div>
</div>
</div>
<UAlert
v-else-if="!loading"
title="Kein Benutzer ausgewählt"
description="Wähle links einen Benutzer aus, um seine Zuordnungen zu bearbeiten."
color="gray"
variant="soft"
/>
<div v-else class="admin-scroll">
<USkeleton class="h-80" />
</div>
</UCard>
</div>
<div v-else-if="item.label === 'Tenants'" class="admin-grid mt-5 grid grid-cols-1 xl:grid-cols-3 gap-5">
<UCard class="admin-card xl:col-span-1">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">Tenants</h2>
<div class="flex items-center gap-2">
<UBadge variant="subtle">{{ tenants.length }}</UBadge>
<UButton
size="sm"
icon="i-heroicons-plus"
@click="createTenantModalOpen = true"
>
Tenant
</UButton>
</div>
</div>
<div class="admin-scroll">
<UTable
v-if="!loading"
:rows="tenantTableRows"
:columns="tenantTableColumns"
@select="selectTenant"
/>
<USkeleton v-else class="h-80" />
</div>
</UCard>
<UCard class="admin-card xl:col-span-2">
<div v-if="tenantForm" class="admin-scroll space-y-6">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">{{ tenantForm.name }}</h2>
<p class="text-sm text-gray-500">Tenant-ID {{ tenantForm.id }}</p>
</div>
<UButton
color="primary"
:loading="savingTenant"
@click="saveTenant"
>
Tenant speichern
</UButton>
</div>
<UForm :state="tenantForm" class="grid grid-cols-1 md:grid-cols-2 gap-6">
<UFormGroup label="Name">
<UInput v-model="tenantForm.name" />
</UFormGroup>
<UFormGroup label="Kürzel">
<UInput v-model="tenantForm.short" />
</UFormGroup>
</UForm>
<div>
<UDivider label="Zugeordnete Benutzer" class="mb-4" />
<div class="flex flex-wrap gap-2">
<UBadge
v-for="user in getUsersForTenant(tenantForm.id)"
:key="`${tenantForm.id}-${user.id}`"
:color="user.is_admin ? 'primary' : 'gray'"
variant="subtle"
>
{{ user.display_name }}
</UBadge>
</div>
</div>
</div>
<UAlert
v-else-if="!loading"
title="Kein Tenant ausgewählt"
description="Wähle links einen Tenant aus, um ihn zu bearbeiten."
color="gray"
variant="soft"
/>
<div v-else class="admin-scroll">
<USkeleton class="h-80" />
</div>
</UCard>
</div>
</template>
</UTabs>
</UDashboardPanelContent>
<UModal v-model="createUserModalOpen">
<UCard>
<template #header>
<div class="text-lg font-semibold">Benutzer anlegen</div>
</template>
<UForm
:state="createUserForm"
class="space-y-4"
@submit.prevent="createUser"
>
<UFormGroup label="E-Mail">
<UInput v-model="createUserForm.email" type="email" />
</UFormGroup>
<UFormGroup label="Initialpasswort">
<UInput
v-model="createUserForm.password"
type="text"
placeholder="Leer lassen für automatisches Passwort"
/>
</UFormGroup>
<UFormGroup label="Vorname für neues Profil">
<UInput v-model="createUserForm.first_name" />
</UFormGroup>
<UFormGroup label="Nachname für neues Profil">
<UInput v-model="createUserForm.last_name" />
</UFormGroup>
<UFormGroup label="Administrative Freigabe">
<div class="flex items-center gap-3 h-10">
<UToggle v-model="createUserForm.is_admin" />
<span class="text-sm text-gray-600">Benutzer darf die Administration öffnen</span>
</div>
</UFormGroup>
<UFormGroup label="Multi-Tenant">
<div class="flex items-center gap-3 h-10">
<UToggle v-model="createUserForm.multiTenant" />
<span class="text-sm text-gray-600">Mehrere Tenant-Zuordnungen erlauben</span>
</div>
</UFormGroup>
<div class="flex justify-end gap-3 pt-2">
<UButton color="gray" variant="soft" @click="createUserModalOpen = false">
Abbrechen
</UButton>
<UButton type="submit" color="primary" :loading="creatingUser">
Benutzer anlegen
</UButton>
</div>
</UForm>
</UCard>
</UModal>
<UModal v-model="createTenantModalOpen">
<UCard>
<template #header>
<div class="text-lg font-semibold">Tenant anlegen</div>
</template>
<UForm
:state="createTenantForm"
class="space-y-4"
@submit.prevent="createTenant"
>
<UFormGroup label="Name">
<UInput v-model="createTenantForm.name" />
</UFormGroup>
<UFormGroup label="Kürzel">
<UInput v-model="createTenantForm.short" />
</UFormGroup>
<UAlert
title="Seed-Daten"
description="Beim Anlegen werden Standard-Datei-Tags und Systemordner für Dokumente und Eingangsbelege erzeugt."
color="primary"
variant="soft"
/>
<div class="flex justify-end gap-3 pt-2">
<UButton color="gray" variant="soft" @click="createTenantModalOpen = false">
Abbrechen
</UButton>
<UButton type="submit" color="primary" :loading="creatingTenant">
Tenant anlegen
</UButton>
</div>
</UForm>
</UCard>
</UModal>
<div class="mx-5 mb-5">
<UAlert
v-if="createdUserPassword"
title="Initialpasswort für neuen Benutzer"
:description="createdUserPassword"
color="amber"
variant="soft"
close-button
@close="createdUserPassword = ''"
/>
</div>
</template>
<style scoped>
.admin-tabs :deep(.tabs-content) {
height: 100%;
min-height: 0;
}
.admin-tabs :deep(.tab-pane) {
height: 100%;
min-height: 0;
}
.admin-grid {
height: calc(100vh - 13rem);
min-height: 0;
}
.admin-card {
height: 100%;
min-height: 0;
}
.admin-card :deep(.divide-y) {
height: 100%;
}
.admin-card :deep(.px-4.py-5.sm\:p-6) {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.admin-scroll {
min-height: 0;
flex: 1;
overflow-y: auto;
}
</style>

View File

@@ -1,4 +1,8 @@
<script setup>
import {
TAX_EVALUATION_PERIOD_OPTIONS,
normalizeTaxEvaluationPeriod
} from "~/composables/useTaxEvaluation"
const auth = useAuthStore()
const defaultFeatures = {
@@ -119,6 +123,7 @@ const setupPage = async () => {
const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) })
const businessInfo = ref(auth.activeTenantData.businessInfo)
const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
const taxEvaluationPeriod = ref(normalizeTaxEvaluationPeriod(auth.activeTenantData.taxEvaluationPeriod))
const accountChartOptions = [
{ label: "SKR 03", value: "skr03" },
{ label: "Verein", value: "verein" }
@@ -137,6 +142,7 @@ const updateTenant = async (newData) => {
itemInfo.value = res
auth.activeTenantData = res
features.value = { ...defaultFeatures, ...(res?.features || {}) }
taxEvaluationPeriod.value = normalizeTaxEvaluationPeriod(res?.taxEvaluationPeriod)
}
}
const saveFeatures = async () => {
@@ -226,6 +232,23 @@ setupPage()
>
Kontenrahmen speichern
</UButton>
<UFormGroup
label="USt-Auswertung:"
class="mt-6"
>
<USelectMenu
v-model="taxEvaluationPeriod"
:options="TAX_EVALUATION_PERIOD_OPTIONS"
option-attribute="label"
value-attribute="value"
/>
</UFormGroup>
<UButton
class="mt-3"
@click="updateTenant({taxEvaluationPeriod: taxEvaluationPeriod})"
>
Zeitraum speichern
</UButton>
</UForm>
</UCard>

View File

@@ -31,6 +31,12 @@ const variableDefinitions = [
{ key: '{{lohnkosten}}', label: 'Lohnkosten', desc: 'Ausgewiesene Lohnkosten' },
]
const conditionalExamples = [
'{{#if vorname}}Hallo {{vorname}},{{/if}}',
'{{#if (eq zahlungsart "Überweisung")}}Bitte überweisen Sie den Betrag.{{/if}}',
'{{#if (gt zahlungsziel_in_tagen 14)}}Vielen Dank für Ihre Zahlung innerhalb des erweiterten Zahlungsziels.{{/if}}',
]
// --- Shortcuts ---
defineShortcuts({
'+': () => openModal()
@@ -148,7 +154,7 @@ const getDocLabel = (type) => {
color="primary"
variant="soft"
title="Platzhalter nutzen"
description="Nutzen Sie die Variablen im Editor, um dynamische Inhalte (wie Kundennamen) automatisch einzufügen."
description="Nutzen Sie Variablen und Bedingungen im Editor, um dynamische Inhalte automatisch einzufügen."
class="mb-4 mx-5 mt-2"
/>
@@ -313,6 +319,25 @@ const getDocLabel = (type) => {
<UIcon name="i-heroicons-plus-circle" class="w-5 h-5 text-gray-300 group-hover:text-primary-500"/>
</button>
</div>
<div class="mt-6">
<h5 class="text-sm font-semibold mb-2 flex items-center gap-2">
<UIcon name="i-heroicons-code-bracket"/>
Bedingungen
</h5>
<p class="text-xs text-gray-500 mb-3">
Unterstuetzt sind zum Beispiel `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `and`, `or`, `not` und `includes`.
</p>
<div class="flex flex-col gap-2">
<code
v-for="example in conditionalExamples"
:key="example"
class="text-xs whitespace-pre-wrap break-words rounded bg-white dark:bg-gray-900 px-2 py-1.5 border border-gray-200 dark:border-gray-700"
>
{{ example }}
</code>
</div>
</div>
</div>
</div>
@@ -349,4 +374,4 @@ const getDocLabel = (type) => {
</template>
<style scoped>
</style>
</style>