feat(planning): ship holiday-aware planning and assistant upgrades

This commit is contained in:
2026-03-28 22:49:28 +01:00
parent 2a005794e7
commit 4f48afe7b4
151 changed files with 17738 additions and 1940 deletions
+637 -69
View File
@@ -1,6 +1,20 @@
import { z } from "zod";
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 ──────────────────────────────────────────────────────
@@ -30,6 +44,7 @@ const RESOURCE_COLUMNS: ColumnDef[] = [
{ 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" },
@@ -49,6 +64,7 @@ const PROJECT_COLUMNS: ColumnDef[] = [
{ 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" },
@@ -61,10 +77,19 @@ const PROJECT_COLUMNS: ColumnDef[] = [
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" },
@@ -77,10 +102,55 @@ const ASSIGNMENT_COLUMNS: ColumnDef[] = [
{ 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<EntityKey, ColumnDef[]> = {
resource: RESOURCE_COLUMNS,
project: PROJECT_COLUMNS,
assignment: ASSIGNMENT_COLUMNS,
resource_month: RESOURCE_MONTH_COLUMNS,
};
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -89,6 +159,7 @@ const ENTITY_MAP = {
resource: "resource",
project: "project",
assignment: "assignment",
resource_month: "resource_month",
} as const;
type EntityKey = keyof typeof ENTITY_MAP;
@@ -110,6 +181,7 @@ const ALLOWED_SCALAR_FIELDS: Record<EntityKey, Set<string>> = {
"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 {
@@ -132,15 +204,14 @@ function buildSelect(entity: EntityKey, columns: string[]): Record<string, unkno
if (!def) continue;
if (colKey.includes(".")) {
// Relation column, e.g. "country.name" => select: { country: { select: { name: true } } }
const relationName = def.prismaPath ?? colKey.split(".")[0]!;
const fieldName = colKey.split(".").slice(1).join(".");
const existing = select[relationName];
if (existing && typeof existing === "object" && existing !== null && "select" in existing) {
(existing as { select: Record<string, boolean> }).select[fieldName] = true;
} else {
select[relationName] = { select: { [fieldName]: true } };
}
const fieldSegments = colKey.split(".").slice(1);
const relationSelect = existing && typeof existing === "object" && existing !== null && "select" in existing
? (existing as { select: Record<string, unknown> }).select
: {};
mergeSelectPath(relationSelect, fieldSegments);
select[relationName] = { select: relationSelect };
} else {
select[colKey] = true;
}
@@ -149,6 +220,29 @@ function buildSelect(entity: EntityKey, columns: string[]): Record<string, unkno
return select;
}
function mergeSelectPath(
target: Record<string, unknown>,
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<string, unknown> }).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.
@@ -246,6 +340,8 @@ function csvEscape(value: unknown): string {
// ─── 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"]),
@@ -253,24 +349,171 @@ const FilterSchema = z.object({
});
const ReportInputSchema = z.object({
entity: z.enum(["resource", "project", "assignment"]),
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<ReportTemplateRecord[]>;
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<unknown>;
};
}).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: z.enum(["resource", "project", "assignment"]) }))
.input(z.object({ entity: reportEntitySchema }))
.query(({ input }) => {
const columns = COLUMN_MAP[input.entity];
if (!columns) {
@@ -285,40 +528,7 @@ export const reportRouter = createTRPCRouter({
getReportData: controllerProcedure
.input(ReportInputSchema)
.query(async ({ ctx, input }) => {
const { entity, columns, filters, sortBy, sortDir, limit, offset } = input;
const select = buildSelect(entity, columns);
const where = buildWhere(entity, filters);
// Build orderBy (only scalar fields)
let orderBy: Record<string, string> | undefined;
if (sortBy) {
const validField = getValidScalarField(entity, sortBy);
if (validField) {
orderBy = { [validField]: sortDir };
}
}
const modelDelegate = getModelDelegate(ctx.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 }),
]);
// Flatten nested relations into dot-notation keys
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
// Ensure column order matches request (plus id)
const outputColumns = ["id", ...columns.filter((c) => c !== "id")];
return { rows, columns: outputColumns, totalCount };
return executeReportQuery(ctx.db, input);
}),
/**
@@ -329,33 +539,12 @@ export const reportRouter = createTRPCRouter({
limit: z.number().int().min(1).max(50000).default(5000),
}))
.mutation(async ({ ctx, input }) => {
const { entity, columns, filters, sortBy, sortDir, limit } = input;
const select = buildSelect(entity, columns);
const where = buildWhere(entity, filters);
let orderBy: Record<string, string> | undefined;
if (sortBy) {
const validField = getValidScalarField(entity, sortBy);
if (validField) {
orderBy = { [validField]: sortDir };
}
}
const modelDelegate = getModelDelegate(ctx.db, entity);
const rawRows = await (modelDelegate as any).findMany({
select,
where,
...(orderBy ? { orderBy } : {}),
take: limit,
});
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
const outputColumns = ["id", ...columns.filter((c) => c !== "id")];
const result = await executeReportQuery(ctx.db, { ...input, offset: 0 });
const rows = result.rows;
const outputColumns = result.columns;
// Build CSV
const entityColumns = COLUMN_MAP[entity];
const entityColumns = COLUMN_MAP[input.entity];
const headerLabels = outputColumns.map((key) => {
const def = entityColumns.find((c) => c.key === key);
return def?.label ?? key;
@@ -372,6 +561,385 @@ export const reportRouter = createTRPCRouter({
}),
});
type ReportInput = z.infer<typeof ReportInputSchema>;
type FilterInput = z.infer<typeof FilterSchema>;
async function executeReportQuery(
db: any,
input: ReportInput,
): Promise<{ rows: Record<string, unknown>[]; 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<string, string> | 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<string, unknown>[]).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<string, unknown>[]; 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<string>())];
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<string, unknown>) => 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<string, unknown>,
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<string, unknown>[],
sortBy: string | undefined,
sortDir: "asc" | "desc",
columns: ColumnDef[],
): Record<string, unknown>[] {
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<string, unknown>, columns: string[]): Record<string, unknown> {
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) {