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
+72 -72
View File
@@ -9,6 +9,7 @@ import {
emitTaskStatusChanged,
emitBroadcastSent,
} from "../sse/event-bus.js";
import { createNotification } from "../lib/create-notification.js";
import { resolveRecipients } from "../lib/notification-targeting.js";
import { sendEmail } from "../lib/email.js";
@@ -154,31 +155,28 @@ export const notificationRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const currentUserId = ctx.dbUser.id;
const n = await ctx.db.notification.create({
data: {
userId: input.userId,
type: input.type,
title: input.title,
...(input.body !== undefined ? { body: input.body } : {}),
...(input.entityId !== undefined ? { entityId: input.entityId } : {}),
...(input.entityType !== undefined ? { entityType: input.entityType } : {}),
...(input.category !== undefined ? { category: input.category } : {}),
...(input.priority !== undefined ? { priority: input.priority } : {}),
...(input.link !== undefined ? { link: input.link } : {}),
...(input.taskStatus !== undefined ? { taskStatus: input.taskStatus } : {}),
...(input.taskAction !== undefined ? { taskAction: input.taskAction } : {}),
...(input.assigneeId !== undefined ? { assigneeId: input.assigneeId } : {}),
...(input.dueDate !== undefined ? { dueDate: input.dueDate } : {}),
...(input.channel !== undefined ? { channel: input.channel } : {}),
senderId: input.senderId ?? currentUserId,
},
const notificationId = await createNotification({
db: ctx.db,
userId: input.userId,
type: input.type,
title: input.title,
body: input.body,
entityId: input.entityId,
entityType: input.entityType,
category: input.category,
priority: input.priority,
link: input.link,
taskStatus: input.taskStatus,
taskAction: input.taskAction,
assigneeId: input.assigneeId,
dueDate: input.dueDate,
channel: input.channel,
senderId: input.senderId ?? currentUserId,
});
emitNotificationCreated(input.userId, n.id);
// Emit task-specific events
if (input.category === "TASK" || input.category === "APPROVAL") {
emitTaskAssigned(input.userId, n.id);
emitTaskAssigned(input.userId, notificationId);
}
// Email if channel includes email
@@ -187,6 +185,8 @@ export const notificationRouter = createTRPCRouter({
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;
}),
@@ -332,6 +332,9 @@ export const notificationRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
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({
data: {
userId,
@@ -479,54 +482,51 @@ export const notificationRouter = createTRPCRouter({
// 4. Create individual notifications for each recipient
const isTask = input.category === "TASK" || input.category === "APPROVAL";
const notifications = await Promise.all(
recipientIds.map((recipientUserId) =>
ctx.db.notification.create({
data: {
userId: recipientUserId,
type: `BROADCAST_${input.category}`,
title: input.title,
...(input.body !== undefined ? { body: input.body } : {}),
...(input.link !== undefined ? { link: input.link } : {}),
category: input.category,
priority: input.priority,
channel: input.channel,
sourceId: broadcast.id,
senderId,
...(isTask ? { taskStatus: "OPEN" as const } : {}),
...(input.taskAction !== undefined ? { taskAction: input.taskAction } : {}),
...(input.dueDate !== undefined ? { dueDate: input.dueDate } : {}),
},
}),
),
);
// SSE emit handled by createNotification; task events need separate emit
const notificationIds: Array<{ id: string; userId: string }> = [];
for (const recipientUserId of recipientIds) {
const nId = await createNotification({
db: ctx.db,
userId: recipientUserId,
type: `BROADCAST_${input.category}`,
title: input.title,
body: input.body,
link: input.link,
category: input.category,
priority: input.priority,
channel: input.channel,
sourceId: broadcast.id,
senderId,
taskStatus: isTask ? "OPEN" : undefined,
taskAction: input.taskAction,
dueDate: input.dueDate,
});
notificationIds.push({ id: nId, userId: recipientUserId });
if (isTask) {
emitTaskAssigned(recipientUserId, nId);
}
}
// 5. Update broadcast with sent info
await ctx.db.notificationBroadcast.update({
where: { id: broadcast.id },
data: {
sentAt: new Date(),
recipientCount: notifications.length,
recipientCount: notificationIds.length,
},
});
// 6. Emit SSE events
for (const n of notifications) {
emitNotificationCreated(n.userId, n.id);
if (isTask) {
emitTaskAssigned(n.userId, n.id);
}
}
emitBroadcastSent(broadcast.id, notifications.length);
// 6. Broadcast-level SSE event
emitBroadcastSent(broadcast.id, notificationIds.length);
// 7. Send emails if channel includes email (non-blocking)
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);
}
}
return { ...broadcast, recipientCount: notifications.length, sentAt: new Date() };
return { ...broadcast, recipientCount: notificationIds.length, sentAt: new Date() };
}),
/** List broadcasts */
@@ -565,33 +565,33 @@ export const notificationRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const senderId = ctx.dbUser.id;
const n = await ctx.db.notification.create({
data: {
userId: input.userId,
type: "TASK_CREATED",
category: "TASK",
taskStatus: "OPEN",
title: input.title,
priority: input.priority,
senderId,
channel: input.channel,
...(input.body !== undefined ? { body: input.body } : {}),
...(input.dueDate !== undefined ? { dueDate: input.dueDate } : {}),
...(input.taskAction !== undefined ? { taskAction: input.taskAction } : {}),
...(input.entityId !== undefined ? { entityId: input.entityId } : {}),
...(input.entityType !== undefined ? { entityType: input.entityType } : {}),
...(input.link !== undefined ? { link: input.link } : {}),
},
const notificationId = await createNotification({
db: ctx.db,
userId: input.userId,
type: "TASK_CREATED",
category: "TASK",
taskStatus: "OPEN",
title: input.title,
priority: input.priority,
senderId,
channel: input.channel,
body: input.body,
dueDate: input.dueDate,
taskAction: input.taskAction,
entityId: input.entityId,
entityType: input.entityType,
link: input.link,
});
emitNotificationCreated(input.userId, n.id);
emitTaskAssigned(input.userId, n.id);
emitTaskAssigned(input.userId, notificationId);
// Send email if channel includes email
if (input.channel === "email" || input.channel === "both") {
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;
}),