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" + /> +
+
+ +