diff --git a/backend/db/migrations/0028_teams.sql b/backend/db/migrations/0028_teams.sql new file mode 100644 index 0000000..5d38de0 --- /dev/null +++ b/backend/db/migrations/0028_teams.sql @@ -0,0 +1,31 @@ +CREATE TABLE "teams" ( + "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "teams_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1), + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "tenant" bigint NOT NULL, + "name" text NOT NULL, + "description" text, + "branch" bigint, + "archived" boolean DEFAULT false NOT NULL, + "updated_at" timestamp with time zone, + "updated_by" uuid +); +--> statement-breakpoint +CREATE TABLE "auth_profile_teams" ( + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "profile_id" uuid NOT NULL, + "team_id" bigint NOT NULL, + "created_by" uuid, + CONSTRAINT "auth_profile_teams_profile_id_team_id_pk" PRIMARY KEY("profile_id","team_id") +); +--> statement-breakpoint +ALTER TABLE "teams" ADD CONSTRAINT "teams_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "teams" ADD CONSTRAINT "teams_branch_branches_id_fk" FOREIGN KEY ("branch") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "teams" ADD CONSTRAINT "teams_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 "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_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_teams" ADD CONSTRAINT "auth_profile_teams_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "auth_profile_teams" ADD CONSTRAINT "auth_profile_teams_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/0029_events_quick.sql b/backend/db/migrations/0029_events_quick.sql new file mode 100644 index 0000000..883df1d --- /dev/null +++ b/backend/db/migrations/0029_events_quick.sql @@ -0,0 +1 @@ +ALTER TABLE "events" ADD COLUMN "quick" boolean DEFAULT false NOT NULL; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 1b1c5d9..312fd46 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -197,6 +197,20 @@ "when": 1774602000000, "tag": "0027_product_supplier_link", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1776124800000, + "tag": "0028_teams", + "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1776211200000, + "tag": "0029_events_quick", + "breakpoints": true } ] } diff --git a/backend/db/schema/auth_profile_teams.ts b/backend/db/schema/auth_profile_teams.ts new file mode 100644 index 0000000..80782b3 --- /dev/null +++ b/backend/db/schema/auth_profile_teams.ts @@ -0,0 +1,30 @@ +import { pgTable, uuid, bigint, timestamp } from "drizzle-orm/pg-core" + +import { authProfiles } from "./auth_profiles" +import { teams } from "./teams" +import { authUsers } from "./auth_users" + +export const authProfileTeams = pgTable( + "auth_profile_teams", + { + created_at: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + + profile_id: uuid("profile_id") + .notNull() + .references(() => authProfiles.id, { onDelete: "cascade" }), + + team_id: bigint("team_id", { mode: "number" }) + .notNull() + .references(() => teams.id, { onDelete: "cascade" }), + + created_by: uuid("created_by").references(() => authUsers.id), + }, + (table) => ({ + primaryKey: [table.profile_id, table.team_id], + }) +) + +export type AuthProfileTeam = typeof authProfileTeams.$inferSelect +export type NewAuthProfileTeam = typeof authProfileTeams.$inferInsert diff --git a/backend/db/schema/events.ts b/backend/db/schema/events.ts index b703c3e..0f435ba 100644 --- a/backend/db/schema/events.ts +++ b/backend/db/schema/events.ts @@ -31,6 +31,7 @@ export const events = pgTable( endDate: timestamp("endDate", { withTimezone: true }), eventtype: text("eventtype").default("Umsetzung"), + quick: boolean("quick").notNull().default(false), project: bigint("project", { mode: "number" }), // FK follows when projects.ts exists diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index 4e8b063..7f4dd1a 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -1,6 +1,7 @@ export * from "./accounts" export * from "./auth_profiles" export * from "./auth_profile_branches" +export * from "./auth_profile_teams" export * from "./auth_role_permisssions" export * from "./auth_roles" export * from "./auth_tenant_users" @@ -69,6 +70,7 @@ export * from "./staff_time_entry_connects" export * from "./staff_zeitstromtimestamps" export * from "./statementallocations" export * from "./tasks" +export * from "./teams" export * from "./taxtypes" export * from "./tenants" export * from "./texttemplates" diff --git a/backend/db/schema/teams.ts b/backend/db/schema/teams.ts new file mode 100644 index 0000000..ce919c9 --- /dev/null +++ b/backend/db/schema/teams.ts @@ -0,0 +1,40 @@ +import { + pgTable, + bigint, + timestamp, + text, + boolean, + uuid, +} from "drizzle-orm/pg-core" + +import { tenants } from "./tenants" +import { authUsers } from "./auth_users" +import { branches } from "./branches" + +export const teams = pgTable("teams", { + 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(), + description: text("description"), + + branch: bigint("branch", { mode: "number" }) + .references(() => branches.id), + + archived: boolean("archived").notNull().default(false), + + updatedAt: timestamp("updated_at", { withTimezone: true }), + updatedBy: uuid("updated_by").references(() => authUsers.id), +}) + +export type Team = typeof teams.$inferSelect +export type NewTeam = typeof teams.$inferInsert diff --git a/backend/db/schema/tenants.ts b/backend/db/schema/tenants.ts index 190069d..772d8e0 100644 --- a/backend/db/schema/tenants.ts +++ b/backend/db/schema/tenants.ts @@ -93,6 +93,7 @@ export const tenants = pgTable( incomingInvoices: true, costcentres: true, branches: true, + teams: true, accounts: true, ownaccounts: true, banking: true, diff --git a/backend/src/routes/internal/tenant.ts b/backend/src/routes/internal/tenant.ts index 4133bb2..527e6e5 100644 --- a/backend/src/routes/internal/tenant.ts +++ b/backend/src/routes/internal/tenant.ts @@ -9,6 +9,7 @@ import { import {and, eq, inArray} from "drizzle-orm" import { enrichProfilesWithBranches } from "../../utils/profileBranches" +import { enrichProfilesWithTeams } from "../../utils/profileTeams" export default async function tenantRoutesInternal(server: FastifyInstance) { @@ -62,7 +63,8 @@ export default async function tenantRoutesInternal(server: FastifyInstance) { eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.user_id, userIds) )) - const profiles = await enrichProfilesWithBranches(server, profileRows) + const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) + const profiles = await enrichProfilesWithTeams(server, profilesWithBranches) const combined = users.map(u => { const profile = profiles.find(p => p.user_id === u.id) @@ -98,7 +100,8 @@ export default async function tenantRoutesInternal(server: FastifyInstance) { .from(authProfiles) .where(eq(authProfiles.tenant_id, tenantId)) - return await enrichProfilesWithBranches(server, profileRows) + const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) + return await enrichProfilesWithTeams(server, profilesWithBranches) } catch (err) { console.error("/tenant/profiles ERROR:", err) diff --git a/backend/src/routes/profiles.ts b/backend/src/routes/profiles.ts index 888910b..0aa50c9 100644 --- a/backend/src/routes/profiles.ts +++ b/backend/src/routes/profiles.ts @@ -9,6 +9,11 @@ import { resolveTenantBranchIds, syncProfileBranches, } from "../utils/profileBranches"; +import { + enrichProfilesWithTeams, + resolveTenantTeamIds, + syncProfileTeams, +} from "../utils/profileTeams"; export default async function authProfilesRoutes(server: FastifyInstance) { @@ -24,7 +29,10 @@ export default async function authProfilesRoutes(server: FastifyInstance) { return reply.code(400).send({ error: "No tenant selected" }); } - const profile = await loadProfileWithBranches(server, id, tenantId) + const profileWithBranches = await loadProfileWithBranches(server, id, tenantId) + const [profile] = profileWithBranches + ? await enrichProfilesWithTeams(server, [profileWithBranches]) + : [null] if (!profile) { return reply.code(404).send({ error: "User not found or not in tenant" }); @@ -45,7 +53,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) { const forbidden = [ "id", "user_id", "tenant_id", "created_at", "updated_at", "updatedAt", "updatedBy", "old_profile_id", "full_name", - "branch", "branches", "branch_ids" + "branch" ] forbidden.forEach(f => delete cleaned[f]) @@ -95,6 +103,19 @@ export default async function authProfilesRoutes(server: FastifyInstance) { ], body.branch_id ?? body.branch?.id ?? null ) + const teamIds = await resolveTenantTeamIds( + server, + tenantId, + [ + ...(Array.isArray(body.team_ids) ? body.team_ids : []), + ...(Array.isArray(body.teams) ? body.teams : []), + ], + ) + + delete body.branch_ids + delete body.branches + delete body.team_ids + delete body.teams const updateData = { ...body, @@ -119,8 +140,12 @@ export default async function authProfilesRoutes(server: FastifyInstance) { } await syncProfileBranches(server, id, branchIds, userId) + await syncProfileTeams(server, id, teamIds, userId) - const profile = await loadProfileWithBranches(server, id, tenantId) + const profileWithBranches = await loadProfileWithBranches(server, id, tenantId) + const [profile] = profileWithBranches + ? await enrichProfilesWithTeams(server, [profileWithBranches]) + : [null] return profile || updated[0] } catch (err) { @@ -128,6 +153,9 @@ export default async function authProfilesRoutes(server: FastifyInstance) { if (err instanceof Error && ["INVALID_BRANCH_SELECTION", "INVALID_PRIMARY_BRANCH"].includes(err.message)) { return reply.code(400).send({ error: "Ungültige Niederlassungsauswahl" }) } + if (err instanceof Error && err.message === "INVALID_TEAM_SELECTION") { + return reply.code(400).send({ error: "Ungültige Teamauswahl" }) + } return reply.code(500).send({ error: "Internal Server Error" }) } }) diff --git a/backend/src/routes/tenant.ts b/backend/src/routes/tenant.ts index 74728ec..b65b2cf 100644 --- a/backend/src/routes/tenant.ts +++ b/backend/src/routes/tenant.ts @@ -13,6 +13,7 @@ import { import {and, desc, eq, inArray} from "drizzle-orm" import { enrichProfilesWithBranches } from "../utils/profileBranches" +import { enrichProfilesWithTeams } from "../utils/profileTeams" export default async function tenantRoutes(server: FastifyInstance) { @@ -132,7 +133,8 @@ export default async function tenantRoutes(server: FastifyInstance) { eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.user_id, userIds) )) - const profiles = await enrichProfilesWithBranches(server, profileRows) + const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) + const profiles = await enrichProfilesWithTeams(server, profilesWithBranches) const combined = users.map(u => { const profile = profiles.find(p => p.user_id === u.id) @@ -167,7 +169,8 @@ export default async function tenantRoutes(server: FastifyInstance) { .from(authProfiles) .where(eq(authProfiles.tenant_id, tenantId)) - const data = await enrichProfilesWithBranches(server, profileRows) + const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) + const data = await enrichProfilesWithTeams(server, profilesWithBranches) return { data } } catch (err) { diff --git a/backend/src/utils/history.ts b/backend/src/utils/history.ts index 3f9346c..c0e5289 100644 --- a/backend/src/utils/history.ts +++ b/backend/src/utils/history.ts @@ -32,6 +32,7 @@ const HISTORY_ENTITY_LABELS: Record = { incominginvoices: "Eingangsrechnungen", files: "Dateien", memberrelations: "Mitgliedsverhältnisse", + teams: "Teams", } export function getHistoryEntityLabel(entity: string) { diff --git a/backend/src/utils/profileTeams.ts b/backend/src/utils/profileTeams.ts new file mode 100644 index 0000000..edafaa5 --- /dev/null +++ b/backend/src/utils/profileTeams.ts @@ -0,0 +1,117 @@ +import { and, eq, inArray } from "drizzle-orm" +import { FastifyInstance } from "fastify" + +import { authProfileTeams, branches, teams } from "../../db/schema" + +function normalizeTeamIds(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 enrichProfilesWithTeams(server: FastifyInstance, profiles: any[]) { + if (!profiles.length) return profiles + + const profileIds = profiles.map((profile) => profile.id).filter(Boolean) + if (!profileIds.length) return profiles + + const profileTeamRows = await server.db + .select() + .from(authProfileTeams) + .where(inArray(authProfileTeams.profile_id, profileIds)) + + const teamIds = [...new Set(profileTeamRows.map((row) => row.team_id).filter(Boolean))] + const teamRows = teamIds.length + ? await server.db.select().from(teams).where(inArray(teams.id, teamIds)) + : [] + + const branchIds = [...new Set(teamRows.map((team) => team.branch).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 teamMap = new Map(teamRows.map((team) => [ + team.id, + { + ...team, + branch: team.branch ? branchMap.get(team.branch) || null : null, + }, + ])) + const teamIdsByProfile = new Map() + + for (const row of profileTeamRows) { + const current = teamIdsByProfile.get(row.profile_id) || [] + current.push(row.team_id) + teamIdsByProfile.set(row.profile_id, current) + } + + return profiles.map((profile) => { + const assignedTeamIds = [...new Set(teamIdsByProfile.get(profile.id) || [])] + return { + ...profile, + teams: assignedTeamIds + .map((teamId) => teamMap.get(teamId)) + .filter(Boolean), + team_ids: assignedTeamIds, + } + }) +} + +export async function resolveTenantTeamIds( + server: FastifyInstance, + tenantId: number, + values: any[], +) { + const requestedTeamIds = normalizeTeamIds(values) + + if (!requestedTeamIds.length) { + return [] + } + + const validTeams = await server.db + .select({ id: teams.id }) + .from(teams) + .where( + and( + eq(teams.tenant, tenantId), + inArray(teams.id, requestedTeamIds) + ) + ) + + const validTeamIds = validTeams.map((team) => team.id) + + if (validTeamIds.length !== requestedTeamIds.length) { + throw new Error("INVALID_TEAM_SELECTION") + } + + return validTeamIds +} + +export async function syncProfileTeams( + server: FastifyInstance, + profileId: string, + teamIds: number[], + userId?: string | null +) { + await server.db + .delete(authProfileTeams) + .where(eq(authProfileTeams.profile_id, profileId)) + + if (!teamIds.length) return + + await server.db + .insert(authProfileTeams) + .values(teamIds.map((teamId) => ({ + profile_id: profileId, + team_id: teamId, + created_by: userId || null, + }))) +} diff --git a/backend/src/utils/resource.config.ts b/backend/src/utils/resource.config.ts index ea7debb..bc59300 100644 --- a/backend/src/utils/resource.config.ts +++ b/backend/src/utils/resource.config.ts @@ -36,6 +36,7 @@ import { spaces, statementallocations, tasks, + teams, texttemplates, units, vehicles, @@ -171,6 +172,11 @@ export const resourceConfig = { searchColumns: ["name","number","description"], numberRangeHolder: "number", }, + teams: { + table: teams, + searchColumns: ["name", "description"], + mtoLoad: ["branch"], + }, tasks: { table: tasks, }, diff --git a/frontend/components/EntityEdit.vue b/frontend/components/EntityEdit.vue index f317818..ce7462d 100644 --- a/frontend/components/EntityEdit.vue +++ b/frontend/components/EntityEdit.vue @@ -63,6 +63,20 @@ const generateOldItemData = () => { } generateOldItemData() +const inputColumnCount = computed(() => { + return Array.isArray(dataType.inputColumns) && dataType.inputColumns.length > 0 + ? dataType.inputColumns.length + : 1 +}) + +const getInputColumnStyle = () => { + if (props.platform === 'mobile') return undefined + + return { + width: `${100 / inputColumnCount.value}%` + } +} + // --- ÄNDERUNG START: Computed Property statt Watcher/Function --- // Dies berechnet den Status automatisch neu, egal woher die Daten kommen (Init oder User-Eingabe) @@ -436,7 +450,8 @@ const updateItem = async () => {
diff --git a/frontend/components/MainNav.vue b/frontend/components/MainNav.vue index 7ab1d56..0b617b6 100644 --- a/frontend/components/MainNav.vue +++ b/frontend/components/MainNav.vue @@ -239,6 +239,11 @@ 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", diff --git a/frontend/composables/useRole.js b/frontend/composables/useRole.js index 390752a..3e453f1 100644 --- a/frontend/composables/useRole.js +++ b/frontend/composables/useRole.js @@ -244,6 +244,18 @@ export const useRole = () => { label: "Kostenstellen erstellen", parent: "costcentres" }, + teams: { + label: "Teams", + showToAllUsers: false + }, + "teams-viewAll": { + label: "Alle Teams einsehen", + parent: "teams" + }, + "teams-create": { + label: "Teams erstellen", + parent: "teams" + }, ownaccounts: { label: "Buchungskonten", showToAllUsers: false diff --git a/frontend/pages/organisation/plantafel.vue b/frontend/pages/organisation/plantafel.vue index c6e9af8..ed06d9c 100644 --- a/frontend/pages/organisation/plantafel.vue +++ b/frontend/pages/organisation/plantafel.vue @@ -6,9 +6,11 @@ import resourceTimelinePlugin from "@fullcalendar/resource-timeline" import { parseDate } from "@internationalized/date" const router = useRouter() +const auth = useAuthStore() const profileStore = useProfileStore() const toast = useToast() -const { $dayjs } = useNuxtApp() +const { $api, $dayjs } = useNuxtApp() +const { create: createEvent } = useEntities("events") const { list: listStaffTimeSpans, createEntry, update: updateStaffTimeEntry } = useStaffTime() const loading = ref(true) @@ -27,6 +29,21 @@ const resources = ref([]) const events = ref([]) const profiles = ref([]) const inventoryitems = ref([]) +const savingQuickConfig = ref(false) +const quickEntryConfig = reactive({ + name: "Quick-Eintrag", + color: "#2563eb" +}) +const quickEntryColorOptions = [ + { label: "Blau", value: "#2563eb" }, + { label: "Grün", value: "#16a34a" }, + { label: "Orange", value: "#ea580c" }, + { label: "Rot", value: "#dc2626" }, + { label: "Pink", value: "#db2777" }, + { label: "Türkis", value: "#0f766e" }, + { label: "Grau", value: "#4b5563" }, + { label: "Schwarz", value: "#111827" } +] const isAbsenceModalOpen = ref(false) const absenceForm = reactive({ @@ -75,6 +92,7 @@ const endDateValue = computed({ const resourceTypeOptions = [ { label: "Alle Ressourcen", value: "all" }, + { label: "Teams", value: "Team" }, { label: "Profile", value: "Profile" }, { label: "Inventarartikel", value: "Inventarartikel" } ] @@ -124,6 +142,15 @@ const absenceModalTitle = computed(() => : (absenceForm.type === "sick" ? "Krank eintragen" : "Urlaub eintragen") ) +const resolvedQuickEntryConfig = computed(() => { + const config = profileStore.ownTenant?.calendarConfig?.quickEntry || {} + + return { + name: config.name || "Quick-Eintrag", + color: config.color || "#2563eb" + } +}) + const visibleResources = computed(() => { if (selectedType.value === "all") return resources.value return resources.value.filter((resource) => resource.type === selectedType.value) @@ -184,8 +211,7 @@ const calendarOptions = computed(() => ({ } }, select(info) { - const resourceIds = info.resource?.id ? [info.resource.id] : [] - router.push(`/standardEntity/events/create?startDate=${encodeURIComponent(info.startStr)}&endDate=${encodeURIComponent(info.endStr)}&resources=${encodeURIComponent(JSON.stringify(resourceIds))}&source=timeline`) + createQuickEvent(info) }, eventClick(info) { if (info.event.extendedProps.entrytype === "staff-absence") { @@ -199,7 +225,7 @@ const calendarOptions = computed(() => ({ return } - router.push(`/standardEntity/events/show/${info.event.extendedProps.eventId}`) + router.push(`/standardEntity/events/edit/${info.event.extendedProps.eventId}`) }, datesSet(info) { const nextFrom = $dayjs(info.start).format("YYYY-MM-DD") @@ -225,7 +251,12 @@ function resolveEventColor(eventType) { function resolveEventTitle(event, projectsById) { if (event.name) return event.name if (event.project && projectsById.has(event.project)) return projectsById.get(event.project).name - return "Planung" + return event.quick ? resolvedQuickEntryConfig.value.name : "Planung" +} + +function resolveRenderedEventColor(event) { + if (event?.quick) return resolvedQuickEntryConfig.value.color + return resolveEventColor(event.eventtype) } function getProfileLabel(profile) { @@ -241,23 +272,83 @@ function getAbsenceColor(type) { return type === "sick" ? "#dc2626" : "#d97706" } +function getTeamLabel(team) { + if (!team) return "Team" + return team.branch?.name ? `${team.name} (${team.branch.name})` : team.name +} + +function getProfileResourceIds(profile) { + const assignedTeams = Array.isArray(profile?.teams) ? profile.teams.filter((team) => team?.id) : [] + + if (!assignedTeams.length) { + return [`P-${profile.id}`] + } + + return assignedTeams.map((team) => `T-${team.id}:P-${profile.id}`) +} + +function normalizeSelectedResourceIds(resourceId) { + if (!resourceId) return [] + + if (resourceId.startsWith("T-") && resourceId.includes(":P-")) { + return [`P-${resourceId.split(":P-")[1]}`] + } + + if (resourceId.startsWith("T-")) { + const teamId = Number(resourceId.replace("T-", "")) + return profiles.value + .filter((profile) => (profile.teams || []).some((team) => team?.id === teamId)) + .map((profile) => `P-${profile.id}`) + } + + return [resourceId] +} + function buildResources({ profiles, inventoryitems }) { - return [ - ...profiles - .filter((profile) => !profile.archived) - .map((profile) => ({ - id: `P-${profile.id}`, - type: "Profile", - title: getProfileLabel(profile) - })), - ...inventoryitems - .filter((item) => !item.archived && item.usePlanning) - .map((item) => ({ - id: `I-${item.id}`, - type: "Inventarartikel", - title: item.name - })) - ] + const teamResources = [] + const profileResources = [] + + profiles + .filter((profile) => !profile.archived) + .forEach((profile) => { + const assignedTeams = Array.isArray(profile?.teams) ? profile.teams.filter((team) => team?.id) : [] + + if (!assignedTeams.length) { + profileResources.push({ + id: `P-${profile.id}`, + type: "Profile", + title: getProfileLabel(profile) + }) + return + } + + assignedTeams.forEach((team) => { + if (!teamResources.find((resource) => resource.id === `T-${team.id}`)) { + teamResources.push({ + id: `T-${team.id}`, + type: "Team", + title: getTeamLabel(team) + }) + } + + profileResources.push({ + id: `T-${team.id}:P-${profile.id}`, + parentId: `T-${team.id}`, + type: "Profile", + title: getProfileLabel(profile) + }) + }) + }) + + const inventoryResources = inventoryitems + .filter((item) => !item.archived && item.usePlanning) + .map((item) => ({ + id: `I-${item.id}`, + type: "Inventarartikel", + title: item.name + })) + + return [...teamResources, ...profileResources, ...inventoryResources] } function buildEvents({ rawEvents, projectsById }) { @@ -265,7 +356,9 @@ function buildEvents({ rawEvents, projectsById }) { .filter((event) => !event.archived) .map((event) => { const resourceIds = [ - ...(event.profiles || []).map((profileId) => `P-${profileId}`), + ...(profiles.value + .filter((profile) => (event.profiles || []).includes(profile.id)) + .flatMap((profile) => getProfileResourceIds(profile))), ...(event.inventoryitems || []).map((itemId) => `I-${itemId}`) ] @@ -274,8 +367,8 @@ function buildEvents({ rawEvents, projectsById }) { start: event.startDate, end: event.endDate, resourceIds, - backgroundColor: resolveEventColor(event.eventtype), - borderColor: resolveEventColor(event.eventtype), + backgroundColor: resolveRenderedEventColor(event), + borderColor: resolveRenderedEventColor(event), textColor: "#ffffff", entrytype: "event", eventId: event.id @@ -303,7 +396,7 @@ function buildAbsenceEvents(spansByUserId) { title: getAbsenceTitle(entry), start: $dayjs(entry.started_at).startOf("day").toISOString(), end: $dayjs(entry.stopped_at || entry.started_at).add(1, "day").startOf("day").toISOString(), - resourceIds: [`P-${profile.id}`], + resourceIds: getProfileResourceIds(profile), allDay: true, backgroundColor: getAbsenceColor(entry.type), borderColor: getAbsenceColor(entry.type), @@ -350,6 +443,94 @@ function moveCalendarToday() { api.today() } +function openQuickConfig() { + quickEntryConfig.name = resolvedQuickEntryConfig.value.name + quickEntryConfig.color = resolvedQuickEntryConfig.value.color +} + +async function saveQuickConfig() { + if (savingQuickConfig.value) return + + const name = quickEntryConfig.name?.trim() + if (!name) { + toast.add({ title: "Name fehlt", description: "Bitte einen Namen für Quick-Einträge angeben.", color: "orange" }) + return + } + + savingQuickConfig.value = true + + try { + const currentTenantData = auth.activeTenantData || {} + const nextCalendarConfig = { + ...(currentTenantData.calendarConfig || {}), + quickEntry: { + name, + color: quickEntryConfig.color || "#2563eb" + } + } + + const updatedTenant = await $api(`/api/tenant/other/${auth.activeTenant}`, { + method: "PUT", + body: { + data: { + calendarConfig: nextCalendarConfig + } + } + }) + + auth.activeTenantData = updatedTenant + profileStore.ownTenant = updatedTenant + toast.add({ title: "Quick-Einträge gespeichert", color: "green" }) + await loadPlanningBoard() + } catch (error) { + console.error("saveQuickConfig failed", error) + toast.add({ + title: "Quick-Konfiguration konnte nicht gespeichert werden", + description: error?.message || "Bitte erneut versuchen.", + color: "red" + }) + } finally { + savingQuickConfig.value = false + } +} + +async function createQuickEvent(info) { + const resourceIds = normalizeSelectedResourceIds(info.resource?.id) + + const payload = { + name: resolvedQuickEntryConfig.value.name, + quick: true, + startDate: info.startStr, + endDate: info.endStr, + profiles: resourceIds + .filter((resourceId) => resourceId.startsWith("P-")) + .map((resourceId) => resourceId.replace("P-", "")), + inventoryitems: resourceIds + .filter((resourceId) => resourceId.startsWith("I-")) + .map((resourceId) => Number(resourceId.replace("I-", ""))), + inventoryitemgroups: [], + vehicles: [], + notes: "", + link: "", + project: null, + customer: null, + vendor: null, + } + + try { + await createEvent(payload, true) + toast.add({ title: "Quick-Eintrag angelegt", color: "green" }) + await loadPlanningBoard() + } catch (error) { + console.error("createQuickEvent failed", error) + toast.add({ + title: "Quick-Eintrag konnte nicht angelegt werden", + description: error?.message || "Bitte erneut versuchen.", + color: "red" + }) + } +} + function openAbsenceModal(type = "vacation", preset = {}) { absenceForm.mode = preset.entry ? "edit" : "create" absenceForm.entry = preset.entry || null @@ -414,13 +595,15 @@ async function loadPlanningBoard() { loading.value = true try { - const [rawEvents, projects, profileRows, inventoryItemRows] = await Promise.all([ + const [rawEvents, projects, profileResponse, inventoryItemRows] = await Promise.all([ useEntities("events").select(), useEntities("projects").select(), - useEntities("profiles").select(), + useNuxtApp().$api("/api/tenant/profiles"), useEntities("inventoryitems").select() ]) + const profileRows = profileResponse?.data || [] + const absenceSpansByUserId = await Promise.all( (profileRows || []) .filter((profile) => profile.user_id) @@ -537,6 +720,78 @@ onMounted(() => {