Compare commits
4 Commits
d9e5df07bf
...
6fcaf3f65c
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fcaf3f65c | |||
| dce0046e63 | |||
| 02b5769049 | |||
| f125617af0 |
@@ -4,6 +4,7 @@ import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import {
|
||||
authTenantUsers,
|
||||
authProfiles,
|
||||
customers,
|
||||
authRoles,
|
||||
authUserRoles,
|
||||
authUsers,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
tenants,
|
||||
} from "../../db/schema";
|
||||
import { generateRandomPassword, hashPassword } from "../utils/password";
|
||||
import { sendMail } from "../utils/mailer";
|
||||
|
||||
export default async function adminRoutes(server: FastifyInstance) {
|
||||
const deriveNameFromEmail = (email: string) => {
|
||||
@@ -255,6 +257,33 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
return currentUser;
|
||||
};
|
||||
|
||||
const ensurePortalRoleForTenant = async (tenantId: number, createdBy: string) => {
|
||||
const existingRoles = await server.db
|
||||
.select({
|
||||
id: authRoles.id,
|
||||
name: authRoles.name,
|
||||
})
|
||||
.from(authRoles)
|
||||
.where(eq(authRoles.tenant_id, tenantId));
|
||||
|
||||
const portalRole = existingRoles.find((role) => role.name === "Kundenportal");
|
||||
if (portalRole) return portalRole.id;
|
||||
|
||||
const [createdRole] = await server.db
|
||||
.insert(authRoles)
|
||||
.values({
|
||||
name: "Kundenportal",
|
||||
description: "Automatisch angelegte Rolle für eingeladene Kundenportal-Benutzer",
|
||||
tenant_id: tenantId,
|
||||
created_by: createdBy,
|
||||
})
|
||||
.returning({
|
||||
id: authRoles.id,
|
||||
});
|
||||
|
||||
return createdRole.id;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GET /admin/overview
|
||||
// -------------------------------------------------------------
|
||||
@@ -422,6 +451,233 @@ export default async function adminRoutes(server: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
server.post("/admin/customers/:customerId/invite-portal-user", async (req, reply) => {
|
||||
try {
|
||||
const currentUser = await requireAdmin(req, reply);
|
||||
if (!currentUser) return;
|
||||
|
||||
const tenantId = Number(req.user?.tenant_id);
|
||||
const { customerId } = req.params as { customerId: string };
|
||||
|
||||
if (!tenantId) {
|
||||
return reply.code(400).send({ error: "No tenant selected" });
|
||||
}
|
||||
|
||||
const [tenantRecord] = await server.db
|
||||
.select({
|
||||
id: tenants.id,
|
||||
name: tenants.name,
|
||||
portalDomain: tenants.portalDomain,
|
||||
})
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, tenantId))
|
||||
.limit(1);
|
||||
|
||||
const [customerRecord] = await server.db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(and(eq(customers.id, Number(customerId)), eq(customers.tenant, tenantId)))
|
||||
.limit(1);
|
||||
|
||||
if (!customerRecord) {
|
||||
return reply.code(404).send({ error: "Customer not found" });
|
||||
}
|
||||
|
||||
const customerInfo = customerRecord.infoData && typeof customerRecord.infoData === "object" ? customerRecord.infoData as Record<string, any> : {};
|
||||
const email = String(customerInfo.email || customerInfo.invoiceEmail || "").trim().toLowerCase();
|
||||
|
||||
if (!email) {
|
||||
return reply.code(400).send({ error: "Customer has no email address" });
|
||||
}
|
||||
|
||||
const generatedPassword = generateRandomPassword(14);
|
||||
const passwordHash = await hashPassword(generatedPassword);
|
||||
|
||||
const [existingUser] = await server.db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
email: authUsers.email,
|
||||
is_admin: authUsers.is_admin,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.email, email))
|
||||
.limit(1);
|
||||
|
||||
const derivedName = deriveNameFromEmail(email);
|
||||
const firstName = customerRecord.firstname?.trim() || derivedName.first_name;
|
||||
const lastName = customerRecord.lastname?.trim() || derivedName.last_name;
|
||||
|
||||
let userId = existingUser?.id || null;
|
||||
let createdNewUser = false;
|
||||
|
||||
if (existingUser) {
|
||||
const [existingProfile] = await server.db
|
||||
.select({
|
||||
id: authProfiles.id,
|
||||
customer_for_portal: authProfiles.customer_for_portal,
|
||||
})
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.user_id, existingUser.id),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.is_admin) {
|
||||
return reply.code(409).send({ error: "Email address is already used by an admin user" });
|
||||
}
|
||||
|
||||
if (!existingProfile) {
|
||||
return reply.code(409).send({ error: "Email address is already used by another user" });
|
||||
}
|
||||
|
||||
if (existingProfile.customer_for_portal && existingProfile.customer_for_portal !== customerRecord.id) {
|
||||
return reply.code(409).send({ error: "Email address is already assigned to another portal customer" });
|
||||
}
|
||||
|
||||
await server.db
|
||||
.update(authUsers)
|
||||
.set({
|
||||
passwordHash,
|
||||
must_change_password: true,
|
||||
multiTenant: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(authUsers.id, existingUser.id));
|
||||
|
||||
userId = existingUser.id;
|
||||
} else {
|
||||
const [createdUser] = await server.db
|
||||
.insert(authUsers)
|
||||
.values({
|
||||
email,
|
||||
passwordHash,
|
||||
is_admin: false,
|
||||
multiTenant: false,
|
||||
must_change_password: true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning({
|
||||
id: authUsers.id,
|
||||
});
|
||||
|
||||
userId = createdUser.id;
|
||||
createdNewUser = true;
|
||||
}
|
||||
|
||||
const portalRoleId = await ensurePortalRoleForTenant(tenantId, currentUser.id);
|
||||
|
||||
const existingMemberships = await server.db
|
||||
.select()
|
||||
.from(authTenantUsers)
|
||||
.where(and(
|
||||
eq(authTenantUsers.user_id, userId!),
|
||||
eq(authTenantUsers.tenant_id, tenantId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!existingMemberships.length) {
|
||||
await server.db
|
||||
.insert(authTenantUsers)
|
||||
.values({
|
||||
tenant_id: tenantId,
|
||||
user_id: userId!,
|
||||
created_by: currentUser.id,
|
||||
});
|
||||
}
|
||||
|
||||
const existingPortalRoleAssignment = await server.db
|
||||
.select()
|
||||
.from(authUserRoles)
|
||||
.where(and(
|
||||
eq(authUserRoles.user_id, userId!),
|
||||
eq(authUserRoles.tenant_id, tenantId),
|
||||
eq(authUserRoles.role_id, portalRoleId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!existingPortalRoleAssignment.length) {
|
||||
await server.db
|
||||
.insert(authUserRoles)
|
||||
.values({
|
||||
user_id: userId!,
|
||||
tenant_id: tenantId,
|
||||
role_id: portalRoleId,
|
||||
created_by: currentUser.id,
|
||||
});
|
||||
}
|
||||
|
||||
const [existingTenantProfile] = await server.db
|
||||
.select({
|
||||
id: authProfiles.id,
|
||||
user_id: authProfiles.user_id,
|
||||
customer_for_portal: authProfiles.customer_for_portal,
|
||||
})
|
||||
.from(authProfiles)
|
||||
.where(and(
|
||||
eq(authProfiles.user_id, userId!),
|
||||
eq(authProfiles.tenant_id, tenantId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (existingTenantProfile) {
|
||||
await server.db
|
||||
.update(authProfiles)
|
||||
.set({
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
customer_for_portal: customerRecord.id,
|
||||
active: true,
|
||||
})
|
||||
.where(eq(authProfiles.id, existingTenantProfile.id));
|
||||
} else {
|
||||
await server.db
|
||||
.insert(authProfiles)
|
||||
.values({
|
||||
user_id: userId!,
|
||||
tenant_id: tenantId,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
customer_for_portal: customerRecord.id,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
|
||||
const portalUrl = tenantRecord?.portalDomain ? `https://${tenantRecord.portalDomain}/login` : null;
|
||||
|
||||
const mailResult = await sendMail(
|
||||
email,
|
||||
`FEDEO | Einladung ins Kundenportal`,
|
||||
`
|
||||
<p>Hallo${customerRecord.name ? ` ${customerRecord.name}` : ""},</p>
|
||||
<p>für Sie wurde ein Zugang zum FEDEO Kundenportal eingerichtet.</p>
|
||||
<p><strong>E-Mail:</strong> ${email}</p>
|
||||
<p><strong>Initialpasswort:</strong> ${generatedPassword}</p>
|
||||
<p>Bitte ändern Sie dieses Passwort direkt nach dem ersten Login.</p>
|
||||
${portalUrl ? `<p><strong>Login:</strong> <a href="${portalUrl}">${portalUrl}</a></p>` : ""}
|
||||
<p>Viele Grüße<br>${tenantRecord?.name || "FEDEO"}</p>
|
||||
`
|
||||
);
|
||||
|
||||
if (!mailResult.success) {
|
||||
return reply.code(500).send({ error: "Invitation email could not be sent" });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
createdNewUser,
|
||||
email,
|
||||
initialPassword: generatedPassword,
|
||||
portalUrl,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("ERROR /admin/customers/:customerId/invite-portal-user:", err);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// POST /admin/tenants
|
||||
// -------------------------------------------------------------
|
||||
|
||||
@@ -10,7 +10,9 @@ import { secrets } from "../utils/secrets"
|
||||
import { saveFile } from "../utils/files"
|
||||
|
||||
import { eq, inArray } from "drizzle-orm"
|
||||
import { and } from "drizzle-orm"
|
||||
import {
|
||||
authProfiles,
|
||||
files,
|
||||
createddocuments,
|
||||
customers
|
||||
@@ -18,6 +20,55 @@ import {
|
||||
|
||||
|
||||
export default async function fileRoutes(server: FastifyInstance) {
|
||||
const getPortalCustomerId = async (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
|
||||
}
|
||||
|
||||
const loadSingleFileForRequest = async (req: any, id: string) => {
|
||||
const tenantId = req.user?.tenant_id
|
||||
if (!tenantId) return null
|
||||
|
||||
const portalCustomerId = await getPortalCustomerId(req)
|
||||
|
||||
if (!portalCustomerId) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.id, id), eq(files.tenant, tenantId)))
|
||||
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const rows = await server.db
|
||||
.select({
|
||||
file: files,
|
||||
})
|
||||
.from(files)
|
||||
.leftJoin(createddocuments, eq(files.createddocument, createddocuments.id))
|
||||
.where(and(
|
||||
eq(files.id, id),
|
||||
eq(files.tenant, tenantId),
|
||||
eq(createddocuments.customer, portalCustomerId),
|
||||
eq(createddocuments.availableInPortal, true)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
return rows[0]?.file || null
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// MULTIPART INIT
|
||||
@@ -80,12 +131,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
|
||||
// 🔹 EINZELNE DATEI
|
||||
if (id) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.id, id))
|
||||
|
||||
const file = rows[0]
|
||||
const file = await loadSingleFileForRequest(req, id)
|
||||
if (!file) return reply.code(404).send({ error: "Not found" })
|
||||
|
||||
return file
|
||||
@@ -135,12 +181,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
// 1️⃣ SINGLE DOWNLOAD
|
||||
// -------------------------------------------------
|
||||
if (id) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.id, id))
|
||||
|
||||
const file = rows[0]
|
||||
const file = await loadSingleFileForRequest(req, id)
|
||||
if (!file) return reply.code(404).send({ error: "File not found" })
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
@@ -217,12 +258,7 @@ export default async function fileRoutes(server: FastifyInstance) {
|
||||
// SINGLE FILE PRESIGNED URL
|
||||
// -------------------------------------------------
|
||||
if (id) {
|
||||
const rows = await server.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.id, id))
|
||||
|
||||
const file = rows[0]
|
||||
const file = await loadSingleFileForRequest(req, id)
|
||||
if (!file) return reply.code(404).send({ error: "Not found" })
|
||||
|
||||
const url = await getSignedUrl(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
sql,
|
||||
} from "drizzle-orm"
|
||||
|
||||
import { authProfiles } from "../../../db/schema";
|
||||
import { resourceConfig } from "../../utils/resource.config";
|
||||
import { useNextNumberRangeNumber } from "../../utils/functions";
|
||||
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
|
||||
@@ -18,6 +19,9 @@ import { diffObjects } from "../../utils/diff";
|
||||
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service";
|
||||
import { decrypt, encrypt } from "../../utils/crypt";
|
||||
|
||||
const PORTAL_ALLOWED_RESOURCES = new Set(["customers", "contracts", "createddocuments"])
|
||||
const PORTAL_VISIBLE_DOCUMENT_TYPES = ["invoices", "advanceInvoices", "cancellationInvoices"]
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// SQL Suche auf mehreren Feldern (Haupttabelle + Relationen)
|
||||
// -------------------------------------------------------------
|
||||
@@ -130,6 +134,65 @@ function applyResourceWhereFilters(resource: string, table: any, whereCond: any)
|
||||
return whereCond
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function applyPortalScope(resource: string, table: any, whereCond: any, portalCustomerId: number | null) {
|
||||
if (!portalCustomerId) return whereCond
|
||||
|
||||
if (!PORTAL_ALLOWED_RESOURCES.has(resource)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (resource === "customers") {
|
||||
return and(whereCond, eq(table.id, portalCustomerId))
|
||||
}
|
||||
|
||||
if (resource === "contracts") {
|
||||
return and(whereCond, eq(table.customer, portalCustomerId))
|
||||
}
|
||||
|
||||
if (resource === "createddocuments") {
|
||||
return and(
|
||||
whereCond,
|
||||
eq(table.customer, portalCustomerId),
|
||||
eq(table.availableInPortal, true),
|
||||
inArray(table.type, PORTAL_VISIBLE_DOCUMENT_TYPES)
|
||||
)
|
||||
}
|
||||
|
||||
return whereCond
|
||||
}
|
||||
|
||||
function sanitizePortalCustomerUpdate(payload: Record<string, any>) {
|
||||
const nextInfoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
|
||||
|
||||
return {
|
||||
name: payload.name,
|
||||
firstname: payload.firstname,
|
||||
lastname: payload.lastname,
|
||||
salutation: payload.salutation,
|
||||
title: payload.title,
|
||||
nameAddition: payload.nameAddition,
|
||||
infoData: nextInfoData,
|
||||
}
|
||||
}
|
||||
|
||||
function getTenantColumn(resource: string, table: any) {
|
||||
const config = resourceConfig[resource]
|
||||
const tenantKey = config?.tenantKey || "tenant"
|
||||
@@ -271,11 +334,16 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
if (!config) {
|
||||
return reply.code(404).send({ error: "Unknown resource" })
|
||||
}
|
||||
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
|
||||
return reply.code(403).send({ error: "Forbidden" })
|
||||
}
|
||||
const table = config.table
|
||||
|
||||
const tenantColumn = getTenantColumn(resource, table)
|
||||
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
|
||||
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
||||
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
|
||||
let q = server.db.select().from(table).$dynamic()
|
||||
|
||||
const searchCols: any[] = (config.searchColumns || []).map(c => table[c])
|
||||
@@ -380,6 +448,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
if (!config) {
|
||||
return reply.code(404).send({ error: "Unknown resource" });
|
||||
}
|
||||
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
|
||||
return reply.code(403).send({ error: "Forbidden" })
|
||||
}
|
||||
const table = config.table;
|
||||
|
||||
const { queryConfig } = req;
|
||||
@@ -389,6 +461,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
const tenantColumn = getTenantColumn(resource, table);
|
||||
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined;
|
||||
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
||||
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
|
||||
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
|
||||
const debugSearchColumnNames: string[] = [...(config.searchColumns || [])];
|
||||
const parsedFilters: Array<{ key: string; value: any }> = []
|
||||
@@ -489,6 +562,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
}
|
||||
let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
|
||||
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond)
|
||||
distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId)
|
||||
|
||||
if (search) {
|
||||
const searchCond = buildSearchCondition(searchCols, search.trim())
|
||||
@@ -570,10 +644,15 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
|
||||
|
||||
const { resource, no_relations } = req.params as { resource: string, no_relations?: boolean }
|
||||
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
|
||||
return reply.code(403).send({ error: "Forbidden" })
|
||||
}
|
||||
const table = resourceConfig[resource].table
|
||||
|
||||
let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId))
|
||||
whereCond = applyResourceWhereFilters(resource, table, whereCond)
|
||||
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
|
||||
|
||||
const projRows = await server.db
|
||||
.select()
|
||||
@@ -624,6 +703,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
try {
|
||||
if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" });
|
||||
const { resource } = req.params as { resource: string };
|
||||
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||
if (portalCustomerId) {
|
||||
return reply.code(403).send({ error: "Forbidden" })
|
||||
}
|
||||
if (resource === "accounts") {
|
||||
return reply.code(403).send({ error: "Accounts are read-only" })
|
||||
}
|
||||
@@ -703,8 +786,12 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
const body = req.body as Record<string, any>
|
||||
const tenantId = req.user?.tenant_id
|
||||
const userId = req.user?.user_id
|
||||
const portalCustomerId = await getPortalCustomerId(server, req)
|
||||
|
||||
if (!tenantId || !userId) return reply.code(401).send({ error: "Unauthorized" })
|
||||
if (portalCustomerId && resource !== "customers") {
|
||||
return reply.code(403).send({ error: "Forbidden" })
|
||||
}
|
||||
|
||||
const table = resourceConfig[resource].table
|
||||
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
|
||||
@@ -712,13 +799,25 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
const [oldRecord] = await server.db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(eq(table.id, id), eq(table.tenant, tenantId)))
|
||||
.where(applyPortalScope(resource, table, and(eq(table.id, id), eq(table.tenant, tenantId)), portalCustomerId))
|
||||
.limit(1)
|
||||
|
||||
if (!oldRecord) {
|
||||
return reply.code(404).send({ error: "Resource not found" })
|
||||
}
|
||||
|
||||
let data: Record<string, any> = { ...body, updated_at: new Date().toISOString(), updated_by: userId }
|
||||
//@ts-ignore
|
||||
delete data.updatedBy; delete data.updatedAt;
|
||||
|
||||
if (portalCustomerId) {
|
||||
data = {
|
||||
...sanitizePortalCustomerUpdate(data),
|
||||
updated_at: data.updated_at,
|
||||
updated_by: data.updated_by,
|
||||
}
|
||||
}
|
||||
|
||||
if (resource === "members") {
|
||||
data = normalizeMemberPayload(data)
|
||||
const validationError = validateMemberPayload(data)
|
||||
|
||||
@@ -192,7 +192,7 @@ export const resourceConfig = {
|
||||
table: createddocuments,
|
||||
mtoLoad: ["customer", "project", "contact", "contract", "plant","letterhead","createddocument"],
|
||||
mtmLoad: ["statementallocations","files","createddocuments"],
|
||||
mtmListLoad: ["statementallocations"],
|
||||
mtmListLoad: ["statementallocations", "files"],
|
||||
},
|
||||
texttemplates: {
|
||||
table: texttemplates
|
||||
|
||||
@@ -50,10 +50,12 @@ const route = useRoute()
|
||||
const dataStore = useDataStore()
|
||||
const modal = useModal()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const dataType = dataStore.dataTypes[type]
|
||||
|
||||
const openTab = ref(String(route.query.tabIndex || 0))
|
||||
const portalInviteLoading = ref(false)
|
||||
|
||||
|
||||
|
||||
@@ -152,6 +154,31 @@ const openCustomerInventoryLabelPrint = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const invitePortalUser = async () => {
|
||||
if (type !== "customers" || !props.item?.id) return
|
||||
|
||||
portalInviteLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await useAdmin().invitePortalUser(Number(props.item.id))
|
||||
toast.add({
|
||||
title: "Portal-Einladung versendet",
|
||||
description: `E-Mail: ${response.email}${response.initialPassword ? ` | Initialpasswort: ${response.initialPassword}` : ""}`,
|
||||
timeout: 9000
|
||||
})
|
||||
emit("updateNeeded")
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
title: "Portal-Einladung fehlgeschlagen",
|
||||
description: err?.data?.error || "Die Einladung konnte nicht erstellt werden.",
|
||||
color: "error",
|
||||
timeout: 7000
|
||||
})
|
||||
} finally {
|
||||
portalInviteLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -217,6 +244,15 @@ const openCustomerInventoryLabelPrint = () => {
|
||||
>
|
||||
Label
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="type === 'customers' && auth.user?.is_admin"
|
||||
icon="i-heroicons-envelope"
|
||||
variant="outline"
|
||||
:loading="portalInviteLoading"
|
||||
@click="invitePortalUser"
|
||||
>
|
||||
Portal einladen
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="router.push(`/standardEntity/${type}/edit/${item.id}`)"
|
||||
>
|
||||
@@ -246,6 +282,15 @@ const openCustomerInventoryLabelPrint = () => {
|
||||
>
|
||||
Label
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="type === 'customers' && auth.user?.is_admin"
|
||||
icon="i-heroicons-envelope"
|
||||
variant="outline"
|
||||
:loading="portalInviteLoading"
|
||||
@click="invitePortalUser"
|
||||
>
|
||||
Portal einladen
|
||||
</UButton>
|
||||
<UButton
|
||||
@click="router.push(`/standardEntity/${type}/edit/${item.id}`)"
|
||||
>
|
||||
|
||||
@@ -26,6 +26,10 @@ const documentTypeItems = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const selectedDocumentType = computed(() => {
|
||||
return documentTypeItems.value.find((item) => item.key === documentTypeToUse.value) || null
|
||||
})
|
||||
|
||||
const visibleImportKeys = computed(() => {
|
||||
return Object.keys(optionsToImport.value).filter((key) => {
|
||||
if (documentTypeToUse.value !== props.type) {
|
||||
@@ -83,12 +87,12 @@ const startImport = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal>
|
||||
<UModal :ui="{ content: 'sm:max-w-2xl' }">
|
||||
<template #content>
|
||||
<UCard class="mx-auto w-full max-w-2xl shadow-xl ring-1 ring-black/5">
|
||||
<template #header>
|
||||
<div class="mx-auto flex max-h-[85vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-default shadow-xl ring-1 ring-black/5">
|
||||
<div class="border-b border-default px-6 py-5 sm:px-7">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary ring-1 ring-primary/15">
|
||||
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary ring-1 ring-primary/15">
|
||||
<UIcon name="i-heroicons-document-duplicate" class="h-6 w-6" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
@@ -96,10 +100,10 @@ const startImport = () => {
|
||||
<p class="mt-1 text-sm text-muted">Wähle den Zieltyp und welche Inhalte in das neue Dokument übernommen werden sollen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<UFormField label="Dokumententyp" required>
|
||||
<div class="flex-1 space-y-6 overflow-y-auto px-6 py-5 sm:px-7">
|
||||
<UFormField label="Dokumententyp" required class="w-full">
|
||||
<USelectMenu
|
||||
v-model="documentTypeToUse"
|
||||
:items="documentTypeItems"
|
||||
@@ -107,9 +111,16 @@ const startImport = () => {
|
||||
label-key="labelSingle"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:placeholder="selectedDocumentType?.labelSingle || 'Dokumententyp wählen'"
|
||||
:search-input="{ placeholder: 'Dokumententyp suchen...' }"
|
||||
:filter-fields="['labelSingle']"
|
||||
/>
|
||||
>
|
||||
<template #default>
|
||||
<span class="block truncate">
|
||||
{{ selectedDocumentType?.labelSingle || "Dokumententyp wählen" }}
|
||||
</span>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</UFormField>
|
||||
|
||||
<div class="space-y-3">
|
||||
@@ -130,17 +141,15 @@ const startImport = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton color="neutral" variant="ghost" @click="modal.close()">
|
||||
Abbrechen
|
||||
</UButton>
|
||||
<UButton @click="startImport">
|
||||
Kopieren
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
<div class="flex shrink-0 justify-end gap-2 border-t border-default px-6 py-4 sm:px-7">
|
||||
<UButton color="neutral" variant="ghost" @click="modal.close()">
|
||||
Abbrechen
|
||||
</UButton>
|
||||
<UButton @click="startImport">
|
||||
Kopieren
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import {
|
||||
getIncomingInvoiceDepreciationRows,
|
||||
getIncomingInvoiceImmediateExpenseNet,
|
||||
getStatementAllocationDepreciationRow,
|
||||
getStatementAllocationImmediateExpenseAmount
|
||||
} from "~/composables/useDepreciation"
|
||||
|
||||
@@ -79,9 +78,9 @@ const loadSummary = async () => {
|
||||
|
||||
const statementDate = allocation?.bankstatement?.date || allocation?.bankstatement?.valueDate || allocation?.date || allocation?.created_at
|
||||
const date = dayjs(statementDate)
|
||||
const amount = Number(allocation?.amount || 0)
|
||||
const amount = getStatementAllocationImmediateExpenseAmount(allocation)
|
||||
|
||||
return amount < 0 && date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day")
|
||||
return amount > 0 && date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day")
|
||||
})
|
||||
|
||||
const income = outputDocs.reduce((sum: number, doc: any) => {
|
||||
@@ -103,12 +102,11 @@ const loadSummary = async () => {
|
||||
}, 0)
|
||||
|
||||
const directAccountExpenses = directExpenses.reduce((sum: number, allocation: any) => {
|
||||
return sum + getStatementAllocationImmediateExpenseAmount(allocation)
|
||||
return sum + Number(getStatementAllocationImmediateExpenseAmount(allocation) || 0)
|
||||
}, 0)
|
||||
|
||||
const depreciationRows = [
|
||||
...inputDocs.flatMap((invoice: any) => getIncomingInvoiceDepreciationRows(invoice, bounds.start, bounds.end)),
|
||||
...(allocations || []).map((allocation: any) => getStatementAllocationDepreciationRow(allocation, bounds.start, bounds.end)).filter(Boolean)
|
||||
...inputDocs.flatMap((invoice: any) => getIncomingInvoiceDepreciationRows(invoice, bounds.start, bounds.end))
|
||||
]
|
||||
|
||||
const depreciations = depreciationRows.reduce((sum: number, row: any) => sum + Number(row.amount || 0), 0)
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import dayjs from "dayjs";
|
||||
import { Line } from "vue-chartjs";
|
||||
import {
|
||||
getIncomingInvoiceImmediateExpenseGross,
|
||||
getIncomingInvoiceImmediateExpenseNet
|
||||
} from "~/composables/useDepreciation";
|
||||
|
||||
dayjs.extend(customParseFormat)
|
||||
|
||||
@@ -84,7 +88,7 @@ const loadData = async () => {
|
||||
])
|
||||
|
||||
incomeDocuments.value = (docs || []).filter((item) => item.state === "Gebucht" && ["invoices", "advanceInvoices", "cancellationInvoices"].includes(item.type))
|
||||
expenseInvoices.value = (incoming || []).filter((item) => item.date)
|
||||
expenseInvoices.value = (incoming || []).filter((item) => item.state === "Gebucht" && item.date)
|
||||
}
|
||||
|
||||
const yearsInData = computed(() => {
|
||||
@@ -128,18 +132,9 @@ const computeDocumentAmount = (doc) => {
|
||||
}
|
||||
|
||||
const computeIncomingInvoiceAmount = (invoice) => {
|
||||
let amount = 0
|
||||
|
||||
;(invoice.accounts || []).forEach((account) => {
|
||||
const net = Number(account.amountNet || 0)
|
||||
const tax = Number(account.amountTax || 0)
|
||||
const grossValue = Number(account.amountGross)
|
||||
const gross = Number.isFinite(grossValue) ? grossValue : (net + tax)
|
||||
|
||||
amount += amountMode.value === "gross" ? gross : net
|
||||
})
|
||||
|
||||
return Number(amount.toFixed(2))
|
||||
return amountMode.value === "gross"
|
||||
? getIncomingInvoiceImmediateExpenseGross(invoice)
|
||||
: getIncomingInvoiceImmediateExpenseNet(invoice)
|
||||
}
|
||||
|
||||
const buckets = computed(() => {
|
||||
|
||||
@@ -90,6 +90,12 @@ export const useAdmin = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const invitePortalUser = async (customerId: number) => {
|
||||
return await $api(`/api/admin/customers/${customerId}/invite-portal-user`, {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
|
||||
const updateTenant = async (id: number, body: Record<string, any>) => {
|
||||
return await $api(`/api/admin/tenants/${id}`, {
|
||||
method: "PUT",
|
||||
@@ -103,6 +109,7 @@ export const useAdmin = () => {
|
||||
updateUser,
|
||||
updateUserAccess,
|
||||
createTenant,
|
||||
invitePortalUser,
|
||||
updateTenant,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +291,9 @@ export const getIncomingInvoiceDepreciationRows = (invoice: any, rangeStart: any
|
||||
}
|
||||
|
||||
export const getStatementAllocationImmediateExpenseAmount = (allocation: any) => {
|
||||
const hasLinkedDocument = allocation?.incominginvoice || allocation?.createddocument || allocation?.ii_id || allocation?.cd_id
|
||||
if (hasLinkedDocument) return 0
|
||||
|
||||
const mode = normalizeExpenseBookingMode(allocation?.bookingMode)
|
||||
const amount = Number(allocation?.amount || 0)
|
||||
if (mode !== "expense" || amount >= 0) return 0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
const auth = useAuthStore()
|
||||
const isPortalUser = Boolean(auth.profile?.customer_for_portal)
|
||||
|
||||
// DEBUG: Was sieht die Middleware wirklich?
|
||||
console.log("🔒 Middleware Check auf:", to.path)
|
||||
@@ -33,10 +34,14 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
if (auth.user.must_change_password) {
|
||||
return navigateTo("/password-change")
|
||||
}
|
||||
return navigateTo("/")
|
||||
return navigateTo(isPortalUser ? "/customer-portal" : "/")
|
||||
}
|
||||
|
||||
if (auth.user.must_change_password && to.path !== "/password-change") {
|
||||
return navigateTo("/password-change")
|
||||
}
|
||||
})
|
||||
|
||||
if (isPortalUser && !["/customer-portal", "/password-change"].includes(to.path)) {
|
||||
return navigateTo("/customer-portal")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
getIncomingInvoiceImmediateExpenseNet,
|
||||
isDepreciationBookingMode,
|
||||
normalizeIncomingInvoiceAccount,
|
||||
getStatementAllocationDepreciationRow,
|
||||
getStatementAllocationImmediateExpenseAmount
|
||||
} from "~/composables/useDepreciation"
|
||||
|
||||
@@ -156,7 +155,13 @@ const filteredStatementAllocations = computed(() => {
|
||||
})
|
||||
|
||||
const filteredAccountStatementAllocations = computed(() => {
|
||||
return filteredStatementAllocations.value.filter((allocation) => allocation.account !== null && allocation.account !== undefined)
|
||||
return filteredStatementAllocations.value.filter((allocation) => {
|
||||
if (allocation.account === null || allocation.account === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
return getStatementAllocationImmediateExpenseAmount(allocation) > 0
|
||||
})
|
||||
})
|
||||
|
||||
const selectedPeriodBounds = computed(() => {
|
||||
@@ -181,7 +186,7 @@ const expenseNetTotal = computed(() => {
|
||||
}, 0)
|
||||
|
||||
const directAccountExpenses = filteredAccountStatementAllocations.value.reduce((sum, allocation) => {
|
||||
return sum + getStatementAllocationImmediateExpenseAmount(allocation)
|
||||
return sum + Number(getStatementAllocationImmediateExpenseAmount(allocation) || 0)
|
||||
}, 0)
|
||||
|
||||
const depreciations = depreciationTotal.value
|
||||
@@ -193,7 +198,7 @@ const expenseGrossTotal = computed(() => {
|
||||
const invoiceExpenses = filteredIncomingInvoices.value.reduce((sum, invoice) => sum + getIncomingInvoiceImmediateExpenseGross(invoice), 0)
|
||||
|
||||
const directAccountExpenses = filteredAccountStatementAllocations.value.reduce((sum, allocation) => {
|
||||
return sum + getStatementAllocationImmediateExpenseAmount(allocation)
|
||||
return sum + Number(getStatementAllocationImmediateExpenseAmount(allocation) || 0)
|
||||
}, 0)
|
||||
|
||||
const depreciations = depreciationTotal.value
|
||||
@@ -249,13 +254,9 @@ const expenseDocumentCount = computed(() => {
|
||||
const depreciationRows = computed(() => {
|
||||
const invoiceRows = filteredIncomingInvoices.value.flatMap((invoice) => getIncomingInvoiceDepreciationRows(invoice, selectedPeriodBounds.value.start, selectedPeriodBounds.value.end))
|
||||
|
||||
const allocationRows = filteredStatementAllocations.value
|
||||
.map((allocation) => getStatementAllocationDepreciationRow(allocation, selectedPeriodBounds.value.start, selectedPeriodBounds.value.end))
|
||||
.filter(Boolean)
|
||||
|
||||
const grouped = new Map<string, any>()
|
||||
|
||||
;[...invoiceRows, ...allocationRows].forEach((row: any) => {
|
||||
;invoiceRows.forEach((row: any) => {
|
||||
const key = row.group || `${row.mode}:${row.label}`
|
||||
const current = grouped.get(key) || {
|
||||
id: key,
|
||||
|
||||
@@ -16,7 +16,8 @@ const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const incomingInvoices = ref<any[]>([])
|
||||
const statementAllocations = ref<any[]>([])
|
||||
const asOfDate = ref(dayjs().format("YYYY-MM-DD"))
|
||||
const periodStart = ref(dayjs().startOf("month").format("YYYY-MM-DD"))
|
||||
const periodEnd = ref(dayjs().endOf("month").format("YYYY-MM-DD"))
|
||||
const selectedAsset = ref<any | null>(null)
|
||||
const editState = ref<any | null>(null)
|
||||
const editOpen = computed({
|
||||
@@ -32,6 +33,22 @@ const formatCurrency = (value: number) => new Intl.NumberFormat("de-DE", {
|
||||
}).format(Number(value || 0))
|
||||
|
||||
const isRelevantInputInvoice = (invoice: any) => invoice?.state === "Gebucht" && !!invoice?.date
|
||||
const normalizedPeriod = computed(() => {
|
||||
const start = dayjs(periodStart.value)
|
||||
const end = dayjs(periodEnd.value)
|
||||
|
||||
if (start.isValid() && end.isValid() && start.isAfter(end, "day")) {
|
||||
return {
|
||||
start: end.startOf("day"),
|
||||
end: start.endOf("day"),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start: start.isValid() ? start.startOf("day") : dayjs().startOf("month"),
|
||||
end: end.isValid() ? end.endOf("day") : dayjs().endOf("month"),
|
||||
}
|
||||
})
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
@@ -62,9 +79,9 @@ const depreciationAssets = computed(() => {
|
||||
depreciationStartDate: normalized.depreciationStartDate || invoice.date,
|
||||
depreciationMethod: normalized.depreciationMethod,
|
||||
residualValue: normalized.residualValue,
|
||||
}, asOfDate.value)
|
||||
}, normalizedPeriod.value.end)
|
||||
|
||||
const currentPeriodRow = getIncomingInvoiceDepreciationRows(invoice, dayjs(asOfDate.value).startOf("month"), dayjs(asOfDate.value).endOf("month"))
|
||||
const currentPeriodRow = getIncomingInvoiceDepreciationRows(invoice, normalizedPeriod.value.start, normalizedPeriod.value.end)
|
||||
.find((row: any) => row.index === index)
|
||||
|
||||
return {
|
||||
@@ -102,9 +119,9 @@ const depreciationAssets = computed(() => {
|
||||
depreciationStartDate: allocation.depreciationStartDate || allocation.created_at,
|
||||
depreciationMethod: allocation.depreciationMethod,
|
||||
residualValue: allocation.residualValue,
|
||||
}, asOfDate.value)
|
||||
}, normalizedPeriod.value.end)
|
||||
|
||||
const currentPeriodRow = getStatementAllocationDepreciationRow(allocation, dayjs(asOfDate.value).startOf("month"), dayjs(asOfDate.value).endOf("month"))
|
||||
const currentPeriodRow = getStatementAllocationDepreciationRow(allocation, normalizedPeriod.value.start, normalizedPeriod.value.end)
|
||||
|
||||
return {
|
||||
key: `allocation-${allocation.id}`,
|
||||
@@ -260,7 +277,8 @@ onMounted(loadData)
|
||||
<UDashboardNavbar title="Abschreibungen">
|
||||
<template #right>
|
||||
<div class="flex items-center gap-2">
|
||||
<UInput v-model="asOfDate" type="date" class="w-44" />
|
||||
<UInput v-model="periodStart" type="date" class="w-44" />
|
||||
<UInput v-model="periodEnd" type="date" class="w-44" />
|
||||
<UButton icon="i-heroicons-arrow-path" variant="outline" :loading="loading" @click="loadData">
|
||||
Aktualisieren
|
||||
</UButton>
|
||||
@@ -278,7 +296,7 @@ onMounted(loadData)
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Bereits abgeschrieben</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-primary-600 dark:text-primary-400">{{ formatCurrency(totals.depreciated) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Stand {{ dayjs(asOfDate).format("DD.MM.YYYY") }}</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Stand {{ normalizedPeriod.end.format("DD.MM.YYYY") }}</div>
|
||||
</UCard>
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Restbuchwert</div>
|
||||
@@ -286,9 +304,9 @@ onMounted(loadData)
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Nach Restwertlogik</div>
|
||||
</UCard>
|
||||
<UCard>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Aktuelle Abschreibung</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Abschreibung im Zeitraum</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-error">{{ formatCurrency(totals.currentPeriodAmount) }}</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ totals.bundleCount }} Sammelposten</div>
|
||||
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ normalizedPeriod.start.format("DD.MM.YYYY") }} - {{ normalizedPeriod.end.format("DD.MM.YYYY") }}</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
@@ -351,7 +369,7 @@ onMounted(loadData)
|
||||
<div class="mt-1 font-semibold text-primary-600 dark:text-primary-400">{{ formatCurrency(asset.depreciated) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Aktueller Zeitraum</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Im Zeitraum</div>
|
||||
<div class="mt-1 font-semibold text-error">{{ formatCurrency(asset.currentPeriodAmount) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -14,10 +14,13 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const dataStore = useDataStore()
|
||||
const toast = useToast()
|
||||
|
||||
const itemInfo = ref({})
|
||||
const linkedDocument =ref({})
|
||||
const links = ref([])
|
||||
const portalReleaseLoading = ref(false)
|
||||
const portalEligibleTypes = ["invoices", "advanceInvoices", "cancellationInvoices"]
|
||||
|
||||
const setupPage = async () => {
|
||||
if(route.params) {
|
||||
@@ -51,6 +54,30 @@ const openBankstatements = () => {
|
||||
navigateTo(`/banking/statements/edit/${itemInfo.value.statementallocations[0].bankstatement}`)
|
||||
}
|
||||
}
|
||||
|
||||
const togglePortalRelease = async () => {
|
||||
if (!itemInfo.value?.id) return
|
||||
|
||||
portalReleaseLoading.value = true
|
||||
|
||||
try {
|
||||
const nextValue = !itemInfo.value.availableInPortal
|
||||
await useEntities("createddocuments").update(itemInfo.value.id, {
|
||||
availableInPortal: nextValue
|
||||
}, true)
|
||||
|
||||
itemInfo.value = {
|
||||
...itemInfo.value,
|
||||
availableInPortal: nextValue
|
||||
}
|
||||
|
||||
toast.add({
|
||||
title: nextValue ? "Für Kundenportal freigegeben" : "Portal-Freigabe entfernt"
|
||||
})
|
||||
} finally {
|
||||
portalReleaseLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -87,6 +114,15 @@ const openBankstatements = () => {
|
||||
>
|
||||
E-Mail
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="portalEligibleTypes.includes(itemInfo.type) && itemInfo.state !== 'Entwurf'"
|
||||
:icon="itemInfo.availableInPortal ? 'i-heroicons-eye-slash' : 'i-heroicons-eye'"
|
||||
variant="outline"
|
||||
:loading="portalReleaseLoading"
|
||||
@click="togglePortalRelease"
|
||||
>
|
||||
{{ itemInfo.availableInPortal ? "Portal-Freigabe entfernen" : "Für Kundenportal freigeben" }}
|
||||
</UButton>
|
||||
<UTooltip
|
||||
v-if="itemInfo.type === 'invoices' || itemInfo.type === 'advanceInvoices'"
|
||||
:text="links.find(i => i.type === 'cancellationInvoices') ? 'Bereits stoniert' : ''"
|
||||
@@ -170,6 +206,14 @@ const openBankstatements = () => {
|
||||
</template>
|
||||
</UDashboardToolbar>
|
||||
<UDashboardPanelContent>
|
||||
<div
|
||||
v-if="portalEligibleTypes.includes(itemInfo.type) && itemInfo.state !== 'Entwurf'"
|
||||
class="px-4 py-3"
|
||||
>
|
||||
<UBadge :color="itemInfo.availableInPortal ? 'primary' : 'neutral'" variant="soft">
|
||||
{{ itemInfo.availableInPortal ? "Im Kundenportal sichtbar" : "Nicht im Kundenportal freigegeben" }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<!-- <object
|
||||
:data="linkedDocument.url"
|
||||
class="w-full previewDocumentMobile"
|
||||
@@ -187,4 +231,4 @@ const openBankstatements = () => {
|
||||
aspect-ratio: 1 / 1.414;
|
||||
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
502
frontend/pages/customer-portal.vue
Normal file
502
frontend/pages/customer-portal.vue
Normal file
@@ -0,0 +1,502 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from "dayjs"
|
||||
|
||||
definePageMeta({
|
||||
layout: "notLoggedIn"
|
||||
})
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
const customer = ref<any | null>(null)
|
||||
const contracts = ref<any[]>([])
|
||||
const invoices = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const expandedInvoiceId = ref<number | null>(null)
|
||||
const downloadingInvoiceId = ref<number | null>(null)
|
||||
const activeTab = ref("0")
|
||||
|
||||
const customerForm = reactive({
|
||||
name: "",
|
||||
firstname: "",
|
||||
lastname: "",
|
||||
salutation: "",
|
||||
title: "",
|
||||
nameAddition: "",
|
||||
email: "",
|
||||
invoiceEmail: "",
|
||||
phone: "",
|
||||
mobile: "",
|
||||
website: "",
|
||||
street: "",
|
||||
special: "",
|
||||
zip: "",
|
||||
city: "",
|
||||
country: ""
|
||||
})
|
||||
|
||||
const portalCustomerId = computed(() => auth.profile?.customer_for_portal || null)
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
{
|
||||
label: "Offene Rechnungen",
|
||||
value: invoices.value.filter((invoice) => getOpenAmount(invoice) > 0).length,
|
||||
icon: "i-heroicons-document-text"
|
||||
},
|
||||
{
|
||||
label: "Aktive Vertrage",
|
||||
value: contracts.value.filter((contract) => contract.active).length,
|
||||
icon: "i-heroicons-clipboard-document-check"
|
||||
},
|
||||
{
|
||||
label: "Dokumente im Portal",
|
||||
value: invoices.value.length,
|
||||
icon: "i-heroicons-folder-open"
|
||||
}
|
||||
])
|
||||
|
||||
const portalTabs = computed(() => [
|
||||
{
|
||||
label: "Kundendaten",
|
||||
slot: "customer",
|
||||
value: "0"
|
||||
},
|
||||
{
|
||||
label: `Rechnungen (${invoices.value.length})`,
|
||||
slot: "invoices",
|
||||
value: "1"
|
||||
},
|
||||
{
|
||||
label: `Verträge (${contracts.value.length})`,
|
||||
slot: "contracts",
|
||||
value: "2"
|
||||
}
|
||||
])
|
||||
|
||||
function fillFormFromCustomer(record: any) {
|
||||
const infoData = record?.infoData || {}
|
||||
|
||||
customerForm.name = record?.name || ""
|
||||
customerForm.firstname = record?.firstname || ""
|
||||
customerForm.lastname = record?.lastname || ""
|
||||
customerForm.salutation = record?.salutation || ""
|
||||
customerForm.title = record?.title || ""
|
||||
customerForm.nameAddition = record?.nameAddition || ""
|
||||
customerForm.email = infoData.email || ""
|
||||
customerForm.invoiceEmail = infoData.invoiceEmail || ""
|
||||
customerForm.phone = infoData.tel || ""
|
||||
customerForm.mobile = infoData.mobileTel || ""
|
||||
customerForm.website = infoData.web || ""
|
||||
customerForm.street = infoData.street || ""
|
||||
customerForm.special = infoData.special || ""
|
||||
customerForm.zip = infoData.zip || ""
|
||||
customerForm.city = infoData.city || ""
|
||||
customerForm.country = infoData.country || ""
|
||||
}
|
||||
|
||||
function getDocumentTypeLabel(type: string) {
|
||||
if (type === "advanceInvoices") return "Abschlagsrechnung"
|
||||
if (type === "cancellationInvoices") return "Stornorechnung"
|
||||
return "Rechnung"
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) return "-"
|
||||
|
||||
const date = dayjs(value)
|
||||
return date.isValid() ? date.format("DD.MM.YYYY") : value
|
||||
}
|
||||
|
||||
function formatCurrency(value: number) {
|
||||
return useCurrency(value)
|
||||
}
|
||||
|
||||
function getInvoiceAmount(invoice: any) {
|
||||
return useSum().getCreatedDocumentSum(invoice, invoices.value)
|
||||
}
|
||||
|
||||
function getOpenAmount(invoice: any) {
|
||||
return useSum().getCreatedDocumentOpenAmount(invoice, invoices.value)
|
||||
}
|
||||
|
||||
function toggleInvoice(invoiceId: number) {
|
||||
expandedInvoiceId.value = expandedInvoiceId.value === invoiceId ? null : invoiceId
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
}
|
||||
|
||||
async function downloadInvoice(invoice: any) {
|
||||
const fileId = invoice?.files?.[0]?.id
|
||||
if (!fileId) return
|
||||
|
||||
downloadingInvoiceId.value = invoice.id
|
||||
|
||||
try {
|
||||
await useFiles().downloadFile(fileId)
|
||||
} finally {
|
||||
downloadingInvoiceId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPortalData() {
|
||||
if (!portalCustomerId.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const [customerRecord, contractRows, invoiceRows] = 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)
|
||||
])
|
||||
|
||||
customer.value = customerRecord
|
||||
contracts.value = (contractRows || []).filter((item: any) => !item.archived)
|
||||
invoices.value = (invoiceRows || []).filter((item: any) => !item.archived)
|
||||
fillFormFromCustomer(customerRecord)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCustomerData() {
|
||||
if (!customer.value?.id) return
|
||||
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: customerForm.name,
|
||||
firstname: customerForm.firstname,
|
||||
lastname: customerForm.lastname,
|
||||
salutation: customerForm.salutation,
|
||||
title: customerForm.title,
|
||||
nameAddition: customerForm.nameAddition,
|
||||
infoData: {
|
||||
...(customer.value.infoData || {}),
|
||||
email: customerForm.email,
|
||||
invoiceEmail: customerForm.invoiceEmail,
|
||||
tel: customerForm.phone,
|
||||
mobileTel: customerForm.mobile,
|
||||
web: customerForm.website,
|
||||
street: customerForm.street,
|
||||
special: customerForm.special,
|
||||
zip: customerForm.zip,
|
||||
city: customerForm.city,
|
||||
country: customerForm.country
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await useEntities("customers").update(customer.value.id, payload, true)
|
||||
customer.value = updated
|
||||
fillFormFromCustomer(updated)
|
||||
toast.add({ title: "Kundendaten gespeichert" })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPortalData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-b from-neutral-50 via-white to-neutral-100">
|
||||
<UContainer class="py-8 lg:py-12">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="rounded-3xl border border-neutral-200 bg-white/90 p-6 shadow-sm lg:p-8">
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm font-medium uppercase tracking-[0.2em] text-primary-600">Kundenportal</p>
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold text-neutral-900">
|
||||
{{ customer?.name || "Ihr Zugang" }}
|
||||
</h1>
|
||||
<p class="mt-2 max-w-2xl text-sm text-neutral-600">
|
||||
Hier konnen Sie Ihre Kundendaten pflegen, Rechnungen einsehen und aktive Vertrage im Blick behalten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<UButton color="neutral" variant="soft" icon="i-heroicons-arrow-path" :loading="loading" @click="loadPortalData">
|
||||
Aktualisieren
|
||||
</UButton>
|
||||
<UButton color="neutral" variant="outline" icon="i-heroicons-arrow-right-on-rectangle" @click="logout">
|
||||
Abmelden
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-3">
|
||||
<div
|
||||
v-for="card in summaryCards"
|
||||
:key="card.label"
|
||||
class="rounded-2xl border border-neutral-200 bg-neutral-50 p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-neutral-500">{{ card.label }}</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-neutral-900">{{ card.value }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white p-2 text-primary-600 shadow-sm">
|
||||
<UIcon :name="card.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="rounded-3xl border border-neutral-200 bg-white p-8 text-center text-neutral-500">
|
||||
Portal wird geladen...
|
||||
</div>
|
||||
|
||||
<div v-else-if="!portalCustomerId" class="rounded-3xl border border-amber-200 bg-amber-50 p-8 text-amber-900">
|
||||
Fuer diesen Zugang ist noch kein Portal-Kunde hinterlegt.
|
||||
</div>
|
||||
|
||||
<UCard v-else :ui="{ body: 'p-4 sm:p-6' }">
|
||||
<UTabs v-model="activeTab" :items="portalTabs" class="portal-tabs">
|
||||
<template #customer>
|
||||
<div class="pt-4">
|
||||
<div class="mb-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-neutral-900">Kundendaten</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500">Aenderungen werden direkt in Ihrem Kundenstamm gespeichert.</p>
|
||||
</div>
|
||||
<UBadge color="primary" variant="subtle">Bearbeitbar</UBadge>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<UFormField label="Name / Firma">
|
||||
<UInput v-model="customerForm.name" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Firmenzusatz">
|
||||
<UInput v-model="customerForm.nameAddition" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Anrede">
|
||||
<UInput v-model="customerForm.salutation" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Titel">
|
||||
<UInput v-model="customerForm.title" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Vorname">
|
||||
<UInput v-model="customerForm.firstname" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Nachname">
|
||||
<UInput v-model="customerForm.lastname" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="E-Mail">
|
||||
<UInput v-model="customerForm.email" type="email" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Rechnungs-E-Mail">
|
||||
<UInput v-model="customerForm.invoiceEmail" type="email" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Telefon">
|
||||
<UInput v-model="customerForm.phone" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Mobil">
|
||||
<UInput v-model="customerForm.mobile" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Webseite">
|
||||
<UInput v-model="customerForm.website" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Land">
|
||||
<UInput v-model="customerForm.country" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Strasse" class="md:col-span-2">
|
||||
<UInput v-model="customerForm.street" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Adresszusatz" class="md:col-span-2">
|
||||
<UInput v-model="customerForm.special" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="PLZ">
|
||||
<UInput v-model="customerForm.zip" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Ort">
|
||||
<UInput v-model="customerForm.city" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<UButton icon="i-heroicons-check" :loading="saving" @click="saveCustomerData">
|
||||
Kundendaten speichern
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #invoices>
|
||||
<div class="pt-4">
|
||||
<div class="mb-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-neutral-900">Rechnungen</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500">Alle fuer das Portal freigegebenen Rechnungen Ihres Kundenkontos.</p>
|
||||
</div>
|
||||
<UBadge color="neutral" variant="subtle">{{ invoices.length }}</UBadge>
|
||||
</div>
|
||||
|
||||
<div v-if="!invoices.length" class="rounded-2xl border border-dashed border-neutral-200 p-6 text-sm text-neutral-500">
|
||||
Aktuell sind keine Rechnungen im Portal verfuegbar.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="invoice in invoices"
|
||||
:key="invoice.id"
|
||||
class="rounded-2xl border border-neutral-200 bg-neutral-50 p-4"
|
||||
>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="font-semibold text-neutral-900">{{ invoice.documentNumber || `Dokument #${invoice.id}` }}</p>
|
||||
<UBadge color="primary" variant="soft">{{ getDocumentTypeLabel(invoice.type) }}</UBadge>
|
||||
<UBadge color="neutral" variant="soft">{{ invoice.state }}</UBadge>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-neutral-500">
|
||||
Datum: {{ formatDate(invoice.documentDate) }}
|
||||
<span v-if="invoice.contract?.name"> | Vertrag: {{ invoice.contract.contractNumber || "-" }} {{ invoice.contract.name }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-3 text-sm lg:items-end">
|
||||
<div class="text-neutral-600">
|
||||
Gesamt: <span class="font-semibold text-neutral-900">{{ formatCurrency(getInvoiceAmount(invoice)) }}</span>
|
||||
</div>
|
||||
<div class="text-neutral-600">
|
||||
Offen: <span class="font-semibold text-neutral-900">{{ formatCurrency(getOpenAmount(invoice)) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UButton
|
||||
v-if="invoice.files?.length"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
icon="i-heroicons-arrow-down-tray"
|
||||
:loading="downloadingInvoiceId === invoice.id"
|
||||
@click="downloadInvoice(invoice)"
|
||||
>
|
||||
PDF herunterladen
|
||||
</UButton>
|
||||
<UButton color="neutral" variant="soft" size="sm" @click="toggleInvoice(invoice.id)">
|
||||
{{ expandedInvoiceId === invoice.id ? "Details ausblenden" : "Details ansehen" }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="expandedInvoiceId === invoice.id" class="mt-4 border-t border-neutral-200 pt-4">
|
||||
<div v-if="invoice.rows?.length" class="space-y-3">
|
||||
<div
|
||||
v-for="(row, index) in invoice.rows.filter((entry: any) => !['pagebreak'].includes(entry.mode))"
|
||||
:key="`${invoice.id}-${index}`"
|
||||
class="flex flex-col gap-1 rounded-xl bg-white p-3 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="pr-4">
|
||||
<p class="font-medium text-neutral-900">{{ row.text || row.title || row.description || "Position" }}</p>
|
||||
<p class="text-xs text-neutral-500">
|
||||
Menge: {{ row.quantity || "-" }} | Einzelpreis: {{ formatCurrency(Number(row.price || 0)) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-sm font-medium text-neutral-900">
|
||||
{{ formatCurrency((Number(row.quantity || 0) * Number(row.price || 0)) * (1 - Number(row.discountPercent || 0) / 100)) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-neutral-500">
|
||||
Fuer dieses Dokument liegen keine Positionsdaten vor.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #contracts>
|
||||
<div class="pt-4">
|
||||
<div class="mb-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-neutral-900">Vertraege</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500">Ihre aktuell zugeordneten Vertrage auf einen Blick.</p>
|
||||
</div>
|
||||
<UBadge color="neutral" variant="subtle">{{ contracts.length }}</UBadge>
|
||||
</div>
|
||||
|
||||
<div v-if="!contracts.length" class="rounded-2xl border border-dashed border-neutral-200 p-6 text-sm text-neutral-500">
|
||||
Es sind aktuell keine Vertraege hinterlegt.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="contract in contracts"
|
||||
:key="contract.id"
|
||||
class="rounded-2xl border border-neutral-200 bg-neutral-50 p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="font-semibold text-neutral-900">{{ contract.name }}</p>
|
||||
<UBadge :color="contract.active ? 'green' : 'neutral'" variant="soft">
|
||||
{{ contract.active ? "Aktiv" : "Inaktiv" }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-neutral-500">
|
||||
Vertragsnummer: {{ contract.contractNumber || "-" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right text-sm text-neutral-500">
|
||||
<p>{{ contract.contracttype?.name || "Ohne Vertragstyp" }}</p>
|
||||
<p>{{ contract.billingInterval || "Kein Intervall hinterlegt" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<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">
|
||||
{{ formatDate(contract.startDate) }} bis {{ formatDate(contract.endDate) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white p-3">
|
||||
<p class="text-xs uppercase tracking-wide text-neutral-400">Abrechnung</p>
|
||||
<p class="mt-1 text-sm font-medium text-neutral-900">
|
||||
{{ contract.paymentType || "Nicht hinterlegt" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="contract.notes" class="mt-4 text-sm text-neutral-600">
|
||||
{{ contract.notes }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UTabs>
|
||||
</UCard>
|
||||
</div>
|
||||
</UContainer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue"
|
||||
import dayjs from "dayjs"
|
||||
import { CalendarDate, parseDate } from "@internationalized/date"
|
||||
|
||||
// --- Helper für Schnellauswahl ---
|
||||
|
||||
@@ -78,6 +79,40 @@ const createExportData = ref({
|
||||
mandantennr: null
|
||||
})
|
||||
|
||||
const toCalendarDate = (value: Date | string | null) => {
|
||||
if (!value) return null
|
||||
|
||||
const parsed = dayjs(value)
|
||||
if (!parsed.isValid()) return null
|
||||
|
||||
try {
|
||||
return parseDate(parsed.format("YYYY-MM-DD"))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fromCalendarDate = (value: CalendarDate | null, boundary: "start" | "end") => {
|
||||
if (!value) return null
|
||||
|
||||
const date = dayjs(value.toString())
|
||||
return boundary === "start" ? date.startOf("day").toDate() : date.endOf("day").toDate()
|
||||
}
|
||||
|
||||
const startDateValue = computed({
|
||||
get: () => toCalendarDate(createExportData.value.start_date),
|
||||
set: (value) => {
|
||||
createExportData.value.start_date = fromCalendarDate(value, "start")
|
||||
}
|
||||
})
|
||||
|
||||
const endDateValue = computed({
|
||||
get: () => toCalendarDate(createExportData.value.end_date),
|
||||
set: (value) => {
|
||||
createExportData.value.end_date = fromCalendarDate(value, "end")
|
||||
}
|
||||
})
|
||||
|
||||
const createExport = async () => {
|
||||
const res = await useNuxtApp().$api("/api/exports/datev",{
|
||||
method: "POST",
|
||||
@@ -182,29 +217,45 @@ const createExport = async () => {
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<UFormField label="Start:" class="flex-1">
|
||||
<UPopover :popper="{ placement: 'bottom-start' }">
|
||||
<UPopover>
|
||||
<UButton
|
||||
icon="i-heroicons-calendar-days-20-solid"
|
||||
:label="createExportData.start_date ? dayjs(createExportData.start_date).format('DD.MM.YYYY') : 'Datum auswählen'"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
class="w-full justify-start"
|
||||
/>
|
||||
<template #panel="{ close }">
|
||||
<LazyDatePicker v-model="createExportData.start_date" @close="close" />
|
||||
<template #content>
|
||||
<div class="p-2">
|
||||
<UCalendar v-model="startDateValue" />
|
||||
<div class="flex justify-end border-t border-default pt-2">
|
||||
<UButton color="neutral" variant="ghost" size="sm" @click="createExportData.start_date = dayjs().startOf('day').toDate()">
|
||||
Heute
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Ende:" class="flex-1">
|
||||
<UPopover :popper="{ placement: 'bottom-start' }">
|
||||
<UPopover>
|
||||
<UButton
|
||||
icon="i-heroicons-calendar-days-20-solid"
|
||||
:label="createExportData.end_date ? dayjs(createExportData.end_date).format('DD.MM.YYYY') : 'Datum auswählen'"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
class="w-full justify-start"
|
||||
/>
|
||||
<template #panel="{ close }">
|
||||
<LazyDatePicker v-model="createExportData.end_date" @close="close" />
|
||||
<template #content>
|
||||
<div class="p-2">
|
||||
<UCalendar v-model="endDateValue" />
|
||||
<div class="flex justify-end border-t border-default pt-2">
|
||||
<UButton color="neutral" variant="ghost" size="sm" @click="createExportData.end_date = dayjs().endOf('day').toDate()">
|
||||
Heute
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</UFormField>
|
||||
|
||||
Reference in New Issue
Block a user