Compare commits

...

17 Commits

Author SHA1 Message Date
0f5275b870 Ausgangsrechnungen zeigen korrektes Belegdatum
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m34s
Build and Push Docker Images / build-frontend (push) Successful in 1m12s
Build and Push Docker Images / build-docs (push) Successful in 17s
2026-05-08 20:24:58 +02:00
4f37811dcc Suche im zentralen Logbuch hinzufügen 2026-05-08 20:20:44 +02:00
d7eced3e77 Vertragstypen für Änderungsanfragen pflegen 2026-05-08 20:09:24 +02:00
6a5c1e844d Vertragstypen im Kundenportal freigeben 2026-05-08 20:04:26 +02:00
5dc44e571f Kundenportal Vertragsanfragen ergänzen 2026-05-08 20:01:57 +02:00
2b1a9a456b Ausgangsbelege nach offenen Belegen filterbar machen 2026-05-08 19:36:48 +02:00
bf5d7aaed2 Dateien in Organisation verschoben 2026-05-08 19:27:00 +02:00
e166248c0d Mitarbeiternavigation neu gruppiert 2026-05-08 19:25:07 +02:00
cba4ea52e8 Bearbeiten-Aktion im Plantafel-Mitarbeitermodal ergänzen 2026-04-29 16:37:12 +02:00
0f14f7ac3d Verfügbarkeitshinweise für Mitarbeiter und Plantafel-Details ergänzen 2026-04-29 16:33:39 +02:00
2d26cedaa3 Benutzeranlage direkt aus Mitarbeiterprofil ermöglichen 2026-04-29 16:23:35 +02:00
d5aed2140e Plantafel-Modal für Profile ohne Benutzerzuordnung absichern 2026-04-29 16:19:03 +02:00
cfc5efb556 Plantafel um Mitarbeiterdetails mit Resturlaub erweitern 2026-04-29 16:12:13 +02:00
898a5459fa Ergänze Team-Zuordnung im Mitarbeiterimport 2026-04-29 15:58:32 +02:00
3b7bcb7940 Erweitere Dry-Run-Ausgabe für Mitarbeiterimport 2026-04-29 15:51:07 +02:00
2aaff0088e Füge Branch-Mapping für Mitarbeiterlisten-Import von Tenant 41 hinzu 2026-04-29 15:41:42 +02:00
e9bbc196f7 Füge Importskript für Mitarbeiterlisten mit Tenant- und Niederlassungszuordnung hinzu 2026-04-28 14:06:09 +02:00
27 changed files with 2234 additions and 147 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE "auth_profiles"
ADD COLUMN "availability_note" text;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "historyitems" ADD COLUMN "contract" bigint;
--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_contract_contracts_id_fk" FOREIGN KEY ("contract") REFERENCES "public"."contracts"("id") ON DELETE cascade ON UPDATE no action;

View File

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

View File

@@ -239,6 +239,20 @@
"when": 1777003200000,
"tag": "0033_costcentres_parent",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1778191200000,
"tag": "0035_contract_history",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1778194800000,
"tag": "0036_allowed_contracttypes",
"breakpoints": true
}
]
}

View File

