Compare commits
10 Commits
55bb2589a4
...
8dfcffc92b
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dfcffc92b | |||
| 9ecacdab50 | |||
| 44fb50b11e | |||
| 23c4d21f44 | |||
| 6f77bccd85 | |||
| be336a51ab | |||
| ac2e2fcfe9 | |||
| 9dbb194c8a | |||
| 0aacb18aaa | |||
| e3a1636018 |
2
backend/db/migrations/0021_admin_user_flag.sql
Normal file
2
backend/db/migrations/0021_admin_user_flag.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "auth_users"
|
||||
ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false;
|
||||
2
backend/db/migrations/0022_task_dependencies.sql
Normal file
2
backend/db/migrations/0022_task_dependencies.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "tasks"
|
||||
ADD COLUMN "dependency_ids" jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
2
backend/db/migrations/0023_tax_evaluation_period.sql
Normal file
2
backend/db/migrations/0023_tax_evaluation_period.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "tenants"
|
||||
ADD COLUMN "taxEvaluationPeriod" text DEFAULT 'monthly' NOT NULL;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
26
backend/src/utils/handlebars.ts
Normal file
26
backend/src/utils/handlebars.ts
Normal 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();
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
289
frontend/pages/accounting/tax.vue
Normal file
289
frontend/pages/accounting/tax.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
888
frontend/pages/settings/admin.vue
Normal file
888
frontend/pages/settings/admin.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user