Compare commits
17 Commits
20818beb3a
...
0f5275b870
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f5275b870 | |||
| 4f37811dcc | |||
| d7eced3e77 | |||
| 6a5c1e844d | |||
| 5dc44e571f | |||
| 2b1a9a456b | |||
| bf5d7aaed2 | |||
| e166248c0d | |||
| cba4ea52e8 | |||
| 0f14f7ac3d | |||
| 2d26cedaa3 | |||
| d5aed2140e | |||
| cfc5efb556 | |||
| 898a5459fa | |||
| 3b7bcb7940 | |||
| 2aaff0088e | |||
| e9bbc196f7 |
2
backend/db/migrations/0034_profile_availability_note.sql
Normal file
2
backend/db/migrations/0034_profile_availability_note.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "auth_profiles"
|
||||
ADD COLUMN "availability_note" text;
|
||||
3
backend/db/migrations/0035_contract_history.sql
Normal file
3
backend/db/migrations/0035_contract_history.sql
Normal 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;
|
||||
1
backend/db/migrations/0036_allowed_contracttypes.sql
Normal file
1
backend/db/migrations/0036_allowed_contracttypes.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "contracts" ADD COLUMN "allowedContracttypes" jsonb DEFAULT '[]'::jsonb NOT NULL;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
589
backend/scripts/import-mitarbeiterliste.ts
Normal file
589
backend/scripts/import-mitarbeiterliste.ts
Normal 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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/'/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
|
||||
})
|
||||
7
backend/scripts/mitarbeiterliste-tenant-41-branches.json
Normal file
7
backend/scripts/mitarbeiterliste-tenant-41-branches.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"1848 Pütt": 1,
|
||||
"Strandcafé": 3,
|
||||
"Oceans11": 4,
|
||||
"Oceans 11": 4,
|
||||
"Winnys": 5
|
||||
}
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
230
backend/src/routes/portal/contracts.ts
Normal file
230
backend/src/routes/portal/contracts.ts
Normal 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." }
|
||||
})
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
// -------------------------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user