import { Prisma } from "@capakraken/db"; import { isChargeabilityActualBooking, isChargeabilityRelevantProject, listAssignmentBookings, } from "@capakraken/application"; import type { WeekdayAvailability } from "@capakraken/shared"; import { createTRPCRouter, controllerProcedure } from "../trpc.js"; import { calculateEffectiveAvailableHours, calculateEffectiveBookedHours, countEffectiveWorkingDays, getAvailabilityHoursForDate, loadResourceDailyAvailabilityContexts, } from "../lib/resource-capacity.js"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; // ─── Column Definitions ────────────────────────────────────────────────────── interface ColumnDef { key: string; label: string; dataType: "string" | "number" | "date" | "boolean"; /** Prisma select path — nested relations use dot notation */ prismaPath?: string; } const RESOURCE_COLUMNS: ColumnDef[] = [ { key: "id", label: "ID", dataType: "string" }, { key: "eid", label: "Employee ID", dataType: "string" }, { key: "displayName", label: "Name", dataType: "string" }, { key: "email", label: "Email", dataType: "string" }, { key: "chapter", label: "Chapter", dataType: "string" }, { key: "resourceType", label: "Resource Type", dataType: "string" }, { key: "lcrCents", label: "LCR (cents)", dataType: "number" }, { key: "ucrCents", label: "UCR (cents)", dataType: "number" }, { key: "currency", label: "Currency", dataType: "string" }, { key: "chargeabilityTarget", label: "Chargeability Target (%)", dataType: "number" }, { key: "fte", label: "FTE", dataType: "number" }, { key: "isActive", label: "Active", dataType: "boolean" }, { key: "chgResponsibility", label: "Chg Responsibility", dataType: "boolean" }, { key: "rolledOff", label: "Rolled Off", dataType: "boolean" }, { key: "departed", label: "Departed", dataType: "boolean" }, { key: "postalCode", label: "Postal Code", dataType: "string" }, { key: "federalState", label: "Federal State", dataType: "string" }, { key: "country.code", label: "Country Code", dataType: "string", prismaPath: "country" }, { key: "country.name", label: "Country", dataType: "string", prismaPath: "country" }, { key: "metroCity.name", label: "Metro City", dataType: "string", prismaPath: "metroCity" }, { key: "orgUnit.name", label: "Org Unit", dataType: "string", prismaPath: "orgUnit" }, { key: "managementLevelGroup.name", label: "Mgmt Level Group", dataType: "string", prismaPath: "managementLevelGroup" }, { key: "managementLevel.name", label: "Mgmt Level", dataType: "string", prismaPath: "managementLevel" }, { key: "areaRole.name", label: "Area Role", dataType: "string", prismaPath: "areaRole" }, { key: "createdAt", label: "Created At", dataType: "date" }, { key: "updatedAt", label: "Updated At", dataType: "date" }, ]; const PROJECT_COLUMNS: ColumnDef[] = [ { key: "id", label: "ID", dataType: "string" }, { key: "shortCode", label: "Short Code", dataType: "string" }, { key: "name", label: "Name", dataType: "string" }, { key: "orderType", label: "Order Type", dataType: "string" }, { key: "allocationType", label: "Allocation Type", dataType: "string" }, { key: "status", label: "Status", dataType: "string" }, { key: "winProbability", label: "Win Probability (%)", dataType: "number" }, { key: "budgetCents", label: "Budget (cents)", dataType: "number" }, { key: "clientId", label: "Client ID", dataType: "string" }, { key: "startDate", label: "Start Date", dataType: "date" }, { key: "endDate", label: "End Date", dataType: "date" }, { key: "responsiblePerson", label: "Responsible Person", dataType: "string" }, { key: "client.name", label: "Client", dataType: "string", prismaPath: "client" }, { key: "utilizationCategory.name", label: "Util. Category", dataType: "string", prismaPath: "utilizationCategory" }, { key: "blueprint.name", label: "Blueprint", dataType: "string", prismaPath: "blueprint" }, { key: "createdAt", label: "Created At", dataType: "date" }, { key: "updatedAt", label: "Updated At", dataType: "date" }, ]; const ASSIGNMENT_COLUMNS: ColumnDef[] = [ { key: "id", label: "ID", dataType: "string" }, { key: "resourceId", label: "Resource ID", dataType: "string" }, { key: "projectId", label: "Project ID", dataType: "string" }, { key: "resource.displayName", label: "Resource", dataType: "string", prismaPath: "resource" }, { key: "resource.eid", label: "Resource EID", dataType: "string", prismaPath: "resource" }, { key: "resource.chapter", label: "Resource Chapter", dataType: "string", prismaPath: "resource" }, { key: "resource.country.code", label: "Resource Country Code", dataType: "string", prismaPath: "resource" }, { key: "resource.federalState", label: "Resource State", dataType: "string", prismaPath: "resource" }, { key: "resource.country.name", label: "Resource Country", dataType: "string", prismaPath: "resource" }, { key: "resource.metroCity.name", label: "Resource City", dataType: "string", prismaPath: "resource" }, { key: "project.name", label: "Project", dataType: "string", prismaPath: "project" }, { key: "project.shortCode", label: "Project Code", dataType: "string", prismaPath: "project" }, { key: "project.status", label: "Project Status", dataType: "string", prismaPath: "project" }, { key: "project.client.name", label: "Project Client", dataType: "string", prismaPath: "project" }, { key: "startDate", label: "Start Date", dataType: "date" }, { key: "endDate", label: "End Date", dataType: "date" }, { key: "hoursPerDay", label: "Hours/Day", dataType: "number" }, { key: "percentage", label: "Percentage", dataType: "number" }, { key: "role", label: "Role (legacy)", dataType: "string" }, { key: "roleEntity.name", label: "Role", dataType: "string", prismaPath: "roleEntity" }, { key: "dailyCostCents", label: "Daily Cost (cents)", dataType: "number" }, { key: "status", label: "Status", dataType: "string" }, { key: "createdAt", label: "Created At", dataType: "date" }, { key: "updatedAt", label: "Updated At", dataType: "date" }, ]; const RESOURCE_MONTH_COLUMNS: ColumnDef[] = [ { key: "id", label: "Row ID", dataType: "string" }, { key: "resourceId", label: "Resource ID", dataType: "string" }, { key: "monthKey", label: "Month", dataType: "string" }, { key: "periodStart", label: "Period Start", dataType: "date" }, { key: "periodEnd", label: "Period End", dataType: "date" }, { key: "eid", label: "Employee ID", dataType: "string" }, { key: "displayName", label: "Name", dataType: "string" }, { key: "email", label: "Email", dataType: "string" }, { key: "chapter", label: "Chapter", dataType: "string" }, { key: "resourceType", label: "Resource Type", dataType: "string" }, { key: "isActive", label: "Active", dataType: "boolean" }, { key: "chgResponsibility", label: "Chg Responsibility", dataType: "boolean" }, { key: "rolledOff", label: "Rolled Off", dataType: "boolean" }, { key: "departed", label: "Departed", dataType: "boolean" }, { key: "countryCode", label: "Country Code", dataType: "string" }, { key: "countryName", label: "Country", dataType: "string" }, { key: "federalState", label: "Federal State", dataType: "string" }, { key: "metroCityName", label: "Metro City", dataType: "string" }, { key: "orgUnitName", label: "Org Unit", dataType: "string" }, { key: "managementLevelGroupName", label: "Mgmt Level Group", dataType: "string" }, { key: "managementLevelName", label: "Mgmt Level", dataType: "string" }, { key: "fte", label: "FTE", dataType: "number" }, { key: "lcrCents", label: "LCR (cents)", dataType: "number" }, { key: "ucrCents", label: "UCR (cents)", dataType: "number" }, { key: "currency", label: "Currency", dataType: "string" }, { key: "monthlyChargeabilityTargetPct", label: "Target Chargeability (%)", dataType: "number" }, { key: "monthlyTargetHours", label: "Target Hours", dataType: "number" }, { key: "monthlyBaseWorkingDays", label: "Base Working Days", dataType: "number" }, { key: "monthlyEffectiveWorkingDays", label: "Effective Working Days", dataType: "number" }, { key: "monthlyBaseAvailableHours", label: "Base Available Hours", dataType: "number" }, { key: "monthlySahHours", label: "SAH", dataType: "number" }, { key: "monthlyPublicHolidayCount", label: "Holiday Dates", dataType: "number" }, { key: "monthlyPublicHolidayWorkdayCount", label: "Holiday Workdays", dataType: "number" }, { key: "monthlyPublicHolidayHoursDeduction", label: "Holiday Hours Deduction", dataType: "number" }, { key: "monthlyAbsenceDayEquivalent", label: "Absence Day Equivalent", dataType: "number" }, { key: "monthlyAbsenceHoursDeduction", label: "Absence Hours Deduction", dataType: "number" }, { key: "monthlyActualBookedHours", label: "Actual Booked Hours", dataType: "number" }, { key: "monthlyExpectedBookedHours", label: "Expected Booked Hours", dataType: "number" }, { key: "monthlyActualChargeabilityPct", label: "Actual Chargeability (%)", dataType: "number" }, { key: "monthlyExpectedChargeabilityPct", label: "Expected Chargeability (%)", dataType: "number" }, { key: "monthlyUnassignedHours", label: "Unassigned Hours", dataType: "number" }, ]; const COLUMN_MAP: Record = { resource: RESOURCE_COLUMNS, project: PROJECT_COLUMNS, assignment: ASSIGNMENT_COLUMNS, resource_month: RESOURCE_MONTH_COLUMNS, }; // ─── Helpers ──────────────────────────────────────────────────────────────── const ENTITY_MAP = { resource: "resource", project: "project", assignment: "assignment", resource_month: "resource_month", } as const; type EntityKey = keyof typeof ENTITY_MAP; /** Allowlist of top-level scalar fields per entity that can be filtered/sorted on. */ const ALLOWED_SCALAR_FIELDS: Record> = { resource: new Set([ "id", "eid", "displayName", "email", "chapter", "resourceType", "lcrCents", "ucrCents", "currency", "chargeabilityTarget", "fte", "isActive", "chgResponsibility", "rolledOff", "departed", "postalCode", "federalState", "createdAt", "updatedAt", ]), project: new Set([ "id", "shortCode", "name", "orderType", "allocationType", "status", "winProbability", "budgetCents", "startDate", "endDate", "responsiblePerson", "createdAt", "updatedAt", ]), assignment: new Set([ "id", "startDate", "endDate", "hoursPerDay", "percentage", "role", "dailyCostCents", "status", "createdAt", "updatedAt", ]), resource_month: new Set(RESOURCE_MONTH_COLUMNS.map((column) => column.key)), }; function getValidScalarField(entity: EntityKey, field: string): string | null { // Only allow top-level scalar fields for filter/sort (no relation traversal in where/orderBy) if (ALLOWED_SCALAR_FIELDS[entity].has(field)) return field; return null; } /** * Build a Prisma `select` object from the requested columns. * Always includes `id`. For relation columns like "country.name", * we include the relation with `select: { name: true }`. */ function buildSelect(entity: EntityKey, columns: string[]): Record { const entityColumns = COLUMN_MAP[entity]; const select: Record = { id: true }; for (const colKey of columns) { const def = entityColumns.find((c) => c.key === colKey); if (!def) continue; if (colKey.includes(".")) { const relationName = def.prismaPath ?? colKey.split(".")[0]!; const existing = select[relationName]; const fieldSegments = colKey.split(".").slice(1); const relationSelect = existing && typeof existing === "object" && existing !== null && "select" in existing ? (existing as { select: Record }).select : {}; mergeSelectPath(relationSelect, fieldSegments); select[relationName] = { select: relationSelect }; } else { select[colKey] = true; } } return select; } function mergeSelectPath( target: Record, segments: string[], ): void { const [head, ...tail] = segments; if (!head) { return; } if (tail.length === 0) { target[head] = true; return; } const existing = target[head]; const nestedSelect = existing && typeof existing === "object" && existing !== null && "select" in existing ? (existing as { select: Record }).select : {}; mergeSelectPath(nestedSelect, tail); target[head] = { select: nestedSelect }; } /** * Build a Prisma `where` from the filter array. * Only scalar top-level fields are allowed for safety. */ function buildWhere( entity: EntityKey, filters: Array<{ field: string; op: string; value: string }>, ): Record { const where: Record = {}; for (const filter of filters) { const field = getValidScalarField(entity, filter.field); if (!field) continue; const entityColumns = COLUMN_MAP[entity]; const colDef = entityColumns.find((c) => c.key === field); const dataType = colDef?.dataType ?? "string"; // Parse value based on data type let parsedValue: unknown = filter.value; if (dataType === "number") { parsedValue = Number(filter.value); if (Number.isNaN(parsedValue as number)) continue; } else if (dataType === "boolean") { parsedValue = filter.value === "true"; } else if (dataType === "date") { parsedValue = new Date(filter.value); if (Number.isNaN((parsedValue as Date).getTime())) continue; } switch (filter.op) { case "eq": where[field] = parsedValue; break; case "neq": where[field] = { not: parsedValue }; break; case "gt": where[field] = { gt: parsedValue }; break; case "lt": where[field] = { lt: parsedValue }; break; case "gte": where[field] = { gte: parsedValue }; break; case "lte": where[field] = { lte: parsedValue }; break; case "contains": if (dataType === "string") { where[field] = { contains: filter.value, mode: "insensitive" }; } break; case "in": if (dataType === "string") { where[field] = { in: filter.value.split(",").map((v) => v.trim()) }; } break; } } return where; } /** * Flatten a Prisma result row so nested relations become dot-notation keys. * E.g. { country: { name: "DE" } } => { "country.name": "DE" } */ function flattenRow(row: Record, prefix = ""): Record { const result: Record = {}; for (const [key, value] of Object.entries(row)) { const fullKey = prefix ? `${prefix}.${key}` : key; if (value !== null && typeof value === "object" && !(value instanceof Date) && !Array.isArray(value)) { Object.assign(result, flattenRow(value as Record, fullKey)); } else { result[fullKey] = value; } } return result; } /** * Format a value for CSV output. */ function csvEscape(value: unknown): string { if (value === null || value === undefined) return ""; if (value instanceof Date) return value.toISOString(); const str = String(value); if (str.includes(",") || str.includes('"') || str.includes("\n")) { return `"${str.replace(/"/g, '""')}"`; } return str; } // ─── Input Schema ─────────────────────────────────────────────────────────── const reportEntitySchema = z.enum(["resource", "project", "assignment", "resource_month"]); const FilterSchema = z.object({ field: z.string().min(1), op: z.enum(["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"]), value: z.string(), }); const ReportInputSchema = z.object({ entity: reportEntitySchema, columns: z.array(z.string()).min(1), filters: z.array(FilterSchema).default([]), groupBy: z.string().optional(), sortBy: z.string().optional(), sortDir: z.enum(["asc", "desc"]).default("asc"), periodMonth: z.string().regex(/^\d{4}-\d{2}$/).optional(), limit: z.number().int().min(1).max(5000).default(50), offset: z.number().int().min(0).default(0), }); const ReportTemplateConfigSchema = ReportInputSchema.omit({ limit: true, offset: true }); const ReportTemplateEntity = { RESOURCE: "RESOURCE", PROJECT: "PROJECT", ASSIGNMENT: "ASSIGNMENT", RESOURCE_MONTH: "RESOURCE_MONTH", } as const; type ReportTemplateEntity = (typeof ReportTemplateEntity)[keyof typeof ReportTemplateEntity]; type ReportTemplateRecord = { id: string; name: string; description: string | null; entity: ReportTemplateEntity; config: unknown; isShared: boolean; ownerId: string; updatedAt: Date; }; function getReportTemplateDelegate(db: unknown) { return (db as { reportTemplate: { findMany: (args: unknown) => Promise; findUnique: (args: unknown) => Promise<{ ownerId: string } | null>; update: (args: unknown) => Promise<{ id: string; updatedAt: Date }>; upsert: (args: unknown) => Promise<{ id: string; updatedAt: Date }>; delete: (args: unknown) => Promise; }; }).reportTemplate; } // ─── Router ────────────────────────────────────────────────────────────────── export const reportRouter = createTRPCRouter({ listTemplates: controllerProcedure.query(async ({ ctx }) => { const reportTemplate = getReportTemplateDelegate(ctx.db); const templates = await reportTemplate.findMany({ where: { OR: [ { ownerId: ctx.dbUser!.id }, { isShared: true }, ], }, orderBy: [{ name: "asc" }], select: { id: true, name: true, description: true, entity: true, config: true, isShared: true, ownerId: true, updatedAt: true, }, }); return templates.map((template: ReportTemplateRecord) => ({ id: template.id, name: template.name, description: template.description, entity: fromTemplateEntity(template.entity), config: ReportTemplateConfigSchema.parse(template.config), isShared: template.isShared, isOwner: template.ownerId === ctx.dbUser!.id, updatedAt: template.updatedAt, })); }), saveTemplate: controllerProcedure .input(z.object({ id: z.string().optional(), name: z.string().trim().min(1).max(120), description: z.string().trim().max(500).optional(), isShared: z.boolean().default(false), config: ReportTemplateConfigSchema, })) .mutation(async ({ ctx, input }) => { const reportTemplate = getReportTemplateDelegate(ctx.db); const payload = input.config as unknown as Prisma.InputJsonValue; const entity = toTemplateEntity(input.config.entity); if (input.id) { const existing = await reportTemplate.findUnique({ where: { id: input.id }, select: { ownerId: true }, }); if (!existing || existing.ownerId !== ctx.dbUser!.id) { throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be updated" }); } return reportTemplate.update({ where: { id: input.id }, data: { name: input.name, description: input.description, entity, config: payload, isShared: input.isShared, }, select: { id: true, updatedAt: true }, }); } return reportTemplate.upsert({ where: { ownerId_name: { ownerId: ctx.dbUser!.id, name: input.name, }, }, update: { description: input.description, entity, config: payload, isShared: input.isShared, }, create: { ownerId: ctx.dbUser!.id, name: input.name, description: input.description, entity, config: payload, isShared: input.isShared, }, select: { id: true, updatedAt: true }, }); }), deleteTemplate: controllerProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const reportTemplate = getReportTemplateDelegate(ctx.db); const existing = await reportTemplate.findUnique({ where: { id: input.id }, select: { ownerId: true }, }); if (!existing || existing.ownerId !== ctx.dbUser!.id) { throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be deleted" }); } await reportTemplate.delete({ where: { id: input.id } }); return { ok: true }; }), /** * Return available columns for a given entity type. */ getAvailableColumns: controllerProcedure .input(z.object({ entity: reportEntitySchema })) .query(({ input }) => { const columns = COLUMN_MAP[input.entity]; if (!columns) { throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${input.entity}` }); } return columns.map(({ key, label, dataType }) => ({ key, label, dataType })); }), /** * Fetch report data with dynamic columns, filters, sorting and pagination. */ getReportData: controllerProcedure .input(ReportInputSchema) .query(async ({ ctx, input }) => { return executeReportQuery(ctx.db, input); }), /** * Same as getReportData but returns a CSV string for download. */ exportReport: controllerProcedure .input(ReportInputSchema.omit({ offset: true }).extend({ limit: z.number().int().min(1).max(50000).default(5000), })) .mutation(async ({ ctx, input }) => { const result = await executeReportQuery(ctx.db, { ...input, offset: 0 }); const rows = result.rows; const outputColumns = result.columns; // Build CSV const entityColumns = COLUMN_MAP[input.entity]; const headerLabels = outputColumns.map((key) => { const def = entityColumns.find((c) => c.key === key); return def?.label ?? key; }); const csvLines = [ headerLabels.map(csvEscape).join(","), ...rows.map((row) => outputColumns.map((col) => csvEscape(row[col])).join(","), ), ]; return { csv: csvLines.join("\n"), rowCount: rows.length }; }), }); type ReportInput = z.infer; type FilterInput = z.infer; async function executeReportQuery( db: any, input: ReportInput, ): Promise<{ rows: Record[]; columns: string[]; totalCount: number }> { if (input.entity === "resource_month") { return executeResourceMonthReport(db, input); } const { entity, columns, filters, sortBy, sortDir, limit, offset } = input; const select = buildSelect(entity, columns); const where = buildWhere(entity, filters); let orderBy: Record | undefined; if (sortBy) { const validField = getValidScalarField(entity, sortBy); if (validField) { orderBy = { [validField]: sortDir }; } } const modelDelegate = getModelDelegate(db, entity); const [rawRows, totalCount] = await Promise.all([ (modelDelegate as any).findMany({ select, where, ...(orderBy ? { orderBy } : {}), take: limit, skip: offset, }), (modelDelegate as any).count({ where }), ]); const rows = (rawRows as Record[]).map((row) => flattenRow(row)); const outputColumns = ["id", ...columns.filter((column) => column !== "id")]; return { rows: rows.map((row) => pickColumns(row, outputColumns)), columns: outputColumns, totalCount, }; } async function executeResourceMonthReport( db: any, input: ReportInput, ): Promise<{ rows: Record[]; columns: string[]; totalCount: number }> { const periodMonth = input.periodMonth ?? new Date().toISOString().slice(0, 7); const [year, month] = periodMonth.split("-").map(Number) as [number, number]; const periodStart = new Date(Date.UTC(year, month - 1, 1)); const periodEnd = new Date(Date.UTC(year, month, 0)); const resources = await db.resource.findMany({ select: { id: true, eid: true, displayName: true, email: true, chapter: true, resourceType: true, isActive: true, chgResponsibility: true, rolledOff: true, departed: true, lcrCents: true, ucrCents: true, currency: true, fte: true, availability: true, chargeabilityTarget: true, federalState: true, countryId: true, metroCityId: true, country: { select: { code: true, name: true } }, metroCity: { select: { name: true } }, orgUnit: { select: { name: true } }, managementLevelGroup: { select: { name: true, targetPercentage: true } }, managementLevel: { select: { name: true } }, }, orderBy: { displayName: "asc" }, }); const resourceIds = resources.map((resource: any) => resource.id); const [bookings, contexts] = await Promise.all([ resourceIds.length > 0 ? listAssignmentBookings(db, { startDate: periodStart, endDate: periodEnd, resourceIds, }) : Promise.resolve([]), loadResourceDailyAvailabilityContexts( db, resources.map((resource: any) => ({ id: resource.id, availability: resource.availability as WeekdayAvailability, countryId: resource.countryId, countryCode: resource.country?.code, federalState: resource.federalState, metroCityId: resource.metroCityId, metroCityName: resource.metroCity?.name, })), periodStart, periodEnd, ), ]); const rows = resources.map((resource: any) => { const availability = resource.availability as WeekdayAvailability; const context = contexts.get(resource.id); const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id); const baseWorkingDays = countEffectiveWorkingDays({ availability, periodStart, periodEnd, context: undefined, }); const effectiveWorkingDays = countEffectiveWorkingDays({ availability, periodStart, periodEnd, context, }); const baseAvailableHours = calculateEffectiveAvailableHours({ availability, periodStart, periodEnd, context: undefined, }); const sahHours = calculateEffectiveAvailableHours({ availability, periodStart, periodEnd, context, }); const holidayDates = [...(context?.holidayDates ?? new Set())]; const publicHolidayWorkdayCount = holidayDates.reduce((count, isoDate) => ( count + (getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0) ), 0); const publicHolidayHoursDeduction = holidayDates.reduce((sum, isoDate) => ( sum + getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)) ), 0); let absenceDayEquivalent = 0; let absenceHoursDeduction = 0; for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) { const dayHours = getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)); if (dayHours <= 0 || context?.holidayDates.has(isoDate)) { continue; } absenceDayEquivalent += fraction; absenceHoursDeduction += dayHours * fraction; } const actualBookedHours = resourceBookings .filter((booking) => isChargeabilityActualBooking(booking, false)) .reduce((sum, booking) => sum + calculateEffectiveBookedHours({ availability, startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, periodStart, periodEnd, context, }), 0); const expectedBookedHours = resourceBookings .filter((booking) => isChargeabilityRelevantProject(booking.project, true)) .reduce((sum, booking) => sum + calculateEffectiveBookedHours({ availability, startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, periodStart, periodEnd, context, }), 0); const targetPct = resource.managementLevelGroup?.targetPercentage != null ? resource.managementLevelGroup.targetPercentage * 100 : resource.chargeabilityTarget; return { id: `${resource.id}:${periodMonth}`, resourceId: resource.id, monthKey: periodMonth, periodStart: periodStart.toISOString(), periodEnd: periodEnd.toISOString(), eid: resource.eid, displayName: resource.displayName, email: resource.email, chapter: resource.chapter, resourceType: resource.resourceType, isActive: resource.isActive, chgResponsibility: resource.chgResponsibility, rolledOff: resource.rolledOff, departed: resource.departed, countryCode: resource.country?.code ?? null, countryName: resource.country?.name ?? null, federalState: resource.federalState, metroCityName: resource.metroCity?.name ?? null, orgUnitName: resource.orgUnit?.name ?? null, managementLevelGroupName: resource.managementLevelGroup?.name ?? null, managementLevelName: resource.managementLevel?.name ?? null, fte: roundMetric(resource.fte), lcrCents: resource.lcrCents, ucrCents: resource.ucrCents, currency: resource.currency, monthlyChargeabilityTargetPct: roundMetric(targetPct), monthlyTargetHours: roundMetric((sahHours * targetPct) / 100), monthlyBaseWorkingDays: roundMetric(baseWorkingDays), monthlyEffectiveWorkingDays: roundMetric(effectiveWorkingDays), monthlyBaseAvailableHours: roundMetric(baseAvailableHours), monthlySahHours: roundMetric(sahHours), monthlyPublicHolidayCount: holidayDates.length, monthlyPublicHolidayWorkdayCount: publicHolidayWorkdayCount, monthlyPublicHolidayHoursDeduction: roundMetric(publicHolidayHoursDeduction), monthlyAbsenceDayEquivalent: roundMetric(absenceDayEquivalent), monthlyAbsenceHoursDeduction: roundMetric(absenceHoursDeduction), monthlyActualBookedHours: roundMetric(actualBookedHours), monthlyExpectedBookedHours: roundMetric(expectedBookedHours), monthlyActualChargeabilityPct: roundMetric(sahHours > 0 ? (actualBookedHours / sahHours) * 100 : 0), monthlyExpectedChargeabilityPct: roundMetric(sahHours > 0 ? (expectedBookedHours / sahHours) * 100 : 0), monthlyUnassignedHours: roundMetric(Math.max(0, sahHours - actualBookedHours)), }; }); const filteredRows = rows.filter((row: Record) => input.filters.every((filter) => matchesInMemoryFilter( row, filter, RESOURCE_MONTH_COLUMNS, ))); const sortedRows = sortInMemoryRows(filteredRows, input.sortBy, input.sortDir, RESOURCE_MONTH_COLUMNS); const totalCount = sortedRows.length; const pagedRows = sortedRows.slice(input.offset, input.offset + input.limit); const outputColumns = ["id", ...input.columns.filter((column) => column !== "id")]; return { rows: pagedRows.map((row) => pickColumns(row, outputColumns)), columns: outputColumns, totalCount, }; } function parseFilterValue(def: ColumnDef | undefined, value: string): unknown { if (!def) return value; if (def.dataType === "number") { const parsed = Number(value); return Number.isNaN(parsed) ? null : parsed; } if (def.dataType === "boolean") { return value === "true"; } if (def.dataType === "date") { const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? null : parsed.getTime(); } return value; } function matchesInMemoryFilter( row: Record, filter: FilterInput, columns: ColumnDef[], ): boolean { const def = columns.find((column) => column.key === filter.field); if (!def) { return true; } const rowValueRaw = row[filter.field]; const rowValue = def.dataType === "date" && typeof rowValueRaw === "string" ? new Date(rowValueRaw).getTime() : rowValueRaw; const parsedFilterValue = parseFilterValue(def, filter.value); if (parsedFilterValue === null) { return false; } switch (filter.op) { case "eq": return rowValue === parsedFilterValue; case "neq": return rowValue !== parsedFilterValue; case "gt": return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue > parsedFilterValue; case "lt": return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue < parsedFilterValue; case "gte": return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue >= parsedFilterValue; case "lte": return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue <= parsedFilterValue; case "contains": return typeof rowValue === "string" && rowValue.toLowerCase().includes(filter.value.toLowerCase()); case "in": return filter.value.split(",").map((value) => value.trim()).includes(String(rowValue ?? "")); default: return true; } } function sortInMemoryRows( rows: Record[], sortBy: string | undefined, sortDir: "asc" | "desc", columns: ColumnDef[], ): Record[] { if (!sortBy) { return rows; } const def = columns.find((column) => column.key === sortBy); if (!def) { return rows; } const direction = sortDir === "asc" ? 1 : -1; return [...rows].sort((left, right) => { const leftValue = left[sortBy]; const rightValue = right[sortBy]; if (leftValue == null && rightValue == null) return 0; if (leftValue == null) return 1; if (rightValue == null) return -1; if (def.dataType === "number") { return direction * (Number(leftValue) - Number(rightValue)); } if (def.dataType === "boolean") { return direction * (Number(Boolean(leftValue)) - Number(Boolean(rightValue))); } if (def.dataType === "date") { return direction * (new Date(String(leftValue)).getTime() - new Date(String(rightValue)).getTime()); } return direction * String(leftValue).localeCompare(String(rightValue), "de"); }); } function pickColumns(row: Record, columns: string[]): Record { return Object.fromEntries(columns.map((column) => [column, row[column]])); } function roundMetric(value: number): number { return Math.round(value * 10) / 10; } function toTemplateEntity(entity: EntityKey): ReportTemplateEntity { switch (entity) { case "resource": return ReportTemplateEntity.RESOURCE; case "project": return ReportTemplateEntity.PROJECT; case "assignment": return ReportTemplateEntity.ASSIGNMENT; case "resource_month": return ReportTemplateEntity.RESOURCE_MONTH; default: throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` }); } } function fromTemplateEntity(entity: ReportTemplateEntity): EntityKey { switch (entity) { case ReportTemplateEntity.RESOURCE: return "resource"; case ReportTemplateEntity.PROJECT: return "project"; case ReportTemplateEntity.ASSIGNMENT: return "assignment"; case ReportTemplateEntity.RESOURCE_MONTH: return "resource_month"; default: throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` }); } } /** Resolve the Prisma model delegate from entity key. */ function getModelDelegate(db: any, entity: EntityKey) { switch (entity) { case "resource": return db.resource; case "project": return db.project; case "assignment": return db.assignment; default: throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` }); } }