From 5dc44e571f27dba2d4d10f770db3fb23c26ea6d1 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Fri, 8 May 2026 20:01:57 +0200 Subject: [PATCH] =?UTF-8?q?Kundenportal=20Vertragsanfragen=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migrations/0035_contract_history.sql | 3 + backend/db/migrations/meta/_journal.json | 7 + backend/db/schema/historyitems.ts | 6 + backend/src/index.ts | 2 + backend/src/routes/history.ts | 2 + backend/src/routes/portal/contracts.ts | 221 +++++++++++ backend/src/utils/history.ts | 2 + frontend/pages/customer-portal.vue | 353 +++++++++++++++++- frontend/stores/data.js | 2 +- 9 files changed, 588 insertions(+), 10 deletions(-) create mode 100644 backend/db/migrations/0035_contract_history.sql create mode 100644 backend/src/routes/portal/contracts.ts diff --git a/backend/db/migrations/0035_contract_history.sql b/backend/db/migrations/0035_contract_history.sql new file mode 100644 index 0000000..6ae0b71 --- /dev/null +++ b/backend/db/migrations/0035_contract_history.sql @@ -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; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 7c3440b..8c4e6d3 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -239,6 +239,13 @@ "when": 1777003200000, "tag": "0033_costcentres_parent", "breakpoints": true + }, + { + "idx": 34, + "version": "7", + "when": 1778191200000, + "tag": "0035_contract_history", + "breakpoints": true } ] } diff --git a/backend/db/schema/historyitems.ts b/backend/db/schema/historyitems.ts index dd9f9a9..46fb6d5 100644 --- a/backend/db/schema/historyitems.ts +++ b/backend/db/schema/historyitems.ts @@ -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), diff --git a/backend/src/index.ts b/backend/src/index.ts index d7bb1cf..935e500 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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"}) diff --git a/backend/src/routes/history.ts b/backend/src/routes/history.ts index 0bcb816..ef57bf4 100644 --- a/backend/src/routes/history.ts +++ b/backend/src/routes/history.ts @@ -5,6 +5,7 @@ import { authProfiles, historyitems } from "../../db/schema"; const columnMap: Record = { customers: historyitems.customer, + contracts: historyitems.contract, members: historyitems.customer, vendors: historyitems.vendor, projects: historyitems.project, @@ -30,6 +31,7 @@ const columnMap: Record = { const insertFieldMap: Record = { customers: "customer", + contracts: "contract", members: "customer", vendors: "vendor", projects: "project", diff --git a/backend/src/routes/portal/contracts.ts b/backend/src/routes/portal/contracts.ts new file mode 100644 index 0000000..2a10745 --- /dev/null +++ b/backend/src/routes/portal/contracts.ts @@ -0,0 +1,221 @@ +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, + 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 [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." } + }) +} diff --git a/backend/src/utils/history.ts b/backend/src/utils/history.ts index c0e5289..a54efbb 100644 --- a/backend/src/utils/history.ts +++ b/backend/src/utils/history.ts @@ -3,6 +3,7 @@ import { historyitems } from "../../db/schema"; const HISTORY_ENTITY_LABELS: Record = { customers: "Kunden", + contracts: "Verträge", members: "Mitglieder", vendors: "Lieferanten", projects: "Projekte", @@ -63,6 +64,7 @@ export async function insertHistoryItem( const columnMap: Record = { customers: "customer", + contracts: "contract", members: "customer", vendors: "vendor", projects: "project", diff --git a/frontend/pages/customer-portal.vue b/frontend/pages/customer-portal.vue index f2f1045..263bf23 100644 --- a/frontend/pages/customer-portal.vue +++ b/frontend/pages/customer-portal.vue @@ -10,12 +10,27 @@ const toast = useToast() const customer = ref(null) const contracts = ref([]) +const contracttypes = ref([]) const invoices = ref([]) const loading = ref(true) const saving = ref(false) const expandedInvoiceId = ref(null) const downloadingInvoiceId = ref(null) const activeTab = ref("0") +const contractChangeModalOpen = ref(false) +const cancellationModalOpen = ref(false) +const submittingContractRequest = ref(false) +const selectedContract = ref(null) + +const contractChangeForm = reactive({ + contracttype: null as number | null, + message: "" +}) + +const cancellationForm = reactive({ + requestedEndDate: "", + message: "" +}) const customerForm = reactive({ name: "", @@ -112,6 +127,58 @@ 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 openContractChangeRequest(contract: any) { + selectedContract.value = contract + contractChangeForm.contracttype = contract.contracttype?.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 +208,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 +259,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 +583,149 @@ onMounted(async () => { +
+ + Änderung anfragen + + + Kündigung anfragen + +
+
-

Laufzeit

+

Vertragstyp

- {{ formatDate(contract.startDate) }} bis {{ formatDate(contract.endDate) }} + {{ contract.contracttype?.name || "Nicht hinterlegt" }}

-

Abrechnung

+

Ansprechpartner

+

+ {{ getContactLabel(contract.contact) }} +

+
+
+

Aktiv

+

+ {{ formatBoolean(contract.active) }} +

+
+
+

Wiederkehrend

+

+ {{ formatBoolean(contract.recurring) }} +

+
+
+

Startdatum

+

+ {{ formatDate(contract.startDate) }} +

+
+
+

Enddatum

+

+ {{ formatDate(contract.endDate) }} +

+
+
+

Unterschrieben am

+

+ {{ formatDate(contract.signDate) }} +

+
+
+

Laufzeit

+

+ {{ contract.duration || "-" }} +

+
+
+

Zahlungsart

{{ contract.paymentType || "Nicht hinterlegt" }}

+
+

Abrechnungsintervall

+

+ {{ contract.billingInterval || "-" }} +

+
+
+

Rechnungsversand

+

+ {{ contract.invoiceDispatch || "-" }} +

+
+
+

Kontoinhaber

+

+ {{ contract.bankingOwner || "-" }} +

+
+
+

Bank

+

+ {{ contract.bankingName || "-" }} +

+
+
+

IBAN

+

+ {{ maskIban(contract.bankingIban) }} +

+
+
+

BIC

+

+ {{ contract.bankingBIC || "-" }} +

+
+
+

SEPA-Referenz

+

+ {{ contract.sepaRef || "-" }} +

+
+
+

SEPA-Datum

+

+ {{ formatDate(contract.sepaDate) }} +

+
+
+

Erstellt am

+

+ {{ formatDate(contract.createdAt) }} +

+
+
+

Aktualisiert am

+

+ {{ formatDate(contract.updatedAt) }} +

+
+
+

Notizen

+

+ {{ contract.notes }} +

+
- -

- {{ contract.notes }} -

@@ -498,5 +734,104 @@ onMounted(async () => { + + + + + + + + diff --git a/frontend/stores/data.js b/frontend/stores/data.js index 68e35c5..6abe4db 100644 --- a/frontend/stores/data.js +++ b/frontend/stores/data.js @@ -994,7 +994,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,