From 368fd6d7ad626a3580638c42c493893b43950dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 15 Mar 2026 09:29:12 +0100 Subject: [PATCH] feat: calculation rules engine for decoupled cost attribution and chargeability Introduces an admin-configurable rules engine that determines per-day cost attribution (CHARGE/ZERO/REDUCE) and chargeability reporting (COUNT/SKIP) for absence types (sick, vacation, public holiday). Includes shared types, Zod schemas, Prisma model, rule matching with specificity scoring, default rules, calculator integration, CRUD API router, seed data, chargeability report integration, and admin UI. 283/283 engine tests, 209/209 API tests, 0 TS errors. Co-Authored-By: claude-flow --- .../(app)/admin/calculation-rules/page.tsx | 5 + .../admin/CalculationRulesClient.tsx | 356 ++++++++++++++++++ packages/api/src/router/calculation-rules.ts | 95 +++++ .../api/src/router/chargeability-report.ts | 80 +++- packages/api/src/router/index.ts | 2 + packages/api/src/router/timeline.ts | 147 ++++++-- packages/db/prisma/schema.prisma | 50 +++ packages/db/src/seed.ts | 35 ++ .../src/__tests__/calculator-rules.test.ts | 243 ++++++++++++ .../engine/src/__tests__/rules-engine.test.ts | 148 ++++++++ packages/engine/src/allocation/calculator.ts | 109 +++++- packages/engine/src/budget/monitor.ts | 10 +- .../engine/src/chargeability/calculator.ts | 7 +- packages/engine/src/index.ts | 1 + packages/engine/src/rules/default-rules.ts | 60 +++ packages/engine/src/rules/engine.ts | 91 +++++ packages/engine/src/rules/index.ts | 3 + .../src/schemas/calculation-rules.schema.ts | 22 ++ packages/shared/src/schemas/index.ts | 1 + .../shared/src/types/calculation-rules.ts | 32 ++ packages/shared/src/types/engine.ts | 22 ++ packages/shared/src/types/index.ts | 1 + plan.md | 286 ++++++++++++++ 23 files changed, 1753 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/app/(app)/admin/calculation-rules/page.tsx create mode 100644 apps/web/src/components/admin/CalculationRulesClient.tsx create mode 100644 packages/api/src/router/calculation-rules.ts create mode 100644 packages/engine/src/__tests__/calculator-rules.test.ts create mode 100644 packages/engine/src/__tests__/rules-engine.test.ts create mode 100644 packages/engine/src/rules/default-rules.ts create mode 100644 packages/engine/src/rules/engine.ts create mode 100644 packages/engine/src/rules/index.ts create mode 100644 packages/shared/src/schemas/calculation-rules.schema.ts create mode 100644 packages/shared/src/types/calculation-rules.ts create mode 100644 plan.md diff --git a/apps/web/src/app/(app)/admin/calculation-rules/page.tsx b/apps/web/src/app/(app)/admin/calculation-rules/page.tsx new file mode 100644 index 0000000..40e39f9 --- /dev/null +++ b/apps/web/src/app/(app)/admin/calculation-rules/page.tsx @@ -0,0 +1,5 @@ +import { CalculationRulesClient } from "~/components/admin/CalculationRulesClient.js"; + +export default function CalculationRulesPage() { + return ; +} diff --git a/apps/web/src/components/admin/CalculationRulesClient.tsx b/apps/web/src/components/admin/CalculationRulesClient.tsx new file mode 100644 index 0000000..7d7e586 --- /dev/null +++ b/apps/web/src/components/admin/CalculationRulesClient.tsx @@ -0,0 +1,356 @@ +"use client"; + +import { useState } from "react"; +import { trpc } from "~/lib/trpc/client.js"; + +const TRIGGER_TYPES = ["SICK", "VACATION", "PUBLIC_HOLIDAY", "CUSTOM"] as const; +const COST_EFFECTS = ["CHARGE", "ZERO", "REDUCE"] as const; +const CHARGEABILITY_EFFECTS = ["COUNT", "SKIP"] as const; +const ORDER_TYPES = ["BD", "CHARGEABLE", "INTERNAL", "OVERHEAD"] as const; + +const TRIGGER_LABELS: Record = { + SICK: "Sick Leave", + VACATION: "Vacation", + PUBLIC_HOLIDAY: "Public Holiday", + CUSTOM: "Custom", +}; + +const COST_LABELS: Record = { + CHARGE: "Charge to Project", + ZERO: "No Project Cost", + REDUCE: "Reduced Cost", +}; + +const CHG_LABELS: Record = { + COUNT: "Person Chargeable", + SKIP: "Not Chargeable", +}; + +type RuleRow = { + id: string; + name: string; + description: string | null; + triggerType: string; + projectId: string | null; + orderType: string | null; + costEffect: string; + costReductionPercent: number | null; + chargeabilityEffect: string; + priority: number; + isActive: boolean; + project?: { id: string; name: string; shortCode: string } | null; +}; + +type EditingRule = { + id?: string; + name: string; + description: string; + triggerType: string; + projectId: string; + orderType: string; + costEffect: string; + costReductionPercent: number; + chargeabilityEffect: string; + priority: number; + isActive: boolean; +}; + +const emptyRule: EditingRule = { + name: "", + description: "", + triggerType: "SICK", + projectId: "", + orderType: "", + costEffect: "ZERO", + costReductionPercent: 0, + chargeabilityEffect: "COUNT", + priority: 0, + isActive: true, +}; + +export function CalculationRulesClient() { + const [editing, setEditing] = useState(null); + const [error, setError] = useState(null); + + const utils = trpc.useUtils(); + const { data: rules, isLoading } = trpc.calculationRule.list.useQuery(); + + const createMut = trpc.calculationRule.create.useMutation({ + onSuccess: () => { void utils.calculationRule.list.invalidate(); setEditing(null); }, + onError: (e) => setError(e.message), + }); + const updateMut = trpc.calculationRule.update.useMutation({ + onSuccess: () => { void utils.calculationRule.list.invalidate(); setEditing(null); }, + onError: (e) => setError(e.message), + }); + const deleteMut = trpc.calculationRule.delete.useMutation({ + onSuccess: () => { void utils.calculationRule.list.invalidate(); }, + onError: (e) => setError(e.message), + }); + + function openCreate() { + setEditing({ ...emptyRule }); + setError(null); + } + + function openEdit(r: RuleRow) { + setEditing({ + id: r.id, + name: r.name, + description: r.description ?? "", + triggerType: r.triggerType, + projectId: r.projectId ?? "", + orderType: r.orderType ?? "", + costEffect: r.costEffect, + costReductionPercent: r.costReductionPercent ?? 0, + chargeabilityEffect: r.chargeabilityEffect, + priority: r.priority, + isActive: r.isActive, + }); + setError(null); + } + + function handleSave() { + if (!editing) return; + setError(null); + + const payload = { + name: editing.name, + triggerType: editing.triggerType as (typeof TRIGGER_TYPES)[number], + costEffect: editing.costEffect as (typeof COST_EFFECTS)[number], + chargeabilityEffect: editing.chargeabilityEffect as (typeof CHARGEABILITY_EFFECTS)[number], + priority: editing.priority, + isActive: editing.isActive, + ...(editing.description ? { description: editing.description } : {}), + ...(editing.projectId ? { projectId: editing.projectId } : {}), + ...(editing.orderType ? { orderType: editing.orderType } : {}), + ...(editing.costEffect === "REDUCE" ? { costReductionPercent: editing.costReductionPercent } : {}), + }; + + if (editing.id) { + updateMut.mutate({ id: editing.id, ...payload }); + } else { + createMut.mutate(payload); + } + } + + if (isLoading) return
Loading...
; + + return ( +
+
+
+

Calculation Rules

+

+ Configure how absences affect project costs and chargeability reporting. +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* ── Rules Table ── */} +
+ + + + + + + + + + + + + + + {(rules ?? []).map((r) => { + const rule = r as unknown as RuleRow; + return ( + + + + + + + + + + + ); + })} + {(rules ?? []).length === 0 && ( + + + + )} + +
NameTriggerCost EffectChargeabilityScopePriorityActiveActions
{rule.name} + + {TRIGGER_LABELS[rule.triggerType] ?? rule.triggerType} + + + {COST_LABELS[rule.costEffect] ?? rule.costEffect} + {rule.costEffect === "REDUCE" && rule.costReductionPercent != null && ( + ({rule.costReductionPercent}%) + )} + + {CHG_LABELS[rule.chargeabilityEffect] ?? rule.chargeabilityEffect} + + {rule.project ? rule.project.shortCode : rule.orderType ? rule.orderType : "Global"} + {rule.priority} + + + + +
+ No calculation rules configured. Add a rule to control how absences affect costs and chargeability. +
+
+ + {/* ── Edit/Create Modal ── */} + {editing && ( +
+
+

+ {editing.id ? "Edit Rule" : "New Rule"} +

+
+
+ + setEditing({ ...editing, name: e.target.value })} + className="w-full rounded-md border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" + /> +
+
+ +