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>
223 lines
9.2 KiB
TypeScript
223 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { clsx } from "clsx";
|
|
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
|
import { formatCents } from "~/lib/format.js";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
|
|
interface ApplyExperienceMultipliersProps {
|
|
estimateId: string;
|
|
canEdit: boolean;
|
|
onApplied?: () => void;
|
|
}
|
|
|
|
export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: ApplyExperienceMultipliersProps) {
|
|
const utils = trpc.useUtils();
|
|
const { data: sets, isLoading } = trpc.experienceMultiplier.list.useQuery();
|
|
|
|
const [selectedSetId, setSelectedSetId] = useState<string>("");
|
|
const [showPreview, setShowPreview] = useState(false);
|
|
const [confirmApply, setConfirmApply] = useState(false);
|
|
|
|
const previewQuery = trpc.experienceMultiplier.preview.useQuery(
|
|
{ estimateId, multiplierSetId: selectedSetId },
|
|
{ enabled: showPreview && Boolean(selectedSetId) },
|
|
);
|
|
|
|
const applyMutation = trpc.experienceMultiplier.applyRules.useMutation({
|
|
onSuccess: (result) => {
|
|
utils.estimate.getById.invalidate();
|
|
setShowPreview(false);
|
|
onApplied?.();
|
|
alert(
|
|
`Updated ${result.linesUpdated} demand line(s).\n` +
|
|
`Hours: ${result.totalOriginalHours}h -> ${result.totalAdjustedHours}h`,
|
|
);
|
|
},
|
|
});
|
|
|
|
// Auto-select default set
|
|
if (!selectedSetId && sets) {
|
|
const defaultSet = sets.find((s) => s.isDefault) ?? sets[0];
|
|
if (defaultSet) {
|
|
setSelectedSetId(defaultSet.id);
|
|
}
|
|
}
|
|
|
|
if (!canEdit) return null;
|
|
|
|
if (isLoading) {
|
|
return <p className="text-sm text-gray-400">Loading experience multipliers...</p>;
|
|
}
|
|
|
|
if (!sets || sets.length === 0) {
|
|
return (
|
|
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500">
|
|
No experience multiplier sets defined.{" "}
|
|
<a href="/admin/experience-multipliers" className="text-brand-600 hover:underline">
|
|
Create one
|
|
</a>{" "}
|
|
to apply rate and effort adjustments.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
|
<h3 className="mb-3 text-base font-semibold text-gray-900">Apply experience multipliers <InfoTooltip content="Experience multipliers adjust hours, cost rates, and bill rates based on rules like seniority level or chapter. A multiplier >1 increases effort, <1 decreases it." /></h3>
|
|
|
|
<div className="flex flex-wrap items-end gap-4">
|
|
<label className="block">
|
|
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Multiplier set <InfoTooltip content="Select which set of rules to apply. Each set contains rules that match by chapter, role, or level." /></span>
|
|
<select
|
|
value={selectedSetId}
|
|
onChange={(e) => {
|
|
setSelectedSetId(e.target.value);
|
|
setShowPreview(false);
|
|
}}
|
|
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
|
|
>
|
|
{sets.map((s) => (
|
|
<option key={s.id} value={s.id}>
|
|
{s.name} ({s.rules.length} rules){s.isDefault ? " *" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<button
|
|
onClick={() => setShowPreview(!showPreview)}
|
|
disabled={!selectedSetId}
|
|
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
{showPreview ? "Hide preview" : "Preview"}
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => {
|
|
if (!selectedSetId) return;
|
|
setConfirmApply(true);
|
|
}}
|
|
disabled={!selectedSetId || applyMutation.isPending}
|
|
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
|
>
|
|
{applyMutation.isPending ? "Applying..." : "Apply multipliers"}
|
|
</button>
|
|
</div>
|
|
|
|
{applyMutation.error && (
|
|
<p className="mt-2 text-sm text-red-600">{applyMutation.error.message}</p>
|
|
)}
|
|
|
|
{/* Preview */}
|
|
{showPreview && previewQuery.data && (
|
|
<div className="mt-4 space-y-3">
|
|
<div className="flex flex-wrap gap-4 text-sm text-gray-600">
|
|
<span>{previewQuery.data.demandLineCount} demand lines</span>
|
|
<span>{previewQuery.data.ruleCount} rules</span>
|
|
<span className="font-semibold text-brand-700">
|
|
{previewQuery.data.linesChanged} line(s) would be adjusted
|
|
</span>
|
|
</div>
|
|
|
|
{previewQuery.data.linesChanged > 0 && (
|
|
<div className="rounded-xl bg-blue-50 p-3 text-sm text-blue-700">
|
|
Total cost: {formatCents(previewQuery.data.totalOriginalCostCents)} {"->"}{" "}
|
|
{formatCents(previewQuery.data.totalAdjustedCostCents)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Per-line preview */}
|
|
{previewQuery.data.previews.length > 0 && (
|
|
<details className="text-sm">
|
|
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">
|
|
Show all {previewQuery.data.previews.length} lines
|
|
</summary>
|
|
<div className="mt-2 overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
|
<th className="py-2 pr-3 font-medium">Name</th>
|
|
<th className="px-3 py-2 font-medium">Chapter</th>
|
|
<th className="px-3 py-2 text-right font-medium">Cost rate</th>
|
|
<th className="px-3 py-2 text-right font-medium">Bill rate</th>
|
|
<th className="px-3 py-2 text-right font-medium">Hours</th>
|
|
<th className="pl-3 py-2 font-medium">Changes</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{previewQuery.data.previews.map((p, i) => (
|
|
<tr
|
|
key={p.demandLineId}
|
|
className={clsx(
|
|
"border-b border-gray-100",
|
|
p.hasChanges ? "bg-amber-50" : i % 2 === 0 ? "" : "bg-gray-50",
|
|
)}
|
|
>
|
|
<td className="py-1.5 pr-3 text-gray-900">{p.name}</td>
|
|
<td className="px-3 py-1.5 text-gray-500">{p.chapter ?? "\u2014"}</td>
|
|
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
|
|
{p.hasChanges && p.adjustedCostRateCents !== p.originalCostRateCents ? (
|
|
<>
|
|
<span className="line-through text-gray-400">{formatCents(p.originalCostRateCents)}</span>{" "}
|
|
{formatCents(p.adjustedCostRateCents)}
|
|
</>
|
|
) : (
|
|
formatCents(p.originalCostRateCents)
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
|
|
{p.hasChanges && p.adjustedBillRateCents !== p.originalBillRateCents ? (
|
|
<>
|
|
<span className="line-through text-gray-400">{formatCents(p.originalBillRateCents)}</span>{" "}
|
|
{formatCents(p.adjustedBillRateCents)}
|
|
</>
|
|
) : (
|
|
formatCents(p.originalBillRateCents)
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
|
|
{p.hasChanges && p.adjustedHours !== p.originalHours ? (
|
|
<>
|
|
<span className="line-through text-gray-400">{p.originalHours}h</span>{" "}
|
|
{p.adjustedHours}h
|
|
</>
|
|
) : (
|
|
`${p.originalHours}h`
|
|
)}
|
|
</td>
|
|
<td className="pl-3 py-1.5 text-xs text-gray-500">
|
|
{p.hasChanges ? p.appliedRules[0] ?? "" : "No change"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</details>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{showPreview && previewQuery.isLoading && (
|
|
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
|
|
)}
|
|
|
|
{confirmApply && (
|
|
<ConfirmDialog
|
|
title="Apply experience multipliers"
|
|
message="This will update cost/bill rates and hours on matching demand lines. Continue?"
|
|
confirmLabel="Apply"
|
|
onConfirm={() => {
|
|
applyMutation.mutate({ estimateId, multiplierSetId: selectedSetId });
|
|
setConfirmApply(false);
|
|
}}
|
|
onCancel={() => setConfirmApply(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|