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"; "use client";
import { useState } from "react"; 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 { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
@@ -71,6 +73,7 @@ const emptyRule: EditingRule = {
export function CalculationRulesClient() { export function CalculationRulesClient() {
const [editing, setEditing] = useState<EditingRule | null>(null); const [editing, setEditing] = useState<EditingRule | null>(null);
const [confirmDeleteRule, setConfirmDeleteRule] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -219,7 +222,7 @@ export function CalculationRulesClient() {
<td className="px-4 py-3 text-right text-sm"> <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={() => openEdit(rule)} className="mr-2 text-blue-600 hover:underline dark:text-blue-400">Edit</button>
<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" className="text-red-600 hover:underline dark:text-red-400"
> >
Delete Delete
@@ -240,9 +243,9 @@ export function CalculationRulesClient() {
</div> </div>
{/* ── Edit/Create Modal ── */} {/* ── Edit/Create Modal ── */}
{editing && ( <AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-lg">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> {editing && (<>
<div className="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800"> <div className="p-6">
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100"> <h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Rule" : "New Rule"} {editing.id ? "Edit Rule" : "New Rule"}
</h2> </h2>
@@ -364,7 +367,21 @@ export function CalculationRulesClient() {
</button> </button>
</div> </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> </div>
); );
@@ -1,6 +1,8 @@
"use client"; "use client";
import { useState } from "react"; 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 { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
@@ -58,6 +60,7 @@ export function CountriesClient() {
const [editing, setEditing] = useState<EditingCountry | null>(null); const [editing, setEditing] = useState<EditingCountry | null>(null);
const [cityName, setCityName] = useState(""); const [cityName, setCityName] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [confirmDeleteCity, setConfirmDeleteCity] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -236,11 +239,7 @@ export function CountriesClient() {
{city.name} {city.name}
<button <button
type="button" type="button"
onClick={() => { onClick={() => setConfirmDeleteCity(city.id)}
if (confirm(`Delete metro city "${city.name}"?`)) {
deleteCityMut.mutate({ id: city.id });
}
}}
className="text-gray-400 hover:text-red-500 text-xs leading-none ml-1" className="text-gray-400 hover:text-red-500 text-xs leading-none ml-1"
> >
&times; &times;
@@ -274,9 +273,8 @@ export function CountriesClient() {
})()} })()}
{/* Create/Edit Modal */} {/* Create/Edit Modal */}
{editing && ( <AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-lg" className="flex flex-col max-h-[90vh]">
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> {editing && (<>
<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]">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <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"> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Country" : "Add Country"} {editing.id ? "Edit Country" : "Add Country"}
@@ -406,8 +404,21 @@ export function CountriesClient() {
{isPending ? "Saving..." : editing.id ? "Update" : "Create"} {isPending ? "Saving..." : editing.id ? "Update" : "Create"}
</button> </button>
</div> </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> </div>
); );
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
@@ -87,6 +88,7 @@ export function EffortRulesClient() {
const [editing, setEditing] = useState<EditingRuleSet | null>(null); const [editing, setEditing] = useState<EditingRuleSet | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
function handleSave() { function handleSave() {
if (!editing) return; if (!editing) return;
@@ -375,11 +377,7 @@ export function EffortRulesClient() {
Edit Edit
</button> </button>
<button <button
onClick={() => { onClick={() => setConfirmDelete(rs.id)}
if (confirm(`Delete rule set "${rs.name}"?`)) {
deleteMutation.mutate({ id: rs.id });
}
}}
className="rounded-xl border border-red-200 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-50" className="rounded-xl border border-red-200 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
> >
Delete Delete
@@ -416,6 +414,20 @@ export function EffortRulesClient() {
)} )}
</div> </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> </div>
); );
} }
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
@@ -97,6 +98,7 @@ export function ExperienceMultipliersClient() {
const [editing, setEditing] = useState<EditingSet | null>(null); const [editing, setEditing] = useState<EditingSet | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
function handleSave() { function handleSave() {
if (!editing) return; if (!editing) return;
@@ -422,11 +424,7 @@ export function ExperienceMultipliersClient() {
Edit Edit
</button> </button>
<button <button
onClick={() => { onClick={() => setConfirmDelete(s.id)}
if (confirm(`Delete multiplier set "${s.name}"?`)) {
deleteMutation.mutate({ id: s.id });
}
}}
className="rounded-xl border border-red-200 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-50" className="rounded-xl border border-red-200 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
> >
Delete Delete
@@ -471,6 +469,20 @@ export function ExperienceMultipliersClient() {
)} )}
</div> </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> </div>
); );
} }
@@ -1,6 +1,8 @@
"use client"; "use client";
import { useState } from "react"; 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 { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
@@ -29,6 +31,7 @@ type EditingLevel = {
export function ManagementLevelsClient() { export function ManagementLevelsClient() {
const [editingGroup, setEditingGroup] = useState<EditingGroup | null>(null); const [editingGroup, setEditingGroup] = useState<EditingGroup | null>(null);
const [editingLevel, setEditingLevel] = useState<EditingLevel | null>(null); const [editingLevel, setEditingLevel] = useState<EditingLevel | null>(null);
const [confirmDeleteLevel, setConfirmDeleteLevel] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -185,11 +188,7 @@ export function ManagementLevelsClient() {
</button> </button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => setConfirmDeleteLevel(level.id)}
if (confirm(`Delete level "${level.name}"?`)) {
deleteLevelMut.mutate({ id: level.id });
}
}}
className="text-xs text-red-500 hover:text-red-700 font-medium" className="text-xs text-red-500 hover:text-red-700 font-medium"
> >
Delete Delete
@@ -207,9 +206,8 @@ export function ManagementLevelsClient() {
</div> </div>
{/* Group Modal */} {/* Group Modal */}
{editingGroup && ( <AnimatedModal open={editingGroup !== null} onClose={() => setEditingGroup(null)} maxWidth="max-w-md">
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> {editingGroup && (<>
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <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"> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editingGroup.id ? "Edit Group" : "Add Group"} {editingGroup.id ? "Edit Group" : "Add Group"}
@@ -264,14 +262,12 @@ export function ManagementLevelsClient() {
{isGroupPending ? "Saving..." : editingGroup.id ? "Update" : "Create"} {isGroupPending ? "Saving..." : editingGroup.id ? "Update" : "Create"}
</button> </button>
</div> </div>
</div> </>)}
</div> </AnimatedModal>
)}
{/* Level Modal */} {/* Level Modal */}
{editingLevel && ( <AnimatedModal open={editingLevel !== null} onClose={() => setEditingLevel(null)} maxWidth="max-w-sm">
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> {editingLevel && (<>
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-sm mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <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"> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editingLevel.id ? "Edit Level" : "Add Level"} {editingLevel.id ? "Edit Level" : "Add Level"}
@@ -316,8 +312,21 @@ export function ManagementLevelsClient() {
{isLevelPending ? "Saving..." : editingLevel.id ? "Update" : "Create"} {isLevelPending ? "Saving..." : editingLevel.id ? "Update" : "Create"}
</button> </button>
</div> </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> </div>
); );
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
@@ -195,9 +196,8 @@ export function OrgUnitsClient() {
</div> </div>
{/* Create/Edit Modal */} {/* Create/Edit Modal */}
{editing && ( <AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-md">
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> {editing && (<>
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <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"> <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}`}`} {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"} {isPending ? "Saving..." : editing.id ? "Update" : "Create"}
</button> </button>
</div> </div>
</div> </>)}
</div> </AnimatedModal>
)}
</div> </div>
); );
} }
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { formatCents } from "~/lib/format.js"; import { formatCents } from "~/lib/format.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
@@ -101,6 +102,8 @@ export function RateCardsClient() {
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [editingCard, setEditingCard] = useState<EditingCard | null>(null); const [editingCard, setEditingCard] = useState<EditingCard | null>(null);
const [editingLine, setEditingLine] = useState<EditingLine | 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 [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -260,7 +263,6 @@ export function RateCardsClient() {
} }
async function handleDeleteLine(lineId: string) { async function handleDeleteLine(lineId: string) {
if (!confirm("Delete this rate line?")) return;
try { try {
await deleteLineMut.mutateAsync({ lineId }); await deleteLineMut.mutateAsync({ lineId });
invalidateAll(); invalidateAll();
@@ -270,7 +272,6 @@ export function RateCardsClient() {
} }
async function handleDeactivate(id: string) { async function handleDeactivate(id: string) {
if (!confirm("Deactivate this rate card?")) return;
try { try {
await deactivateMut.mutateAsync({ id }); await deactivateMut.mutateAsync({ id });
invalidateAll(); invalidateAll();
@@ -445,7 +446,7 @@ export function RateCardsClient() {
{detail.isActive ? ( {detail.isActive ? (
<button <button
type="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" className="px-3 py-1.5 text-sm text-gray-500 hover:text-red-600 font-medium"
> >
Deactivate Deactivate
@@ -528,7 +529,7 @@ export function RateCardsClient() {
</button> </button>
<button <button
type="button" type="button"
onClick={() => handleDeleteLine(line.id)} onClick={() => setConfirmDeleteLine(line.id)}
className="text-xs text-red-500 hover:text-red-700 font-medium" className="text-xs text-red-500 hover:text-red-700 font-medium"
> >
Delete Delete
@@ -780,6 +781,34 @@ export function RateCardsClient() {
</div> </div>
</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> </div>
); );
} }
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
@@ -159,9 +160,8 @@ export function UtilizationCategoriesClient() {
</div> </div>
{/* Create/Edit Modal */} {/* Create/Edit Modal */}
{editing && ( <AnimatedModal open={editing !== null} onClose={() => setEditing(null)} maxWidth="max-w-md">
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> {editing && (<>
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <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"> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Category" : "Add Category"} {editing.id ? "Edit Category" : "Add Category"}
@@ -236,9 +236,8 @@ export function UtilizationCategoriesClient() {
{isPending ? "Saving..." : editing.id ? "Update" : "Create"} {isPending ? "Saving..." : editing.id ? "Update" : "Create"}
</button> </button>
</div> </div>
</div> </>)}
</div> </AnimatedModal>
)}
</div> </div>
); );
} }
@@ -3,67 +3,17 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Link from "next/link"; import Link from "next/link";
import { trpc } from "~/lib/trpc/client.js"; import { PROFICIENCY_LABELS, ProficiencyBadge, GapIndicator, formatDate } from "~/components/analytics/skills/shared.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import { useTableSort } from "~/hooks/useTableSort.js"; import { useTableSort } from "~/hooks/useTableSort.js";
import { trpc } from "~/lib/trpc/client.js";
const SkillDistributionChart = dynamic( const SkillDistributionChart = dynamic(
() => import("~/components/analytics/SkillDistributionChart.js"), () => import("~/components/analytics/SkillDistributionChart.js"),
{ ssr: false, loading: () => <div className="h-80 shimmer-skeleton rounded-xl" /> }, { 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() { export function SkillMarketplace() {
const [searchSkill, setSearchSkill] = useState(""); const [searchSkill, setSearchSkill] = useState("");
const [minProficiency, setMinProficiency] = useState(1); const [minProficiency, setMinProficiency] = useState(1);
@@ -2,6 +2,7 @@
import { useState, useId } from "react"; import { useState, useId } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { PROFICIENCY_LABELS, proficiencyClasses, ProficiencyBadge } from "~/components/analytics/skills/shared.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js"; import { useTableSort } from "~/hooks/useTableSort.js";
import { trpc } from "~/lib/trpc/client.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" /> }, { 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 }; type SkillRule = { skill: string; minProficiency: number };
export function SkillsAnalytics() { export function SkillsAnalytics() {
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { CommentInput } from "./CommentInput.js"; import { CommentInput } from "./CommentInput.js";
interface CommentAuthor { interface CommentAuthor {
@@ -118,6 +119,7 @@ function SingleComment({
isReply?: boolean; isReply?: boolean;
}) { }) {
const [showReplyInput, setShowReplyInput] = useState(false); const [showReplyInput, setShowReplyInput] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const createMutation = trpc.comment.create.useMutation({ const createMutation = trpc.comment.create.useMutation({
@@ -199,11 +201,7 @@ function SingleComment({
)} )}
<button <button
type="button" type="button"
onClick={() => { onClick={() => setConfirmDelete(true)}
if (window.confirm("Delete this comment?")) {
deleteMutation.mutate({ id: comment.id });
}
}}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
className="text-xs text-gray-400 hover:text-rose-600 dark:hover:text-rose-400" className="text-xs text-gray-400 hover:text-rose-600 dark:hover:text-rose-400"
> >
@@ -236,6 +234,20 @@ function SingleComment({
</div> </div>
</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 */} {/* Render replies */}
{"replies" in comment && comment.replies.length > 0 && ( {"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"> <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 { useState } from "react";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
interface ApplyEffortRulesProps { interface ApplyEffortRulesProps {
@@ -17,6 +18,7 @@ export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffort
const [selectedRuleSetId, setSelectedRuleSetId] = useState<string>(""); const [selectedRuleSetId, setSelectedRuleSetId] = useState<string>("");
const [mode, setMode] = useState<"replace" | "append">("replace"); const [mode, setMode] = useState<"replace" | "append">("replace");
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
const [confirmApply, setConfirmApply] = useState(false);
const previewQuery = trpc.effortRule.preview.useQuery( const previewQuery = trpc.effortRule.preview.useQuery(
{ estimateId, ruleSetId: selectedRuleSetId }, { estimateId, ruleSetId: selectedRuleSetId },
@@ -106,10 +108,7 @@ export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffort
<button <button
onClick={() => { onClick={() => {
if (!selectedRuleSetId) return; if (!selectedRuleSetId) return;
const action = mode === "replace" ? "replace all existing demand lines" : "append new demand lines"; setConfirmApply(true);
if (confirm(`This will ${action}. Continue?`)) {
applyMutation.mutate({ estimateId, ruleSetId: selectedRuleSetId, mode });
}
}} }}
disabled={!selectedRuleSetId || applyMutation.isPending} 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" 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 && ( {showPreview && previewQuery.isLoading && (
<p className="mt-3 text-sm text-gray-400">Computing preview...</p> <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> </div>
); );
} }
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatCents } from "~/lib/format.js"; import { formatCents } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.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 [selectedSetId, setSelectedSetId] = useState<string>("");
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
const [confirmApply, setConfirmApply] = useState(false);
const previewQuery = trpc.experienceMultiplier.preview.useQuery( const previewQuery = trpc.experienceMultiplier.preview.useQuery(
{ estimateId, multiplierSetId: selectedSetId }, { estimateId, multiplierSetId: selectedSetId },
@@ -96,9 +98,7 @@ export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: A
<button <button
onClick={() => { onClick={() => {
if (!selectedSetId) return; if (!selectedSetId) return;
if (confirm("This will update cost/bill rates and hours on matching demand lines. Continue?")) { setConfirmApply(true);
applyMutation.mutate({ estimateId, multiplierSetId: selectedSetId });
}
}} }}
disabled={!selectedSetId || applyMutation.isPending} 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" 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 && ( {showPreview && previewQuery.isLoading && (
<p className="mt-3 text-sm text-gray-400">Computing preview...</p> <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> </div>
); );
} }
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { usePermissions } from "~/hooks/usePermissions.js"; import { usePermissions } from "~/hooks/usePermissions.js";
import { TaskCard } from "./TaskCard.js"; import { TaskCard } from "./TaskCard.js";
@@ -31,6 +32,7 @@ export function NotificationCenterClient() {
const [activeTab, setActiveTab] = useState<TabKey>(initialTab); const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
const { canEdit } = usePermissions(); const { canEdit } = usePermissions();
const [showTaskModal, setShowTaskModal] = useState(false); const [showTaskModal, setShowTaskModal] = useState(false);
const [confirmDeleteReminder, setConfirmDeleteReminder] = useState<string | null>(null);
const [reminderModal, setReminderModal] = useState<{ const [reminderModal, setReminderModal] = useState<{
open: boolean; open: boolean;
reminder: { reminder: {
@@ -374,11 +376,7 @@ export function NotificationCenterClient() {
</button> </button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => setConfirmDeleteReminder(r.id)}
if (window.confirm("Delete this reminder?")) {
deleteReminder.mutate({ id: r.id });
}
}}
disabled={deleteReminder.isPending} 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" 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" title="Delete"
@@ -413,6 +411,20 @@ export function NotificationCenterClient() {
onSuccess={() => setShowTaskModal(false)} 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> </div>
); );
} }
@@ -1,9 +1,10 @@
"use client"; "use client";
import { useRef, useState } from "react"; 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 { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
const RECURRENCE_OPTIONS = [ const RECURRENCE_OPTIONS = [
{ value: "", label: "None" }, { value: "", label: "None" },
@@ -50,6 +51,7 @@ export function ReminderModal({ reminder, onClose, onSuccess }: ReminderModalPro
const [recurrence, setRecurrence] = useState(reminder?.recurrence ?? ""); const [recurrence, setRecurrence] = useState(reminder?.recurrence ?? "");
const [link, setLink] = useState(reminder?.link ?? ""); const [link, setLink] = useState(reminder?.link ?? "");
const [serverError, setServerError] = useState<string | null>(null); const [serverError, setServerError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true); useFocusTrap(panelRef, true);
@@ -128,8 +130,7 @@ export function ReminderModal({ reminder, onClose, onSuccess }: ReminderModalPro
function handleDelete() { function handleDelete() {
if (!reminder) return; if (!reminder) return;
if (!window.confirm("Delete this reminder?")) return; setConfirmDelete(true);
deleteMutation.mutate({ id: reminder.id });
} }
const inputClass = const inputClass =
@@ -303,6 +304,20 @@ export function ReminderModal({ reminder, onClose, onSuccess }: ReminderModalPro
</div> </div>
</form> </form>
</div> </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> </div>
); );
} }
+4 -20
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useState } from "react";
import type { RoleWithResourceCount } from "@planarchy/shared"; import type { RoleWithResourceCount } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js"; 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"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const PRESET_COLORS = [ const PRESET_COLORS = [
@@ -33,9 +33,6 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
const [color, setColor] = useState(role?.color ?? PRESET_COLORS[0]!); const [color, setColor] = useState(role?.color ?? PRESET_COLORS[0]!);
const [serverError, setServerError] = useState<string | null>(null); const [serverError, setServerError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const createMutation = trpc.role.create.useMutation({ const createMutation = trpc.role.create.useMutation({
@@ -82,19 +79,7 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
const labelClass = "app-label"; const labelClass = "app-label";
return ( return (
<div <AnimatedModal open onClose={onClose} maxWidth="max-w-md">
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();
}}
>
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700"> <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"> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{isEditing ? "Edit Role" : "New Role"} {isEditing ? "Edit Role" : "New Role"}
@@ -199,7 +184,6 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
</button> </button>
</div> </div>
</form> </form>
</div> </AnimatedModal>
</div>
); );
} }
@@ -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>
);
}
+37 -109
View File
@@ -1,9 +1,11 @@
"use client"; "use client";
import { useState, useRef, useEffect, useMemo } from "react"; import { useCallback } from "react";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import type { ProjectStatus } from "@planarchy/shared"; import type { ProjectStatus } from "@planarchy/shared";
import { EntityCombobox } from "./EntityCombobox.js";
type ProjectItem = { id: string; shortCode: string; name: string };
interface ProjectComboboxProps { interface ProjectComboboxProps {
value: string | null; value: string | null;
@@ -15,122 +17,48 @@ interface ProjectComboboxProps {
} }
export function ProjectCombobox({ export function ProjectCombobox({
value,
onChange,
placeholder = "Search project\u2026",
disabled = false,
status, status,
className = "", ...props
}: ProjectComboboxProps) { }: ProjectComboboxProps) {
const [open, setOpen] = useState(false); const useSearchQuery = (search: string, enabled: boolean) => {
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { data } = trpc.project.list.useQuery( const { data } = trpc.project.list.useQuery(
{ search: debouncedSearch || undefined, limit: 15, ...(status ? { status } : {}) }, { search: search || undefined, limit: 15, ...(status ? { status } : {}) },
{ enabled: open, staleTime: 30_000 }, { enabled, staleTime: 30_000 },
); );
return { data: (data?.projects ?? []) as ProjectItem[] };
};
const projects = data?.projects ?? []; const useSelectedQuery = (_id: string | null, enabled: boolean) => {
const { data } = trpc.project.list.useQuery(
const { data: allData } = trpc.project.list.useQuery(
{ limit: 500 }, { limit: 500 },
{ enabled: !!value && !open, staleTime: 60_000 }, { enabled, staleTime: 60_000 },
);
return { data: (data?.projects ?? []) as ProjectItem[] };
};
const getLabel = useCallback(
(p: ProjectItem) => `${p.shortCode} \u2014 ${p.name}`,
[],
); );
const selectedLabel = useMemo(() => { const renderItem = useCallback(
if (!value) return ""; (p: ProjectItem) => (
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 className="font-medium text-xs text-gray-400 dark:text-gray-500 mr-1.5">{p.shortCode}</span>
<span>{p.name}</span> <span>{p.name}</span>
</button> </>
</li> ),
)) [],
)} );
</ul>
</div> return (
)} <EntityCombobox<ProjectItem>
</div> {...props}
placeholder={props.placeholder ?? "Search project\u2026"}
useSearchQuery={useSearchQuery}
useSelectedQuery={useSelectedQuery}
getLabel={getLabel}
renderItem={renderItem}
/>
); );
} }
+36 -109
View File
@@ -1,8 +1,10 @@
"use client"; "use client";
import { useState, useRef, useEffect, useMemo } from "react"; import { useCallback } from "react";
import { trpc } from "~/lib/trpc/client.js"; 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 { interface ResourceComboboxProps {
value: string | null; value: string | null;
@@ -14,123 +16,48 @@ interface ResourceComboboxProps {
} }
export function ResourceCombobox({ export function ResourceCombobox({
value,
onChange,
placeholder = "Search resource\u2026",
disabled = false,
isActive = true, isActive = true,
className = "", ...props
}: ResourceComboboxProps) { }: ResourceComboboxProps) {
const [open, setOpen] = useState(false); const useSearchQuery = (search: string, enabled: boolean) => {
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { data } = trpc.resource.list.useQuery( const { data } = trpc.resource.list.useQuery(
{ search: debouncedSearch || undefined, limit: 15, isActive }, { search: search || undefined, limit: 15, isActive },
{ enabled: open, staleTime: 30_000 }, { enabled, staleTime: 30_000 },
); );
return { data: (data?.resources ?? []) as ResourceItem[] };
};
const resources = (data?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>; const useSelectedQuery = (_id: string | null, enabled: boolean) => {
const { data } = trpc.resource.list.useQuery(
const selectedQuery = trpc.resource.list.useQuery(
{ limit: 500 }, { limit: 500 },
{ enabled: !!value && !open, staleTime: 60_000 }, { enabled, staleTime: 60_000 },
); );
const selectedResources = (selectedQuery.data?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>; return { data: (data?.resources ?? []) as ResourceItem[] };
};
const selectedLabel = useMemo(() => { const getLabel = useCallback(
if (!value) return ""; (r: ResourceItem) => `${r.displayName} (${r.eid})`,
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(() => { const renderItem = useCallback(
if (!open) return; (r: ResourceItem) => (
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>{r.displayName}</span>
<span className="ml-1.5 text-xs text-gray-400 dark:text-gray-500">{r.eid}</span> <span className="ml-1.5 text-xs text-gray-400 dark:text-gray-500">{r.eid}</span>
</button> </>
</li> ),
)) [],
)} );
</ul>
</div> return (
)} <EntityCombobox<ResourceItem>
</div> {...props}
placeholder={props.placeholder ?? "Search resource\u2026"}
useSearchQuery={useSearchQuery}
useSelectedQuery={useSelectedQuery}
getLabel={getLabel}
renderItem={renderItem}
/>
); );
} }
+1
View File
@@ -3,6 +3,7 @@ export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedu
export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js"; export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js";
export { logger } from "./lib/logger.js"; export { logger } from "./lib/logger.js";
export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js"; export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js";
export { createNotification, createNotificationsForUsers } from "./lib/create-notification.js";
export { checkBudgetThresholds } from "./lib/budget-alerts.js"; export { checkBudgetThresholds } from "./lib/budget-alerts.js";
export { checkPendingEstimateReminders } from "./lib/estimate-reminders.js"; export { checkPendingEstimateReminders } from "./lib/estimate-reminders.js";
export { checkChargeabilityAlerts } from "./lib/chargeability-alerts.js"; export { checkChargeabilityAlerts } from "./lib/chargeability-alerts.js";
+4 -9
View File
@@ -1,7 +1,7 @@
import { listAssignmentBookings } from "@planarchy/application"; import { listAssignmentBookings } from "@planarchy/application";
import { rankResources } from "@planarchy/staffing"; import { rankResources } from "@planarchy/staffing";
import type { SkillEntry } from "@planarchy/shared"; import type { SkillEntry } from "@planarchy/shared";
import { emitNotificationCreated } from "../sse/event-bus.js"; import { createNotificationsForUsers } from "./create-notification.js";
/** /**
* Minimal DB interface for auto-staffing — avoids importing the full PrismaClient. * Minimal DB interface for auto-staffing — avoids importing the full PrismaClient.
@@ -227,10 +227,9 @@ export async function generateAutoSuggestions(
select: { id: true }, select: { id: true },
}); });
for (const manager of managers) { await createNotificationsForUsers({
const notification = await db.notification.create({ db,
data: { userIds: managers.map((m) => m.id),
userId: manager.id,
type: "AUTO_STAFFING_SUGGESTION", type: "AUTO_STAFFING_SUGGESTION",
category: "NOTIFICATION", category: "NOTIFICATION",
priority: "NORMAL", priority: "NORMAL",
@@ -240,11 +239,7 @@ export async function generateAutoSuggestions(
entityType: "demand", entityType: "demand",
link: `/staffing?demandId=${demandRequirementId}`, link: `/staffing?demandId=${demandRequirementId}`,
channel: "in_app", channel: "in_app",
},
}); });
emitNotificationCreated(manager.id, notification.id);
}
} catch { } catch {
// Fire-and-forget: swallow all errors to avoid disrupting the caller. // Fire-and-forget: swallow all errors to avoid disrupting the caller.
} }
+4 -9
View File
@@ -1,5 +1,5 @@
import { listAssignmentBookings } from "@planarchy/application"; import { listAssignmentBookings } from "@planarchy/application";
import { emitNotificationCreated } from "../sse/event-bus.js"; import { createNotificationsForUsers } from "./create-notification.js";
type DbClient = Parameters<typeof listAssignmentBookings>[0] & { type DbClient = Parameters<typeof listAssignmentBookings>[0] & {
project: { project: {
@@ -119,10 +119,9 @@ export async function checkBudgetThresholds(
{ minimumFractionDigits: 2, maximumFractionDigits: 2 }, { minimumFractionDigits: 2, maximumFractionDigits: 2 },
); );
for (const manager of managers) { await createNotificationsForUsers({
const notification = await db.notification.create({ db,
data: { userIds: managers.map((m) => m.id),
userId: manager.id,
type: threshold.type, type: threshold.type,
category: "NOTIFICATION", category: "NOTIFICATION",
priority: threshold.priority, priority: threshold.priority,
@@ -132,10 +131,6 @@ export async function checkBudgetThresholds(
entityType: "project_budget", entityType: "project_budget",
link: `/projects/${projectId}`, link: `/projects/${projectId}`,
channel: "in_app", channel: "in_app",
},
}); });
emitNotificationCreated(manager.id, notification.id);
}
} }
} }
+4 -9
View File
@@ -8,7 +8,7 @@ import {
import type { SpainScheduleRule } from "@planarchy/shared"; import type { SpainScheduleRule } from "@planarchy/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@planarchy/application"; import { isChargeabilityActualBooking, listAssignmentBookings } from "@planarchy/application";
import { VacationStatus } from "@planarchy/db"; import { VacationStatus } from "@planarchy/db";
import { emitNotificationCreated } from "../sse/event-bus.js"; import { createNotificationsForUsers } from "./create-notification.js";
/** /**
* Minimal DB client type for chargeability alerts. * Minimal DB client type for chargeability alerts.
@@ -237,10 +237,9 @@ export async function checkChargeabilityAlerts(
if (existing) continue; if (existing) continue;
for (const manager of managers) { await createNotificationsForUsers({
const notification = await (db as DbClient).notification.create({ db: db as DbClient,
data: { userIds: managers.map((m) => m.id),
userId: manager.id,
type: "CHARGEABILITY_ALERT", type: "CHARGEABILITY_ALERT",
category: "NOTIFICATION", category: "NOTIFICATION",
priority: "HIGH", priority: "HIGH",
@@ -250,12 +249,8 @@ export async function checkChargeabilityAlerts(
entityType: "chargeability_alert", entityType: "chargeability_alert",
link: "/chargeability", link: "/chargeability",
channel: "in_app", channel: "in_app",
},
}); });
emitNotificationCreated(manager.id, notification.id);
}
alertCount++; alertCount++;
} }
+102
View File
@@ -0,0 +1,102 @@
import { emitNotificationCreated } from "../sse/event-bus.js";
export interface CreateNotificationParams {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
db: { notification: { create: (args: any) => Promise<{ id: string; userId: string }> } };
userId: string;
type: string;
title: string;
body?: string | undefined;
link?: string | undefined;
entityId?: string | undefined;
entityType?: string | undefined;
category?: string | undefined;
priority?: string | undefined;
senderId?: string | undefined;
channel?: string | undefined;
taskStatus?: string | undefined;
taskAction?: string | undefined;
assigneeId?: string | undefined;
dueDate?: Date | undefined;
sourceId?: string | undefined;
/** Set to false to suppress the SSE emitNotificationCreated call. Default: true. */
emit?: boolean | undefined;
}
/**
* Create a single in-app notification and optionally emit an SSE event.
*
* Handles the `exactOptionalPropertyTypes` spread pattern internally so
* callers do not need to repeat the `...(val !== undefined ? { key: val } : {})` boilerplate.
*
* Returns the created notification's ID.
*/
export async function createNotification(
params: CreateNotificationParams,
): Promise<string> {
const {
db,
userId,
type,
title,
body,
link,
entityId,
entityType,
category,
priority,
senderId,
channel,
taskStatus,
taskAction,
assigneeId,
dueDate,
sourceId,
emit = true,
} = params;
const notification = await db.notification.create({
data: {
userId,
type,
title,
...(body !== undefined ? { body } : {}),
...(link !== undefined ? { link } : {}),
...(entityId !== undefined ? { entityId } : {}),
...(entityType !== undefined ? { entityType } : {}),
...(category !== undefined ? { category } : {}),
...(priority !== undefined ? { priority } : {}),
...(senderId !== undefined ? { senderId } : {}),
...(channel !== undefined ? { channel } : {}),
...(taskStatus !== undefined ? { taskStatus } : {}),
...(taskAction !== undefined ? { taskAction } : {}),
...(assigneeId !== undefined ? { assigneeId } : {}),
...(dueDate !== undefined ? { dueDate } : {}),
...(sourceId !== undefined ? { sourceId } : {}),
},
});
if (emit) {
emitNotificationCreated(userId, notification.id);
}
return notification.id;
}
/**
* Create one notification per user ID.
*
* Useful for fan-out scenarios (e.g. notifying all managers).
* Returns the count of notifications created.
*/
export async function createNotificationsForUsers(
params: Omit<CreateNotificationParams, "userId"> & { userIds: string[] },
): Promise<number> {
const { userIds, ...rest } = params;
let count = 0;
for (const userId of userIds) {
await createNotification({ ...rest, userId });
count++;
}
return count;
}
+4 -9
View File
@@ -1,4 +1,4 @@
import { emitNotificationCreated } from "../sse/event-bus.js"; import { createNotificationsForUsers } from "./create-notification.js";
type DbClient = { type DbClient = {
estimate: { estimate: {
@@ -138,10 +138,9 @@ export async function checkPendingEstimateReminders(
) )
: REMINDER_DAYS; : REMINDER_DAYS;
for (const manager of managers) { await createNotificationsForUsers({
const notification = await db.notification.create({ db,
data: { userIds: managers.map((m) => m.id),
userId: manager.id,
type: "ESTIMATE_APPROVAL_REMINDER", type: "ESTIMATE_APPROVAL_REMINDER",
category: "REMINDER", category: "REMINDER",
priority: "HIGH", priority: "HIGH",
@@ -151,12 +150,8 @@ export async function checkPendingEstimateReminders(
entityType: "estimate_approval_reminder", entityType: "estimate_approval_reminder",
link: `/estimates/${estimate.id}`, link: `/estimates/${estimate.id}`,
channel: "in_app", channel: "in_app",
},
}); });
emitNotificationCreated(manager.id, notification.id);
}
reminderCount++; reminderCount++;
} }
+3 -5
View File
@@ -1,5 +1,5 @@
import { VacationStatus } from "@planarchy/db"; import { VacationStatus } from "@planarchy/db";
import { emitNotificationCreated } from "../sse/event-bus.js"; import { createNotification } from "./create-notification.js";
type DbClient = { type DbClient = {
vacation: { vacation: {
@@ -189,8 +189,8 @@ export async function checkVacationConflicts(
// Create a notification for the approver if provided // Create a notification for the approver if provided
if (approverUserId) { if (approverUserId) {
const notification = await db.notification.create({ await createNotification({
data: { db,
userId: approverUserId, userId: approverUserId,
type: "VACATION_CONFLICT_WARNING", type: "VACATION_CONFLICT_WARNING",
category: "NOTIFICATION", category: "NOTIFICATION",
@@ -201,9 +201,7 @@ export async function checkVacationConflicts(
entityType: "vacation", entityType: "vacation",
link: "/vacations", link: "/vacations",
channel: "in_app", channel: "in_app",
},
}); });
emitNotificationCreated(approverUserId, notification.id);
} }
} }
+5 -7
View File
@@ -2,7 +2,7 @@ import { z } from "zod";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { SystemRole } from "@planarchy/shared"; import { SystemRole } from "@planarchy/shared";
import { createTRPCRouter, protectedProcedure } from "../trpc.js"; import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import { emitNotificationCreated } from "../sse/event-bus.js"; import { createNotification } from "../lib/create-notification.js";
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -138,9 +138,9 @@ export const commentRouter = createTRPCRouter({
input.body.length > 120 ? `${input.body.slice(0, 120)}...` : input.body; input.body.length > 120 ? `${input.body.slice(0, 120)}...` : input.body;
await Promise.all( await Promise.all(
mentionedUserIds.map(async (userId) => { mentionedUserIds.map((userId) =>
const notification = await ctx.db.notification.create({ createNotification({
data: { db: ctx.db,
userId, userId,
type: "COMMENT_MENTION", type: "COMMENT_MENTION",
title: `${authorName} mentioned you in a comment`, title: `${authorName} mentioned you in a comment`,
@@ -150,10 +150,8 @@ export const commentRouter = createTRPCRouter({
senderId: authorId, senderId: authorId,
link: `/estimates/${input.entityId}?tab=comments`, link: `/estimates/${input.entityId}?tab=comments`,
channel: "in_app", channel: "in_app",
},
});
emitNotificationCreated(userId, notification.id);
}), }),
),
); );
} }
+52 -52
View File
@@ -9,6 +9,7 @@ import {
emitTaskStatusChanged, emitTaskStatusChanged,
emitBroadcastSent, emitBroadcastSent,
} from "../sse/event-bus.js"; } from "../sse/event-bus.js";
import { createNotification } from "../lib/create-notification.js";
import { resolveRecipients } from "../lib/notification-targeting.js"; import { resolveRecipients } from "../lib/notification-targeting.js";
import { sendEmail } from "../lib/email.js"; import { sendEmail } from "../lib/email.js";
@@ -154,31 +155,28 @@ export const notificationRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const currentUserId = ctx.dbUser.id; const currentUserId = ctx.dbUser.id;
const n = await ctx.db.notification.create({ const notificationId = await createNotification({
data: { db: ctx.db,
userId: input.userId, userId: input.userId,
type: input.type, type: input.type,
title: input.title, title: input.title,
...(input.body !== undefined ? { body: input.body } : {}), body: input.body,
...(input.entityId !== undefined ? { entityId: input.entityId } : {}), entityId: input.entityId,
...(input.entityType !== undefined ? { entityType: input.entityType } : {}), entityType: input.entityType,
...(input.category !== undefined ? { category: input.category } : {}), category: input.category,
...(input.priority !== undefined ? { priority: input.priority } : {}), priority: input.priority,
...(input.link !== undefined ? { link: input.link } : {}), link: input.link,
...(input.taskStatus !== undefined ? { taskStatus: input.taskStatus } : {}), taskStatus: input.taskStatus,
...(input.taskAction !== undefined ? { taskAction: input.taskAction } : {}), taskAction: input.taskAction,
...(input.assigneeId !== undefined ? { assigneeId: input.assigneeId } : {}), assigneeId: input.assigneeId,
...(input.dueDate !== undefined ? { dueDate: input.dueDate } : {}), dueDate: input.dueDate,
...(input.channel !== undefined ? { channel: input.channel } : {}), channel: input.channel,
senderId: input.senderId ?? currentUserId, senderId: input.senderId ?? currentUserId,
},
}); });
emitNotificationCreated(input.userId, n.id);
// Emit task-specific events // Emit task-specific events
if (input.category === "TASK" || input.category === "APPROVAL") { if (input.category === "TASK" || input.category === "APPROVAL") {
emitTaskAssigned(input.userId, n.id); emitTaskAssigned(input.userId, notificationId);
} }
// Email if channel includes email // Email if channel includes email
@@ -187,6 +185,8 @@ export const notificationRouter = createTRPCRouter({
void sendNotificationEmail(ctx.db, input.userId, input.title, input.body); void sendNotificationEmail(ctx.db, input.userId, input.title, input.body);
} }
// Re-fetch for return value (to maintain API contract)
const n = await ctx.db.notification.findUnique({ where: { id: notificationId } });
return n; return n;
}), }),
@@ -332,6 +332,9 @@ export const notificationRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userId = await resolveUserId(ctx); const userId = await resolveUserId(ctx);
// Reminders have extra fields (remindAt, nextRemindAt, recurrence) not covered
// by the generic helper, so we keep the direct create here but still use
// the exactOptionalPropertyTypes spread pattern.
return ctx.db.notification.create({ return ctx.db.notification.create({
data: { data: {
userId, userId,
@@ -479,54 +482,51 @@ export const notificationRouter = createTRPCRouter({
// 4. Create individual notifications for each recipient // 4. Create individual notifications for each recipient
const isTask = input.category === "TASK" || input.category === "APPROVAL"; const isTask = input.category === "TASK" || input.category === "APPROVAL";
const notifications = await Promise.all( // SSE emit handled by createNotification; task events need separate emit
recipientIds.map((recipientUserId) => const notificationIds: Array<{ id: string; userId: string }> = [];
ctx.db.notification.create({ for (const recipientUserId of recipientIds) {
data: { const nId = await createNotification({
db: ctx.db,
userId: recipientUserId, userId: recipientUserId,
type: `BROADCAST_${input.category}`, type: `BROADCAST_${input.category}`,
title: input.title, title: input.title,
...(input.body !== undefined ? { body: input.body } : {}), body: input.body,
...(input.link !== undefined ? { link: input.link } : {}), link: input.link,
category: input.category, category: input.category,
priority: input.priority, priority: input.priority,
channel: input.channel, channel: input.channel,
sourceId: broadcast.id, sourceId: broadcast.id,
senderId, senderId,
...(isTask ? { taskStatus: "OPEN" as const } : {}), taskStatus: isTask ? "OPEN" : undefined,
...(input.taskAction !== undefined ? { taskAction: input.taskAction } : {}), taskAction: input.taskAction,
...(input.dueDate !== undefined ? { dueDate: input.dueDate } : {}), dueDate: input.dueDate,
}, });
}), notificationIds.push({ id: nId, userId: recipientUserId });
), if (isTask) {
); emitTaskAssigned(recipientUserId, nId);
}
}
// 5. Update broadcast with sent info // 5. Update broadcast with sent info
await ctx.db.notificationBroadcast.update({ await ctx.db.notificationBroadcast.update({
where: { id: broadcast.id }, where: { id: broadcast.id },
data: { data: {
sentAt: new Date(), sentAt: new Date(),
recipientCount: notifications.length, recipientCount: notificationIds.length,
}, },
}); });
// 6. Emit SSE events // 6. Broadcast-level SSE event
for (const n of notifications) { emitBroadcastSent(broadcast.id, notificationIds.length);
emitNotificationCreated(n.userId, n.id);
if (isTask) {
emitTaskAssigned(n.userId, n.id);
}
}
emitBroadcastSent(broadcast.id, notifications.length);
// 7. Send emails if channel includes email (non-blocking) // 7. Send emails if channel includes email (non-blocking)
if (input.channel === "email" || input.channel === "both") { if (input.channel === "email" || input.channel === "both") {
for (const n of notifications) { for (const n of notificationIds) {
void sendNotificationEmail(ctx.db, n.userId, input.title, input.body); void sendNotificationEmail(ctx.db, n.userId, input.title, input.body);
} }
} }
return { ...broadcast, recipientCount: notifications.length, sentAt: new Date() }; return { ...broadcast, recipientCount: notificationIds.length, sentAt: new Date() };
}), }),
/** List broadcasts */ /** List broadcasts */
@@ -565,8 +565,8 @@ export const notificationRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const senderId = ctx.dbUser.id; const senderId = ctx.dbUser.id;
const n = await ctx.db.notification.create({ const notificationId = await createNotification({
data: { db: ctx.db,
userId: input.userId, userId: input.userId,
type: "TASK_CREATED", type: "TASK_CREATED",
category: "TASK", category: "TASK",
@@ -575,23 +575,23 @@ export const notificationRouter = createTRPCRouter({
priority: input.priority, priority: input.priority,
senderId, senderId,
channel: input.channel, channel: input.channel,
...(input.body !== undefined ? { body: input.body } : {}), body: input.body,
...(input.dueDate !== undefined ? { dueDate: input.dueDate } : {}), dueDate: input.dueDate,
...(input.taskAction !== undefined ? { taskAction: input.taskAction } : {}), taskAction: input.taskAction,
...(input.entityId !== undefined ? { entityId: input.entityId } : {}), entityId: input.entityId,
...(input.entityType !== undefined ? { entityType: input.entityType } : {}), entityType: input.entityType,
...(input.link !== undefined ? { link: input.link } : {}), link: input.link,
},
}); });
emitNotificationCreated(input.userId, n.id); emitTaskAssigned(input.userId, notificationId);
emitTaskAssigned(input.userId, n.id);
// Send email if channel includes email // Send email if channel includes email
if (input.channel === "email" || input.channel === "both") { if (input.channel === "email" || input.channel === "both") {
void sendNotificationEmail(ctx.db, input.userId, input.title, input.body); void sendNotificationEmail(ctx.db, input.userId, input.title, input.body);
} }
// Re-fetch for return value
const n = await ctx.db.notification.findUnique({ where: { id: notificationId } });
return n; return n;
}), }),
+7 -10
View File
@@ -4,7 +4,8 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js"; import { findUniqueOrThrow } from "../db/helpers.js";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { emitVacationCreated, emitVacationUpdated, emitNotificationCreated, emitTaskAssigned } from "../sse/event-bus.js"; import { emitVacationCreated, emitVacationUpdated, emitTaskAssigned } from "../sse/event-bus.js";
import { createNotification } from "../lib/create-notification.js";
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js"; import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
import { sendEmail } from "../lib/email.js"; import { sendEmail } from "../lib/email.js";
import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js"; import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js";
@@ -55,17 +56,15 @@ async function notifyVacationStatus(
: `Your vacation request has been ${statusLabel}.`; : `Your vacation request has been ${statusLabel}.`;
// In-app notification // In-app notification
const notification = await db.notification.create({ await createNotification({
data: { db,
userId: resource.user.id, userId: resource.user.id,
type: `VACATION_${newStatus}`, type: `VACATION_${newStatus}`,
title, title,
body, body,
entityId: vacationId, entityId: vacationId,
entityType: "vacation", entityType: "vacation",
},
}); });
emitNotificationCreated(resource.user.id, notification.id);
// Email (non-blocking) // Email (non-blocking)
if (resource.user.email) { if (resource.user.email) {
@@ -233,8 +232,8 @@ export const vacationRouter = createTRPCRouter({
for (const manager of managers) { for (const manager of managers) {
if (manager.id === userRecord.id) continue; if (manager.id === userRecord.id) continue;
const task = await ctx.db.notification.create({ const taskId = await createNotification({
data: { db: ctx.db,
userId: manager.id, userId: manager.id,
category: "APPROVAL", category: "APPROVAL",
type: "VACATION_APPROVAL", type: "VACATION_APPROVAL",
@@ -248,10 +247,8 @@ export const vacationRouter = createTRPCRouter({
link: "/vacations", link: "/vacations",
senderId: userRecord.id, senderId: userRecord.id,
channel: "in_app", channel: "in_app",
},
}); });
emitNotificationCreated(manager.id, task.id); emitTaskAssigned(manager.id, taskId);
emitTaskAssigned(manager.id, task.id);
} }
} }