ac845d72b7
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>
389 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|