Kundenportal Vertragsanfragen ergänzen
This commit is contained in:
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;
|
||||||
@@ -239,6 +239,13 @@
|
|||||||
"when": 1777003200000,
|
"when": 1777003200000,
|
||||||
"tag": "0033_costcentres_parent",
|
"tag": "0033_costcentres_parent",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 34,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778191200000,
|
||||||
|
"tag": "0035_contract_history",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { inventoryitemgroups } from "./inventoryitemgroups"
|
|||||||
import { authUsers } from "./auth_users"
|
import { authUsers } from "./auth_users"
|
||||||
import {files} from "./files";
|
import {files} from "./files";
|
||||||
import { memberrelations } from "./memberrelations";
|
import { memberrelations } from "./memberrelations";
|
||||||
|
import { contracts } from "./contracts";
|
||||||
|
|
||||||
export const historyitems = pgTable("historyitems", {
|
export const historyitems = pgTable("historyitems", {
|
||||||
id: bigint("id", { mode: "number" })
|
id: bigint("id", { mode: "number" })
|
||||||
@@ -52,6 +53,11 @@ export const historyitems = pgTable("historyitems", {
|
|||||||
{ onDelete: "cascade" }
|
{ onDelete: "cascade" }
|
||||||
),
|
),
|
||||||
|
|
||||||
|
contract: bigint("contract", { mode: "number" }).references(
|
||||||
|
() => contracts.id,
|
||||||
|
{ onDelete: "cascade" }
|
||||||
|
),
|
||||||
|
|
||||||
tenant: bigint("tenant", { mode: "number" })
|
tenant: bigint("tenant", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => tenants.id),
|
.references(() => tenants.id),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import staffTimeConnectRoutes from "./routes/staff/timeconnects";
|
|||||||
import userRoutes from "./routes/auth/user";
|
import userRoutes from "./routes/auth/user";
|
||||||
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
|
import publiclinksAuthenticatedRoutes from "./routes/publiclinks/publiclinks-authenticated";
|
||||||
import wikiRoutes from "./routes/wiki";
|
import wikiRoutes from "./routes/wiki";
|
||||||
|
import portalContractRoutes from "./routes/portal/contracts";
|
||||||
|
|
||||||
//Public Links
|
//Public Links
|
||||||
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
|
||||||
@@ -146,6 +147,7 @@ async function main() {
|
|||||||
await subApp.register(publiclinksAuthenticatedRoutes);
|
await subApp.register(publiclinksAuthenticatedRoutes);
|
||||||
await subApp.register(resourceRoutes);
|
await subApp.register(resourceRoutes);
|
||||||
await subApp.register(wikiRoutes);
|
await subApp.register(wikiRoutes);
|
||||||
|
await subApp.register(portalContractRoutes);
|
||||||
|
|
||||||
},{prefix: "/api"})
|
},{prefix: "/api"})
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { authProfiles, historyitems } from "../../db/schema";
|
|||||||
|
|
||||||
const columnMap: Record<string, any> = {
|
const columnMap: Record<string, any> = {
|
||||||
customers: historyitems.customer,
|
customers: historyitems.customer,
|
||||||
|
contracts: historyitems.contract,
|
||||||
members: historyitems.customer,
|
members: historyitems.customer,
|
||||||
vendors: historyitems.vendor,
|
vendors: historyitems.vendor,
|
||||||
projects: historyitems.project,
|
projects: historyitems.project,
|
||||||
@@ -30,6 +31,7 @@ const columnMap: Record<string, any> = {
|
|||||||
|
|
||||||
const insertFieldMap: Record<string, string> = {
|
const insertFieldMap: Record<string, string> = {
|
||||||
customers: "customer",
|
customers: "customer",
|
||||||
|
contracts: "contract",
|
||||||
members: "customer",
|
members: "customer",
|
||||||
vendors: "vendor",
|
vendors: "vendor",
|
||||||
projects: "project",
|
projects: "project",
|
||||||
|
|||||||
221
backend/src/routes/portal/contracts.ts
Normal file
221
backend/src/routes/portal/contracts.ts
Normal file
@@ -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." }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { historyitems } from "../../db/schema";
|
|||||||
|
|
||||||
const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
const HISTORY_ENTITY_LABELS: Record<string, string> = {
|
||||||
customers: "Kunden",
|
customers: "Kunden",
|
||||||
|
contracts: "Verträge",
|
||||||
members: "Mitglieder",
|
members: "Mitglieder",
|
||||||
vendors: "Lieferanten",
|
vendors: "Lieferanten",
|
||||||
projects: "Projekte",
|
projects: "Projekte",
|
||||||
@@ -63,6 +64,7 @@ export async function insertHistoryItem(
|
|||||||
|
|
||||||
const columnMap: Record<string, string> = {
|
const columnMap: Record<string, string> = {
|
||||||
customers: "customer",
|
customers: "customer",
|
||||||
|
contracts: "contract",
|
||||||
members: "customer",
|
members: "customer",
|
||||||
vendors: "vendor",
|
vendors: "vendor",
|
||||||
projects: "project",
|
projects: "project",
|
||||||
|
|||||||
@@ -10,12 +10,27 @@ const toast = useToast()
|
|||||||
|
|
||||||
const customer = ref<any | null>(null)
|
const customer = ref<any | null>(null)
|
||||||
const contracts = ref<any[]>([])
|
const contracts = ref<any[]>([])
|
||||||
|
const contracttypes = ref<any[]>([])
|
||||||
const invoices = ref<any[]>([])
|
const invoices = ref<any[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const expandedInvoiceId = ref<number | null>(null)
|
const expandedInvoiceId = ref<number | null>(null)
|
||||||
const downloadingInvoiceId = ref<number | null>(null)
|
const downloadingInvoiceId = ref<number | null>(null)
|
||||||
const activeTab = ref("0")
|
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({
|
const customerForm = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -112,6 +127,58 @@ function formatCurrency(value: number) {
|
|||||||
return useCurrency(value)
|
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) {
|
function getInvoiceAmount(invoice: any) {
|
||||||
return useSum().getCreatedDocumentSum(invoice, invoices.value)
|
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() {
|
async function loadPortalData() {
|
||||||
if (!portalCustomerId.value) {
|
if (!portalCustomerId.value) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -150,15 +259,17 @@ async function loadPortalData() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [customerRecord, contractRows, invoiceRows] = await Promise.all([
|
const [customerRecord, contractRows, invoiceRows, contracttypeRows] = await Promise.all([
|
||||||
useEntities("customers").selectSingle(portalCustomerId.value),
|
useEntities("customers").selectSingle(portalCustomerId.value),
|
||||||
useEntities("contracts").select("*, contracttype(id,name)", "startDate", true),
|
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
|
customer.value = customerRecord
|
||||||
contracts.value = (contractRows || []).filter((item: any) => !item.archived)
|
contracts.value = (contractRows || []).filter((item: any) => !item.archived)
|
||||||
invoices.value = (invoiceRows || []).filter((item: any) => !item.archived)
|
invoices.value = (invoiceRows || []).filter((item: any) => !item.archived)
|
||||||
|
contracttypes.value = (contracttypeRows || []).filter((item: any) => !item.archived)
|
||||||
fillFormFromCustomer(customerRecord)
|
fillFormFromCustomer(customerRecord)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -472,31 +583,255 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</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"
|
||||||
|
@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="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
<div class="rounded-xl bg-white p-3">
|
<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">
|
<p class="mt-1 text-sm font-medium text-neutral-900">
|
||||||
{{ formatDate(contract.startDate) }} bis {{ formatDate(contract.endDate) }}
|
{{ contract.contracttype?.name || "Nicht hinterlegt" }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl bg-white p-3">
|
<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">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">
|
<p class="mt-1 text-sm font-medium text-neutral-900">
|
||||||
{{ contract.paymentType || "Nicht hinterlegt" }}
|
{{ contract.paymentType || "Nicht hinterlegt" }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
<div class="rounded-xl bg-white p-3">
|
||||||
<p v-if="contract.notes" class="mt-4 text-sm text-neutral-600">
|
<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 }}
|
{{ contract.notes }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UTabs>
|
</UTabs>
|
||||||
</UCard>
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
</UContainer>
|
</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="contracttypes"
|
||||||
|
value-key="id"
|
||||||
|
label-key="name"
|
||||||
|
class="w-full"
|
||||||
|
placeholder="Vertragstyp auswählen"
|
||||||
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -994,7 +994,7 @@ export const useDataStore = defineStore('data', () => {
|
|||||||
inputType: "textarea",
|
inputType: "textarea",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
showTabs: [{label: 'Informationen'},{label: 'Dateien'},{label: 'Wiki'}]
|
showTabs: [{label: 'Informationen'},{label: 'Dateien'},{label: 'Logbuch'},{label: 'Wiki'}]
|
||||||
},
|
},
|
||||||
contracttypes: {
|
contracttypes: {
|
||||||
isArchivable: true,
|
isArchivable: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user