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>
This commit is contained in:
2026-03-22 21:50:39 +01:00
parent c7b76e086d
commit ac845d72b7
29 changed files with 737 additions and 607 deletions
@@ -1,6 +1,8 @@
"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";
@@ -71,6 +73,7 @@ const emptyRule: EditingRule = {
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();
@@ -219,7 +222,7 @@ export function CalculationRulesClient() {
<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 }); }}
onClick={() => setConfirmDeleteRule(rule.id)}
className="text-red-600 hover:underline dark:text-red-400"
>
Delete
@@ -240,9 +243,9 @@ export function CalculationRulesClient() {
</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">
<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>
@@ -363,8 +366,22 @@ export function CalculationRulesClient() {
{createMut.isPending || updateMut.isPending ? "Saving..." : "Save"}
</button>
</div>
</div>
</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>
);
@@ -1,6 +1,8 @@
"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";
@@ -58,6 +60,7 @@ export function CountriesClient() {
const [editing, setEditing] = useState<EditingCountry | null>(null);
const [cityName, setCityName] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [confirmDeleteCity, setConfirmDeleteCity] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
@@ -236,11 +239,7 @@ export function CountriesClient() {
{city.name}
<button
type="button"
onClick={() => {
if (confirm(`Delete metro city "${city.name}"?`)) {
deleteCityMut.mutate({ id: city.id });
}
}}
onClick={() => setConfirmDeleteCity(city.id)}
className="text-gray-400 hover:text-red-500 text-xs leading-none ml-1"
>
&times;
@@ -274,9 +273,8 @@ export function CountriesClient() {
})()}
{/* Create/Edit Modal */}
{editing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-lg mx-4 flex flex-col max-h-[90vh]">
<AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-lg" className="flex flex-col max-h-[90vh]">
{editing && (<>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Country" : "Add Country"}
@@ -406,8 +404,21 @@ export function CountriesClient() {
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
</>)}
</AnimatedModal>
{confirmDeleteCity && (
<ConfirmDialog
title="Delete metro city"
message="Are you sure you want to delete this metro city?"
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
deleteCityMut.mutate({ id: confirmDeleteCity });
setConfirmDeleteCity(null);
}}
onCancel={() => setConfirmDeleteCity(null)}
/>
)}
</div>
);
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -87,6 +88,7 @@ export function EffortRulesClient() {
const [editing, setEditing] = useState<EditingRuleSet | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
function handleSave() {
if (!editing) return;
@@ -375,11 +377,7 @@ export function EffortRulesClient() {
Edit
</button>
<button
onClick={() => {
if (confirm(`Delete rule set "${rs.name}"?`)) {
deleteMutation.mutate({ id: rs.id });
}
}}
onClick={() => setConfirmDelete(rs.id)}
className="rounded-xl border border-red-200 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
>
Delete
@@ -416,6 +414,20 @@ export function EffortRulesClient() {
)}
</div>
))}
{confirmDelete && (
<ConfirmDialog
title="Delete rule set"
message="Are you sure you want to delete this rule set? This action cannot be undone."
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
deleteMutation.mutate({ id: confirmDelete });
setConfirmDelete(null);
}}
onCancel={() => setConfirmDelete(null)}
/>
)}
</div>
);
}
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -97,6 +98,7 @@ export function ExperienceMultipliersClient() {
const [editing, setEditing] = useState<EditingSet | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
function handleSave() {
if (!editing) return;
@@ -422,11 +424,7 @@ export function ExperienceMultipliersClient() {
Edit
</button>
<button
onClick={() => {
if (confirm(`Delete multiplier set "${s.name}"?`)) {
deleteMutation.mutate({ id: s.id });
}
}}
onClick={() => setConfirmDelete(s.id)}
className="rounded-xl border border-red-200 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
>
Delete
@@ -471,6 +469,20 @@ export function ExperienceMultipliersClient() {
)}
</div>
))}
{confirmDelete && (
<ConfirmDialog
title="Delete multiplier set"
message="Are you sure you want to delete this multiplier set? This action cannot be undone."
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
deleteMutation.mutate({ id: confirmDelete });
setConfirmDelete(null);
}}
onCancel={() => setConfirmDelete(null)}
/>
)}
</div>
);
}
@@ -1,6 +1,8 @@
"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";
@@ -29,6 +31,7 @@ type EditingLevel = {
export function ManagementLevelsClient() {
const [editingGroup, setEditingGroup] = useState<EditingGroup | null>(null);
const [editingLevel, setEditingLevel] = useState<EditingLevel | null>(null);
const [confirmDeleteLevel, setConfirmDeleteLevel] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
@@ -185,11 +188,7 @@ export function ManagementLevelsClient() {
</button>
<button
type="button"
onClick={() => {
if (confirm(`Delete level "${level.name}"?`)) {
deleteLevelMut.mutate({ id: level.id });
}
}}
onClick={() => setConfirmDeleteLevel(level.id)}
className="text-xs text-red-500 hover:text-red-700 font-medium"
>
Delete
@@ -207,9 +206,8 @@ export function ManagementLevelsClient() {
</div>
{/* Group Modal */}
{editingGroup && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<AnimatedModal open={editingGroup !== null} onClose={() => setEditingGroup(null)} maxWidth="max-w-md">
{editingGroup && (<>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editingGroup.id ? "Edit Group" : "Add Group"}
@@ -264,14 +262,12 @@ export function ManagementLevelsClient() {
{isGroupPending ? "Saving..." : editingGroup.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
</>)}
</AnimatedModal>
{/* Level Modal */}
{editingLevel && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-sm mx-4">
<AnimatedModal open={editingLevel !== null} onClose={() => setEditingLevel(null)} maxWidth="max-w-sm">
{editingLevel && (<>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editingLevel.id ? "Edit Level" : "Add Level"}
@@ -316,8 +312,21 @@ export function ManagementLevelsClient() {
{isLevelPending ? "Saving..." : editingLevel.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
</>)}
</AnimatedModal>
{confirmDeleteLevel && (
<ConfirmDialog
title="Delete level"
message="Are you sure you want to delete this level?"
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
deleteLevelMut.mutate({ id: confirmDeleteLevel });
setConfirmDeleteLevel(null);
}}
onCancel={() => setConfirmDeleteLevel(null)}
/>
)}
</div>
);
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -195,9 +196,8 @@ export function OrgUnitsClient() {
</div>
{/* Create/Edit Modal */}
{editing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-md">
{editing && (<>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Org Unit" : `Add ${LEVEL_LABELS[editing.level] ?? `L${editing.level}`}`}
@@ -275,9 +275,8 @@ export function OrgUnitsClient() {
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
</>)}
</AnimatedModal>
</div>
);
}
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { formatCents } from "~/lib/format.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -101,6 +102,8 @@ export function RateCardsClient() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [editingCard, setEditingCard] = useState<EditingCard | null>(null);
const [editingLine, setEditingLine] = useState<EditingLine | null>(null);
const [confirmDeleteLine, setConfirmDeleteLine] = useState<string | null>(null);
const [confirmDeactivate, setConfirmDeactivate] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
@@ -260,7 +263,6 @@ export function RateCardsClient() {
}
async function handleDeleteLine(lineId: string) {
if (!confirm("Delete this rate line?")) return;
try {
await deleteLineMut.mutateAsync({ lineId });
invalidateAll();
@@ -270,7 +272,6 @@ export function RateCardsClient() {
}
async function handleDeactivate(id: string) {
if (!confirm("Deactivate this rate card?")) return;
try {
await deactivateMut.mutateAsync({ id });
invalidateAll();
@@ -445,7 +446,7 @@ export function RateCardsClient() {
{detail.isActive ? (
<button
type="button"
onClick={() => handleDeactivate(detail.id)}
onClick={() => setConfirmDeactivate(detail.id)}
className="px-3 py-1.5 text-sm text-gray-500 hover:text-red-600 font-medium"
>
Deactivate
@@ -528,7 +529,7 @@ export function RateCardsClient() {
</button>
<button
type="button"
onClick={() => handleDeleteLine(line.id)}
onClick={() => setConfirmDeleteLine(line.id)}
className="text-xs text-red-500 hover:text-red-700 font-medium"
>
Delete
@@ -780,6 +781,34 @@ export function RateCardsClient() {
</div>
</div>
)}
{confirmDeleteLine && (
<ConfirmDialog
title="Delete rate line"
message="Are you sure you want to delete this rate line?"
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
void handleDeleteLine(confirmDeleteLine);
setConfirmDeleteLine(null);
}}
onCancel={() => setConfirmDeleteLine(null)}
/>
)}
{confirmDeactivate && (
<ConfirmDialog
title="Deactivate rate card"
message="Are you sure you want to deactivate this rate card?"
confirmLabel="Deactivate"
variant="danger"
onConfirm={() => {
void handleDeactivate(confirmDeactivate);
setConfirmDeactivate(null);
}}
onCancel={() => setConfirmDeactivate(null)}
/>
)}
</div>
);
}
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -159,9 +160,8 @@ export function UtilizationCategoriesClient() {
</div>
{/* Create/Edit Modal */}
{editing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-md">
{editing && (<>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Category" : "Add Category"}
@@ -236,9 +236,8 @@ export function UtilizationCategoriesClient() {
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
</>)}
</AnimatedModal>
</div>
);
}