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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (1–5), 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user