@@ -74,6 +74,7 @@ export const authProfiles = pgTable("auth_profiles", {
contract_type: text("contract_type"),
position: text("position"),
qualification: text("qualification"),
availability_note: text("availability_note"),
address_street: text("address_street"),
address_zip: text("address_zip"),

View File

@@ -52,6 +52,7 @@ export const contracts = pgTable(
contracttype: bigint("contracttype", { mode: "number" }).references(
() => contracttypes.id
),
allowedContracttypes: jsonb("allowedContracttypes").notNull().default([]),
bankingIban: text("bankingIban"),
bankingBIC: text("bankingBIC"),

View File

@@ -35,6 +35,7 @@ import { inventoryitemgroups } from "./inventoryitemgroups"
import { authUsers } from "./auth_users"
import {files} from "./files";
import { memberrelations } from "./memberrelations";
import { contracts } from "./contracts";
export const historyitems = pgTable("historyitems", {
id: bigint("id", { mode: "number" })
@@ -52,6 +53,11 @@ export const historyitems = pgTable("historyitems", {
{ onDelete: "cascade" }
),
contract: bigint("contract", { mode: "number" }).references(
() => contracts.id,
{ onDelete: "cascade" }
),
tenant: bigint("tenant", { mode: "number" })
.notNull()
.references(() => tenants.id),

View File

@@ -13,6 +13,7 @@
"schema:index": "ts-node scripts/generate-schema-index.ts",
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
"members:import:csv": "tsx scripts/import-members-csv.ts",
"profiles:import:mitarbeiterliste": "tsx scripts/import-mitarbeiterliste.ts",
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
},
"repository": {

View File

@@ -0,0 +1,589 @@
import * as fs from "node:fs"
import * as path from "node:path"
import { execFileSync } from "node:child_process"
import { and, eq } from "drizzle-orm"
import {
authProfileBranches,
authProfiles,
authProfileTeams,
branches,
teams,
tenants,
} from "../db/schema"
type ImportRow = {
rowNumber: number
mitarbeiter: string
betrieb: string
anstellung: string
position: string
bereich: string
stundenMonat: number | null
urlaub: number | null
}
type CliOptions = {
workbookPath: string
tenantId: number
dryRun: boolean
defaultBranchId: number | null
branchMap: Record<string, number>
}
type ImportAction = "create" | "update"
function printHelp() {
console.log(`
Importiert die Excel-Datei "Mitarbeiterliste.xlsm" in auth_profiles.
Verwendung:
npm run profiles:import:mitarbeiterliste -- --tenant-id=12 --branch-map-file=./branch-map.json /pfad/zur/Mitarbeiterliste.xlsm
Optionen:
--tenant-id=ID Pflicht. Tenant-ID für den Import.
--branch-map='{"Name":1}' Optional. JSON-Mapping Betrieb -> branchId.
--branch-map-file=DATEI Optional. JSON-Datei mit Betrieb -> branchId.
--default-branch-id=ID Optional. Fallback-Branch-ID für nicht gemappte Betriebe.
--dry-run Führt keine Schreiboperationen aus.
--help Zeigt diese Hilfe an.
Beispiel branch-map.json:
{
"Strandcafé": 10,
"1848 Pütt": 11,
"Oceans11": 12,
"Winnys": 13
}
`.trim())
}
function parseArgs(argv: string[]): CliOptions | null {
const options: CliOptions = {
workbookPath: "/Users/florianfederspiel/Downloads/Mitarbeiterliste.xlsm",
tenantId: Number.NaN,
dryRun: false,
defaultBranchId: null,
branchMap: {},
}
for (const arg of argv) {
if (arg === "--help" || arg === "-h") {
return null
}
if (arg === "--dry-run") {
options.dryRun = true
continue
}
if (arg.startsWith("--tenant-id=")) {
options.tenantId = Number(arg.slice("--tenant-id=".length))
continue
}
if (arg.startsWith("--default-branch-id=")) {
options.defaultBranchId = Number(arg.slice("--default-branch-id=".length))
continue
}
if (arg.startsWith("--branch-map=")) {
options.branchMap = parseBranchMap(arg.slice("--branch-map=".length), "CLI")
continue
}
if (arg.startsWith("--branch-map-file=")) {
const branchMapPath = path.resolve(arg.slice("--branch-map-file=".length))
options.branchMap = parseBranchMap(fs.readFileSync(branchMapPath, "utf8"), branchMapPath)
continue
}
if (!arg.startsWith("--")) {
options.workbookPath = path.resolve(arg)
continue
}
throw new Error(`Unbekanntes Argument: ${arg}`)
}
if (!Number.isFinite(options.tenantId)) {
throw new Error("Bitte --tenant-id=... angeben.")
}
if (options.defaultBranchId != null && !Number.isFinite(options.defaultBranchId)) {
throw new Error("--default-branch-id muss numerisch sein.")
}
return options
}
function parseBranchMap(raw: string, sourceLabel: string): Record<string, number> {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (error) {
throw new Error(`Branch-Mapping aus ${sourceLabel} konnte nicht gelesen werden: ${(error as Error).message}`)
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Branch-Mapping aus ${sourceLabel} muss ein JSON-Objekt sein.`)
}
const normalizedEntries = Object.entries(parsed).map(([key, value]) => {
const branchId = Number(value)
if (!Number.isFinite(branchId)) {
throw new Error(`Ungültige Branch-ID für "${key}" in ${sourceLabel}.`)
}
return [normalizeKey(key), branchId] as const
})
return Object.fromEntries(normalizedEntries)
}
function normalizeKey(value: string) {
return String(value || "")
.trim()
.replace(/\s+/g, " ")
.toLocaleLowerCase("de-DE")
}
function decodeXmlText(value: string) {
return value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, "\"")
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
}
function getWorkbookXml(workbookPath: string, innerPath: string) {
return execFileSync("unzip", ["-p", workbookPath, innerPath], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
})
}
function readSharedStrings(workbookPath: string) {
const xml = getWorkbookXml(workbookPath, "xl/sharedStrings.xml")
return [...xml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g)].map((match) => {
const parts = [...match[1].matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((part) => decodeXmlText(part[1]))
return parts.join("")
})
}
function readSheetRows(workbookPath: string, sharedStrings: string[]) {
const sheetXml = getWorkbookXml(workbookPath, "xl/worksheets/sheet1.xml")
const rows: Record<string, string>[] = []
for (const rowMatch of sheetXml.matchAll(/<row\b[^>]*r="(\d+)"[^>]*>([\s\S]*?)<\/row>/g)) {
const cellMap: Record<string, string> = {}
const rowXml = rowMatch[2]
for (const cellMatch of rowXml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
const attrs = cellMatch[1]
const cellXml = cellMatch[2]
const refMatch = attrs.match(/r="([A-Z]+)\d+"/)
if (!refMatch) continue
const column = refMatch[1]
const typeMatch = attrs.match(/t="([^"]+)"/)
const type = typeMatch?.[1] || ""
const valueMatch = cellXml.match(/<v>([\s\S]*?)<\/v>/)
const inlineTextMatch = cellXml.match(/<t\b[^>]*>([\s\S]*?)<\/t>/)
let value = ""
if (type === "s" && valueMatch) {
value = sharedStrings[Number(valueMatch[1])] || ""
} else if (inlineTextMatch) {
value = decodeXmlText(inlineTextMatch[1])
} else if (valueMatch) {
value = decodeXmlText(valueMatch[1])
}
cellMap[column] = value.trim()
}
rows.push(cellMap)
}
return rows
}
function parseNumber(value: string) {
const normalized = String(value || "").trim().replace(",", ".")
if (!normalized) return null
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
}
function parseWorkbook(workbookPath: string): ImportRow[] {
const sharedStrings = readSharedStrings(workbookPath)
const rows = readSheetRows(workbookPath, sharedStrings)
if (!rows.length) {
throw new Error("Die Arbeitsmappe enthält keine Zeilen.")
}
const header = rows[0]
if (header.A !== "Mitarbeiter" || header.B !== "Betrieb") {
throw new Error("Unerwartetes Format der Excel-Datei. Erwartet wurden die Spalten 'Mitarbeiter' und 'Betrieb'.")
}
return rows
.slice(1)
.map((row, index) => ({
rowNumber: index + 2,
mitarbeiter: row.A || "",
betrieb: row.B || "",
anstellung: row.C || "",
position: row.D || "",
bereich: row.E || "",
stundenMonat: parseNumber(row.F || ""),
urlaub: parseNumber(row.G || ""),
}))
.filter((row) => row.mitarbeiter)
}
function splitFullName(fullName: string) {
const parts = fullName.trim().split(/\s+/).filter(Boolean)
if (parts.length <= 1) {
return {
firstName: fullName.trim(),
lastName: "Unbekannt",
}
}
return {
firstName: parts.slice(0, -1).join(" "),
lastName: parts[parts.length - 1],
}
}
function toWeeklyHours(monthlyHours: number | null) {
if (monthlyHours == null) return null
return Math.round(((monthlyHours * 12) / 52) * 100) / 100
}
function formatValue(value: unknown) {
if (value == null) return "-"
if (typeof value === "object") return JSON.stringify(value)
return String(value)
}
function mapEmploymentCategory(value: string) {
const normalized = normalizeKey(value)
if (normalized === "aushilfe") return "Aushilfen"
if (normalized === "teilzeit" || normalized === "vollzeit") return "Festangestellte"
return null
}
function buildTeamName(bereich: string, anstellung: string) {
const employmentCategory = mapEmploymentCategory(anstellung)
const normalizedBereich = String(bereich || "").trim()
if (!normalizedBereich || !employmentCategory) return null
return `${normalizedBereich} ${employmentCategory}`
}
function collectFieldChanges(existing: any, nextPayload: Record<string, unknown>) {
if (!existing) return []
const changes: string[] = []
for (const [field, nextValue] of Object.entries(nextPayload)) {
const currentValue = existing[field]
const currentFormatted = formatValue(currentValue)
const nextFormatted = formatValue(nextValue)
if (currentFormatted !== nextFormatted) {
changes.push(`${field}: ${currentFormatted} -> ${nextFormatted}`)
}
}
return changes
}
async function validateTenantAndBranches(
db: any,
tenantId: number,
branchIds: number[]
) {
const [tenant] = await db
.select({ id: tenants.id, name: tenants.name })
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1)
if (!tenant) {
throw new Error(`Tenant ${tenantId} wurde nicht gefunden.`)
}
const branchRows = await db
.select({ id: branches.id, name: branches.name })
.from(branches)
.where(eq(branches.tenant, tenantId))
const validBranchIds = new Set(branchRows.map((branch: any) => Number(branch.id)))
for (const branchId of branchIds) {
if (!validBranchIds.has(branchId)) {
throw new Error(`Branch-ID ${branchId} gehört nicht zum Tenant ${tenantId}.`)
}
}
return {
tenant,
branchRows,
}
}
async function loadTenantTeams(db: any, tenantId: number) {
const teamRows = await db
.select({
id: teams.id,
name: teams.name,
branch: teams.branch,
archived: teams.archived,
})
.from(teams)
.where(eq(teams.tenant, tenantId))
return new Map(
teamRows
.filter((team: any) => !team.archived)
.map((team: any) => [`${team.branch}::${normalizeKey(team.name)}`, team])
)
}
async function main() {
const options = parseArgs(process.argv.slice(2))
if (!options) {
printHelp()
return
}
if (!fs.existsSync(options.workbookPath)) {
throw new Error(`Excel-Datei nicht gefunden: ${options.workbookPath}`)
}
const rows = parseWorkbook(options.workbookPath)
if (!rows.length) {
throw new Error("Keine importierbaren Mitarbeiter gefunden.")
}
const mappedBranchIds = [
...new Set(
Object.values(options.branchMap)
.concat(options.defaultBranchId != null ? [options.defaultBranchId] : [])
),
]
const { db, pool } = await import("../db")
try {
const { tenant } = await validateTenantAndBranches(db, options.tenantId, mappedBranchIds)
const teamByBranchAndName = await loadTenantTeams(db, options.tenantId)
const existingProfiles = await db
.select()
.from(authProfiles)
.where(eq(authProfiles.tenant_id, options.tenantId))
const existingByName = new Map<string, any>()
for (const profile of existingProfiles) {
const key = normalizeKey(`${profile.first_name} ${profile.last_name}`)
if (existingByName.has(key)) {
throw new Error(`Mehrdeutiger bestehender Mitarbeiter: ${profile.first_name} ${profile.last_name}`)
}
existingByName.set(key, profile)
}
const duplicateImportNames = new Set<string>()
const seenImportNames = new Set<string>()
for (const row of rows) {
const key = normalizeKey(row.mitarbeiter)
if (seenImportNames.has(key)) duplicateImportNames.add(row.mitarbeiter)
seenImportNames.add(key)
}
if (duplicateImportNames.size) {
throw new Error(`Die Excel-Datei enthält doppelte Mitarbeiternamen: ${[...duplicateImportNames].join(", ")}`)
}
const missingBranchMappings = new Set<string>()
const missingTeams = new Set<string>()
const preparedRows = rows.map((row) => {
const branchKey = normalizeKey(row.betrieb)
const branchId = options.branchMap[branchKey] ?? options.defaultBranchId ?? null
if (!branchId) {
missingBranchMappings.add(row.betrieb || `(Zeile ${row.rowNumber})`)
}
const teamName = buildTeamName(row.bereich, row.anstellung)
const team = branchId && teamName
? (teamByBranchAndName.get(`${branchId}::${normalizeKey(teamName)}`) as any) || null
: null
if (branchId && teamName && !team) {
missingTeams.add(`${row.betrieb} | ${teamName}`)
}
return {
...row,
branchId,
teamId: team?.id ?? null,
teamName,
weeklyHours: toWeeklyHours(row.stundenMonat),
}
})
if (missingBranchMappings.size) {
throw new Error(
`Für folgende Betriebe fehlt eine Branch-ID: ${[...missingBranchMappings].join(", ")}`
)
}
if (missingTeams.size) {
throw new Error(
`Für folgende Niederlassung-/Bereich-Kombinationen fehlen Teams: ${[...missingTeams].join(", ")}`
)
}
let createdProfiles = 0
let updatedProfiles = 0
const actionLogs: string[] = []
for (const row of preparedRows) {
const nameParts = splitFullName(row.mitarbeiter)
const nameKey = normalizeKey(row.mitarbeiter)
const existing = existingByName.get(nameKey)
const tempConfig = {
...((existing?.temp_config && typeof existing.temp_config === "object") ? existing.temp_config : {}),
mitarbeiterImport: {
betrieb: row.betrieb,
bereich: row.bereich,
stundenMonat: row.stundenMonat,
urlaub: row.urlaub,
quelle: path.basename(options.workbookPath),
importiertAm: new Date().toISOString(),
},
}
const payload = {
tenant_id: options.tenantId,
branch_id: row.branchId,
first_name: nameParts.firstName,
last_name: nameParts.lastName,
contract_type: row.anstellung || null,
position: row.position || null,
qualification: row.bereich || null,
weekly_working_hours: row.weeklyHours ?? existing?.weekly_working_hours ?? 0,
annual_paid_leave_days: row.urlaub != null ? Math.round(row.urlaub) : existing?.annual_paid_leave_days ?? null,
temp_config: tempConfig,
active: existing?.active ?? true,
}
const action: ImportAction = existing ? "update" : "create"
const fieldChanges = existing ? collectFieldChanges(existing, payload) : []
const actionPrefix = action === "create" ? "ERSTELLEN" : "AKTUALISIEREN"
const branchLabel = `${row.betrieb} -> ${row.branchId}`
const teamLabel = row.teamName ? `${row.teamName} -> ${row.teamId}` : "-"
if (action === "create") {
actionLogs.push(
`[${actionPrefix}] ${row.mitarbeiter} | Branch ${branchLabel} | Team ${teamLabel} | Vertrag ${row.anstellung || "-"} | Position ${row.position || "-"}`
)
} else {
actionLogs.push(
`[${actionPrefix}] ${row.mitarbeiter} | Branch ${branchLabel} | Team ${teamLabel} | Änderungen: ${fieldChanges.length ? fieldChanges.join("; ") : "keine"}`
)
}
if (!existing) {
if (!options.dryRun) {
const [created] = await db
.insert(authProfiles)
.values(payload)
.returning()
if (!created) {
throw new Error(`Profil für "${row.mitarbeiter}" konnte nicht erstellt werden.`)
}
await db.insert(authProfileBranches).values({
profile_id: created.id,
branch_id: row.branchId,
})
if (row.teamId) {
await db.insert(authProfileTeams).values({
profile_id: created.id,
team_id: row.teamId,
})
}
existingByName.set(nameKey, created)
}
createdProfiles += 1
continue
}
if (!options.dryRun) {
await db
.update(authProfiles)
.set(payload)
.where(
and(
eq(authProfiles.id, existing.id),
eq(authProfiles.tenant_id, options.tenantId)
)
)
await db
.delete(authProfileBranches)
.where(eq(authProfileBranches.profile_id, existing.id))
await db.insert(authProfileBranches).values({
profile_id: existing.id,
branch_id: row.branchId,
})
await db
.delete(authProfileTeams)
.where(eq(authProfileTeams.profile_id, existing.id))
if (row.teamId) {
await db.insert(authProfileTeams).values({
profile_id: existing.id,
team_id: row.teamId,
})
}
}
updatedProfiles += 1
}
console.log("")
console.log(`[IMPORT MITARBEITER] Tenant: ${tenant.id} (${tenant.name})`)
console.log(`[IMPORT MITARBEITER] Datei: ${options.workbookPath}`)
console.log(`[IMPORT MITARBEITER] Dry-Run: ${options.dryRun ? "JA" : "NEIN"}`)
console.log(`[IMPORT MITARBEITER] Zeilen gelesen: ${rows.length}`)
console.log(`[IMPORT MITARBEITER] Profile erstellt: ${createdProfiles}`)
console.log(`[IMPORT MITARBEITER] Profile aktualisiert: ${updatedProfiles}`)
if (actionLogs.length) {
console.log("[IMPORT MITARBEITER] Details:")
for (const logLine of actionLogs) {
console.log(` ${logLine}`)
}
}
console.log("")
} finally {
await pool.end()
}
}
main().catch((error) => {
console.error("[IMPORT MITARBEITER] Fehler:", error)
process.exitCode = 1
})

View File

@@ -0,0 +1,7 @@
{
"1848 Pütt": 1,
"Strandcafé": 3,
"Oceans11": 4,
"Oceans 11": 4,
"Winnys": 5
}

View File

@@ -29,6 +29,7 @@ import staffTimeConnectRoutes from "./routes/staff/timeconnects";
import userRoutes from "./routes/auth/user";
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
import wikiRoutes from "./routes/wiki";
import portalContractRoutes from "./routes/portal/contracts";
//Public Links
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
@@ -146,6 +147,7 @@ async function main() {
await subApp.register(publiclinksAuthenticatedRoutes);
await subApp.register(resourceRoutes);
await subApp.register(wikiRoutes);
await subApp.register(portalContractRoutes);
},{prefix: "/api"})

View File

@@ -451,6 +451,116 @@ export default async function adminRoutes(server: FastifyInstance) {
}
});
// -------------------------------------------------------------
// POST /admin/profiles/:profileId/create-user
// -------------------------------------------------------------
server.post("/admin/profiles/:profileId/create-user", async (req, reply) => {
try {
const currentUser = await requireAdmin(req, reply);
if (!currentUser) return;
const { profileId } = req.params as { profileId: string };
const body = req.body as { email?: string };
const email = body.email?.trim().toLowerCase();
if (!email) {
return reply.code(400).send({ error: "email required" });
}
const [profile] = await server.db
.select({
id: authProfiles.id,
tenant_id: authProfiles.tenant_id,
user_id: authProfiles.user_id,
first_name: authProfiles.first_name,
last_name: authProfiles.last_name,
email: authProfiles.email,
})
.from(authProfiles)
.where(eq(authProfiles.id, profileId))
.limit(1);
if (!profile) {
return reply.code(404).send({ error: "Profile not found" });
}
if (profile.user_id) {
return reply.code(409).send({ error: "Profile already linked to a user" });
}
const existingUsers = await server.db
.select({ id: authUsers.id })
.from(authUsers)
.where(eq(authUsers.email, email))
.limit(1);
if (existingUsers.length) {
return reply.code(409).send({ error: "User with this email already exists" });
}
const initialPassword = generateRandomPassword(14);
const passwordHash = await hashPassword(initialPassword);
const result = await server.db.transaction(async (tx) => {
const [createdUser] = await tx
.insert(authUsers)
.values({
email,
passwordHash,
is_admin: false,
multiTenant: true,
must_change_password: true,
updatedAt: new Date(),
})
.returning({
id: authUsers.id,
email: authUsers.email,
must_change_password: authUsers.must_change_password,
is_admin: authUsers.is_admin,
multiTenant: authUsers.multiTenant,
created_at: authUsers.created_at,
});
await tx
.insert(authTenantUsers)
.values({
tenant_id: profile.tenant_id,
user_id: createdUser.id,
created_by: currentUser.id,
});
const [updatedProfile] = await tx
.update(authProfiles)
.set({
user_id: createdUser.id,
email,
})
.where(eq(authProfiles.id, profile.id))
.returning({
id: authProfiles.id,
tenant_id: authProfiles.tenant_id,
user_id: authProfiles.user_id,
first_name: authProfiles.first_name,
last_name: authProfiles.last_name,
email: authProfiles.email,
});
return {
user: createdUser,
profile: updatedProfile,
};
});
return {
...result,
initialPassword,
};
} catch (err) {
console.error("ERROR /admin/profiles/:profileId/create-user:", err);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
server.post("/admin/customers/:customerId/invite-portal-user", async (req, reply) => {
try {
const currentUser = await requireAdmin(req, reply);

View File

@@ -5,6 +5,7 @@ import { authProfiles, historyitems } from "../../db/schema";
const columnMap: Record<string, any> = {
customers: historyitems.customer,
contracts: historyitems.contract,
members: historyitems.customer,
vendors: historyitems.vendor,
projects: historyitems.project,
@@ -30,6 +31,7 @@ const columnMap: Record<string, any> = {
const insertFieldMap: Record<string, string> = {
customers: "customer",
contracts: "contract",
members: "customer",
vendors: "vendor",
projects: "project",

View File

@@ -0,0 +1,230 @@
import { FastifyInstance } from "fastify"
import { and, eq } from "drizzle-orm"
import { authProfiles, contracts, contracttypes } from "../../../db/schema"
import { insertHistoryItem } from "../../utils/history"
async function getPortalCustomerId(server: FastifyInstance, req: any) {
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
if (!tenantId || !userId) return null
const [profile] = await server.db
.select({ customer_for_portal: authProfiles.customer_for_portal })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, tenantId),
eq(authProfiles.user_id, userId)
))
.limit(1)
return profile?.customer_for_portal || null
}
async function getPortalContract(server: FastifyInstance, req: any, contractId: number) {
const portalCustomerId = await getPortalCustomerId(server, req)
if (!portalCustomerId) return null
const [contract] = await server.db
.select({
id: contracts.id,
name: contracts.name,
tenant: contracts.tenant,
customer: contracts.customer,
contracttype: contracts.contracttype,
allowedContracttypes: contracts.allowedContracttypes,
archived: contracts.archived,
})
.from(contracts)
.where(and(
eq(contracts.id, contractId),
eq(contracts.tenant, req.user?.tenant_id),
eq(contracts.customer, portalCustomerId),
eq(contracts.archived, false)
))
.limit(1)
return contract || null
}
function normalizeMessage(message: unknown) {
if (typeof message !== "string") return ""
return message.trim()
}
function appendMessage(text: string, message: string) {
return message ? `${text} Nachricht: ${message}` : text
}
function formatDateForHistory(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "Europe/Berlin",
}).format(date)
}
export default async function portalContractRoutes(server: FastifyInstance) {
server.post<{
Params: { id: string }
Body: { contracttype?: number | string; message?: string }
}>("/portal/contracts/:id/change-request", {
schema: {
tags: ["Portal"],
summary: "Request contract type change from customer portal",
params: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
},
},
body: {
type: "object",
required: ["contracttype"],
properties: {
contracttype: { anyOf: [{ type: "number" }, { type: "string" }] },
message: { type: "string", nullable: true },
},
},
},
}, async (req, reply) => {
const contractId = Number(req.params.id)
const requestedContracttypeId = Number(req.body.contracttype)
if (!Number.isInteger(contractId) || !Number.isInteger(requestedContracttypeId)) {
return reply.code(400).send({ error: "Ungültige Anfrage" })
}
const contract = await getPortalContract(server, req, contractId)
if (!contract) {
return reply.code(404).send({ error: "Vertrag nicht gefunden" })
}
const [requestedContracttype] = await server.db
.select({
id: contracttypes.id,
name: contracttypes.name,
})
.from(contracttypes)
.where(and(
eq(contracttypes.id, requestedContracttypeId),
eq(contracttypes.tenant, req.user?.tenant_id),
eq(contracttypes.archived, false)
))
.limit(1)
if (!requestedContracttype) {
return reply.code(400).send({ error: "Ungültiger Vertragstyp" })
}
const allowedContracttypes = Array.isArray(contract.allowedContracttypes)
? contract.allowedContracttypes.map((id) => Number(id)).filter((id) => Number.isInteger(id))
: []
if (!allowedContracttypes.includes(requestedContracttype.id)) {
return reply.code(400).send({ error: "Dieser Vertragstyp steht für diesen Vertrag nicht zur Auswahl" })
}
const [currentContracttype] = contract.contracttype
? await server.db
.select({
id: contracttypes.id,
name: contracttypes.name,
})
.from(contracttypes)
.where(and(
eq(contracttypes.id, contract.contracttype),
eq(contracttypes.tenant, req.user?.tenant_id)
))
.limit(1)
: []
const message = normalizeMessage(req.body.message)
const oldName = currentContracttype?.name || "Ohne Vertragstyp"
const newName = requestedContracttype.name
const text = appendMessage(
`Kundenportal: Änderung des Vertragstyps von "${oldName}" auf "${newName}" angefragt.`,
message
)
await insertHistoryItem(server, {
tenant_id: req.user?.tenant_id,
created_by: req.user?.user_id || null,
entity: "contracts",
entityId: contract.id,
action: "unchanged",
oldVal: { contracttype: contract.contracttype, name: oldName },
newVal: { contracttype: requestedContracttype.id, name: newName },
text,
})
return { success: true, message: "Ihre Anfrage wurde übermittelt." }
})
server.post<{
Params: { id: string }
Body: { requestedEndDate?: string; message?: string }
}>("/portal/contracts/:id/cancellation-request", {
schema: {
tags: ["Portal"],
summary: "Request contract cancellation from customer portal",
params: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
},
},
body: {
type: "object",
required: ["requestedEndDate"],
properties: {
requestedEndDate: { type: "string" },
message: { type: "string", nullable: true },
},
},
},
}, async (req, reply) => {
const contractId = Number(req.params.id)
const requestedEndDate = typeof req.body.requestedEndDate === "string"
? req.body.requestedEndDate.trim()
: ""
if (!Number.isInteger(contractId) || !requestedEndDate) {
return reply.code(400).send({ error: "Ungültige Anfrage" })
}
const parsedDate = new Date(requestedEndDate)
if (Number.isNaN(parsedDate.getTime())) {
return reply.code(400).send({ error: "Ungültiges Kündigungsdatum" })
}
const contract = await getPortalContract(server, req, contractId)
if (!contract) {
return reply.code(404).send({ error: "Vertrag nicht gefunden" })
}
const message = normalizeMessage(req.body.message)
const text = appendMessage(
`Kundenportal: Kündigung zum ${formatDateForHistory(requestedEndDate)} angefragt.`,
message
)
await insertHistoryItem(server, {
tenant_id: req.user?.tenant_id,
created_by: req.user?.user_id || null,
entity: "contracts",
entityId: contract.id,
action: "unchanged",
newVal: { requestedEndDate },
text,
})
return { success: true, message: "Ihre Anfrage wurde übermittelt." }
})
}

View File

@@ -19,7 +19,7 @@ import { diffObjects } from "../../utils/diff";
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service";
import { decrypt, encrypt } from "../../utils/crypt";
const PORTAL_ALLOWED_RESOURCES = new Set(["customers", "contracts", "createddocuments"])
const PORTAL_ALLOWED_RESOURCES = new Set(["customers", "contracts", "createddocuments", "contracttypes"])
const PORTAL_VISIBLE_DOCUMENT_TYPES = ["invoices", "advanceInvoices", "cancellationInvoices"]
// -------------------------------------------------------------

View File

@@ -3,6 +3,7 @@ import { historyitems } from "../../db/schema";
const HISTORY_ENTITY_LABELS: Record<string, string> = {
customers: "Kunden",
contracts: "Verträge",
members: "Mitglieder",
vendors: "Lieferanten",
projects: "Projekte",
@@ -63,6 +64,7 @@ export async function insertHistoryItem(
const columnMap: Record<string, string> = {
customers: "customer",
contracts: "contract",
members: "customer",
vendors: "vendor",
projects: "project",

View File

@@ -14,6 +14,11 @@ const props = defineProps({
renderHeadline: {
type: Boolean,
default: false
},
search: {
type: String,
required: false,
default: ""
}
})
@@ -72,6 +77,21 @@ const renderText = (text) => {
return text
}
const filteredItems = computed(() => {
const search = props.search.trim().toLowerCase()
if (!search) return items.value
return items.value.filter((item) => {
return [
item.text,
item.created_by_profile?.full_name,
item.created_by ? "" : "FEDEO Bot",
dayjs(item.created_at).format("DD.MM.YY HH:mm")
].some((value) => String(value || "").toLowerCase().includes(search))
})
})
</script>
<template>
@@ -135,8 +155,8 @@ const renderText = (text) => {
<!-- ITEM LIST -->
<div style="height: 90%; overflow-y: scroll">
<div
v-if="items.length > 0"
v-for="(item,index) in items.slice().reverse()"
v-if="filteredItems.length > 0"
v-for="(item,index) in filteredItems.slice().reverse()"
>
<USeparator
class="my-3"
@@ -159,6 +179,14 @@ const renderText = (text) => {
</div>
</div>
</div>
<UAlert
v-else
color="neutral"
variant="soft"
icon="i-heroicons-magnifying-glass"
title="Keine Logbucheinträge gefunden."
description="Passe die Suche an, um weitere Einträge zu sehen."
/>
</div>

View File

@@ -52,14 +52,14 @@ const links = computed(() => {
to: "/wiki",
icon: "i-heroicons-book-open"
} : null,
]
const documentChildren = [
featureEnabled("files") ? {
label: "Dateien",
to: "/files",
icon: "i-heroicons-document"
} : null,
]
const documentChildren = [
featureEnabled("createdletters") ? {
label: "Anschreiben",
to: "/createdletters",
@@ -113,6 +113,16 @@ const links = computed(() => {
]
const staffChildren = [
featureEnabled("staffProfiles") ? {
label: "Mitarbeiterprofile",
to: "/staff/profiles",
icon: "i-heroicons-user-group"
} : null,
featureEnabled("teams") ? {
label: "Teams",
to: "/standardEntity/teams",
icon: "i-heroicons-users"
} : null,
featureEnabled("staffTime") ? {
label: "Zeiten",
to: "/staff/time",
@@ -249,16 +259,6 @@ const links = computed(() => {
to: "/standardEntity/branches",
icon: "i-heroicons-building-office-2"
} : null,
featureEnabled("teams") ? {
label: "Teams",
to: "/standardEntity/teams",
icon: "i-heroicons-users"
} : null,
featureEnabled("staffProfiles") ? {
label: "Mitarbeiter",
to: "/staff/profiles",
icon: "i-heroicons-user-group"
} : null,
featureEnabled("hourrates") ? {
label: "Stundensätze",
to: "/standardEntity/hourrates",

View File

@@ -69,6 +69,13 @@ export const useAdmin = () => {
})
}
const createUserForProfile = async (profileId: string, body: Record<string, any>) => {
return await $api(`/api/admin/profiles/${profileId}/create-user`, {
method: "POST",
body,
})
}
const updateUser = async (id: string, body: Record<string, any>) => {
return await $api(`/api/admin/users/${id}`, {
method: "PUT",
@@ -106,6 +113,7 @@ export const useAdmin = () => {
return {
getOverview,
createUser,
createUserForProfile,
updateUser,
updateUserAccess,
createTenant,

View File

@@ -908,7 +908,7 @@ setup()
<div class="text-xs text-gray-500 flex gap-3 mt-0.5">
<span class="flex items-center gap-1">
<UIcon name="i-heroicons-calendar" class="w-3 h-3"/>
{{ dayjs(document.date).format("DD.MM.YYYY") }}
{{ dayjs(document.documentDate || document.date).format("DD.MM.YYYY") }}
</span>
<span class="text-primary-600 font-medium"
v-if="Number(document.openSum) < Number(document.total)">

View File

@@ -34,10 +34,11 @@
v-if="selectableFilters.length > 0"
v-model="selectedFilters"
:color="selectedFilters.length > 0 ? 'primary' : 'white'"
:options="selectableFilters"
:ui-menu="{ width: 'min-w-max' }"
:items="selectableFilters"
:content="{ width: 'min-w-max' }"
icon="i-heroicons-adjustments-horizontal-solid"
multiple
@update:model-value="value => tempStore.modifyFilter('createddocumentsList', 'main', value)"
>
<template #label>
Filter
@@ -289,7 +290,7 @@ const getCancellationInvoice = (row) => {
const hasCancellationInvoice = (row) => Boolean(getCancellationInvoice(row))
const openUnpaidInvoicesFilter = {
name: 'Nur offene Rechnungen',
name: 'Nur offene Belege',
filterFunction: (row) => {
return useSum().isOpenCreatedDocument(row, items.value)
}
@@ -297,7 +298,11 @@ const openUnpaidInvoicesFilter = {
const availableFilters = computed(() => [...dataType.filters, openUnpaidInvoicesFilter])
const selectableFilters = computed(() => availableFilters.value.map(i => i.name))
const selectedFilters = ref(dataType.filters.filter(i => i.default).map(i => i.name) || [])
const selectedFilters = ref(
tempStore.filters?.createddocumentsList?.main
|| dataType.filters.filter(i => i.default).map(i => i.name)
|| []
)
const filteredRows = computed(() => {
let tempItems = items.value.filter(i => types.value.find(x => {

View File

@@ -10,12 +10,27 @@ const toast = useToast()
const customer = ref<any | null>(null)
const contracts = ref<any[]>([])
const contracttypes = ref<any[]>([])
const invoices = ref<any[]>([])
const loading = ref(true)
const saving = ref(false)
const expandedInvoiceId = ref<number | null>(null)
const downloadingInvoiceId = ref<number | null>(null)
const activeTab = ref("0")
const contractChangeModalOpen = ref(false)
const cancellationModalOpen = ref(false)
const submittingContractRequest = ref(false)
const selectedContract = ref<any | null>(null)
const contractChangeForm = reactive({
contracttype: null as number | null,
message: ""
})
const cancellationForm = reactive({
requestedEndDate: "",
message: ""
})
const customerForm = reactive({
name: "",
@@ -112,6 +127,76 @@ function formatCurrency(value: number) {
return useCurrency(value)
}
function formatBoolean(value: boolean | null | undefined) {
if (typeof value !== "boolean") return "-"
return value ? "Ja" : "Nein"
}
function formatValue(value: any) {
if (value === null || typeof value === "undefined" || value === "") return "-"
if (typeof value === "boolean") return formatBoolean(value)
if (typeof value === "object") {
if (Object.keys(value).length === 0) return "-"
return JSON.stringify(value)
}
return String(value)
}
function maskIban(value?: string | null) {
if (!value) return "-"
const clean = value.replace(/\s+/g, "")
if (clean.length <= 4) return clean
const country = clean.slice(0, 2)
const lastTwo = clean.slice(-2)
const maskedLength = Math.max(clean.length - 4, 0)
const masked = "*".repeat(maskedLength).replace(/(.{4})/g, "$1 ").trim()
return `${country} ${masked} ${lastTwo}`.replace(/\s+/g, " ").trim()
}
function getContactLabel(contact: any) {
if (!contact) return "-"
if (typeof contact !== "object") return String(contact)
return contact.fullName || [contact.firstName, contact.lastName].filter(Boolean).join(" ") || contact.email || contact.id || "-"
}
function getAllowedContracttypeIds(contract: any) {
if (!Array.isArray(contract?.allowedContracttypes)) return []
return contract.allowedContracttypes.map((id: any) => Number(id)).filter((id: number) => Number.isInteger(id))
}
function getAllowedContracttypes(contract: any) {
const allowedIds = getAllowedContracttypeIds(contract)
return contracttypes.value.filter((item: any) => allowedIds.includes(Number(item.id)))
}
const selectedContractAllowedContracttypes = computed(() => {
if (!selectedContract.value) return []
return getAllowedContracttypes(selectedContract.value)
})
function openContractChangeRequest(contract: any) {
const allowedTypes = getAllowedContracttypes(contract)
selectedContract.value = contract
contractChangeForm.contracttype = allowedTypes.some((item: any) => item.id === contract.contracttype?.id)
? contract.contracttype.id
: allowedTypes[0]?.id || null
contractChangeForm.message = ""
contractChangeModalOpen.value = true
}
function openCancellationRequest(contract: any) {
selectedContract.value = contract
cancellationForm.requestedEndDate = contract.endDate ? dayjs(contract.endDate).format("YYYY-MM-DD") : ""
cancellationForm.message = ""
cancellationModalOpen.value = true
}
function getInvoiceAmount(invoice: any) {
return useSum().getCreatedDocumentSum(invoice, invoices.value)
}
@@ -141,6 +226,48 @@ async function downloadInvoice(invoice: any) {
}
}
async function submitContractChangeRequest() {
if (!selectedContract.value?.id || !contractChangeForm.contracttype) return
submittingContractRequest.value = true
try {
await useNuxtApp().$api(`/api/portal/contracts/${selectedContract.value.id}/change-request`, {
method: "POST",
body: {
contracttype: contractChangeForm.contracttype,
message: contractChangeForm.message
}
})
contractChangeModalOpen.value = false
toast.add({ title: "Ihre Anfrage wurde übermittelt." })
} finally {
submittingContractRequest.value = false
}
}
async function submitCancellationRequest() {
if (!selectedContract.value?.id || !cancellationForm.requestedEndDate) return
submittingContractRequest.value = true
try {
await useNuxtApp().$api(`/api/portal/contracts/${selectedContract.value.id}/cancellation-request`, {
method: "POST",
body: {
requestedEndDate: cancellationForm.requestedEndDate,
message: cancellationForm.message
}
})
cancellationModalOpen.value = false
toast.add({ title: "Ihre Anfrage wurde übermittelt." })
} finally {
submittingContractRequest.value = false
}
}
async function loadPortalData() {
if (!portalCustomerId.value) {
loading.value = false
@@ -150,15 +277,17 @@ async function loadPortalData() {
loading.value = true
try {
const [customerRecord, contractRows, invoiceRows] = await Promise.all([
const [customerRecord, contractRows, invoiceRows, contracttypeRows] = await Promise.all([
useEntities("customers").selectSingle(portalCustomerId.value),
useEntities("contracts").select("*, contracttype(id,name)", "startDate", true),
useEntities("createddocuments").select("*, files(*), statementallocations(*), contract(id,name,contractNumber)", "documentDate", true)
useEntities("createddocuments").select("*, files(*), statementallocations(*), contract(id,name,contractNumber)", "documentDate", true),
useEntities("contracttypes").select("*", "name", true)
])
customer.value = customerRecord
contracts.value = (contractRows || []).filter((item: any) => !item.archived)
invoices.value = (invoiceRows || []).filter((item: any) => !item.archived)
contracttypes.value = (contracttypeRows || []).filter((item: any) => !item.archived)
fillFormFromCustomer(customerRecord)
} finally {
loading.value = false
@@ -472,24 +601,166 @@ onMounted(async () => {
</div>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<UButton
color="primary"
variant="soft"
icon="i-heroicons-pencil-square"
class="justify-center"
:disabled="getAllowedContracttypes(contract).length === 0"
@click="openContractChangeRequest(contract)"
>
Änderung anfragen
</UButton>
<UButton
color="red"
variant="soft"
icon="i-heroicons-document-minus"
class="justify-center"
@click="openCancellationRequest(contract)"
>
Kündigung anfragen
</UButton>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">Laufzeit</p>
<p class="text-xs uppercase tracking-wide text-neutral-400">Vertragstyp</p>
<p class="mt-1 text-sm font-medium text-neutral-900">
{{ formatDate(contract.startDate) }} bis {{ formatDate(contract.endDate) }}
{{ contract.contracttype?.name || "Nicht hinterlegt" }}
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">Abrechnung</p>
<p class="text-xs uppercase tracking-wide text-neutral-400">Änderung möglich zu</p>
<div v-if="getAllowedContracttypes(contract).length" class="mt-2 flex flex-wrap gap-2">
<UBadge
v-for="type in getAllowedContracttypes(contract)"
:key="type.id"
color="primary"
variant="soft"
>
{{ type.name }}
</UBadge>
</div>
<p v-else class="mt-1 text-sm font-medium text-neutral-900">
Keine Änderungstypen freigegeben
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">Ansprechpartner</p>
<p class="mt-1 text-sm font-medium text-neutral-900">
{{ getContactLabel(contract.contact) }}
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">Aktiv</p>
<p class="mt-1 text-sm font-medium text-neutral-900">
{{ formatBoolean(contract.active) }}
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">Wiederkehrend</p>
<p class="mt-1 text-sm font-medium text-neutral-900">
{{ formatBoolean(contract.recurring) }}
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">Startdatum</p>
<p class="mt-1 text-sm font-medium text-neutral-900">
{{ formatDate(contract.startDate) }}
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">Enddatum</p>
<p class="mt-1 text-sm font-medium text-neutral-900">
{{ formatDate(contract.endDate) }}
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">Unterschrieben am</p>
<p class="mt-1 text-sm font-medium text-neutral-900">
{{ formatDate(contract.signDate) }}
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">Laufzeit</p>
<p class="mt-1 text-sm font-medium text-neutral-900">
{{ contract.duration || "-" }}
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">Zahlungsart</p>
<p class="mt-1 text-sm font-medium text-neutral-900">
{{ contract.paymentType || "Nicht hinterlegt" }}
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">Abrechnungsintervall</p>
<p class="mt-1 text-sm font-medium text-neutral-900">
{{ contract.billingInterval || "-" }}
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">Rechnungsversand</p>
<p class="mt-1 text-sm font-medium text-neutral-900">
{{ contract.invoiceDispatch || "-" }}
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">Kontoinhaber</p>
<p class="mt-1 text-sm font-medium text-neutral-900">
{{ contract.bankingOwner || "-" }}
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">Bank</p>
<p class="mt-1 text-sm font-medium text-neutral-900">
{{ contract.bankingName || "-" }}
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">IBAN</p>
<p class="mt-1 break-all text-sm font-medium text-neutral-900">
{{ maskIban(contract.bankingIban) }}
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">BIC</p>
<p class="mt-1 text-sm font-medium text-neutral-900">
{{ contract.bankingBIC || "-" }}
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">SEPA-Referenz</p>
<p class="mt-1 text-sm font-medium text-neutral-900">
{{ contract.sepaRef || "-" }}
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">SEPA-Datum</p>
<p class="mt-1 text-sm font-medium text-neutral-900">
{{ formatDate(contract.sepaDate) }}
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">Erstellt am</p>
<p class="mt-1 text-sm font-medium text-neutral-900">
{{ formatDate(contract.createdAt) }}
</p>
</div>
<div class="rounded-xl bg-white p-3">
<p class="text-xs uppercase tracking-wide text-neutral-400">Aktualisiert am</p>
<p class="mt-1 text-sm font-medium text-neutral-900">
{{ formatDate(contract.updatedAt) }}
</p>
</div>
<div v-if="contract.notes" class="rounded-xl bg-white p-3 sm:col-span-2">
<p class="text-xs uppercase tracking-wide text-neutral-400">Notizen</p>
<p class="mt-1 whitespace-pre-wrap break-words text-sm font-medium text-neutral-900">
{{ contract.notes }}
</p>
</div>
</div>
<p v-if="contract.notes" class="mt-4 text-sm text-neutral-600">
{{ contract.notes }}
</p>
</div>
</div>
</div>
@@ -498,5 +769,107 @@ onMounted(async () => {
</UCard>
</div>
</UContainer>
<UModal v-model:open="contractChangeModalOpen">
<template #content>
<div class="space-y-5 p-6">
<div>
<p class="text-sm font-medium uppercase tracking-[0.2em] text-primary-600">Vertrag ändern</p>
<h3 class="mt-2 text-xl font-semibold text-neutral-900">Änderung anfragen</h3>
<p class="mt-1 text-sm text-neutral-500">
Ihre Anfrage wird an unser Team übermittelt. Der Vertrag wird dadurch noch nicht geändert.
</p>
</div>
<div class="space-y-4">
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">Gewünschter Vertragstyp</label>
<USelectMenu
v-model="contractChangeForm.contracttype"
:items="selectedContractAllowedContracttypes"
value-key="id"
label-key="name"
class="w-full"
placeholder="Vertragstyp auswählen"
/>
<p v-if="!selectedContractAllowedContracttypes.length" class="mt-2 text-sm text-neutral-500">
Für diesen Vertrag sind aktuell keine Änderungstypen freigegeben.
</p>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">Nachricht optional</label>
<UTextarea
v-model="contractChangeForm.message"
class="w-full"
:rows="4"
placeholder="Ergänzende Hinweise zur gewünschten Änderung"
/>
</div>
</div>
<div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" @click="contractChangeModalOpen = false">
Abbrechen
</UButton>
<UButton
color="primary"
:loading="submittingContractRequest"
:disabled="!contractChangeForm.contracttype"
@click="submitContractChangeRequest"
>
Anfrage senden
</UButton>
</div>
</div>
</template>
</UModal>
<UModal v-model:open="cancellationModalOpen">
<template #content>
<div class="space-y-5 p-6">
<div>
<p class="text-sm font-medium uppercase tracking-[0.2em] text-red-600">Kündigung</p>
<h3 class="mt-2 text-xl font-semibold text-neutral-900">Kündigung anfragen</h3>
<p class="mt-1 text-sm text-neutral-500">
Ihre Kündigungsanfrage wird dokumentiert und intern geprüft.
</p>
</div>
<div class="space-y-4">
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">Gewünschtes Kündigungsdatum</label>
<UInput
v-model="cancellationForm.requestedEndDate"
type="date"
class="w-full"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">Nachricht optional</label>
<UTextarea
v-model="cancellationForm.message"
class="w-full"
:rows="4"
placeholder="Optionaler Grund oder weitere Hinweise"
/>
</div>
</div>
<div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" @click="cancellationModalOpen = false">
Abbrechen
</UButton>
<UButton
color="red"
:loading="submittingContractRequest"
:disabled="!cancellationForm.requestedEndDate"
@click="submitCancellationRequest"
>
Anfrage senden
</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
const search = ref("")
</script>
<template>
@@ -9,12 +9,32 @@
:class="['text-xl','font-medium']"
>Zentrales Logbuch</h1>
</template>
<template #right>
<UInput
id="searchinput"
v-model="search"
icon="i-heroicons-magnifying-glass"
placeholder="Logbuch durchsuchen..."
class="w-72 max-w-full"
>
<template #trailing v-if="search">
<UButton
color="neutral"
variant="link"
icon="i-heroicons-x-mark"
:padded="false"
aria-label="Suche löschen"
@click="search = ''"
/>
</template>
</UInput>
</template>
</UDashboardNavbar>
<UDashboardPanelContent>
<HistoryDisplay/>
<HistoryDisplay :search="search"/>
</UDashboardPanelContent>
</template>
<style scoped>
</style>
</style>

View File

@@ -4,6 +4,7 @@ import FullCalendar from "@fullcalendar/vue3"
import interactionPlugin from "@fullcalendar/interaction"
import resourceTimelinePlugin from "@fullcalendar/resource-timeline"
import { parseDate } from "@internationalized/date"
import { useDraggable } from "@vueuse/core"
const router = useRouter()
const auth = useAuthStore()
@@ -30,10 +31,16 @@ const events = ref([])
const profiles = ref([])
const inventoryitems = ref([])
const savingQuickConfig = ref(false)
const isQuickConfigModalOpen = ref(false)
const quickConfigWindowEl = ref(null)
const profileDetailsWindowEl = ref(null)
const showQuickConfigEditor = ref(true)
const showQuickPresetManagement = ref(false)
const quickEntryConfig = reactive({
name: "Quick-Eintrag",
color: "#2563eb"
})
const newQuickPresetName = ref("")
const quickEntryColorOptions = [
{ label: "Blau", value: "#2563eb" },
{ label: "Grün", value: "#16a34a" },
@@ -45,7 +52,20 @@ const quickEntryColorOptions = [
{ label: "Schwarz", value: "#111827" }
]
const { style: quickConfigWindowStyle } = useDraggable(quickConfigWindowEl, {
initialValue: { x: 120, y: 100 }
})
const { style: profileDetailsWindowStyle } = useDraggable(profileDetailsWindowEl, {
initialValue: { x: 220, y: 120 }
})
const isAbsenceModalOpen = ref(false)
const isProfileDetailsModalOpen = ref(false)
const loadingProfileVacation = ref(false)
const selectedProfile = ref(null)
const selectedProfileVacationSummary = ref(null)
const profileVacationRequestId = ref(0)
const absenceForm = reactive({
mode: "create",
entry: null,
@@ -142,8 +162,28 @@ const absenceModalTitle = computed(() =>
: (absenceForm.type === "sick" ? "Krank eintragen" : "Urlaub eintragen")
)
const tenantCalendarConfig = computed(() =>
auth.activeTenantData?.calendarConfig
|| profileStore.ownTenant?.calendarConfig
|| {}
)
const resolvedPlanningBoardConfig = computed(() => {
const config = tenantCalendarConfig.value?.planningBoard || {}
const normalizedStartTime = /^\d{2}:\d{2}$/.test(String(config.startTime || "")) ? String(config.startTime) : "06:00"
const normalizedEndTime = /^\d{2}:\d{2}$/.test(String(config.endTime || "")) ? String(config.endTime) : "21:00"
const normalizedSlotMinutes = Number(config.slotMinutes)
const allowedSlotMinutes = [15, 30, 60, 120, 180]
return {
startTime: normalizedStartTime,
endTime: normalizedEndTime,
slotMinutes: allowedSlotMinutes.includes(normalizedSlotMinutes) ? normalizedSlotMinutes : 180
}
})
const resolvedQuickEntryConfig = computed(() => {
const config = profileStore.ownTenant?.calendarConfig?.quickEntry || {}
const config = tenantCalendarConfig.value?.quickEntry || {}
return {
name: config.name || "Quick-Eintrag",
@@ -151,9 +191,76 @@ const resolvedQuickEntryConfig = computed(() => {
}
})
const activeQuickEntryConfig = computed(() => ({
name: quickEntryConfig.name?.trim() || resolvedQuickEntryConfig.value.name,
color: quickEntryConfig.color || resolvedQuickEntryConfig.value.color
}))
const quickEntryPresets = computed(() => {
const rawPresets = tenantCalendarConfig.value?.quickEntryPresets
if (!Array.isArray(rawPresets)) return []
return rawPresets
.filter((preset) => preset?.name && preset?.color)
.map((preset) => ({
name: String(preset.name),
color: String(preset.color)
}))
})
const resourcesById = computed(() => new Map(resources.value.map((resource) => [resource.id, resource])))
const currentVacationYear = computed(() => $dayjs().year())
const selectedProfileTeamsLabel = computed(() =>
(selectedProfile.value?.teams || [])
.map((team) => team?.name)
.filter(Boolean)
.join(", ") || "Keine Teams zugeordnet"
)
const selectedProfileBranchesLabel = computed(() => {
const profile = selectedProfile.value
if (!profile) return "Keine Niederlassung"
const labels = [
profile.branch?.name,
...(profile.branches || []).map((branch) => branch?.name)
].filter(Boolean)
return [...new Set(labels)].join(", ") || "Keine Niederlassung"
})
const selectedProfileVacationDaysTaken = computed(() =>
Number(selectedProfileVacationSummary.value?.sumVacationDays || 0)
)
const selectedProfileAnnualLeaveDays = computed(() => {
const value = selectedProfile.value?.annual_paid_leave_days
return value === null || value === undefined || value === "" ? null : Number(value)
})
const selectedProfileRemainingVacationDays = computed(() => {
if (selectedProfileAnnualLeaveDays.value === null) return null
return Number((selectedProfileAnnualLeaveDays.value - selectedProfileVacationDaysTaken.value).toFixed(2))
})
const selectedProfileAvailabilityNote = computed(() =>
selectedProfile.value?.availability_note?.trim() || "Keine Verfügbarkeitshinweise hinterlegt"
)
const visibleResources = computed(() => {
if (selectedType.value === "all") return resources.value
return resources.value.filter((resource) => resource.type === selectedType.value)
const visibleMap = new Map()
resources.value
.filter((resource) => resource.type === selectedType.value)
.forEach((resource) => {
let current = resource
while (current) {
visibleMap.set(current.id, current)
current = current.parentId ? resourcesById.value.get(current.parentId) : null
}
})
return resources.value.filter((resource) => visibleMap.has(resource.id))
})
const visibleResourceIds = computed(() => new Set(visibleResources.value.map((resource) => resource.id)))
@@ -188,21 +295,47 @@ const calendarOptions = computed(() => ({
headerContent: "Ressource"
}
],
resourceLabelDidMount(info) {
const { resource } = info
const isProfileResource = resource.extendedProps?.resourceKind === "profile" && resource.extendedProps?.profileId
if (!isProfileResource) return
info.el.classList.add("cursor-pointer")
info.el.setAttribute("role", "button")
info.el.setAttribute("tabindex", "0")
info.el.setAttribute("title", "Mitarbeiterdetails öffnen")
const openProfile = () => openProfileDetails(resource.extendedProps.profileId)
const onKeydown = (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
openProfile()
}
}
info.el.addEventListener("click", openProfile)
info.el.addEventListener("keydown", onKeydown)
},
views: {
resourceTimelineDay: {
type: "resourceTimeline",
duration: { days: 1 },
buttonText: "Tag",
slotDuration: { hours: 1 }
slotDuration: { minutes: resolvedPlanningBoardConfig.value.slotMinutes },
snapDuration: { minutes: resolvedPlanningBoardConfig.value.slotMinutes },
slotMinTime: `${resolvedPlanningBoardConfig.value.startTime}:00`,
slotMaxTime: `${resolvedPlanningBoardConfig.value.endTime}:00`
},
resourceTimelineWeek: {
type: "resourceTimeline",
duration: { days: 7 },
buttonText: "Woche",
slotDuration: { hours: 3 },
slotDuration: { minutes: resolvedPlanningBoardConfig.value.slotMinutes },
snapDuration: { minutes: resolvedPlanningBoardConfig.value.slotMinutes },
weekends: false,
slotMinTime: "06:00:00",
slotMaxTime: "21:00:00"
slotMinTime: `${resolvedPlanningBoardConfig.value.startTime}:00`,
slotMaxTime: `${resolvedPlanningBoardConfig.value.endTime}:00`
},
resourceTimelineMonth: {
type: "resourceTimeline",
@@ -245,17 +378,17 @@ const calendarOptions = computed(() => ({
}))
function resolveEventColor(eventType) {
return profileStore.ownTenant?.calendarConfig?.eventTypes?.find((item) => item.label === eventType)?.color || "#111827"
return tenantCalendarConfig.value?.eventTypes?.find((item) => item.label === eventType)?.color || "#111827"
}
function resolveEventTitle(event, projectsById) {
if (event.name) return event.name
if (event.project && projectsById.has(event.project)) return projectsById.get(event.project).name
return event.quick ? resolvedQuickEntryConfig.value.name : "Planung"
return event.quick ? activeQuickEntryConfig.value.name : "Planung"
}
function resolveRenderedEventColor(event) {
if (event?.quick) return resolvedQuickEntryConfig.value.color
if (event?.quick) return activeQuickEntryConfig.value.color
return resolveEventColor(event.eventtype)
}
@@ -274,7 +407,11 @@ function getAbsenceColor(type) {
function getTeamLabel(team) {
if (!team) return "Team"
return team.branch?.name ? `${team.name} (${team.branch.name})` : team.name
return team.name
}
function getBranchResourceId(branchId) {
return `B-${branchId}`
}
function getProfileResourceIds(profile) {
@@ -305,8 +442,10 @@ function normalizeSelectedResourceIds(resourceId) {
}
function buildResources({ profiles, inventoryitems }) {
const branchResources = []
const teamResources = []
const profileResources = []
const topLevelTeamGroupId = "TEAM-UNBOUND"
profiles
.filter((profile) => !profile.archived)
@@ -317,17 +456,40 @@ function buildResources({ profiles, inventoryitems }) {
profileResources.push({
id: `P-${profile.id}`,
type: "Profile",
title: getProfileLabel(profile)
title: getProfileLabel(profile),
resourceKind: "profile",
profileId: profile.id
})
return
}
assignedTeams.forEach((team) => {
if (team?.branch?.id) {
const branchResourceId = getBranchResourceId(team.branch.id)
if (!branchResources.find((resource) => resource.id === branchResourceId)) {
branchResources.push({
id: branchResourceId,
type: "Niederlassung",
title: team.branch.name
})
}
}
if (!team?.branch?.id && !branchResources.find((resource) => resource.id === topLevelTeamGroupId)) {
branchResources.push({
id: topLevelTeamGroupId,
type: "Niederlassung",
title: "Übergreifende Teams"
})
}
if (!teamResources.find((resource) => resource.id === `T-${team.id}`)) {
teamResources.push({
id: `T-${team.id}`,
type: "Team",
title: getTeamLabel(team)
title: getTeamLabel(team),
parentId: team?.branch?.id ? getBranchResourceId(team.branch.id) : topLevelTeamGroupId
})
}
@@ -335,7 +497,9 @@ function buildResources({ profiles, inventoryitems }) {
id: `T-${team.id}:P-${profile.id}`,
parentId: `T-${team.id}`,
type: "Profile",
title: getProfileLabel(profile)
title: getProfileLabel(profile),
resourceKind: "profile",
profileId: profile.id
})
})
})
@@ -348,7 +512,7 @@ function buildResources({ profiles, inventoryitems }) {
title: item.name
}))
return [...teamResources, ...profileResources, ...inventoryResources]
return [...branchResources, ...teamResources, ...profileResources, ...inventoryResources]
}
function buildEvents({ rawEvents, projectsById }) {
@@ -443,30 +607,97 @@ function moveCalendarToday() {
api.today()
}
function openQuickConfig() {
quickEntryConfig.name = resolvedQuickEntryConfig.value.name
quickEntryConfig.color = resolvedQuickEntryConfig.value.color
function formatProfileDate(value) {
if (!value) return "Nicht hinterlegt"
return $dayjs(value).isValid() ? $dayjs(value).format("DD.MM.YYYY") : "Nicht hinterlegt"
}
async function saveQuickConfig() {
if (savingQuickConfig.value) return
function formatProfileNumber(value) {
if (value === null || value === undefined || value === "") return "Nicht hinterlegt"
const name = quickEntryConfig.name?.trim()
if (!name) {
toast.add({ title: "Name fehlt", description: "Bitte einen Namen für Quick-Einträge angeben.", color: "orange" })
const numericValue = Number(value)
return Number.isFinite(numericValue)
? new Intl.NumberFormat("de-DE", { minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(numericValue)
: String(value)
}
async function openProfileDetails(profileId) {
const profile = profiles.value.find((item) => item.id === profileId)
if (!profile) return
selectedProfile.value = profile
selectedProfileVacationSummary.value = null
isProfileDetailsModalOpen.value = true
const requestId = profileVacationRequestId.value + 1
profileVacationRequestId.value = requestId
if (!profile.user_id) {
loadingProfileVacation.value = false
return
}
loadingProfileVacation.value = true
try {
const from = `${currentVacationYear.value}-01-01`
const to = `${currentVacationYear.value}-12-31`
const response = await $api(`/api/staff/time/evaluation?${new URLSearchParams({
from,
to,
targetUserId: profile.user_id
}).toString()}`)
if (profileVacationRequestId.value !== requestId) return
selectedProfileVacationSummary.value = response?.summary || null
} catch (error) {
console.error("openProfileDetails failed", error)
if (profileVacationRequestId.value !== requestId) return
selectedProfileVacationSummary.value = null
toast.add({
title: "Resturlaub konnte nicht geladen werden",
description: "Die Mitarbeiterdetails sind sichtbar, aber die Urlaubsauswertung konnte nicht abgerufen werden.",
color: "orange"
})
} finally {
if (profileVacationRequestId.value === requestId) {
loadingProfileVacation.value = false
}
}
}
function openQuickConfig() {
newQuickPresetName.value = quickEntryConfig.name
showQuickConfigEditor.value = true
showQuickPresetManagement.value = false
isQuickConfigModalOpen.value = true
}
function applyQuickPreset(preset) {
if (!preset) return
quickEntryConfig.name = preset.name
quickEntryConfig.color = preset.color
newQuickPresetName.value = preset.name
}
watch(resolvedQuickEntryConfig, (config) => {
if (!quickEntryConfig.name?.trim()) quickEntryConfig.name = config.name
if (!quickEntryConfig.color) quickEntryConfig.color = config.color
}, { immediate: true })
async function persistQuickPresets(nextPresets) {
if (savingQuickConfig.value) return
savingQuickConfig.value = true
try {
const currentTenantData = auth.activeTenantData || {}
const nextCalendarConfig = {
...(currentTenantData.calendarConfig || {}),
quickEntry: {
name,
color: quickEntryConfig.color || "#2563eb"
}
quickEntryPresets: nextPresets
}
const updatedTenant = await $api(`/api/tenant/other/${auth.activeTenant}`, {
@@ -480,25 +711,49 @@ async function saveQuickConfig() {
auth.activeTenantData = updatedTenant
profileStore.ownTenant = updatedTenant
toast.add({ title: "Quick-Einträge gespeichert", color: "green" })
await loadPlanningBoard()
} catch (error) {
console.error("saveQuickConfig failed", error)
console.error("persistQuickPresets failed", error)
toast.add({
title: "Quick-Konfiguration konnte nicht gespeichert werden",
title: "Vorlagen konnten nicht gespeichert werden",
description: error?.message || "Bitte erneut versuchen.",
color: "red"
})
throw error
} finally {
savingQuickConfig.value = false
}
}
async function saveQuickPreset() {
const presetName = newQuickPresetName.value?.trim() || quickEntryConfig.name?.trim()
if (!presetName) {
toast.add({ title: "Name fehlt", description: "Bitte einen Namen für die Vorlage angeben.", color: "orange" })
return
}
const nextPresets = [
...quickEntryPresets.value.filter((preset) => preset.name !== presetName),
{
name: presetName,
color: quickEntryConfig.color || "#2563eb"
}
]
await persistQuickPresets(nextPresets)
toast.add({ title: "Vorlage gespeichert", color: "green" })
}
async function deleteQuickPreset(presetName) {
const nextPresets = quickEntryPresets.value.filter((preset) => preset.name !== presetName)
await persistQuickPresets(nextPresets)
toast.add({ title: "Vorlage gelöscht", color: "green" })
}
async function createQuickEvent(info) {
const resourceIds = normalizeSelectedResourceIds(info.resource?.id)
const payload = {
name: resolvedQuickEntryConfig.value.name,
name: activeQuickEntryConfig.value.name,
quick: true,
startDate: info.startStr,
endDate: info.endStr,
@@ -720,78 +975,14 @@ onMounted(() => {
</template>
<template #right>
<div class="flex flex-wrap items-center gap-2">
<UPopover>
<UButton
color="neutral"
variant="outline"
icon="i-heroicons-cog-6-tooth"
@click="openQuickConfig"
>
Quick-Einträge
</UButton>
<template #content>
<div class="w-[320px] space-y-4 p-4">
<div>
<h3 class="text-sm font-semibold text-highlighted">Quick-Einträge</h3>
<p class="text-xs text-muted">
Name und Farbe für neu gezogene Quick-Einträge in der Plantafel.
</p>
</div>
<UFormField label="Name">
<UInput v-model="quickEntryConfig.name" class="w-full" />
</UFormField>
<UFormField label="Farbe">
<div class="space-y-3">
<div class="flex flex-wrap gap-2">
<button
v-for="option in quickEntryColorOptions"
:key="option.value"
type="button"
class="flex items-center gap-2 rounded border px-2 py-1 text-xs transition"
:class="quickEntryConfig.color === option.value ? 'border-primary ring-1 ring-primary' : 'border-default'"
@click="quickEntryConfig.color = option.value"
>
<span
class="h-4 w-4 rounded-full border border-white/40"
:style="{ backgroundColor: option.value }"
/>
{{ option.label }}
</button>
</div>
<div class="flex items-center gap-3">
<input
v-model="quickEntryConfig.color"
type="color"
class="h-10 w-14 cursor-pointer rounded border border-default bg-transparent p-1"
>
<UInput v-model="quickEntryConfig.color" class="flex-1" />
</div>
</div>
</UFormField>
<div class="rounded border border-default p-3">
<p class="text-xs text-muted">Vorschau</p>
<div class="mt-2 inline-flex rounded px-3 py-1 text-sm font-medium text-white" :style="{ backgroundColor: quickEntryConfig.color }">
{{ quickEntryConfig.name || "Quick-Eintrag" }}
</div>
</div>
<div class="flex justify-end">
<UButton
color="primary"
:loading="savingQuickConfig"
@click="saveQuickConfig"
>
Speichern
</UButton>
</div>
</div>
</template>
</UPopover>
<UButton
color="neutral"
variant="outline"
icon="i-heroicons-cog-6-tooth"
@click="openQuickConfig"
>
Quick-Einträge
</UButton>
<UButton
color="amber"
variant="soft"
@@ -832,6 +1023,194 @@ onMounted(() => {
/>
</UDashboardPanelContent>
<div
v-if="isQuickConfigModalOpen"
ref="quickConfigWindowEl"
:style="quickConfigWindowStyle"
class="fixed z-[999] flex h-[720px] w-[980px] flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-2xl resize dark:border-gray-800 dark:bg-gray-900"
>
<div class="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3 select-none dark:border-gray-800 dark:bg-gray-800/50">
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-cog-6-tooth" class="text-gray-500" />
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
Quick-Einträge
</h3>
<p class="text-xs text-muted">
Name und Farbe für neu gezogene Quick-Einträge in der Plantafel.
</p>
</div>
</div>
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
size="sm"
@click="isQuickConfigModalOpen = false"
/>
</div>
<div class="flex-1 overflow-auto p-4">
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
<div class="space-y-4">
<div class="rounded border border-default p-3">
<div class="space-y-3">
<div>
<p class="text-sm font-medium text-highlighted">Vorlagen</p>
<p class="text-xs text-muted">Gespeicherte Kombinationen direkt für neue Quick-Einträge anwenden.</p>
</div>
<div v-if="quickEntryPresets.length" class="flex flex-wrap gap-2">
<button
v-for="preset in quickEntryPresets"
:key="preset.name"
type="button"
class="flex items-center gap-2 rounded border border-default px-2 py-1 text-xs transition hover:border-primary"
@click="applyQuickPreset(preset)"
>
<span
class="h-4 w-4 rounded-full border border-white/40"
:style="{ backgroundColor: preset.color }"
/>
{{ preset.name }}
</button>
</div>
<p v-else class="text-sm text-muted">
Es sind noch keine Vorlagen gespeichert.
</p>
</div>
</div>
<div class="rounded border border-default p-3">
<button
type="button"
class="flex w-full items-center justify-between text-left"
@click="showQuickPresetManagement = !showQuickPresetManagement"
>
<div>
<p class="text-sm font-medium text-highlighted">Vorlagen verwalten</p>
<p class="text-xs text-muted">Tenantweite Vorlagen löschen oder aufräumen.</p>
</div>
<UIcon
:name="showQuickPresetManagement ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'"
class="text-muted"
/>
</button>
<div v-if="showQuickPresetManagement" class="mt-4 space-y-4 border-t border-default pt-4">
<div v-if="quickEntryPresets.length" class="space-y-2">
<p class="text-xs font-medium text-muted">Gespeicherte Vorlagen</p>
<div
v-for="preset in quickEntryPresets"
:key="`${preset.name}-${preset.color}`"
class="flex items-center justify-between gap-3 rounded border border-default px-3 py-2"
>
<button
type="button"
class="flex min-w-0 items-center gap-2 text-left"
@click="applyQuickPreset(preset)"
>
<span
class="h-4 w-4 shrink-0 rounded-full border border-white/40"
:style="{ backgroundColor: preset.color }"
/>
<span class="truncate text-sm">{{ preset.name }}</span>
</button>
<UButton
color="error"
variant="ghost"
icon="i-heroicons-trash"
@click="deleteQuickPreset(preset.name)"
/>
</div>
</div>
</div>
</div>
</div>
<div class="rounded border border-default p-3">
<button
type="button"
class="flex w-full items-center justify-between text-left"
@click="showQuickConfigEditor = !showQuickConfigEditor"
>
<div>
<p class="text-sm font-medium text-highlighted">Konfiguration</p>
<p class="text-xs text-muted">Aktuellen Titel und Farbe für Quick-Einträge anpassen.</p>
</div>
<UIcon
:name="showQuickConfigEditor ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'"
class="text-muted"
/>
</button>
<div v-if="showQuickConfigEditor" class="mt-4 space-y-3 border-t border-default pt-4">
<UFormField label="Titel">
<UInput v-model="quickEntryConfig.name" class="w-full" />
</UFormField>
<UFormField label="Vorlagenname">
<UInput v-model="newQuickPresetName" class="w-full" />
</UFormField>
<UFormField label="Farbe">
<div class="space-y-3">
<div class="flex flex-wrap gap-2">
<button
v-for="option in quickEntryColorOptions"
:key="option.value"
type="button"
class="flex items-center gap-2 rounded border px-2 py-1 text-xs transition"
:class="quickEntryConfig.color === option.value ? 'border-primary ring-1 ring-primary' : 'border-default'"
@click="quickEntryConfig.color = option.value"
>
<span
class="h-4 w-4 rounded-full border border-white/40"
:style="{ backgroundColor: option.value }"
/>
{{ option.label }}
</button>
</div>
<div class="flex items-center gap-3">
<input
v-model="quickEntryConfig.color"
type="color"
class="h-10 w-14 cursor-pointer rounded border border-default bg-transparent p-1"
>
<UInput v-model="quickEntryConfig.color" class="flex-1" />
</div>
</div>
</UFormField>
<div class="rounded border border-default p-3">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs text-muted">Vorschau</p>
<div class="mt-2 inline-flex rounded px-3 py-1 text-sm font-medium text-white" :style="{ backgroundColor: quickEntryConfig.color }">
{{ quickEntryConfig.name || "Quick-Eintrag" }}
</div>
</div>
<UButton
color="primary"
:loading="savingQuickConfig"
@click="saveQuickPreset"
>
Vorlage speichern
</UButton>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="absolute bottom-0 right-0 h-4 w-4 cursor-se-resize opacity-50">
<UIcon name="i-heroicons-arrows-pointing-out" class="h-3 w-3 rotate-90 text-gray-400" />
</div>
</div>
<UModal v-model:open="isAbsenceModalOpen">
<template #content>
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
@@ -955,5 +1334,175 @@ onMounted(() => {
</UCard>
</template>
</UModal>
<div
v-if="isProfileDetailsModalOpen"
ref="profileDetailsWindowEl"
:style="profileDetailsWindowStyle"
class="fixed z-[999] flex h-[760px] w-[980px] flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-2xl resize dark:border-gray-800 dark:bg-gray-900"
>
<div class="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3 select-none dark:border-gray-800 dark:bg-gray-800/50">
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-user-circle" class="text-gray-500" />
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ getProfileLabel(selectedProfile) }}
</h3>
<p class="text-xs text-muted">
Mitarbeiterdetails
</p>
</div>
</div>
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
size="sm"
@click="isProfileDetailsModalOpen = false"
/>
</div>
<div v-if="selectedProfile" class="flex-1 overflow-auto p-4">
<div class="space-y-6">
<div class="grid gap-4 md:grid-cols-2">
<div class="rounded-lg border border-default p-4">
<p class="text-xs font-medium uppercase tracking-wide text-muted">Kontakt</p>
<dl class="mt-3 space-y-3 text-sm">
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">E-Mail</dt>
<dd class="text-right font-medium text-highlighted">{{ selectedProfile.email || "Nicht hinterlegt" }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Mobil</dt>
<dd class="text-right font-medium text-highlighted">{{ selectedProfile.mobile_tel || "Nicht hinterlegt" }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Festnetz</dt>
<dd class="text-right font-medium text-highlighted">{{ selectedProfile.fixed_tel || "Nicht hinterlegt" }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Geburtstag</dt>
<dd class="text-right font-medium text-highlighted">{{ formatProfileDate(selectedProfile.birthday) }}</dd>
</div>
</dl>
</div>
<div class="rounded-lg border border-default p-4">
<p class="text-xs font-medium uppercase tracking-wide text-muted">Beschäftigung</p>
<dl class="mt-3 space-y-3 text-sm">
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">MA-Nummer</dt>
<dd class="text-right font-medium text-highlighted">{{ selectedProfile.employee_number || "Nicht hinterlegt" }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Position</dt>
<dd class="text-right font-medium text-highlighted">{{ selectedProfile.position || "Nicht hinterlegt" }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Vertragsart</dt>
<dd class="text-right font-medium text-highlighted">{{ selectedProfile.contract_type || "Nicht hinterlegt" }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Eintrittsdatum</dt>
<dd class="text-right font-medium text-highlighted">{{ formatProfileDate(selectedProfile.entry_date) }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Wochenstunden</dt>
<dd class="text-right font-medium text-highlighted">{{ formatProfileNumber(selectedProfile.weekly_working_hours) }}</dd>
</div>
</dl>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="rounded-lg border border-default p-4">
<p class="text-xs font-medium uppercase tracking-wide text-muted">Zuordnung</p>
<dl class="mt-3 space-y-3 text-sm">
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Niederlassung</dt>
<dd class="text-right font-medium text-highlighted">{{ selectedProfileBranchesLabel }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Teams</dt>
<dd class="text-right font-medium text-highlighted">{{ selectedProfileTeamsLabel }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Status</dt>
<dd class="text-right font-medium text-highlighted">{{ selectedProfile.active ? "Aktiv" : "Inaktiv" }}</dd>
</div>
</dl>
</div>
<div class="rounded-lg border border-default p-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-muted">Urlaub {{ currentVacationYear }}</p>
<p class="mt-1 text-sm text-muted">Anzeige auf Basis der aktuellen Zeitauswertung.</p>
</div>
<UBadge color="amber" variant="subtle">
Resturlaub
</UBadge>
</div>
<div v-if="loadingProfileVacation" class="mt-4 space-y-2">
<USkeleton class="h-6 w-full" />
<USkeleton class="h-6 w-4/5" />
<USkeleton class="h-6 w-3/5" />
</div>
<dl v-else class="mt-4 space-y-3 text-sm">
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Urlaubsanspruch</dt>
<dd class="text-right font-medium text-highlighted">{{ formatProfileNumber(selectedProfileAnnualLeaveDays) }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Bereits verplant/genehmigt</dt>
<dd class="text-right font-medium text-highlighted">{{ formatProfileNumber(selectedProfileVacationDaysTaken) }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt class="text-muted">Resturlaub</dt>
<dd class="text-right text-base font-semibold text-highlighted">
{{ selectedProfileRemainingVacationDays === null ? "Nicht hinterlegt" : formatProfileNumber(selectedProfileRemainingVacationDays) }}
</dd>
</div>
</dl>
</div>
</div>
<div class="rounded-lg border border-default p-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-muted">Verfügbarkeitshinweis</p>
<p class="mt-1 text-sm text-muted">Z. B. bevorzugte Einsatzzeiten oder bekannte Einschränkungen.</p>
</div>
<UBadge color="sky" variant="subtle">
Verfügbarkeit
</UBadge>
</div>
<p class="mt-4 whitespace-pre-wrap text-sm font-medium text-highlighted">
{{ selectedProfileAvailabilityNote }}
</p>
</div>
</div>
</div>
<div class="flex justify-end gap-2 border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-800/30">
<UButton
color="primary"
variant="soft"
icon="i-heroicons-pencil-square"
@click="selectedProfile?.id && router.push(`/staff/profiles/${selectedProfile.id}`)"
>
Bearbeiten
</UButton>
<UButton color="gray" variant="soft" @click="isProfileDetailsModalOpen = false">
Schließen
</UButton>
</div>
<div class="absolute bottom-0 right-0 h-4 w-4 cursor-se-resize opacity-50">
<UIcon name="i-heroicons-arrows-pointing-out" class="h-3 w-3 rotate-90 text-gray-400" />
</div>
</div>
</div>
</template>

View File

@@ -83,7 +83,7 @@ const featureOptions = [
{ key: "incomingInvoices", label: "Buchhaltung: Eingangsbelege" },
{ key: "costcentres", label: "Buchhaltung: Kostenstellen" },
{ key: "branches", label: "Stammdaten: Niederlassungen" },
{ key: "teams", label: "Stammdaten: Teams" },
{ key: "teams", label: "Mitarbeiter: Teams" },
{ key: "accounts", label: "Buchhaltung: Buchungskonten" },
{ key: "ownaccounts", label: "Buchhaltung: Zusätzliche Buchungskonten" },
{ key: "banking", label: "Buchhaltung: Bank" },
@@ -97,7 +97,7 @@ const featureOptions = [
{ key: "services", label: "Stammdaten: Leistungen" },
{ key: "servicecategories", label: "Stammdaten: Leistungskategorien" },
{ key: "memberrelations", label: "Stammdaten: Mitgliedsverhältnisse" },
{ key: "staffProfiles", label: "Stammdaten: Mitarbeiter" },
{ key: "staffProfiles", label: "Mitarbeiter: Mitarbeiterprofile" },
{ key: "hourrates", label: "Stammdaten: Stundensätze" },
{ key: "projecttypes", label: "Stammdaten: Projekttypen" },
{ key: "contracttypes", label: "Stammdaten: Vertragstypen" },

View File

@@ -2,6 +2,8 @@
const route = useRoute()
const toast = useToast()
const auth = useAuthStore()
const admin = useAdmin()
const { $api } = useNuxtApp()
const id = route.params.id as string
@@ -10,11 +12,21 @@ const branches = ref<any[]>([])
const teams = ref<any[]>([])
const pending = ref(true)
const saving = ref(false)
const creatingLinkedUser = ref(false)
const createLinkedUserModalOpen = ref(false)
const createdLinkedUserPassword = ref("")
const createLinkedUserForm = reactive({
email: "",
})
const selectMenuUi = {
base: 'w-full',
content: 'min-w-[min(32rem,90vw)] w-max max-w-[90vw]'
}
const canCreateLinkedUser = computed(() => Boolean(auth.user?.is_admin && profile.value && !profile.value.user_id))
const linkedUserStatusLabel = computed(() => profile.value?.user_id ? "Benutzer verknüpft" : "Kein Benutzer verknüpft")
const linkedUserStatusColor = computed(() => profile.value?.user_id ? "green" : "orange")
async function fetchBranches() {
try {
branches.value = await useEntities("branches").select()
@@ -135,6 +147,54 @@ async function saveProfile() {
}
}
function openCreateLinkedUserModal() {
if (!profile.value) return
createLinkedUserForm.email = profile.value.email || ""
createLinkedUserModalOpen.value = true
}
async function createLinkedUser() {
if (!profile.value || creatingLinkedUser.value) return
const email = createLinkedUserForm.email.trim().toLowerCase()
if (!email) {
toast.add({
title: 'E-Mail fehlt',
description: 'Bitte eine E-Mail-Adresse für den neuen Benutzer angeben.',
color: 'orange'
})
return
}
creatingLinkedUser.value = true
try {
const response = await admin.createUserForProfile(profile.value.id, { email })
createdLinkedUserPassword.value = response?.initialPassword || ""
createLinkedUserModalOpen.value = false
createLinkedUserForm.email = ""
toast.add({
title: 'Benutzer angelegt',
description: createdLinkedUserPassword.value ? `Initialpasswort: ${createdLinkedUserPassword.value}` : undefined,
color: 'green'
})
await fetchProfile()
} catch (err: any) {
console.error('[createLinkedUser]', err)
toast.add({
title: 'Benutzer konnte nicht angelegt werden',
description: err?.data?.error || err?.message || 'Unbekannter Fehler',
color: 'red'
})
} finally {
creatingLinkedUser.value = false
}
}
const weekdays = [
{ key: '1', label: 'Montag' },
{ key: '2', label: 'Dienstag' },
@@ -243,14 +303,25 @@ onMounted(async () => {
<!-- Toolbar -->
<UDashboardToolbar>
<template #right>
<UButton
icon="i-mdi-content-save"
color="primary"
:loading="saving"
@click="saveProfile"
>
Speichern
</UButton>
<div class="flex items-center gap-2">
<UButton
v-if="canCreateLinkedUser"
icon="i-heroicons-user-plus"
color="neutral"
variant="outline"
@click="openCreateLinkedUserModal"
>
Benutzer anlegen
</UButton>
<UButton
icon="i-mdi-content-save"
color="primary"
:loading="saving"
@click="saveProfile"
>
Speichern
</UButton>
</div>
</template>
</UDashboardToolbar>
@@ -263,6 +334,9 @@ onMounted(async () => {
<h2 class="text-xl font-semibold text-gray-900">{{ profile.full_name }}</h2>
<p class="text-sm text-gray-500">{{ profile.employee_number || '' }}</p>
</div>
<UBadge :color="linkedUserStatusColor" variant="subtle">
{{ linkedUserStatusLabel }}
</UBadge>
</div>
<USeparator label="Persönliche Daten" />
@@ -474,9 +548,59 @@ onMounted(async () => {
<UFormField label="Token-ID" class="w-full">
<UInput v-model="profile.token_id" class="w-full" />
</UFormField>
<UFormField label="Verfügbarkeitshinweis" class="w-full md:col-span-2">
<UTextarea
v-model="profile.availability_note"
class="w-full"
:rows="4"
placeholder="z. B. kann nur vormittags eingeplant werden, bevorzugt Außendienst, nicht dienstags verfügbar"
/>
</UFormField>
</UForm>
</UCard>
<USkeleton v-if="pending" height="300px" />
<div v-if="createdLinkedUserPassword" class="mt-4">
<UAlert
title="Initialpasswort für den neuen Benutzer"
:description="createdLinkedUserPassword"
color="amber"
variant="soft"
close-button
@close="createdLinkedUserPassword = ''"
/>
</div>
</UDashboardPanelContent>
<UModal v-model:open="createLinkedUserModalOpen">
<template #content>
<UCard>
<template #header>
<div>
<div class="text-lg font-semibold">Benutzer zum Profil anlegen</div>
<p class="mt-1 text-sm text-gray-500">
Es wird automatisch ein zufälliges Initialpasswort erzeugt und der neue Benutzer direkt mit diesem Mitarbeiterprofil verknüpft.
</p>
</div>
</template>
<UForm :state="createLinkedUserForm" class="space-y-4" @submit.prevent="createLinkedUser">
<UFormField label="E-Mail">
<UInput v-model="createLinkedUserForm.email" type="email" autocomplete="email" />
</UFormField>
<div class="flex justify-end gap-3 pt-2">
<UButton color="gray" variant="soft" @click="createLinkedUserModalOpen = false">
Abbrechen
</UButton>
<UButton type="submit" color="primary" :loading="creatingLinkedUser">
Benutzer anlegen
</UButton>
</div>
</UForm>
</UCard>
</template>
</UModal>
</template>

View File

@@ -853,6 +853,15 @@ export const useDataStore = defineStore('data', () => {
item.billingInterval = selectedContractType.billingInterval || null
},
inputColumn: "Allgemeines"
},{
key: "allowedContracttypes",
label: "Vertragstypen für Änderungsanfragen",
inputType: "select",
selectDataType: "contracttypes",
selectOptionAttribute: "name",
selectSearchAttributes: ["name"],
selectMultiple: true,
inputColumn: "Allgemeines"
},{
key: "active",
label: "Aktiv",
@@ -994,7 +1003,7 @@ export const useDataStore = defineStore('data', () => {
inputType: "textarea",
}
],
showTabs: [{label: 'Informationen'},{label: 'Dateien'},{label: 'Wiki'}]
showTabs: [{label: 'Informationen'},{label: 'Dateien'},{label: 'Logbuch'},{label: 'Wiki'}]
},
contracttypes: {
isArchivable: true,