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:
@@ -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"
|
||||
>
|
||||
×
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user