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,
|
"when": 1773835200000,
|
||||||
"tag": "0021_admin_user_flag",
|
"tag": "0021_admin_user_flag",
|
||||||
"breakpoints": true
|
"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()
|
.notNull()
|
||||||
.default(14),
|
.default(14),
|
||||||
|
|
||||||
|
taxEvaluationPeriod: text("taxEvaluationPeriod")
|
||||||
|
.notNull()
|
||||||
|
.default("monthly"),
|
||||||
|
|
||||||
dokuboxEmailAddresses: jsonb("dokuboxEmailAddresses").default([]),
|
dokuboxEmailAddresses: jsonb("dokuboxEmailAddresses").default([]),
|
||||||
|
|
||||||
dokuboxkey: uuid("dokuboxkey").notNull().defaultRandom(),
|
dokuboxkey: uuid("dokuboxkey").notNull().defaultRandom(),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import quarterOfYear from "dayjs/plugin/quarterOfYear";
|
import quarterOfYear from "dayjs/plugin/quarterOfYear";
|
||||||
import Handlebars from "handlebars";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { eq, inArray, and } from "drizzle-orm"; // Drizzle Operatoren
|
import { eq, inArray, and } from "drizzle-orm"; // Drizzle Operatoren
|
||||||
|
|
||||||
@@ -10,6 +9,7 @@ import { saveFile } from "../utils/files";
|
|||||||
import {FastifyInstance} from "fastify";
|
import {FastifyInstance} from "fastify";
|
||||||
import {useNextNumberRangeNumber} from "../utils/functions";
|
import {useNextNumberRangeNumber} from "../utils/functions";
|
||||||
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
|
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
|
||||||
|
import { documentTemplateHandlebars } from "../utils/handlebars";
|
||||||
|
|
||||||
dayjs.extend(quarterOfYear);
|
dayjs.extend(quarterOfYear);
|
||||||
|
|
||||||
@@ -609,8 +609,8 @@ export function getDocumentDataBackend(
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const templateStartText = Handlebars.compile(itemInfo.startText || "");
|
const templateStartText = documentTemplateHandlebars.compile(itemInfo.startText || "");
|
||||||
const templateEndText = Handlebars.compile(itemInfo.endText || "");
|
const templateEndText = documentTemplateHandlebars.compile(itemInfo.endText || "");
|
||||||
|
|
||||||
// --- 6. Title Sums Formatting ---
|
// --- 6. Title Sums Formatting ---
|
||||||
let returnTitleSums: Record<string, string> = {};
|
let returnTitleSums: Record<string, string> = {};
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
|
|
||||||
const createTenantSeeds = async (tenantId: number, createdBy: string) => {
|
const createTenantSeeds = async (tenantId: number, createdBy: string) => {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
const timestamp = new Date();
|
||||||
|
|
||||||
const insertedTags = await server.db
|
const insertedTags = await server.db
|
||||||
.insert(filetags)
|
.insert(filetags)
|
||||||
@@ -84,77 +85,148 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
const deliveryTag = insertedTags.find((tag) => tag.createdDocumentType === "deliveryNotes");
|
const deliveryTag = insertedTags.find((tag) => tag.createdDocumentType === "deliveryNotes");
|
||||||
const incomingInvoiceTag = insertedTags.find((tag) => tag.incomingDocumentType === "invoices");
|
const incomingInvoiceTag = insertedTags.find((tag) => tag.incomingDocumentType === "invoices");
|
||||||
|
|
||||||
await server.db
|
const insertedFolders = await server.db
|
||||||
.insert(folders)
|
.insert(folders)
|
||||||
.values([
|
.values([
|
||||||
{
|
{
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
name: "Ausgangsrechnungen",
|
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",
|
function: "invoices",
|
||||||
year: currentYear,
|
year: currentYear,
|
||||||
icon: "i-heroicons-document-text",
|
icon: "i-heroicons-document-text",
|
||||||
standardFiletype: invoiceTag?.id,
|
standardFiletype: invoiceTag?.id,
|
||||||
standardFiletypeIsOptional: false,
|
standardFiletypeIsOptional: false,
|
||||||
isSystemUsed: true,
|
isSystemUsed: true,
|
||||||
updatedAt: new Date(),
|
updatedAt: timestamp,
|
||||||
updatedBy: createdBy,
|
updatedBy: createdBy,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
name: "Angebote",
|
name: String(currentYear),
|
||||||
|
parent: folderByName.get("Angebote"),
|
||||||
function: "quotes",
|
function: "quotes",
|
||||||
year: currentYear,
|
year: currentYear,
|
||||||
icon: "i-heroicons-document-duplicate",
|
icon: "i-heroicons-document-duplicate",
|
||||||
standardFiletype: quoteTag?.id,
|
standardFiletype: quoteTag?.id,
|
||||||
standardFiletypeIsOptional: false,
|
standardFiletypeIsOptional: false,
|
||||||
isSystemUsed: true,
|
isSystemUsed: true,
|
||||||
updatedAt: new Date(),
|
updatedAt: timestamp,
|
||||||
updatedBy: createdBy,
|
updatedBy: createdBy,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
name: "Auftragsbestätigungen",
|
name: String(currentYear),
|
||||||
|
parent: folderByName.get("Auftragsbestätigungen"),
|
||||||
function: "confirmationOrders",
|
function: "confirmationOrders",
|
||||||
year: currentYear,
|
year: currentYear,
|
||||||
icon: "i-heroicons-clipboard-document-check",
|
icon: "i-heroicons-clipboard-document-check",
|
||||||
standardFiletype: confirmationTag?.id,
|
standardFiletype: confirmationTag?.id,
|
||||||
standardFiletypeIsOptional: false,
|
standardFiletypeIsOptional: false,
|
||||||
isSystemUsed: true,
|
isSystemUsed: true,
|
||||||
updatedAt: new Date(),
|
updatedAt: timestamp,
|
||||||
updatedBy: createdBy,
|
updatedBy: createdBy,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
name: "Lieferscheine",
|
name: String(currentYear),
|
||||||
|
parent: folderByName.get("Lieferscheine"),
|
||||||
function: "deliveryNotes",
|
function: "deliveryNotes",
|
||||||
year: currentYear,
|
year: currentYear,
|
||||||
icon: "i-heroicons-truck",
|
icon: "i-heroicons-truck",
|
||||||
standardFiletype: deliveryTag?.id,
|
standardFiletype: deliveryTag?.id,
|
||||||
standardFiletypeIsOptional: false,
|
standardFiletypeIsOptional: false,
|
||||||
isSystemUsed: true,
|
isSystemUsed: true,
|
||||||
updatedAt: new Date(),
|
updatedAt: timestamp,
|
||||||
updatedBy: createdBy,
|
updatedBy: createdBy,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
name: "Eingangsrechnungen",
|
name: String(currentYear),
|
||||||
|
parent: folderByName.get("Eingangsrechnungen"),
|
||||||
function: "incomingInvoices",
|
function: "incomingInvoices",
|
||||||
year: currentYear,
|
year: currentYear,
|
||||||
icon: "i-heroicons-inbox-arrow-down",
|
icon: "i-heroicons-inbox-arrow-down",
|
||||||
standardFiletype: incomingInvoiceTag?.id,
|
standardFiletype: incomingInvoiceTag?.id,
|
||||||
standardFiletypeIsOptional: false,
|
standardFiletypeIsOptional: false,
|
||||||
isSystemUsed: true,
|
isSystemUsed: true,
|
||||||
updatedAt: new Date(),
|
updatedAt: timestamp,
|
||||||
updatedBy: createdBy,
|
updatedBy: createdBy,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tenant: tenantId,
|
tenant: tenantId,
|
||||||
name: "Belege Bankeinzahlung",
|
name: String(currentYear),
|
||||||
|
parent: folderByName.get("Belege Bankeinzahlung"),
|
||||||
function: "deposit",
|
function: "deposit",
|
||||||
year: currentYear,
|
year: currentYear,
|
||||||
icon: "i-heroicons-banknotes",
|
icon: "i-heroicons-banknotes",
|
||||||
isSystemUsed: true,
|
isSystemUsed: true,
|
||||||
updatedAt: new Date(),
|
updatedAt: timestamp,
|
||||||
updatedBy: createdBy,
|
updatedBy: createdBy,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export default async function meRoutes(server: FastifyInstance) {
|
|||||||
businessInfo: tenants.businessInfo,
|
businessInfo: tenants.businessInfo,
|
||||||
numberRanges: tenants.numberRanges,
|
numberRanges: tenants.numberRanges,
|
||||||
accountChart: tenants.accountChart,
|
accountChart: tenants.accountChart,
|
||||||
|
taxEvaluationPeriod: tenants.taxEvaluationPeriod,
|
||||||
dokuboxkey: tenants.dokuboxkey,
|
dokuboxkey: tenants.dokuboxkey,
|
||||||
standardEmailForInvoices: tenants.standardEmailForInvoices,
|
standardEmailForInvoices: tenants.standardEmailForInvoices,
|
||||||
standardPaymentDays: tenants.standardPaymentDays,
|
standardPaymentDays: tenants.standardPaymentDays,
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
|||||||
// MULTIPLE PRESIGNED URLs
|
// MULTIPLE PRESIGNED URLs
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||||
return reply.code(400).send({ error: "No ids provided" })
|
return { files: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await server.db
|
const rows = await server.db
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { FastifyInstance } from "fastify";
|
|||||||
import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
|
import {createInvoicePDF, createTimeSheetPDF} from "../utils/pdf";
|
||||||
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
|
import {encodeBase64ToNiimbot, generateLabel, useNextNumberRangeNumber} from "../utils/functions";
|
||||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
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 dayjs from "dayjs";
|
||||||
//import { ready as zplReady } from 'zpl-renderer-js'
|
//import { ready as zplReady } from 'zpl-renderer-js'
|
||||||
//import { renderZPL } from "zpl-image";
|
//import { renderZPL } from "zpl-image";
|
||||||
@@ -28,6 +32,31 @@ dayjs.extend(isSameOrBefore)
|
|||||||
dayjs.extend(duration)
|
dayjs.extend(duration)
|
||||||
dayjs.extend(timezone)
|
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) {
|
export default async function functionRoutes(server: FastifyInstance) {
|
||||||
const streamToBuffer = async (stream: any): Promise<Buffer> =>
|
const streamToBuffer = async (stream: any): Promise<Buffer> =>
|
||||||
new Promise((resolve, reject) => {
|
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) => {
|
server.post('/functions/serial/start', async (req, reply) => {
|
||||||
console.log(req.body)
|
console.log(req.body)
|
||||||
const {executionDate,templateIds,tenantId} = req.body as {executionDate:string,templateIds:Number[],tenantId:Number}
|
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>
|
<script setup>
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const { isHelpSlideoverOpen } = useDashboard()
|
const { isHelpSlideoverOpen } = useDashboard()
|
||||||
const { metaSymbol } = useShortcuts()
|
const { metaSymbol } = useShortcuts()
|
||||||
|
const { entries, pending, error, seenState, refresh, markAsSeen } = useChangelog()
|
||||||
|
|
||||||
const shortcuts = ref(false)
|
const shortcuts = ref(false)
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
@@ -133,6 +136,21 @@ const resetContactRequest = () => {
|
|||||||
title: "",
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -171,8 +189,71 @@ const resetContactRequest = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex flex-col gap-y-3">
|
<div v-else class="flex flex-col gap-y-6">
|
||||||
<UButton v-for="(link, index) in links" :key="index" color="white" v-bind="link" />
|
<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>
|
||||||
<!-- <div class="mt-5" v-if="!loadingContactRequest">
|
<!-- <div class="mt-5" v-if="!loadingContactRequest">
|
||||||
<h1 class="font-semibold">Kontaktanfrage:</h1>
|
<h1 class="font-semibold">Kontaktanfrage:</h1>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const showMemberRelationsNav = computed(() => {
|
|||||||
const isAdmin = computed(() => Boolean(auth.user?.is_admin))
|
const isAdmin = computed(() => Boolean(auth.user?.is_admin))
|
||||||
const tenantFeatures = computed(() => auth.activeTenantData?.features || {})
|
const tenantFeatures = computed(() => auth.activeTenantData?.features || {})
|
||||||
const featureEnabled = (key) => tenantFeatures.value?.[key] !== false
|
const featureEnabled = (key) => tenantFeatures.value?.[key] !== false
|
||||||
|
const visibleItems = (items) => items.filter(item => item && !item.disabled)
|
||||||
|
|
||||||
const links = computed(() => {
|
const links = computed(() => {
|
||||||
const organisationChildren = [
|
const organisationChildren = [
|
||||||
@@ -36,7 +37,7 @@ const links = computed(() => {
|
|||||||
to: "/wiki",
|
to: "/wiki",
|
||||||
icon: "i-heroicons-book-open"
|
icon: "i-heroicons-book-open"
|
||||||
} : null,
|
} : null,
|
||||||
].filter(Boolean)
|
]
|
||||||
|
|
||||||
const documentChildren = [
|
const documentChildren = [
|
||||||
featureEnabled("files") ? {
|
featureEnabled("files") ? {
|
||||||
@@ -56,7 +57,7 @@ const links = computed(() => {
|
|||||||
icon: "i-heroicons-archive-box",
|
icon: "i-heroicons-archive-box",
|
||||||
disabled: true
|
disabled: true
|
||||||
} : null,
|
} : null,
|
||||||
].filter(Boolean)
|
]
|
||||||
|
|
||||||
const communicationChildren = [
|
const communicationChildren = [
|
||||||
featureEnabled("helpdesk") ? {
|
featureEnabled("helpdesk") ? {
|
||||||
@@ -71,7 +72,7 @@ const links = computed(() => {
|
|||||||
icon: "i-heroicons-envelope",
|
icon: "i-heroicons-envelope",
|
||||||
disabled: true
|
disabled: true
|
||||||
} : null,
|
} : null,
|
||||||
].filter(Boolean)
|
]
|
||||||
|
|
||||||
const contactsChildren = [
|
const contactsChildren = [
|
||||||
showMembersNav.value && featureEnabled("members") ? {
|
showMembersNav.value && featureEnabled("members") ? {
|
||||||
@@ -94,7 +95,7 @@ const links = computed(() => {
|
|||||||
to: "/standardEntity/contacts",
|
to: "/standardEntity/contacts",
|
||||||
icon: "i-heroicons-user-group"
|
icon: "i-heroicons-user-group"
|
||||||
} : null,
|
} : null,
|
||||||
].filter(Boolean)
|
]
|
||||||
|
|
||||||
const staffChildren = [
|
const staffChildren = [
|
||||||
featureEnabled("staffTime") ? {
|
featureEnabled("staffTime") ? {
|
||||||
@@ -102,7 +103,7 @@ const links = computed(() => {
|
|||||||
to: "/staff/time",
|
to: "/staff/time",
|
||||||
icon: "i-heroicons-clock",
|
icon: "i-heroicons-clock",
|
||||||
} : null,
|
} : null,
|
||||||
].filter(Boolean)
|
]
|
||||||
|
|
||||||
const accountingChildren = [
|
const accountingChildren = [
|
||||||
featureEnabled("createDocument") ? {
|
featureEnabled("createDocument") ? {
|
||||||
@@ -120,6 +121,11 @@ const links = computed(() => {
|
|||||||
to: "/incomingInvoices",
|
to: "/incomingInvoices",
|
||||||
icon: "i-heroicons-document-text",
|
icon: "i-heroicons-document-text",
|
||||||
} : null,
|
} : null,
|
||||||
|
(featureEnabled("createDocument") || featureEnabled("incomingInvoices")) ? {
|
||||||
|
label: "USt-Auswertung",
|
||||||
|
to: "/accounting/tax",
|
||||||
|
icon: "i-heroicons-calculator",
|
||||||
|
} : null,
|
||||||
featureEnabled("costcentres") ? {
|
featureEnabled("costcentres") ? {
|
||||||
label: "Kostenstellen",
|
label: "Kostenstellen",
|
||||||
to: "/standardEntity/costcentres",
|
to: "/standardEntity/costcentres",
|
||||||
@@ -140,7 +146,7 @@ const links = computed(() => {
|
|||||||
to: "/banking",
|
to: "/banking",
|
||||||
icon: "i-heroicons-document-text",
|
icon: "i-heroicons-document-text",
|
||||||
} : null,
|
} : null,
|
||||||
].filter(Boolean)
|
]
|
||||||
|
|
||||||
const inventoryChildren = [
|
const inventoryChildren = [
|
||||||
has("spaces") && featureEnabled("spaces") ? {
|
has("spaces") && featureEnabled("spaces") ? {
|
||||||
@@ -168,7 +174,7 @@ const links = computed(() => {
|
|||||||
to: "/standardEntity/inventoryitemgroups",
|
to: "/standardEntity/inventoryitemgroups",
|
||||||
icon: "i-heroicons-puzzle-piece"
|
icon: "i-heroicons-puzzle-piece"
|
||||||
} : null,
|
} : null,
|
||||||
].filter(Boolean)
|
]
|
||||||
|
|
||||||
const masterDataChildren = [
|
const masterDataChildren = [
|
||||||
has("products") && featureEnabled("products") ? {
|
has("products") && featureEnabled("products") ? {
|
||||||
@@ -221,7 +227,7 @@ const links = computed(() => {
|
|||||||
to: "/standardEntity/vehicles",
|
to: "/standardEntity/vehicles",
|
||||||
icon: "i-heroicons-truck"
|
icon: "i-heroicons-truck"
|
||||||
} : null,
|
} : null,
|
||||||
].filter(Boolean)
|
]
|
||||||
|
|
||||||
const settingsChildren = [
|
const settingsChildren = [
|
||||||
featureEnabled("settingsNumberRanges") ? {
|
featureEnabled("settingsNumberRanges") ? {
|
||||||
@@ -259,9 +265,19 @@ const links = computed(() => {
|
|||||||
to: "/export",
|
to: "/export",
|
||||||
icon: "i-heroicons-clipboard-document-list"
|
icon: "i-heroicons-clipboard-document-list"
|
||||||
} : null,
|
} : 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 => {
|
...(auth.profile?.pinned_on_navigation || []).map(pin => {
|
||||||
if (pin.type === "external") {
|
if (pin.type === "external") {
|
||||||
return {
|
return {
|
||||||
@@ -293,53 +309,53 @@ const links = computed(() => {
|
|||||||
to: "/historyitems",
|
to: "/historyitems",
|
||||||
icon: "i-heroicons-book-open"
|
icon: "i-heroicons-book-open"
|
||||||
} : null,
|
} : null,
|
||||||
...(organisationChildren.length > 0 ? [{
|
...(visibleOrganisationChildren.length > 0 ? [{
|
||||||
label: "Organisation",
|
label: "Organisation",
|
||||||
icon: "i-heroicons-rectangle-stack",
|
icon: "i-heroicons-rectangle-stack",
|
||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
children: organisationChildren
|
children: visibleOrganisationChildren
|
||||||
}] : []),
|
}] : []),
|
||||||
...(documentChildren.length > 0 ? [{
|
...(visibleDocumentChildren.length > 0 ? [{
|
||||||
label: "Dokumente",
|
label: "Dokumente",
|
||||||
icon: "i-heroicons-rectangle-stack",
|
icon: "i-heroicons-rectangle-stack",
|
||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
children: documentChildren
|
children: visibleDocumentChildren
|
||||||
}] : []),
|
}] : []),
|
||||||
...(communicationChildren.length > 0 ? [{
|
...(visibleCommunicationChildren.length > 0 ? [{
|
||||||
label: "Kommunikation",
|
label: "Kommunikation",
|
||||||
icon: "i-heroicons-megaphone",
|
icon: "i-heroicons-megaphone",
|
||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
children: communicationChildren
|
children: visibleCommunicationChildren
|
||||||
}] : []),
|
}] : []),
|
||||||
...(contactsChildren.length > 0 ? [{
|
...(visibleContactsChildren.length > 0 ? [{
|
||||||
label: "Kontakte",
|
label: "Kontakte",
|
||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
icon: "i-heroicons-user-group",
|
icon: "i-heroicons-user-group",
|
||||||
children: contactsChildren
|
children: visibleContactsChildren
|
||||||
}] : []),
|
}] : []),
|
||||||
...(staffChildren.length > 0 ? [{
|
...(visibleStaffChildren.length > 0 ? [{
|
||||||
label: "Mitarbeiter",
|
label: "Mitarbeiter",
|
||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
icon: "i-heroicons-user-group",
|
icon: "i-heroicons-user-group",
|
||||||
children: staffChildren
|
children: visibleStaffChildren
|
||||||
}] : []),
|
}] : []),
|
||||||
...(accountingChildren.length > 0 ? [{
|
...(visibleAccountingChildren.length > 0 ? [{
|
||||||
label: "Buchhaltung",
|
label: "Buchhaltung",
|
||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
icon: "i-heroicons-chart-bar-square",
|
icon: "i-heroicons-chart-bar-square",
|
||||||
children: accountingChildren
|
children: visibleAccountingChildren
|
||||||
}] : []),
|
}] : []),
|
||||||
...(inventoryChildren.length > 0 ? [{
|
...(visibleInventoryChildren.length > 0 ? [{
|
||||||
label: "Lager",
|
label: "Lager",
|
||||||
icon: "i-heroicons-puzzle-piece",
|
icon: "i-heroicons-puzzle-piece",
|
||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
children: inventoryChildren
|
children: visibleInventoryChildren
|
||||||
}] : []),
|
}] : []),
|
||||||
...(masterDataChildren.length > 0 ? [{
|
...(visibleMasterDataChildren.length > 0 ? [{
|
||||||
label: "Stammdaten",
|
label: "Stammdaten",
|
||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
icon: "i-heroicons-clipboard-document",
|
icon: "i-heroicons-clipboard-document",
|
||||||
children: masterDataChildren
|
children: visibleMasterDataChildren
|
||||||
}] : []),
|
}] : []),
|
||||||
|
|
||||||
...(has("projects") && featureEnabled("projects")) ? [{
|
...(has("projects") && featureEnabled("projects")) ? [{
|
||||||
@@ -357,13 +373,13 @@ const links = computed(() => {
|
|||||||
to: "/standardEntity/plants",
|
to: "/standardEntity/plants",
|
||||||
icon: "i-heroicons-clipboard-document"
|
icon: "i-heroicons-clipboard-document"
|
||||||
}] : [],
|
}] : [],
|
||||||
...(settingsChildren.length > 0 ? [{
|
...(visibleSettingsChildren.length > 0 ? [{
|
||||||
label: "Einstellungen",
|
label: "Einstellungen",
|
||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
icon: "i-heroicons-cog-8-tooth",
|
icon: "i-heroicons-cog-8-tooth",
|
||||||
children: settingsChildren
|
children: visibleSettingsChildren
|
||||||
}] : []),
|
}] : []),
|
||||||
].filter(Boolean)
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
const accordionItems = computed(() =>
|
const accordionItems = computed(() =>
|
||||||
|
|||||||
@@ -16,27 +16,23 @@ const setupPage = async () => {
|
|||||||
let documents = items.filter(i => i.type === "invoices" ||i.type === "advanceInvoices")
|
let documents = items.filter(i => i.type === "invoices" ||i.type === "advanceInvoices")
|
||||||
|
|
||||||
let draftDocuments = documents.filter(i => i.state === "Entwurf")
|
let draftDocuments = documents.filter(i => i.state === "Entwurf")
|
||||||
let finalizedDocuments = documents.filter(i => i.state === "Gebucht")
|
let finalizedDocuments = documents.filter(i => useSum().isOpenCreatedDocument(i, items))
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
finalizedDocuments.forEach(i => {
|
finalizedDocuments.forEach(i => {
|
||||||
if(dayjs().subtract(i.paymentDays,"days").isAfter(i.documentDate)) {
|
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
|
unpaidOverdueInvoicesCount.value += 1
|
||||||
} else {
|
} 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 += 1
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
//unpaidInvoicesCount.value = finalizedDocuments.length
|
//unpaidInvoicesCount.value = finalizedDocuments.length
|
||||||
|
|
||||||
draftDocuments.forEach(i => {
|
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
|
draftInvoicesCount.value = draftDocuments.length
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export const useFiles = () => {
|
|||||||
let data = []
|
let data = []
|
||||||
data = await useEntities("files").select("*, incominginvoice(*), project(*), vendor(*), customer(*), contract(*), plant(*), createddocument(*), vehicle(*), product(*), profile(*), check(*), inventoryitem(*)")
|
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",{
|
const res = await useNuxtApp().$api("/api/files/presigned",{
|
||||||
@@ -138,4 +141,4 @@ export const useFiles = () => {
|
|||||||
|
|
||||||
|
|
||||||
return {uploadFiles, selectDocuments, selectSomeDocuments, selectDocument, downloadFile, dataURLtoFile}
|
return {uploadFiles, selectDocuments, selectSomeDocuments, selectDocument, downloadFile, dataURLtoFile}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
|
|
||||||
export const useSum = () => {
|
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) => {
|
const getIncomingInvoiceSum = (invoice) => {
|
||||||
let sum = 0
|
let sum = 0
|
||||||
invoice.accounts.forEach(account => {
|
invoice.accounts.forEach(account => {
|
||||||
@@ -15,6 +26,7 @@ export const useSum = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getCreatedDocumentSum = (createddocument,createddocuments = []) => {
|
const getCreatedDocumentSum = (createddocument,createddocuments = []) => {
|
||||||
|
const availableCreatedDocuments = unwrapCreatedDocuments(createddocuments)
|
||||||
let totalNet = 0
|
let totalNet = 0
|
||||||
let total19 = 0
|
let total19 = 0
|
||||||
let total7 = 0
|
let total7 = 0
|
||||||
@@ -44,7 +56,9 @@ export const useSum = () => {
|
|||||||
|
|
||||||
|
|
||||||
createddocument.usedAdvanceInvoices.forEach(advanceInvoiceId => {
|
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
|
let priceNet = advanceInvoice.rows.find(i => i.advanceInvoiceData).price
|
||||||
|
|
||||||
@@ -59,6 +73,24 @@ export const useSum = () => {
|
|||||||
return Number(sumToPay.toFixed(2))
|
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) => {
|
const getCreatedDocumentSumDetailed = (createddocument) => {
|
||||||
let totalNet = 0
|
let totalNet = 0
|
||||||
let total19 = 0
|
let total19 = 0
|
||||||
@@ -124,12 +156,24 @@ export const useSum = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getIsPaid = (createddocument,createddocuments) => {
|
const getIsPaid = (createddocument,createddocuments) => {
|
||||||
let amountPaid = 0
|
return getCreatedDocumentOpenAmount(createddocument, createddocuments) === 0
|
||||||
createddocument.statementallocations.forEach(allocation => amountPaid += allocation.amount)
|
|
||||||
|
|
||||||
return Number(amountPaid.toFixed(2)) === getCreatedDocumentSum(createddocument,createddocuments)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 auth = useAuthStore()
|
||||||
const labelPrinter = useLabelPrinterStore()
|
const labelPrinter = useLabelPrinterStore()
|
||||||
const calculatorStore = useCalculatorStore()
|
const calculatorStore = useCalculatorStore()
|
||||||
|
const { hasUnread, refresh: refreshChangelog } = useChangelog()
|
||||||
|
|
||||||
const month = dayjs().format("MM")
|
const month = dayjs().format("MM")
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ const groups = computed(() => [
|
|||||||
].filter(Boolean))
|
].filter(Boolean))
|
||||||
|
|
||||||
// --- Footer Links nutzen jetzt den zentralen Calculator Store ---
|
// --- Footer Links nutzen jetzt den zentralen Calculator Store ---
|
||||||
const footerLinks = computed(() => [
|
const footerItems = computed(() => [
|
||||||
{
|
{
|
||||||
label: 'Taschenrechner',
|
label: 'Taschenrechner',
|
||||||
icon: 'i-heroicons-calculator',
|
icon: 'i-heroicons-calculator',
|
||||||
@@ -123,10 +124,15 @@ const footerLinks = computed(() => [
|
|||||||
{
|
{
|
||||||
label: 'Hilfe & Info',
|
label: 'Hilfe & Info',
|
||||||
icon: 'i-heroicons-question-mark-circle',
|
icon: 'i-heroicons-question-mark-circle',
|
||||||
|
badge: hasUnread.value ? 'Neu' : null,
|
||||||
click: () => isHelpSlideoverOpen.value = true
|
click: () => isHelpSlideoverOpen.value = true
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void refreshChangelog()
|
||||||
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -256,7 +262,25 @@ const footerLinks = computed(() => [
|
|||||||
<UColorModeToggle class="ml-3"/>
|
<UColorModeToggle class="ml-3"/>
|
||||||
<LabelPrinterButton class="w-full"/>
|
<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"/>
|
<UDivider class="sticky bottom-0 w-full"/>
|
||||||
<UserDropdown style="margin-bottom: env(safe-area-inset-bottom, 10px) !important;" class="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")
|
incominginvoices.value = invoiceItems.filter(i => i.state === "Gebucht")
|
||||||
|
|
||||||
const documents = createddocuments.value.filter(i => i.type === "invoices" || i.type === "advanceInvoices")
|
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 => ({
|
.map(i => ({
|
||||||
...i,
|
...i,
|
||||||
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
|
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
|
||||||
statementTotal: Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0)),
|
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
|
openIncomingInvoices.value = invoiceItems
|
||||||
|
|||||||
@@ -53,13 +53,13 @@ const setup = async () => {
|
|||||||
customers.value = (await useEntities("customers").select())
|
customers.value = (await useEntities("customers").select())
|
||||||
vendors.value = (await useEntities("vendors").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 => {
|
openDocuments.value = openDocuments.value.map(i => {
|
||||||
return {
|
return {
|
||||||
...i,
|
...i,
|
||||||
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
|
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
|
||||||
statementTotal: Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0)),
|
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>
|
<script setup>
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import Handlebars from "handlebars"
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import {useFunctions} from "~/composables/useFunctions.js";
|
import {useFunctions} from "~/composables/useFunctions.js";
|
||||||
import EntityModalButtons from "~/components/EntityModalButtons.vue";
|
import EntityModalButtons from "~/components/EntityModalButtons.vue";
|
||||||
|
import { documentTemplateHandlebars } from "~/utils/handlebars";
|
||||||
|
|
||||||
const dataStore = useDataStore()
|
const dataStore = useDataStore()
|
||||||
const profileStore = useProfileStore()
|
const profileStore = useProfileStore()
|
||||||
@@ -1098,8 +1098,8 @@ const getDocumentData = async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
//Compile Start & EndText
|
//Compile Start & EndText
|
||||||
const templateStartText = Handlebars.compile(itemInfo.value.startText);
|
const templateStartText = documentTemplateHandlebars.compile(itemInfo.value.startText);
|
||||||
const templateEndText = Handlebars.compile(itemInfo.value.endText);
|
const templateEndText = documentTemplateHandlebars.compile(itemInfo.value.endText);
|
||||||
|
|
||||||
const generateContext = (itemInfo, contactData) => {
|
const generateContext = (itemInfo, contactData) => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -75,16 +75,16 @@
|
|||||||
<template #state-data="{row}">
|
<template #state-data="{row}">
|
||||||
<span v-if="row.state === 'Entwurf'" class="text-rose-500">{{ row.state }}</span>
|
<span v-if="row.state === 'Entwurf'" class="text-rose-500">{{ row.state }}</span>
|
||||||
<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"
|
class="text-primary-500"
|
||||||
>
|
>
|
||||||
{{ row.state }}
|
{{ row.state }}
|
||||||
</span>
|
</span>
|
||||||
<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"
|
class="text-cyan-500"
|
||||||
>
|
>
|
||||||
Storniert mit {{ items.find(i => i.createddocument && i.createddocument.id === row.id).documentNumber }}
|
Storniert mit {{ getCancellationInvoice(row)?.documentNumber }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="row.state === 'Gebucht'" class="text-primary-500">{{ row.state }}</span>
|
<span v-else-if="row.state === 'Gebucht'" class="text-primary-500">{{ row.state }}</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
|
|
||||||
<template #dueDate-data="{row}">
|
<template #dueDate-data="{row}">
|
||||||
<span
|
<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'] : '' "
|
: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") : '' }}
|
{{ row.documentDate ? dayjs(row.documentDate).add(row.paymentDays, 'day').format("DD.MM.YY") : '' }}
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
|
|
||||||
<template #paid-data="{row}">
|
<template #paid-data="{row}">
|
||||||
<div
|
<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-if="useSum().getIsPaid(row,items)" class="text-primary-500">Bezahlt</span>
|
||||||
<span v-else class="text-rose-600">Offen</span>
|
<span v-else class="text-rose-600">Offen</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,8 +129,8 @@
|
|||||||
|
|
||||||
<template #amountOpen-data="{row}">
|
<template #amountOpen-data="{row}">
|
||||||
<span
|
<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) ">
|
v-if="!['deliveryNotes','cancellationInvoices','quotes','confirmationOrders'].includes(row.type) && row.state !== 'Entwurf' && !hasCancellationInvoice(row) && !useSum().getIsPaid(row,items) ">
|
||||||
{{ displayCurrency(useSum().getCreatedDocumentSum(row, items) - row.statementallocations.reduce((n, {amount}) => n + amount, 0)) }}
|
{{ displayCurrency(useSum().getCreatedDocumentOpenAmount(row, items)) }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</UTable>
|
</UTable>
|
||||||
@@ -264,13 +264,22 @@ const clearSearchString = () => {
|
|||||||
debouncedSearchString.value = ''
|
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 = {
|
const openUnpaidInvoicesFilter = {
|
||||||
name: 'Nur offene Rechnungen',
|
name: 'Nur offene Rechnungen',
|
||||||
filterFunction: (row) => {
|
filterFunction: (row) => {
|
||||||
return ['invoices', 'advanceInvoices'].includes(row.type)
|
return useSum().isOpenCreatedDocument(row, items.value)
|
||||||
&& row.state === 'Gebucht'
|
|
||||||
&& !useSum().getIsPaid(row, items.value)
|
|
||||||
&& !items.value.find(i => i.linkedDocument && i.linkedDocument.id === row.id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,8 +328,6 @@ const getRowsForTab = (tabKey) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isPaid = (item) => {
|
const isPaid = (item) => {
|
||||||
let amountPaid = 0
|
return useSum().getIsPaid(item, items.value)
|
||||||
item.statementallocations.forEach(allocation => amountPaid += allocation.amount)
|
|
||||||
return Number(amountPaid.toFixed(2)) === useSum().getCreatedDocumentSum(item, items.value)
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -371,9 +371,9 @@ const syncdokubox = async () => {
|
|||||||
<UProgress animation="carousel" class="w-1/2"/>
|
<UProgress animation="carousel" class="w-1/2"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div v-if="displayMode === 'list'">
|
<div v-if="displayMode === 'list'">
|
||||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
<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">
|
<thead class="bg-gray-50 dark:bg-gray-800 sticky top-0 z-20">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold sm:pl-6">Name</th>
|
<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>
|
</UDropdown>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -438,6 +443,12 @@ const syncdokubox = async () => {
|
|||||||
/>
|
/>
|
||||||
<span class="text-xs font-medium text-center truncate w-full">{{ entry.label }}</span>
|
<span class="text-xs font-medium text-center truncate w-full">{{ entry.label }}</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</UDashboardPanelContent>
|
</UDashboardPanelContent>
|
||||||
@@ -497,4 +508,4 @@ const syncdokubox = async () => {
|
|||||||
</template>
|
</template>
|
||||||
</UCard>
|
</UCard>
|
||||||
</UModal>
|
</UModal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import DisplayOpenBalances from "~/components/displayOpenBalances.vue"
|
|||||||
import DisplayBankaccounts from "~/components/displayBankaccounts.vue"
|
import DisplayBankaccounts from "~/components/displayBankaccounts.vue"
|
||||||
import DisplayProjectsInPhases from "~/components/displayProjectsInPhases.vue"
|
import DisplayProjectsInPhases from "~/components/displayProjectsInPhases.vue"
|
||||||
import DisplayOpenTasks from "~/components/displayOpenTasks.vue"
|
import DisplayOpenTasks from "~/components/displayOpenTasks.vue"
|
||||||
|
import DisplayTaxSummary from "~/components/displayTaxSummary.vue"
|
||||||
|
|
||||||
setPageLayout("default")
|
setPageLayout("default")
|
||||||
|
|
||||||
@@ -68,6 +69,15 @@ const DASHBOARD_WIDGETS = [
|
|||||||
defaultLayout: { x: 0, y: 7, w: 4, h: 3 },
|
defaultLayout: { x: 0, y: 7, w: 4, h: 3 },
|
||||||
minW: 3,
|
minW: 3,
|
||||||
minH: 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>
|
</template>
|
||||||
</UDashboardNavbar>
|
</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
|
<div
|
||||||
v-for="widget in visibleWidgets"
|
v-for="widget in visibleWidgets"
|
||||||
:key="widget.id"
|
:key="widget.id"
|
||||||
@@ -430,7 +441,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<p class="text-sm">
|
||||||
Es sind aktuell keine Dashboard-Karten sichtbar.
|
Es sind aktuell keine Dashboard-Karten sichtbar.
|
||||||
</p>
|
</p>
|
||||||
@@ -438,6 +449,7 @@ onBeforeUnmount(() => {
|
|||||||
Karte hinzufügen
|
Karte hinzufügen
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
</UDashboardPanelContent>
|
||||||
|
|
||||||
<UModal v-model="manageCardsOpen">
|
<UModal v-model="manageCardsOpen">
|
||||||
<UCard>
|
<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>
|
<script setup>
|
||||||
|
import {
|
||||||
|
TAX_EVALUATION_PERIOD_OPTIONS,
|
||||||
|
normalizeTaxEvaluationPeriod
|
||||||
|
} from "~/composables/useTaxEvaluation"
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const defaultFeatures = {
|
const defaultFeatures = {
|
||||||
@@ -119,6 +123,7 @@ const setupPage = async () => {
|
|||||||
const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) })
|
const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) })
|
||||||
const businessInfo = ref(auth.activeTenantData.businessInfo)
|
const businessInfo = ref(auth.activeTenantData.businessInfo)
|
||||||
const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
|
const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
|
||||||
|
const taxEvaluationPeriod = ref(normalizeTaxEvaluationPeriod(auth.activeTenantData.taxEvaluationPeriod))
|
||||||
const accountChartOptions = [
|
const accountChartOptions = [
|
||||||
{ label: "SKR 03", value: "skr03" },
|
{ label: "SKR 03", value: "skr03" },
|
||||||
{ label: "Verein", value: "verein" }
|
{ label: "Verein", value: "verein" }
|
||||||
@@ -137,6 +142,7 @@ const updateTenant = async (newData) => {
|
|||||||
itemInfo.value = res
|
itemInfo.value = res
|
||||||
auth.activeTenantData = res
|
auth.activeTenantData = res
|
||||||
features.value = { ...defaultFeatures, ...(res?.features || {}) }
|
features.value = { ...defaultFeatures, ...(res?.features || {}) }
|
||||||
|
taxEvaluationPeriod.value = normalizeTaxEvaluationPeriod(res?.taxEvaluationPeriod)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const saveFeatures = async () => {
|
const saveFeatures = async () => {
|
||||||
@@ -226,6 +232,23 @@ setupPage()
|
|||||||
>
|
>
|
||||||
Kontenrahmen speichern
|
Kontenrahmen speichern
|
||||||
</UButton>
|
</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>
|
</UForm>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ const variableDefinitions = [
|
|||||||
{ key: '{{lohnkosten}}', label: 'Lohnkosten', desc: 'Ausgewiesene Lohnkosten' },
|
{ 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 ---
|
// --- Shortcuts ---
|
||||||
defineShortcuts({
|
defineShortcuts({
|
||||||
'+': () => openModal()
|
'+': () => openModal()
|
||||||
@@ -148,7 +154,7 @@ const getDocLabel = (type) => {
|
|||||||
color="primary"
|
color="primary"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
title="Platzhalter nutzen"
|
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"
|
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"/>
|
<UIcon name="i-heroicons-plus-circle" class="w-5 h-5 text-gray-300 group-hover:text-primary-500"/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -349,4 +374,4 @@ const getDocLabel = (type) => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user