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 <ruv@ruv.net>
This commit is contained in:
2026-03-15 09:29:12 +01:00
parent a83edb2f9d
commit 368fd6d7ad
23 changed files with 1753 additions and 53 deletions
@@ -0,0 +1,5 @@
import { CalculationRulesClient } from "~/components/admin/CalculationRulesClient.js";
export default function CalculationRulesPage() {
return <CalculationRulesClient />;
}
@@ -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<string, string> = {
SICK: "Sick Leave",
VACATION: "Vacation",
PUBLIC_HOLIDAY: "Public Holiday",
CUSTOM: "Custom",
};
const COST_LABELS: Record<string, string> = {
CHARGE: "Charge to Project",
ZERO: "No Project Cost",
REDUCE: "Reduced Cost",
};
const CHG_LABELS: Record<string, string> = {
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<EditingRule | null>(null);
const [error, setError] = useState<string | null>(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 <div className="p-6 text-sm text-gray-500">Loading...</div>;
return (
<div className="mx-auto max-w-5xl p-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Calculation Rules</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Configure how absences affect project costs and chargeability reporting.
</p>
</div>
<button
onClick={openCreate}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add Rule
</button>
</div>
{error && (
<div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{/* ── Rules Table ── */}
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Trigger</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Cost Effect</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Chargeability</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Scope</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Priority</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Active</th>
<th className="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
{(rules ?? []).map((r) => {
const rule = r as unknown as RuleRow;
return (
<tr key={rule.id} className={rule.isActive ? "" : "opacity-50"}>
<td className="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">{rule.name}</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs dark:bg-gray-700">
{TRIGGER_LABELS[rule.triggerType] ?? rule.triggerType}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
{COST_LABELS[rule.costEffect] ?? rule.costEffect}
{rule.costEffect === "REDUCE" && rule.costReductionPercent != null && (
<span className="ml-1 text-xs text-gray-400">({rule.costReductionPercent}%)</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
{CHG_LABELS[rule.chargeabilityEffect] ?? rule.chargeabilityEffect}
</td>
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{rule.project ? rule.project.shortCode : rule.orderType ? rule.orderType : "Global"}
</td>
<td className="px-4 py-3 text-sm text-gray-500">{rule.priority}</td>
<td className="px-4 py-3 text-sm">
<span className={`inline-block h-2 w-2 rounded-full ${rule.isActive ? "bg-green-500" : "bg-gray-300"}`} />
</td>
<td className="px-4 py-3 text-right text-sm">
<button onClick={() => openEdit(rule)} className="mr-2 text-blue-600 hover:underline dark:text-blue-400">Edit</button>
<button
onClick={() => { if (confirm("Delete this rule?")) deleteMut.mutate({ id: rule.id }); }}
className="text-red-600 hover:underline dark:text-red-400"
>
Delete
</button>
</td>
</tr>
);
})}
{(rules ?? []).length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-sm text-gray-500">
No calculation rules configured. Add a rule to control how absences affect costs and chargeability.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* ── Edit/Create Modal ── */}
{editing && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800">
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Rule" : "New Rule"}
</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
<input
value={editing.name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<textarea
value={editing.description}
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
rows={2}
className="w-full rounded-md border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Trigger Type</label>
<select
value={editing.triggerType}
onChange={(e) => setEditing({ ...editing, triggerType: 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"
>
{TRIGGER_TYPES.map((t) => <option key={t} value={t}>{TRIGGER_LABELS[t]}</option>)}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Order Type (optional)</label>
<select
value={editing.orderType}
onChange={(e) => setEditing({ ...editing, orderType: 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"
>
<option value="">All (Global)</option>
{ORDER_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Cost Effect</label>
<select
value={editing.costEffect}
onChange={(e) => setEditing({ ...editing, costEffect: 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"
>
{COST_EFFECTS.map((t) => <option key={t} value={t}>{COST_LABELS[t]}</option>)}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Chargeability</label>
<select
value={editing.chargeabilityEffect}
onChange={(e) => setEditing({ ...editing, chargeabilityEffect: 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"
>
{CHARGEABILITY_EFFECTS.map((t) => <option key={t} value={t}>{CHG_LABELS[t]}</option>)}
</select>
</div>
</div>
{editing.costEffect === "REDUCE" && (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Reduction Percent (0-100)
</label>
<input
type="number"
min={0}
max={100}
value={editing.costReductionPercent}
onChange={(e) => setEditing({ ...editing, costReductionPercent: Number(e.target.value) })}
className="w-32 rounded-md border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Priority</label>
<input
type="number"
min={0}
value={editing.priority}
onChange={(e) => setEditing({ ...editing, priority: Number(e.target.value) })}
className="w-32 rounded-md border px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={editing.isActive}
onChange={(e) => setEditing({ ...editing, isActive: e.target.checked })}
className="rounded border-gray-300"
/>
Active
</label>
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setEditing(null)}
className="rounded-md border px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!editing.name || createMut.isPending || updateMut.isPending}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{createMut.isPending || updateMut.isPending ? "Saving..." : "Save"}
</button>
</div>
</div>
</div>
)}
</div>
);
}