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>
);
}
@@ -3,67 +3,17 @@
import { useState, useMemo } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { trpc } from "~/lib/trpc/client.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import { PROFICIENCY_LABELS, ProficiencyBadge, GapIndicator, formatDate } from "~/components/analytics/skills/shared.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { trpc } from "~/lib/trpc/client.js";
const SkillDistributionChart = dynamic(
() => import("~/components/analytics/SkillDistributionChart.js"),
{ ssr: false, loading: () => <div className="h-80 shimmer-skeleton rounded-xl" /> },
);
const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"];
const PROFICIENCY_CLASSES = [
"bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500",
"bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/60 dark:text-blue-200 dark:border-blue-600",
"bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-900/60 dark:text-indigo-200 dark:border-indigo-500",
"bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/60 dark:text-amber-200 dark:border-amber-500",
"bg-green-100 text-green-800 border-green-300 dark:bg-green-900/60 dark:text-green-200 dark:border-green-500",
];
function proficiencyClasses(level: number): string {
const idx = Math.max(0, Math.min(4, Math.round(level) - 1));
return PROFICIENCY_CLASSES[idx] ?? PROFICIENCY_CLASSES[0]!;
}
function ProficiencyBadge({ value }: { value: number }) {
return (
<span className={`inline-block px-2 py-0.5 text-xs rounded font-medium border ${proficiencyClasses(value)}`}>
{value} {PROFICIENCY_LABELS[value] ?? ""}
</span>
);
}
function GapIndicator({ gap }: { gap: number }) {
if (gap > 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded font-semibold bg-red-100 text-red-700 border border-red-200 dark:bg-red-900/40 dark:text-red-300 dark:border-red-700">
-{gap} shortage
</span>
);
}
if (gap < 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded font-semibold bg-green-100 text-green-700 border border-green-200 dark:bg-green-900/40 dark:text-green-300 dark:border-green-700">
+{Math.abs(gap)} surplus
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded font-semibold bg-gray-100 text-gray-500 border border-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600">
balanced
</span>
);
}
function formatDate(iso: string | null): string {
if (!iso) return "Not within 30d";
const d = new Date(iso);
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
}
export function SkillMarketplace() {
const [searchSkill, setSearchSkill] = useState("");
const [minProficiency, setMinProficiency] = useState(1);
@@ -2,6 +2,7 @@
import { useState, useId } from "react";
import dynamic from "next/dynamic";
import { PROFICIENCY_LABELS, proficiencyClasses, ProficiencyBadge } from "~/components/analytics/skills/shared.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -12,30 +13,6 @@ const SkillDistributionChart = dynamic(
{ ssr: false, loading: () => <div className="h-80 shimmer-skeleton rounded-xl" /> },
);
const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"];
// Tailwind class sets per proficiency level (15), dark-mode aware
const PROFICIENCY_CLASSES = [
"bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500",
"bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/60 dark:text-blue-200 dark:border-blue-600",
"bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-900/60 dark:text-indigo-200 dark:border-indigo-500",
"bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/60 dark:text-amber-200 dark:border-amber-500",
"bg-green-100 text-green-800 border-green-300 dark:bg-green-900/60 dark:text-green-200 dark:border-green-500",
];
function proficiencyClasses(level: number): string {
const idx = Math.max(0, Math.min(4, Math.round(level) - 1));
return PROFICIENCY_CLASSES[idx] ?? PROFICIENCY_CLASSES[0]!;
}
function ProficiencyBadge({ value }: { value: number }) {
return (
<span className={`inline-block px-2 py-0.5 text-xs rounded font-medium border ${proficiencyClasses(value)}`}>
{value} {PROFICIENCY_LABELS[value] ?? ""}
</span>
);
}
type SkillRule = { skill: string; minProficiency: number };
export function SkillsAnalytics() {
@@ -3,6 +3,7 @@
import { useState } from "react";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { CommentInput } from "./CommentInput.js";
interface CommentAuthor {
@@ -118,6 +119,7 @@ function SingleComment({
isReply?: boolean;
}) {
const [showReplyInput, setShowReplyInput] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const utils = trpc.useUtils();
const createMutation = trpc.comment.create.useMutation({
@@ -199,11 +201,7 @@ function SingleComment({
)}
<button
type="button"
onClick={() => {
if (window.confirm("Delete this comment?")) {
deleteMutation.mutate({ id: comment.id });
}
}}
onClick={() => setConfirmDelete(true)}
disabled={deleteMutation.isPending}
className="text-xs text-gray-400 hover:text-rose-600 dark:hover:text-rose-400"
>
@@ -236,6 +234,20 @@ function SingleComment({
</div>
</div>
{confirmDelete && (
<ConfirmDialog
title="Delete comment"
message="Are you sure you want to delete this comment?"
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
deleteMutation.mutate({ id: comment.id });
setConfirmDelete(false);
}}
onCancel={() => setConfirmDelete(false)}
/>
)}
{/* Render replies */}
{"replies" in comment && comment.replies.length > 0 && (
<div className="mt-3 space-y-3 border-l-2 border-gray-100 dark:border-gray-700 pl-2">
@@ -2,6 +2,7 @@
import { useState } from "react";
import { clsx } from "clsx";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { trpc } from "~/lib/trpc/client.js";
interface ApplyEffortRulesProps {
@@ -17,6 +18,7 @@ export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffort
const [selectedRuleSetId, setSelectedRuleSetId] = useState<string>("");
const [mode, setMode] = useState<"replace" | "append">("replace");
const [showPreview, setShowPreview] = useState(false);
const [confirmApply, setConfirmApply] = useState(false);
const previewQuery = trpc.effortRule.preview.useQuery(
{ estimateId, ruleSetId: selectedRuleSetId },
@@ -106,10 +108,7 @@ export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffort
<button
onClick={() => {
if (!selectedRuleSetId) return;
const action = mode === "replace" ? "replace all existing demand lines" : "append new demand lines";
if (confirm(`This will ${action}. Continue?`)) {
applyMutation.mutate({ estimateId, ruleSetId: selectedRuleSetId, mode });
}
setConfirmApply(true);
}}
disabled={!selectedRuleSetId || 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"
@@ -210,6 +209,19 @@ export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffort
{showPreview && previewQuery.isLoading && (
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
)}
{confirmApply && (
<ConfirmDialog
title="Apply effort rules"
message={`This will ${mode === "replace" ? "replace all existing demand lines" : "append new demand lines"}. Continue?`}
confirmLabel="Apply"
onConfirm={() => {
applyMutation.mutate({ estimateId, ruleSetId: selectedRuleSetId, mode });
setConfirmApply(false);
}}
onCancel={() => setConfirmApply(false)}
/>
)}
</div>
);
}
@@ -2,6 +2,7 @@
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";
@@ -18,6 +19,7 @@ export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: A
const [selectedSetId, setSelectedSetId] = useState<string>("");
const [showPreview, setShowPreview] = useState(false);
const [confirmApply, setConfirmApply] = useState(false);
const previewQuery = trpc.experienceMultiplier.preview.useQuery(
{ estimateId, multiplierSetId: selectedSetId },
@@ -96,9 +98,7 @@ export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: A
<button
onClick={() => {
if (!selectedSetId) return;
if (confirm("This will update cost/bill rates and hours on matching demand lines. Continue?")) {
applyMutation.mutate({ estimateId, multiplierSetId: selectedSetId });
}
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"
@@ -204,6 +204,19 @@ export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: A
{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>
);
}
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { trpc } from "~/lib/trpc/client.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { TaskCard } from "./TaskCard.js";
@@ -31,6 +32,7 @@ export function NotificationCenterClient() {
const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
const { canEdit } = usePermissions();
const [showTaskModal, setShowTaskModal] = useState(false);
const [confirmDeleteReminder, setConfirmDeleteReminder] = useState<string | null>(null);
const [reminderModal, setReminderModal] = useState<{
open: boolean;
reminder: {
@@ -374,11 +376,7 @@ export function NotificationCenterClient() {
</button>
<button
type="button"
onClick={() => {
if (window.confirm("Delete this reminder?")) {
deleteReminder.mutate({ id: r.id });
}
}}
onClick={() => setConfirmDeleteReminder(r.id)}
disabled={deleteReminder.isPending}
className="p-1 text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
title="Delete"
@@ -413,6 +411,20 @@ export function NotificationCenterClient() {
onSuccess={() => setShowTaskModal(false)}
/>
)}
{confirmDeleteReminder && (
<ConfirmDialog
title="Delete reminder"
message="Are you sure you want to delete this reminder?"
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
deleteReminder.mutate({ id: confirmDeleteReminder });
setConfirmDeleteReminder(null);
}}
onCancel={() => setConfirmDeleteReminder(null)}
/>
)}
</div>
);
}
@@ -1,9 +1,10 @@
"use client";
import { useRef, useState } from "react";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
const RECURRENCE_OPTIONS = [
{ value: "", label: "None" },
@@ -50,6 +51,7 @@ export function ReminderModal({ reminder, onClose, onSuccess }: ReminderModalPro
const [recurrence, setRecurrence] = useState(reminder?.recurrence ?? "");
const [link, setLink] = useState(reminder?.link ?? "");
const [serverError, setServerError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
@@ -128,8 +130,7 @@ export function ReminderModal({ reminder, onClose, onSuccess }: ReminderModalPro
function handleDelete() {
if (!reminder) return;
if (!window.confirm("Delete this reminder?")) return;
deleteMutation.mutate({ id: reminder.id });
setConfirmDelete(true);
}
const inputClass =
@@ -303,6 +304,20 @@ export function ReminderModal({ reminder, onClose, onSuccess }: ReminderModalPro
</div>
</form>
</div>
{confirmDelete && reminder && (
<ConfirmDialog
title="Delete reminder"
message="Are you sure you want to delete this reminder?"
confirmLabel="Delete"
variant="danger"
onConfirm={() => {
deleteMutation.mutate({ id: reminder.id });
setConfirmDelete(false);
}}
onCancel={() => setConfirmDelete(false)}
/>
)}
</div>
);
}
+4 -20
View File
@@ -1,9 +1,9 @@
"use client";
import { useRef, useState } from "react";
import { useState } from "react";
import type { RoleWithResourceCount } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const PRESET_COLORS = [
@@ -33,9 +33,6 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
const [color, setColor] = useState(role?.color ?? PRESET_COLORS[0]!);
const [serverError, setServerError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const utils = trpc.useUtils();
const createMutation = trpc.role.create.useMutation({
@@ -82,19 +79,7 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
const labelClass = "app-label";
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/55 py-8 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className="mx-4 w-full max-w-md rounded-3xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900"
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
>
<AnimatedModal open onClose={onClose} maxWidth="max-w-md">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{isEditing ? "Edit Role" : "New Role"}
@@ -199,7 +184,6 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
</button>
</div>
</form>
</div>
</div>
</AnimatedModal>
);
}
@@ -0,0 +1,136 @@
"use client";
import { useState, useRef, useEffect, useMemo, type ReactNode } from "react";
import { useDebounce } from "~/hooks/useDebounce.js";
interface EntityComboboxProps<T extends { id: string }> {
value: string | null;
onChange: (id: string | null) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
/** Hook that returns search results when the dropdown is open. */
useSearchQuery: (search: string, enabled: boolean) => { data: T[] | undefined };
/** Hook that returns a broader list so the selected item's label can be resolved when the dropdown is closed. */
useSelectedQuery: (id: string | null, enabled: boolean) => { data: T[] | undefined };
/** Derive the display label from an item (shown in the input when closed). */
getLabel: (item: T) => string;
/** Optional custom renderer for each dropdown row. Falls back to `getLabel`. */
renderItem?: (item: T, isSelected: boolean) => ReactNode;
}
export function EntityCombobox<T extends { id: string }>({
value,
onChange,
placeholder = "Search\u2026",
disabled = false,
className = "",
useSearchQuery,
useSelectedQuery,
getLabel,
renderItem,
}: EntityComboboxProps<T>) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { data: searchItems } = useSearchQuery(debouncedSearch, open);
const items = searchItems ?? [];
const { data: selectedItems } = useSelectedQuery(value, !!value && !open);
const selectedLabel = useMemo(() => {
if (!value) return "";
const fromOpen = items.find((i) => i.id === value);
if (fromOpen) return getLabel(fromOpen);
const fromSelected = selectedItems?.find((i) => i.id === value);
if (fromSelected) return getLabel(fromSelected);
return value;
}, [value, items, selectedItems, getLabel]);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
function handleFocus() {
if (disabled) return;
setOpen(true);
setSearch("");
}
function select(id: string | null) {
onChange(id);
setOpen(false);
setSearch("");
inputRef.current?.blur();
}
return (
<div className={`relative ${className}`} ref={containerRef}>
<div className="relative">
<input
ref={inputRef}
type="text"
value={open ? search : selectedLabel}
onChange={(e) => setSearch(e.target.value)}
onFocus={handleFocus}
placeholder={placeholder}
disabled={disabled}
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:opacity-50 disabled:cursor-not-allowed ${
open
? "border-brand-500 ring-2 ring-brand-500"
: "border-gray-300 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500"
} bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500`}
readOnly={!open}
/>
{value && !disabled && !open && (
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); select(null); }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-lg leading-none"
aria-label="Clear"
tabIndex={-1}
>
{"\u00d7"}
</button>
)}
</div>
{open && (
<div className="absolute left-0 right-0 top-full mt-1 z-[60] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden">
<ul className="max-h-52 overflow-y-auto py-1">
{items.length === 0 ? (
<li className="px-3 py-2 text-sm text-gray-400 dark:text-gray-500">No results</li>
) : (
items.map((item) => (
<li key={item.id}>
<button
type="button"
onMouseDown={() => select(item.id)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-brand-50 dark:hover:bg-brand-950/40 ${
item.id === value
? "bg-brand-50 dark:bg-brand-950/40 text-brand-700 dark:text-brand-300 font-medium"
: "text-gray-700 dark:text-gray-200"
}`}
>
{renderItem ? renderItem(item, item.id === value) : getLabel(item)}
</button>
</li>
))
)}
</ul>
</div>
)}
</div>
);
}
+39 -111
View File
@@ -1,9 +1,11 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { useCallback } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import type { ProjectStatus } from "@planarchy/shared";
import { EntityCombobox } from "./EntityCombobox.js";
type ProjectItem = { id: string; shortCode: string; name: string };
interface ProjectComboboxProps {
value: string | null;
@@ -15,122 +17,48 @@ interface ProjectComboboxProps {
}
export function ProjectCombobox({
value,
onChange,
placeholder = "Search project\u2026",
disabled = false,
status,
className = "",
...props
}: ProjectComboboxProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const useSearchQuery = (search: string, enabled: boolean) => {
const { data } = trpc.project.list.useQuery(
{ search: search || undefined, limit: 15, ...(status ? { status } : {}) },
{ enabled, staleTime: 30_000 },
);
return { data: (data?.projects ?? []) as ProjectItem[] };
};
const { data } = trpc.project.list.useQuery(
{ search: debouncedSearch || undefined, limit: 15, ...(status ? { status } : {}) },
{ enabled: open, staleTime: 30_000 },
const useSelectedQuery = (_id: string | null, enabled: boolean) => {
const { data } = trpc.project.list.useQuery(
{ limit: 500 },
{ enabled, staleTime: 60_000 },
);
return { data: (data?.projects ?? []) as ProjectItem[] };
};
const getLabel = useCallback(
(p: ProjectItem) => `${p.shortCode} \u2014 ${p.name}`,
[],
);
const projects = data?.projects ?? [];
const { data: allData } = trpc.project.list.useQuery(
{ limit: 500 },
{ enabled: !!value && !open, staleTime: 60_000 },
const renderItem = useCallback(
(p: ProjectItem) => (
<>
<span className="font-medium text-xs text-gray-400 dark:text-gray-500 mr-1.5">{p.shortCode}</span>
<span>{p.name}</span>
</>
),
[],
);
const selectedLabel = useMemo(() => {
if (!value) return "";
const fromOpen = projects.find((p) => p.id === value);
if (fromOpen) return `${fromOpen.shortCode} \u2014 ${fromOpen.name}`;
const fromAll = allData?.projects.find((p) => p.id === value);
if (fromAll) return `${fromAll.shortCode} \u2014 ${fromAll.name}`;
return value;
}, [value, projects, allData]);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
function handleFocus() {
if (disabled) return;
setOpen(true);
setSearch("");
}
function select(id: string | null) {
onChange(id);
setOpen(false);
setSearch("");
inputRef.current?.blur();
}
return (
<div className={`relative ${className}`} ref={containerRef}>
<div className="relative">
<input
ref={inputRef}
type="text"
value={open ? search : selectedLabel}
onChange={(e) => setSearch(e.target.value)}
onFocus={handleFocus}
placeholder={placeholder}
disabled={disabled}
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:opacity-50 disabled:cursor-not-allowed ${
open
? "border-brand-500 ring-2 ring-brand-500"
: "border-gray-300 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500"
} bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500`}
readOnly={!open}
/>
{value && !disabled && !open && (
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); select(null); }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-lg leading-none"
aria-label="Clear"
tabIndex={-1}
>
\u00d7
</button>
)}
</div>
{open && (
<div className="absolute left-0 right-0 top-full mt-1 z-[60] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden">
<ul className="max-h-52 overflow-y-auto py-1">
{projects.length === 0 ? (
<li className="px-3 py-2 text-sm text-gray-400 dark:text-gray-500">No results</li>
) : (
projects.map((p) => (
<li key={p.id}>
<button
type="button"
onMouseDown={() => select(p.id)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-brand-50 dark:hover:bg-brand-950/40 ${
p.id === value
? "bg-brand-50 dark:bg-brand-950/40 text-brand-700 dark:text-brand-300 font-medium"
: "text-gray-700 dark:text-gray-200"
}`}
>
<span className="font-medium text-xs text-gray-400 dark:text-gray-500 mr-1.5">{p.shortCode}</span>
<span>{p.name}</span>
</button>
</li>
))
)}
</ul>
</div>
)}
</div>
<EntityCombobox<ProjectItem>
{...props}
placeholder={props.placeholder ?? "Search project\u2026"}
useSearchQuery={useSearchQuery}
useSelectedQuery={useSelectedQuery}
getLabel={getLabel}
renderItem={renderItem}
/>
);
}
+39 -112
View File
@@ -1,8 +1,10 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { useCallback } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import { EntityCombobox } from "./EntityCombobox.js";
type ResourceItem = { id: string; displayName: string; eid: string };
interface ResourceComboboxProps {
value: string | null;
@@ -14,123 +16,48 @@ interface ResourceComboboxProps {
}
export function ResourceCombobox({
value,
onChange,
placeholder = "Search resource\u2026",
disabled = false,
isActive = true,
className = "",
...props
}: ResourceComboboxProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const useSearchQuery = (search: string, enabled: boolean) => {
const { data } = trpc.resource.list.useQuery(
{ search: search || undefined, limit: 15, isActive },
{ enabled, staleTime: 30_000 },
);
return { data: (data?.resources ?? []) as ResourceItem[] };
};
const { data } = trpc.resource.list.useQuery(
{ search: debouncedSearch || undefined, limit: 15, isActive },
{ enabled: open, staleTime: 30_000 },
const useSelectedQuery = (_id: string | null, enabled: boolean) => {
const { data } = trpc.resource.list.useQuery(
{ limit: 500 },
{ enabled, staleTime: 60_000 },
);
return { data: (data?.resources ?? []) as ResourceItem[] };
};
const getLabel = useCallback(
(r: ResourceItem) => `${r.displayName} (${r.eid})`,
[],
);
const resources = (data?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>;
const selectedQuery = trpc.resource.list.useQuery(
{ limit: 500 },
{ enabled: !!value && !open, staleTime: 60_000 },
const renderItem = useCallback(
(r: ResourceItem) => (
<>
<span>{r.displayName}</span>
<span className="ml-1.5 text-xs text-gray-400 dark:text-gray-500">{r.eid}</span>
</>
),
[],
);
const selectedResources = (selectedQuery.data?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>;
const selectedLabel = useMemo(() => {
if (!value) return "";
const fromOpen = resources.find((r) => r.id === value);
if (fromOpen) return `${fromOpen.displayName} (${fromOpen.eid})`;
const fromSelected = selectedResources.find((r) => r.id === value);
if (fromSelected) return `${fromSelected.displayName} (${fromSelected.eid})`;
return value;
}, [value, resources, selectedResources]);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
function handleFocus() {
if (disabled) return;
setOpen(true);
setSearch("");
}
function select(id: string | null) {
onChange(id);
setOpen(false);
setSearch("");
inputRef.current?.blur();
}
return (
<div className={`relative ${className}`} ref={containerRef}>
<div className="relative">
<input
ref={inputRef}
type="text"
value={open ? search : selectedLabel}
onChange={(e) => setSearch(e.target.value)}
onFocus={handleFocus}
placeholder={placeholder}
disabled={disabled}
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:opacity-50 disabled:cursor-not-allowed ${
open
? "border-brand-500 ring-2 ring-brand-500"
: "border-gray-300 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500"
} bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500`}
readOnly={!open}
/>
{value && !disabled && !open && (
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); select(null); }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-lg leading-none"
aria-label="Clear"
tabIndex={-1}
>
\u00d7
</button>
)}
</div>
{open && (
<div className="absolute left-0 right-0 top-full mt-1 z-[60] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden">
<ul className="max-h-52 overflow-y-auto py-1">
{resources.length === 0 ? (
<li className="px-3 py-2 text-sm text-gray-400 dark:text-gray-500">No results</li>
) : (
resources.map((r) => (
<li key={r.id}>
<button
type="button"
onMouseDown={() => select(r.id)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-brand-50 dark:hover:bg-brand-950/40 ${
r.id === value
? "bg-brand-50 dark:bg-brand-950/40 text-brand-700 dark:text-brand-300 font-medium"
: "text-gray-700 dark:text-gray-200"
}`}
>
<span>{r.displayName}</span>
<span className="ml-1.5 text-xs text-gray-400 dark:text-gray-500">{r.eid}</span>
</button>
</li>
))
)}
</ul>
</div>
)}
</div>
<EntityCombobox<ResourceItem>
{...props}
placeholder={props.placeholder ?? "Search resource\u2026"}
useSearchQuery={useSearchQuery}
useSelectedQuery={useSelectedQuery}
getLabel={getLabel}
renderItem={renderItem}
/>
);
}