Files
CapaKraken/apps/web/src/components/admin/CalculationRulesClient.tsx
T
Hartmut ac845d72b7 refactor: deduplicate modals, notifications, confirms, comboboxes, proficiency
Modal Overlay (Finding 1 — 6 admin files):
- Migrated CountriesClient, ManagementLevelsClient, OrgUnitsClient,
  CalculationRulesClient, UtilizationCategoriesClient, RoleModal
  from inline fixed-overlay to AnimatedModal component
- Gains: animated transitions, backdrop blur, escape key for free

Notification Helper (Finding 9 — 9 API files, 14 call sites):
- New createNotification() + createNotificationsForUsers() in
  packages/api/src/lib/create-notification.ts
- Handles exactOptionalPropertyTypes spread + SSE emit internally
- Simplified: budget-alerts, estimate-reminders, auto-staffing,
  vacation-conflicts, chargeability-alerts, comment, vacation, notification

ConfirmDialog (Finding 3 — 11 files):
- Replaced all window.confirm() calls with ConfirmDialog component
- Files: CommentThread, EffortRules, ExperienceMultipliers,
  ManagementLevels, CalculationRules, Countries, RateCards,
  ApplyEffortRules, ApplyExperienceMultipliers, NotificationCenter,
  ReminderModal

EntityCombobox (Finding 4 — 3 files):
- New generic EntityCombobox<T> with customization hooks
- ResourceCombobox + ProjectCombobox rewritten as thin wrappers
- All consumers unchanged (backwards-compatible props)

Proficiency Constants (Finding 2 — 2 files):
- SkillsAnalytics + SkillMarketplace now import from skills/shared.tsx
- Deleted ~70 LOC of local duplicate definitions

Regression: 283 engine + 37 staffing tests pass. TypeScript clean.
AI Assistant: all 87 tools verified accessible.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-22 21:50:39 +01:00

389 lines
18 KiB
TypeScript

"use client";
import { useState } from "react";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
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 [confirmDeleteRule, setConfirmDeleteRule] = useState<string | 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">
<span className="flex items-center">Name <InfoTooltip content="A descriptive label for this rule. Use clear names so admins can quickly identify what each rule does." /></span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
<span className="flex items-center">Trigger <InfoTooltip content="The absence type that activates this rule: Sick Leave, Vacation, Public Holiday, or Custom. Determines when the cost/chargeability logic applies." /></span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
<span className="flex items-center">Cost Effect <InfoTooltip content="How this absence affects project costs. 'Charge to Project' bills the project, 'No Project Cost' absorbs the cost centrally, 'Reduced Cost' applies a percentage discount." /></span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
<span className="flex items-center">Chargeability <InfoTooltip content="Whether the person's time counts as chargeable during this absence. 'Person Chargeable' includes the time in chargeability metrics; 'Not Chargeable' excludes it." /></span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
<span className="flex items-center">Scope <InfoTooltip content="Limits the rule to a specific project or order type (BD, Chargeable, Internal, Overhead). 'Global' means the rule applies to all projects." /></span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
<span className="flex items-center">Priority <InfoTooltip content="When multiple rules match the same absence, the one with the highest priority number wins. Use this to create specific overrides for certain projects." /></span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
<span className="flex items-center">Active <InfoTooltip content="Only active rules are evaluated. Deactivate a rule to temporarily disable it without deleting." /></span>
</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={() => setConfirmDeleteRule(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 ── */}
<AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-lg">
{editing && (<>
<div className="p-6">
<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 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Name <InfoTooltip content="A unique, descriptive name for this calculation rule." /></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 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Description <InfoTooltip content="Optional notes explaining when and why this rule exists." /></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 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Trigger Type <InfoTooltip content="The type of absence event that activates this rule." /></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 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Order Type (optional) <InfoTooltip content="Restricts this rule to a specific order type. Leave as 'All' to apply globally." /></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 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Cost Effect <InfoTooltip content="Determines how costs are attributed during this absence. 'Charge' bills the project, 'Zero' removes cost, 'Reduce' applies a percentage reduction." /></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 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Chargeability <InfoTooltip content="Controls whether this absence counts toward the person's chargeability KPI." /></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 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">
Reduction Percent (0-100) <InfoTooltip content="The percentage by which the cost is reduced. E.g. 50 means the project is charged half the normal rate." />
</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 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Priority <InfoTooltip content="Higher numbers take precedence when multiple rules match. Use 0 for default rules and higher values for specific overrides." /></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 <InfoTooltip content="Inactive rules are ignored during cost calculations." />
</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>
</>)}
</AnimatedModal>
{confirmDeleteRule && (
<ConfirmDialog
title="Delete rule"
message="Are you sure you want to delete this calculation rule?"
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
deleteMut.mutate({ id: confirmDeleteRule });
setConfirmDeleteRule(null);
}}
onCancel={() => setConfirmDeleteRule(null)}
/>
)}
</div>
);
}