diff --git a/backend/db/migrations/0024_tenant_branches.sql b/backend/db/migrations/0024_tenant_branches.sql new file mode 100644 index 0000000..cdb8910 --- /dev/null +++ b/backend/db/migrations/0024_tenant_branches.sql @@ -0,0 +1,37 @@ +CREATE TABLE "branches" ( + "id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "tenant" bigint NOT NULL, + "name" text NOT NULL, + "number" text, + "description" text, + "archived" boolean DEFAULT false NOT NULL, + "updated_at" timestamp with time zone, + "updated_by" uuid +); +--> statement-breakpoint +ALTER TABLE "branches" ADD CONSTRAINT "branches_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "branches" ADD CONSTRAINT "branches_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "costcentres" ADD COLUMN "branch" bigint; +--> statement-breakpoint +ALTER TABLE "costcentres" ADD CONSTRAINT "costcentres_branch_branches_id_fk" FOREIGN KEY ("branch") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "auth_profiles" ADD COLUMN "branch_id" bigint; +--> statement-breakpoint +ALTER TABLE "auth_profiles" ADD CONSTRAINT "auth_profiles_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action; +--> statement-breakpoint +CREATE TABLE "auth_profile_branches" ( + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "profile_id" uuid NOT NULL, + "branch_id" bigint NOT NULL, + "created_by" uuid, + CONSTRAINT "auth_profile_branches_profile_id_branch_id_pk" PRIMARY KEY("profile_id","branch_id") +); +--> statement-breakpoint +ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_profile_id_auth_profiles_id_fk" FOREIGN KEY ("profile_id") REFERENCES "public"."auth_profiles"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "auth_profile_branches" ADD CONSTRAINT "auth_profile_branches_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index e33c113..8f5d59c 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1774080000000, "tag": "0023_tax_evaluation_period", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1774393200000, + "tag": "0024_tenant_branches", + "breakpoints": true } ] } diff --git a/backend/db/schema/auth_profile_branches.ts b/backend/db/schema/auth_profile_branches.ts new file mode 100644 index 0000000..f2e8d5d --- /dev/null +++ b/backend/db/schema/auth_profile_branches.ts @@ -0,0 +1,30 @@ +import { pgTable, uuid, bigint, timestamp } from "drizzle-orm/pg-core" + +import { authProfiles } from "./auth_profiles" +import { branches } from "./branches" +import { authUsers } from "./auth_users" + +export const authProfileBranches = pgTable( + "auth_profile_branches", + { + created_at: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + + profile_id: uuid("profile_id") + .notNull() + .references(() => authProfiles.id, { onDelete: "cascade" }), + + branch_id: bigint("branch_id", { mode: "number" }) + .notNull() + .references(() => branches.id, { onDelete: "cascade" }), + + created_by: uuid("created_by").references(() => authUsers.id), + }, + (table) => ({ + primaryKey: [table.profile_id, table.branch_id], + }) +) + +export type AuthProfileBranch = typeof authProfileBranches.$inferSelect +export type NewAuthProfileBranch = typeof authProfileBranches.$inferInsert diff --git a/backend/db/schema/auth_profiles.ts b/backend/db/schema/auth_profiles.ts index 85085e0..c013429 100644 --- a/backend/db/schema/auth_profiles.ts +++ b/backend/db/schema/auth_profiles.ts @@ -10,6 +10,7 @@ import { jsonb, } from "drizzle-orm/pg-core" import { authUsers } from "./auth_users" +import { branches } from "./branches" export const authProfiles = pgTable("auth_profiles", { id: uuid("id").primaryKey().defaultRandom(), @@ -18,6 +19,8 @@ export const authProfiles = pgTable("auth_profiles", { tenant_id: bigint("tenant_id", { mode: "number" }).notNull(), + branch_id: bigint("branch_id", { mode: "number" }).references(() => branches.id), + created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), diff --git a/backend/db/schema/branches.ts b/backend/db/schema/branches.ts new file mode 100644 index 0000000..671fdec --- /dev/null +++ b/backend/db/schema/branches.ts @@ -0,0 +1,37 @@ +import { + pgTable, + bigint, + timestamp, + text, + boolean, + uuid, +} from "drizzle-orm/pg-core" + +import { tenants } from "./tenants" +import { authUsers } from "./auth_users" + +export const branches = pgTable("branches", { + id: bigint("id", { mode: "number" }) + .primaryKey() + .generatedByDefaultAsIdentity(), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + + tenant: bigint("tenant", { mode: "number" }) + .notNull() + .references(() => tenants.id), + + name: text("name").notNull(), + number: text("number"), + description: text("description"), + + archived: boolean("archived").notNull().default(false), + + updatedAt: timestamp("updated_at", { withTimezone: true }), + updatedBy: uuid("updated_by").references(() => authUsers.id), +}) + +export type Branch = typeof branches.$inferSelect +export type NewBranch = typeof branches.$inferInsert diff --git a/backend/db/schema/costcentres.ts b/backend/db/schema/costcentres.ts index 7dccb10..f46f15b 100644 --- a/backend/db/schema/costcentres.ts +++ b/backend/db/schema/costcentres.ts @@ -13,6 +13,7 @@ import { inventoryitems } from "./inventoryitems" import { projects } from "./projects" import { vehicles } from "./vehicles" import { authUsers } from "./auth_users" +import { branches } from "./branches" export const costcentres = pgTable("costcentres", { id: uuid("id").primaryKey().defaultRandom(), @@ -32,6 +33,8 @@ export const costcentres = pgTable("costcentres", { project: bigint("project", { mode: "number" }).references(() => projects.id), + branch: bigint("branch", { mode: "number" }).references(() => branches.id), + inventoryitem: bigint("inventoryitem", { mode: "number" }).references( () => inventoryitems.id ), diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index e0711ba..4e8b063 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -1,5 +1,6 @@ export * from "./accounts" export * from "./auth_profiles" +export * from "./auth_profile_branches" export * from "./auth_role_permisssions" export * from "./auth_roles" export * from "./auth_tenant_users" @@ -8,6 +9,7 @@ export * from "./auth_users" export * from "./bankaccounts" export * from "./bankrequisitions" export * from "./bankstatements" +export * from "./branches" export * from "./checkexecutions" export * from "./checks" export * from "./citys" diff --git a/backend/db/schema/tenants.ts b/backend/db/schema/tenants.ts index fb6d01a..ab4b5e4 100644 --- a/backend/db/schema/tenants.ts +++ b/backend/db/schema/tenants.ts @@ -92,6 +92,7 @@ export const tenants = pgTable( serialInvoice: true, incomingInvoices: true, costcentres: true, + branches: true, accounts: true, ownaccounts: true, banking: true, diff --git a/backend/src/routes/auth/me.ts b/backend/src/routes/auth/me.ts index 1e543b0..5ef98ca 100644 --- a/backend/src/routes/auth/me.ts +++ b/backend/src/routes/auth/me.ts @@ -9,6 +9,7 @@ import { authRolePermissions, } from "../../../db/schema" import { eq, and, or, isNull } from "drizzle-orm" +import { enrichProfilesWithBranches } from "../../utils/profileBranches" export default async function meRoutes(server: FastifyInstance) { server.get("/me", async (req, reply) => { @@ -89,7 +90,8 @@ export default async function meRoutes(server: FastifyInstance) { ) .limit(1) - profile = profileResult?.[0] ?? null + const enrichedProfiles = await enrichProfilesWithBranches(server, profileResult) + profile = enrichedProfiles?.[0] ?? null } // ---------------------------------------------------- diff --git a/backend/src/routes/internal/tenant.ts b/backend/src/routes/internal/tenant.ts index ace0dc4..4133bb2 100644 --- a/backend/src/routes/internal/tenant.ts +++ b/backend/src/routes/internal/tenant.ts @@ -8,6 +8,7 @@ import { } from "../../../db/schema" import {and, eq, inArray} from "drizzle-orm" +import { enrichProfilesWithBranches } from "../../utils/profileBranches" export default async function tenantRoutesInternal(server: FastifyInstance) { @@ -53,7 +54,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) { .where(inArray(authUsers.id, userIds)) // 3) auth_profiles pro Tenant laden - const profiles = await server.db + const profileRows = await server.db .select() .from(authProfiles) .where( @@ -61,6 +62,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) { eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.user_id, userIds) )) + const profiles = await enrichProfilesWithBranches(server, profileRows) const combined = users.map(u => { const profile = profiles.find(p => p.user_id === u.id) @@ -91,12 +93,12 @@ export default async function tenantRoutesInternal(server: FastifyInstance) { const tenantId = req.params.id if (!tenantId) return reply.code(401).send({ error: "Unauthorized" }) - const data = await server.db + const profileRows = await server.db .select() .from(authProfiles) .where(eq(authProfiles.tenant_id, tenantId)) - return data + return await enrichProfilesWithBranches(server, profileRows) } catch (err) { console.error("/tenant/profiles ERROR:", err) diff --git a/backend/src/routes/profiles.ts b/backend/src/routes/profiles.ts index 833f748..888910b 100644 --- a/backend/src/routes/profiles.ts +++ b/backend/src/routes/profiles.ts @@ -4,6 +4,11 @@ import { eq, and } from "drizzle-orm"; import { authProfiles, } from "../../db/schema"; +import { + loadProfileWithBranches, + resolveTenantBranchIds, + syncProfileBranches, +} from "../utils/profileBranches"; export default async function authProfilesRoutes(server: FastifyInstance) { @@ -19,22 +24,13 @@ export default async function authProfilesRoutes(server: FastifyInstance) { return reply.code(400).send({ error: "No tenant selected" }); } - const rows = await server.db - .select() - .from(authProfiles) - .where( - and( - eq(authProfiles.id, id), - eq(authProfiles.tenant_id, tenantId) - ) - ) - .limit(1); + const profile = await loadProfileWithBranches(server, id, tenantId) - if (!rows.length) { + if (!profile) { return reply.code(404).send({ error: "User not found or not in tenant" }); } - return rows[0]; + return profile; } catch (error) { console.error("GET /profiles/:id ERROR:", error); @@ -48,7 +44,8 @@ export default async function authProfilesRoutes(server: FastifyInstance) { // ❌ Systemfelder entfernen const forbidden = [ "id", "user_id", "tenant_id", "created_at", "updated_at", - "updatedAt", "updatedBy", "old_profile_id", "full_name" + "updatedAt", "updatedBy", "old_profile_id", "full_name", + "branch", "branches", "branch_ids" ] forbidden.forEach(f => delete cleaned[f]) @@ -89,8 +86,19 @@ export default async function authProfilesRoutes(server: FastifyInstance) { // Clean + Normalize body = sanitizeProfileUpdate(body) + const { primaryBranchId, branchIds } = await resolveTenantBranchIds( + server, + tenantId, + [ + ...(Array.isArray(body.branch_ids) ? body.branch_ids : []), + ...(Array.isArray(body.branches) ? body.branches : []), + ], + body.branch_id ?? body.branch?.id ?? null + ) + const updateData = { ...body, + branch_id: primaryBranchId, updatedAt: new Date(), updatedBy: userId } @@ -110,10 +118,16 @@ export default async function authProfilesRoutes(server: FastifyInstance) { return reply.code(404).send({ error: "User not found or not in tenant" }) } - return updated[0] + await syncProfileBranches(server, id, branchIds, userId) + + const profile = await loadProfileWithBranches(server, id, tenantId) + return profile || updated[0] } catch (err) { console.error("PUT /profiles/:id ERROR:", err) + if (err instanceof Error && ["INVALID_BRANCH_SELECTION", "INVALID_PRIMARY_BRANCH"].includes(err.message)) { + return reply.code(400).send({ error: "Ungültige Niederlassungsauswahl" }) + } return reply.code(500).send({ error: "Internal Server Error" }) } }) diff --git a/backend/src/routes/tenant.ts b/backend/src/routes/tenant.ts index 73c39c3..74728ec 100644 --- a/backend/src/routes/tenant.ts +++ b/backend/src/routes/tenant.ts @@ -12,6 +12,7 @@ import { } from "../../db/schema" import {and, desc, eq, inArray} from "drizzle-orm" +import { enrichProfilesWithBranches } from "../utils/profileBranches" export default async function tenantRoutes(server: FastifyInstance) { @@ -123,7 +124,7 @@ export default async function tenantRoutes(server: FastifyInstance) { .where(inArray(authUsers.id, userIds)) // 3) auth_profiles pro Tenant laden - const profiles = await server.db + const profileRows = await server.db .select() .from(authProfiles) .where( @@ -131,6 +132,7 @@ export default async function tenantRoutes(server: FastifyInstance) { eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.user_id, userIds) )) + const profiles = await enrichProfilesWithBranches(server, profileRows) const combined = users.map(u => { const profile = profiles.find(p => p.user_id === u.id) @@ -160,11 +162,12 @@ export default async function tenantRoutes(server: FastifyInstance) { const tenantId = req.user?.tenant_id if (!tenantId) return reply.code(401).send({ error: "Unauthorized" }) - const data = await server.db + const profileRows = await server.db .select() .from(authProfiles) .where(eq(authProfiles.tenant_id, tenantId)) + const data = await enrichProfilesWithBranches(server, profileRows) return { data } } catch (err) { diff --git a/backend/src/utils/profileBranches.ts b/backend/src/utils/profileBranches.ts new file mode 100644 index 0000000..c78e784 --- /dev/null +++ b/backend/src/utils/profileBranches.ts @@ -0,0 +1,142 @@ +import { and, eq, inArray } from "drizzle-orm" +import { FastifyInstance } from "fastify" + +import { authProfileBranches, authProfiles, branches } from "../../db/schema" + +function normalizeBranchIds(values: any[]): number[] { + return [...new Set( + values + .map((value) => { + if (typeof value === "number") return value + if (typeof value === "string" && value.trim()) return Number(value) + if (value && typeof value === "object" && "id" in value) return Number(value.id) + return NaN + }) + .filter((value) => Number.isFinite(value)) + )] +} + +export async function enrichProfilesWithBranches(server: FastifyInstance, profiles: any[]) { + if (!profiles.length) return profiles + + const profileIds = profiles.map((profile) => profile.id).filter(Boolean) + if (!profileIds.length) return profiles + + const profileBranchRows = await server.db + .select() + .from(authProfileBranches) + .where(inArray(authProfileBranches.profile_id, profileIds)) + + const branchIds = [...new Set(profileBranchRows.map((row) => row.branch_id).filter(Boolean))] + const branchRows = branchIds.length + ? await server.db.select().from(branches).where(inArray(branches.id, branchIds)) + : [] + + const branchMap = new Map(branchRows.map((branch) => [branch.id, branch])) + const branchIdsByProfile = new Map() + + for (const row of profileBranchRows) { + const current = branchIdsByProfile.get(row.profile_id) || [] + current.push(row.branch_id) + branchIdsByProfile.set(row.profile_id, current) + } + + return profiles.map((profile) => { + const assignedBranchIds = [...new Set(branchIdsByProfile.get(profile.id) || [])] + return { + ...profile, + branch: profile.branch_id ? branchMap.get(profile.branch_id) || null : null, + branches: assignedBranchIds + .map((branchId) => branchMap.get(branchId)) + .filter(Boolean), + branch_ids: assignedBranchIds, + } + }) +} + +export async function loadProfileWithBranches(server: FastifyInstance, profileId: string, tenantId: number) { + const rows = await server.db + .select() + .from(authProfiles) + .where( + and( + eq(authProfiles.id, profileId), + eq(authProfiles.tenant_id, tenantId) + ) + ) + .limit(1) + + if (!rows.length) return null + + const [profile] = await enrichProfilesWithBranches(server, rows) + return profile +} + +export async function resolveTenantBranchIds( + server: FastifyInstance, + tenantId: number, + values: any[], + primaryBranchId?: any +) { + const normalizedPrimaryBranchId = primaryBranchId == null || primaryBranchId === "" + ? null + : Number(primaryBranchId) + + const requestedBranchIds = normalizeBranchIds([ + ...values, + normalizedPrimaryBranchId, + ]) + + if (!requestedBranchIds.length) { + return { + primaryBranchId: normalizedPrimaryBranchId, + branchIds: [], + } + } + + const validBranches = await server.db + .select({ id: branches.id }) + .from(branches) + .where( + and( + eq(branches.tenant, tenantId), + inArray(branches.id, requestedBranchIds) + ) + ) + + const validBranchIds = validBranches.map((branch) => branch.id) + + if (validBranchIds.length !== requestedBranchIds.length) { + throw new Error("INVALID_BRANCH_SELECTION") + } + + if (normalizedPrimaryBranchId != null && !validBranchIds.includes(normalizedPrimaryBranchId)) { + throw new Error("INVALID_PRIMARY_BRANCH") + } + + return { + primaryBranchId: normalizedPrimaryBranchId, + branchIds: validBranchIds, + } +} + +export async function syncProfileBranches( + server: FastifyInstance, + profileId: string, + branchIds: number[], + userId?: string | null +) { + await server.db + .delete(authProfileBranches) + .where(eq(authProfileBranches.profile_id, profileId)) + + if (!branchIds.length) return + + await server.db + .insert(authProfileBranches) + .values(branchIds.map((branchId) => ({ + profile_id: profileId, + branch_id: branchId, + created_by: userId || null, + }))) +} diff --git a/backend/src/utils/resource.config.ts b/backend/src/utils/resource.config.ts index 2bc86ce..76aba95 100644 --- a/backend/src/utils/resource.config.ts +++ b/backend/src/utils/resource.config.ts @@ -4,6 +4,7 @@ import { bankaccounts, bankrequisitions, bankstatements, + branches, entitybankaccounts, events, contacts, @@ -162,7 +163,12 @@ export const resourceConfig = { costcentres: { table: costcentres, searchColumns: ["name","number","description"], - mtoLoad: ["vehicle","project","inventoryitem"], + mtoLoad: ["vehicle","project","inventoryitem","branch"], + numberRangeHolder: "number", + }, + branches: { + table: branches, + searchColumns: ["name","number","description"], numberRangeHolder: "number", }, tasks: { diff --git a/frontend/components/MainNav.vue b/frontend/components/MainNav.vue index d8a8053..c191326 100644 --- a/frontend/components/MainNav.vue +++ b/frontend/components/MainNav.vue @@ -229,6 +229,11 @@ const links = computed(() => { to: "/standardEntity/memberrelations", icon: "i-heroicons-identification" } : null, + featureEnabled("branches") ? { + label: "Niederlassungen", + to: "/standardEntity/branches", + icon: "i-heroicons-building-office-2" + } : null, featureEnabled("staffProfiles") ? { label: "Mitarbeiter", to: "/staff/profiles", @@ -282,11 +287,6 @@ const links = computed(() => { to: "/settings/tenant", icon: "i-heroicons-building-office", } : null, - isAdmin.value ? { - label: "Administration", - to: "/settings/admin", - icon: "i-heroicons-shield-check", - } : null, featureEnabled("export") ? { label: "Export", to: "/export", @@ -294,6 +294,19 @@ const links = computed(() => { } : null, ] + const administrationChildren = isAdmin.value ? [ + { + label: "Benutzer", + to: "/administration/users", + icon: "i-heroicons-users", + }, + { + label: "Tenants", + to: "/administration/tenants", + icon: "i-heroicons-building-office-2", + }, + ] : [] + const visibleOrganisationChildren = visibleItems(organisationChildren) const visibleDocumentChildren = visibleItems(documentChildren) const visibleCommunicationChildren = visibleItems(communicationChildren) @@ -303,6 +316,7 @@ const links = computed(() => { const visibleInventoryChildren = visibleItems(inventoryChildren) const visibleMasterDataChildren = visibleItems(masterDataChildren) const visibleSettingsChildren = visibleItems(settingsChildren) + const visibleAdministrationChildren = visibleItems(administrationChildren) return visibleItems([ ...(auth.profile?.pinned_on_navigation || []).map(pin => { @@ -397,7 +411,12 @@ const links = computed(() => { icon: "i-heroicons-clipboard-document", children: visibleMasterDataChildren }] : []), - + ...(visibleAdministrationChildren.length > 0 ? [{ + label: "Administration", + defaultOpen: false, + icon: "i-heroicons-shield-check", + children: visibleAdministrationChildren + }] : []), ...(visibleSettingsChildren.length > 0 ? [{ label: "Einstellungen", diff --git a/frontend/components/columnRenderings/branch.vue b/frontend/components/columnRenderings/branch.vue new file mode 100644 index 0000000..7e894a4 --- /dev/null +++ b/frontend/components/columnRenderings/branch.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/composables/useAdmin.ts b/frontend/composables/useAdmin.ts new file mode 100644 index 0000000..2b4b907 --- /dev/null +++ b/frontend/composables/useAdmin.ts @@ -0,0 +1,108 @@ +export type AdminRole = { + id: string + name: string + description?: string | null + tenant_id: number | null +} + +export type AdminTenant = { + id: number + name: string + short: string + user_count: number + locked?: string | null +} + +export type AdminUserProfile = { + id: string + user_id: string | null + tenant_id: number + full_name: string | null + first_name: string + last_name: string + email?: string | null + active: boolean +} + +export type AdminUser = { + id: string + email: string + display_name: string + multiTenant: boolean + must_change_password: boolean + is_admin: boolean + profile_defaults: { + first_name: string + last_name: string + } + tenant_ids: number[] + role_assignments: { tenant_id: number; role_id: string }[] + profile_assignments?: { tenant_id: number; profile_id?: string | null }[] + profiles: AdminUserProfile[] +} + +export type AdminOverview = { + users: AdminUser[] + tenants: AdminTenant[] + roles: AdminRole[] + unassignedProfiles: AdminUserProfile[] +} + +export const useAdmin = () => { + const { $api } = useNuxtApp() + + const getOverview = async (): Promise => { + const response = await $api("/api/admin/overview") + + return { + users: response?.users || [], + tenants: response?.tenants || [], + roles: response?.roles || [], + unassignedProfiles: response?.unassignedProfiles || [], + } + } + + const createUser = async (body: Record) => { + return await $api("/api/admin/users", { + method: "POST", + body, + }) + } + + const updateUser = async (id: string, body: Record) => { + return await $api(`/api/admin/users/${id}`, { + method: "PUT", + body, + }) + } + + const updateUserAccess = async (id: string, body: Record) => { + return await $api(`/api/admin/users/${id}/access`, { + method: "PUT", + body, + }) + } + + const createTenant = async (body: Record) => { + return await $api("/api/admin/tenants", { + method: "POST", + body, + }) + } + + const updateTenant = async (id: number, body: Record) => { + return await $api(`/api/admin/tenants/${id}`, { + method: "PUT", + body, + }) + } + + return { + getOverview, + createUser, + updateUser, + updateUserAccess, + createTenant, + updateTenant, + } +} diff --git a/frontend/pages/administration/tenants/[id].vue b/frontend/pages/administration/tenants/[id].vue new file mode 100644 index 0000000..1009deb --- /dev/null +++ b/frontend/pages/administration/tenants/[id].vue @@ -0,0 +1,279 @@ + + + diff --git a/frontend/pages/administration/tenants/index.vue b/frontend/pages/administration/tenants/index.vue new file mode 100644 index 0000000..664827e --- /dev/null +++ b/frontend/pages/administration/tenants/index.vue @@ -0,0 +1,160 @@ + + + diff --git a/frontend/pages/administration/users/[id].vue b/frontend/pages/administration/users/[id].vue new file mode 100644 index 0000000..0000f6e --- /dev/null +++ b/frontend/pages/administration/users/[id].vue @@ -0,0 +1,330 @@ + + + diff --git a/frontend/pages/administration/users/index.vue b/frontend/pages/administration/users/index.vue new file mode 100644 index 0000000..fe30a8a --- /dev/null +++ b/frontend/pages/administration/users/index.vue @@ -0,0 +1,204 @@ + + + diff --git a/frontend/pages/settings/admin.vue b/frontend/pages/settings/admin.vue index dfb4988..f334d35 100644 --- a/frontend/pages/settings/admin.vue +++ b/frontend/pages/settings/admin.vue @@ -1,894 +1,26 @@ - - diff --git a/frontend/pages/settings/tenant.vue b/frontend/pages/settings/tenant.vue index 850eba8..a0d1abd 100644 --- a/frontend/pages/settings/tenant.vue +++ b/frontend/pages/settings/tenant.vue @@ -35,6 +35,7 @@ const defaultFeatures = { serialInvoice: true, incomingInvoices: true, costcentres: true, + branches: true, accounts: true, ownaccounts: true, banking: true, @@ -80,6 +81,7 @@ const featureOptions = [ { key: "serialInvoice", label: "Buchhaltung: Serienvorlagen" }, { key: "incomingInvoices", label: "Buchhaltung: Eingangsbelege" }, { key: "costcentres", label: "Buchhaltung: Kostenstellen" }, + { key: "branches", label: "Stammdaten: Niederlassungen" }, { key: "accounts", label: "Buchhaltung: Buchungskonten" }, { key: "ownaccounts", label: "Buchhaltung: Zusätzliche Buchungskonten" }, { key: "banking", label: "Buchhaltung: Bank" }, diff --git a/frontend/pages/staff/profiles/[id].vue b/frontend/pages/staff/profiles/[id].vue index 6d5711a..1fc304d 100644 --- a/frontend/pages/staff/profiles/[id].vue +++ b/frontend/pages/staff/profiles/[id].vue @@ -6,15 +6,26 @@ const { $api } = useNuxtApp() const id = route.params.id as string const profile = ref(null) +const branches = ref([]) const pending = ref(true) const saving = ref(false) +async function fetchBranches() { + try { + branches.value = await useEntities("branches").select() + } catch (err) { + console.error('[fetchBranches]', err) + branches.value = [] + } +} + /** Profil laden **/ async function fetchProfile() { pending.value = true try { profile.value = await $api(`/api/profiles/${id}`) ensureWorkingHoursStructure() + ensureBranchStructure() } catch (err: any) { console.error('[fetchProfile]', err) toast.add({ @@ -27,6 +38,45 @@ async function fetchProfile() { } } +function ensureBranchStructure() { + if (!profile.value) return + + profile.value.branch_id = profile.value.branch_id ?? profile.value.branch?.id ?? null + + if (!Array.isArray(profile.value.branch_ids)) { + if (Array.isArray(profile.value.branches)) { + profile.value.branch_ids = profile.value.branches + .map((entry: any) => entry?.id ?? entry) + .filter((entry: any) => entry != null) + } else { + profile.value.branch_ids = [] + } + } + + if (profile.value.branch_id && !profile.value.branch_ids.includes(profile.value.branch_id)) { + profile.value.branch_ids = [...profile.value.branch_ids, profile.value.branch_id] + } +} + +const updatePrimaryBranch = (value: number | null) => { + if (!profile.value) return + profile.value.branch_id = value + + if (value && !profile.value.branch_ids.includes(value)) { + profile.value.branch_ids = [...profile.value.branch_ids, value] + } +} + +const updateBranchMemberships = (values: number[]) => { + if (!profile.value) return + + profile.value.branch_ids = values || [] + + if (profile.value.branch_id && !profile.value.branch_ids.includes(profile.value.branch_id)) { + profile.value.branch_id = null + } +} + /** Profil speichern **/ async function saveProfile() { if (saving.value) return @@ -129,7 +179,9 @@ const checkZip = async () => { } } -onMounted(fetchProfile) +onMounted(async () => { + await Promise.all([fetchBranches(), fetchProfile()]) +}) @@ -255,6 +307,33 @@ onMounted(fetchProfile) + + + + + + + + + + + + + + diff --git a/frontend/pages/staff/profiles/index.vue b/frontend/pages/staff/profiles/index.vue index 1ea9101..a1eafc4 100644 --- a/frontend/pages/staff/profiles/index.vue +++ b/frontend/pages/staff/profiles/index.vue @@ -12,7 +12,8 @@ id: profile?.id || null, employee_number: profile?.employee_number || '', full_name: profile?.full_name || user?.full_name || user?.email || 'Ohne Profil', - email: user?.email || profile?.email || '' + email: user?.email || profile?.email || '', + branch_name: profile?.branch?.name || '' } } @@ -48,6 +49,9 @@ },{ key: "email", label: "E-Mail", + },{ + key: "branch_name", + label: "Niederlassung", } ] const selectedColumns = ref(templateColumns) diff --git a/frontend/stores/data.js b/frontend/stores/data.js index 4b32e3d..566ac8e 100644 --- a/frontend/stores/data.js +++ b/frontend/stores/data.js @@ -21,6 +21,7 @@ import recurring from "~/components/columnRenderings/recurring.vue" import description from "~/components/columnRenderings/description.vue" import purchasePrice from "~/components/columnRenderings/purchasePrice.vue"; import project from "~/components/columnRenderings/project.vue"; +import branch from "~/components/columnRenderings/branch.vue"; import created_at from "~/components/columnRenderings/created_at.vue"; import profile from "~/components/columnRenderings/profile.vue"; import profiles from "~/components/columnRenderings/profiles.vue"; @@ -2970,6 +2971,51 @@ export const useDataStore = defineStore('data', () => { redirect: true, historyItemHolder: "profile" }, + branches: { + isArchivable: true, + label: "Niederlassungen", + labelSingle: "Niederlassung", + isStandardEntity: true, + redirect: true, + numberRangeHolder: "number", + historyItemHolder: "branch", + sortColumn: "name", + selectWithInformation: "*", + filters: [{ + name: "Archivierte ausblenden", + default: true, + "filterFunction": function (row) { + if(!row.archived) { + return true + } else { + return false + } + } + }], + templateColumns: [ + { + key: "number", + label: "Nummer", + inputType: "text", + inputIsNumberRange: true, + sortable: true + }, + { + key: "name", + label: "Name", + required: true, + title: true, + inputType: "text", + sortable: true + }, + { + key: "description", + label: "Beschreibung", + inputType: "textarea" + } + ], + showTabs: [{label: 'Informationen'},{label: 'Wiki'}] + }, workingtimes: { isArchivable: true, label: "Anwesenheiten", @@ -3178,7 +3224,7 @@ export const useDataStore = defineStore('data', () => { numberRangeHolder: "number", historyItemHolder: "costcentre", sortColumn: "number", - selectWithInformation: "*, project(*), vehicle(*), inventoryitem(*)", + selectWithInformation: "*, project(*), vehicle(*), inventoryitem(*), branch(*)", filters: [{ name: "Archivierte ausblenden", default: true, @@ -3236,6 +3282,15 @@ export const useDataStore = defineStore('data', () => { selectOptionAttribute: "name", selectSearchAttributes: ['name'], }, + { + key: "branch", + label: "Niederlassung", + component: branch, + inputType: "select", + selectDataType: "branches", + selectOptionAttribute: "name", + selectSearchAttributes: ['name', 'number'], + }, /*{ key: "profiles", label: "Berechtigte Benutzer",