diff --git a/backend/db/migrations/0033_costcentres_parent.sql b/backend/db/migrations/0033_costcentres_parent.sql new file mode 100644 index 0000000..3d8c577 --- /dev/null +++ b/backend/db/migrations/0033_costcentres_parent.sql @@ -0,0 +1,2 @@ +ALTER TABLE "costcentres" ADD COLUMN "parent_costcentre" uuid; +ALTER TABLE "costcentres" ADD CONSTRAINT "costcentres_parent_costcentre_costcentres_id_fk" FOREIGN KEY ("parent_costcentre") REFERENCES "public"."costcentres"("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 5e0b36e..7c3440b 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -232,6 +232,13 @@ "when": 1776298800000, "tag": "0032_manual_statementallocations_invoice_side", "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1777003200000, + "tag": "0033_costcentres_parent", + "breakpoints": true } ] } diff --git a/backend/db/schema/costcentres.ts b/backend/db/schema/costcentres.ts index f46f15b..2a8959f 100644 --- a/backend/db/schema/costcentres.ts +++ b/backend/db/schema/costcentres.ts @@ -29,6 +29,8 @@ export const costcentres = pgTable("costcentres", { number: text("number").notNull(), name: text("name").notNull(), + parentCostcentre: uuid("parent_costcentre").references(() => costcentres.id), + vehicle: bigint("vehicle", { mode: "number" }).references(() => vehicles.id), project: bigint("project", { mode: "number" }).references(() => projects.id), diff --git a/backend/src/routes/resources/main.ts b/backend/src/routes/resources/main.ts index 09e6115..b88cba5 100644 --- a/backend/src/routes/resources/main.ts +++ b/backend/src/routes/resources/main.ts @@ -11,7 +11,7 @@ import { sql, } from "drizzle-orm" -import { authProfiles } from "../../../db/schema"; +import { authProfiles, costcentres } from "../../../db/schema"; import { resourceConfig } from "../../utils/resource.config"; import { useNextNumberRangeNumber } from "../../utils/functions"; import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history"; @@ -260,6 +260,59 @@ function validateMemberPayload(payload: Record) { return null } +async function validateCostCentreParent( + server: FastifyInstance, + tenantId: number, + costCentreId: string | null, + parentCostcentreId: string | null +) { + if (!parentCostcentreId) { + return null + } + + const hierarchyRows = await server.db + .select({ + id: costcentres.id, + parentCostcentre: costcentres.parentCostcentre, + }) + .from(costcentres) + .where(eq(costcentres.tenant, tenantId)) + + const hierarchyMap = new Map( + hierarchyRows.map((row) => [row.id, row.parentCostcentre || null]) + ) + + if (!hierarchyMap.has(parentCostcentreId)) { + return "Die übergeordnete Kostenstelle wurde nicht gefunden." + } + + if (costCentreId && parentCostcentreId === costCentreId) { + return "Eine Kostenstelle kann nicht sich selbst als übergeordnete Kostenstelle haben." + } + + if (!costCentreId) { + return null + } + + let currentParentId: string | null = parentCostcentreId + const visited = new Set() + + while (currentParentId) { + if (currentParentId === costCentreId) { + return "Die ausgewählte übergeordnete Kostenstelle würde einen Zyklus erzeugen." + } + + if (visited.has(currentParentId)) { + break + } + + visited.add(currentParentId) + currentParentId = hierarchyMap.get(currentParentId) || null + } + + return null +} + function maskIban(iban: string) { if (!iban) return "" const cleaned = iban.replace(/\s+/g, "") @@ -730,6 +783,19 @@ export default async function resourceRoutes(server: FastifyInstance) { createData = prepared.data! } + if (resource === "costcentres") { + const validationError = await validateCostCentreParent( + server, + req.user.tenant_id, + null, + createData.parentCostcentre || null + ) + + if (validationError) { + return reply.code(400).send({ error: validationError }) + } + } + if (config.numberRangeHolder && !body[config.numberRangeHolder]) { const numberRangeResource = resource === "members" ? "customers" : resource const result = await useNextNumberRangeNumber(server, req.user.tenant_id, numberRangeResource) @@ -836,6 +902,21 @@ export default async function resourceRoutes(server: FastifyInstance) { } } + if (resource === "costcentres") { + const validationError = await validateCostCentreParent( + server, + tenantId, + oldRecord.id, + Object.prototype.hasOwnProperty.call(data, "parentCostcentre") + ? data.parentCostcentre || null + : oldRecord.parentCostcentre || null + ) + + if (validationError) { + return reply.code(400).send({ error: validationError }) + } + } + Object.keys(data).forEach((key) => { const value = data[key] const shouldNormalize = diff --git a/backend/src/utils/resource.config.ts b/backend/src/utils/resource.config.ts index bc59300..da7ad93 100644 --- a/backend/src/utils/resource.config.ts +++ b/backend/src/utils/resource.config.ts @@ -164,9 +164,13 @@ export const resourceConfig = { costcentres: { table: costcentres, searchColumns: ["name","number","description"], - mtoLoad: ["vehicle","project","inventoryitem","branch"], + mtoLoad: ["parentCostcentre","vehicle","project","inventoryitem","branch"], numberRangeHolder: "number", }, + parentCostcentre: { + table: costcentres, + searchColumns: ["name", "number", "description"], + }, branches: { table: branches, searchColumns: ["name","number","description"], diff --git a/frontend/components/columnRenderings/costcentre.vue b/frontend/components/columnRenderings/costcentre.vue new file mode 100644 index 0000000..efb7ade --- /dev/null +++ b/frontend/components/columnRenderings/costcentre.vue @@ -0,0 +1,32 @@ + + + diff --git a/frontend/components/costcentreDisplay.vue b/frontend/components/costcentreDisplay.vue index 5c8beec..0b226e8 100644 --- a/frontend/components/costcentreDisplay.vue +++ b/frontend/components/costcentreDisplay.vue @@ -10,11 +10,57 @@ const props = defineProps({ const loading = ref(true) const incomingInvoices = ref([]) +const costcentres = ref([]) const selectedYear = ref(String(dayjs().year())) const selectedMonth = ref("all") const currency = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} EUR` +const costCentreMap = computed(() => { + return new Map(costcentres.value.map((costcentre) => [costcentre.id, costcentre])) +}) + +const relevantCostCentreIds = computed(() => { + const rootId = props.item?.id + + if (!rootId) { + return new Set() + } + + const childrenByParent = new Map() + + costcentres.value.forEach((costcentre) => { + if (!costcentre.parentCostcentre) { + return + } + + if (!childrenByParent.has(costcentre.parentCostcentre)) { + childrenByParent.set(costcentre.parentCostcentre, []) + } + + childrenByParent.get(costcentre.parentCostcentre).push(costcentre.id) + }) + + const collectedIds = new Set([rootId]) + const queue = [rootId] + + while (queue.length > 0) { + const currentId = queue.shift() + const childIds = childrenByParent.get(currentId) || [] + + childIds.forEach((childId) => { + if (collectedIds.has(childId)) { + return + } + + collectedIds.add(childId) + queue.push(childId) + }) + } + + return collectedIds +}) + const yearItems = computed(() => { const years = [...new Set( incomingInvoices.value @@ -29,7 +75,7 @@ const monthItems = [ { label: "Ganzes Jahr", value: "all" }, { label: "Januar", value: "1" }, { label: "Februar", value: "2" }, - { label: "Maerz", value: "3" }, + { label: "März", value: "3" }, { label: "April", value: "4" }, { label: "Mai", value: "5" }, { label: "Juni", value: "6" }, @@ -53,12 +99,15 @@ const reportRows = computed(() => { return [] } - const matchingAccounts = (invoice.accounts || []).filter((account) => account.costCentre === props.item.id) + const matchingAccounts = (invoice.accounts || []).filter((account) => + relevantCostCentreIds.value.has(account.costCentre) + ) return matchingAccounts.map((account, index) => { const amountNet = Number(account.amountNet || 0) const amountTax = Number(account.amountTax || 0) const amountGross = Number(account.amountGross || amountNet + amountTax || 0) + const accountCostCentre = costCentreMap.value.get(account.costCentre) return { id: `${invoice.id}-${index}`, @@ -68,6 +117,7 @@ const reportRows = computed(() => { state: invoice.state || "-", vendorName: invoice.vendor?.name || "-", accountLabel: account.account?.label || account.accountLabel || "-", + costCentreName: accountCostCentre ? `${accountCostCentre.number} - ${accountCostCentre.name}` : "-", description: account.description || invoice.description || "-", amountNet, amountTax, @@ -91,6 +141,7 @@ const columns = [ { accessorKey: "date", header: "Datum" }, { accessorKey: "vendorName", header: "Lieferant" }, { accessorKey: "accountLabel", header: "Konto" }, + { accessorKey: "costCentreName", header: "Kostenstelle" }, { accessorKey: "description", header: "Beschreibung" }, { accessorKey: "amountNet", header: "Netto" }, { accessorKey: "amountTax", header: "Steuer" }, @@ -100,10 +151,11 @@ const columns = [ const setupPage = async () => { loading.value = true + costcentres.value = await useEntities("costcentres").select("*", null, false, true) const invoices = await useEntities("incominginvoices").select("*, vendor(id,name)") incomingInvoices.value = invoices.filter((invoice) => - (invoice.accounts || []).some((account) => account.costCentre === props.item.id) + (invoice.accounts || []).some((account) => relevantCostCentreIds.value.has(account.costCentre)) ) const firstYear = yearItems.value[0]?.value @@ -162,7 +214,7 @@ setupPage() v-if="!loading" :data="reportRows" :columns="columns" - :empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Eingangsbelege mit dieser Kostenstelle gefunden' }" + :empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Eingangsbelege mit dieser Kostenstelle oder ihren Unterkostenstellen gefunden' }" class="w-full" > + +