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";
|
"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>
|
||||||
@@ -363,8 +366,22 @@ export function CalculationRulesClient() {
|
|||||||
{createMut.isPending || updateMut.isPending ? "Saving..." : "Save"}
|
{createMut.isPending || updateMut.isPending ? "Saving..." : "Save"}
|
||||||
</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"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
@@ -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 (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 };
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { data } = trpc.project.list.useQuery(
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
{ search: search || undefined, limit: 15, ...(status ? { status } : {}) },
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
{ enabled, staleTime: 30_000 },
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
);
|
||||||
|
return { data: (data?.projects ?? []) as ProjectItem[] };
|
||||||
|
};
|
||||||
|
|
||||||
const { data } = trpc.project.list.useQuery(
|
const useSelectedQuery = (_id: string | null, enabled: boolean) => {
|
||||||
{ search: debouncedSearch || undefined, limit: 15, ...(status ? { status } : {}) },
|
const { data } = trpc.project.list.useQuery(
|
||||||
{ enabled: open, staleTime: 30_000 },
|
{ 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 renderItem = useCallback(
|
||||||
|
(p: ProjectItem) => (
|
||||||
const { data: allData } = trpc.project.list.useQuery(
|
<>
|
||||||
{ limit: 500 },
|
<span className="font-medium text-xs text-gray-400 dark:text-gray-500 mr-1.5">{p.shortCode}</span>
|
||||||
{ enabled: !!value && !open, staleTime: 60_000 },
|
<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 (
|
return (
|
||||||
<div className={`relative ${className}`} ref={containerRef}>
|
<EntityCombobox<ProjectItem>
|
||||||
<div className="relative">
|
{...props}
|
||||||
<input
|
placeholder={props.placeholder ?? "Search project\u2026"}
|
||||||
ref={inputRef}
|
useSearchQuery={useSearchQuery}
|
||||||
type="text"
|
useSelectedQuery={useSelectedQuery}
|
||||||
value={open ? search : selectedLabel}
|
getLabel={getLabel}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
renderItem={renderItem}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { data } = trpc.resource.list.useQuery(
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
{ search: search || undefined, limit: 15, isActive },
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
{ enabled, staleTime: 30_000 },
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
);
|
||||||
|
return { data: (data?.resources ?? []) as ResourceItem[] };
|
||||||
|
};
|
||||||
|
|
||||||
const { data } = trpc.resource.list.useQuery(
|
const useSelectedQuery = (_id: string | null, enabled: boolean) => {
|
||||||
{ search: debouncedSearch || undefined, limit: 15, isActive },
|
const { data } = trpc.resource.list.useQuery(
|
||||||
{ enabled: open, staleTime: 30_000 },
|
{ 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 renderItem = useCallback(
|
||||||
|
(r: ResourceItem) => (
|
||||||
const selectedQuery = trpc.resource.list.useQuery(
|
<>
|
||||||
{ limit: 500 },
|
<span>{r.displayName}</span>
|
||||||
{ enabled: !!value && !open, staleTime: 60_000 },
|
<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 (
|
return (
|
||||||
<div className={`relative ${className}`} ref={containerRef}>
|
<EntityCombobox<ResourceItem>
|
||||||
<div className="relative">
|
{...props}
|
||||||
<input
|
placeholder={props.placeholder ?? "Search resource\u2026"}
|
||||||
ref={inputRef}
|
useSearchQuery={useSearchQuery}
|
||||||
type="text"
|
useSelectedQuery={useSelectedQuery}
|
||||||
value={open ? search : selectedLabel}
|
getLabel={getLabel}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
renderItem={renderItem}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,24 +227,19 @@ 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",
|
title,
|
||||||
title,
|
body,
|
||||||
body,
|
entityId: demandRequirementId,
|
||||||
entityId: demandRequirementId,
|
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.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,23 +119,18 @@ 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,
|
title: `Budget alert: ${project.name} has reached ${threshold.label} of budget`,
|
||||||
title: `Budget alert: ${project.name} has reached ${threshold.label} of budget`,
|
body: `Project ${project.shortCode} "${project.name}" has spent ${formattedSpend} EUR of ${formattedBudget} EUR budget (${Math.round(spendPercent)}%).`,
|
||||||
body: `Project ${project.shortCode} "${project.name}" has spent ${formattedSpend} EUR of ${formattedBudget} EUR budget (${Math.round(spendPercent)}%).`,
|
entityId: projectId,
|
||||||
entityId: projectId,
|
entityType: "project_budget",
|
||||||
entityType: "project_budget",
|
link: `/projects/${projectId}`,
|
||||||
link: `/projects/${projectId}`,
|
channel: "in_app",
|
||||||
channel: "in_app",
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
emitNotificationCreated(manager.id, notification.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,24 +237,19 @@ 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",
|
title: `Low chargeability: ${resource.displayName}`,
|
||||||
title: `Low chargeability: ${resource.displayName}`,
|
body: `${resource.displayName} is at ${chg}% chargeability this month (target: ${target}%, gap: ${gap}pp).`,
|
||||||
body: `${resource.displayName} is at ${chg}% chargeability this month (target: ${target}%, gap: ${gap}pp).`,
|
entityId,
|
||||||
entityId,
|
entityType: "chargeability_alert",
|
||||||
entityType: "chargeability_alert",
|
link: "/chargeability",
|
||||||
link: "/chargeability",
|
channel: "in_app",
|
||||||
channel: "in_app",
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
emitNotificationCreated(manager.id, notification.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
alertCount++;
|
alertCount++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { emitNotificationCreated } from "../sse/event-bus.js";
|
import { createNotificationsForUsers } from "./create-notification.js";
|
||||||
|
|
||||||
type DbClient = {
|
type DbClient = {
|
||||||
estimate: {
|
estimate: {
|
||||||
@@ -138,24 +138,19 @@ 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",
|
title: `Estimate awaiting approval: ${estimate.name} (v${version.versionNumber})`,
|
||||||
title: `Estimate awaiting approval: ${estimate.name} (v${version.versionNumber})`,
|
body: `Estimate "${estimate.name}" version ${version.versionNumber} has been pending approval for ${daysPending} days.`,
|
||||||
body: `Estimate "${estimate.name}" version ${version.versionNumber} has been pending approval for ${daysPending} days.`,
|
entityId: version.id,
|
||||||
entityId: version.id,
|
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++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,21 +189,19 @@ 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",
|
||||||
priority: "HIGH",
|
priority: "HIGH",
|
||||||
title: `Vacation conflict warning: ${vacation.resource.displayName}`,
|
title: `Vacation conflict warning: ${vacation.resource.displayName}`,
|
||||||
body: warning,
|
body: warning,
|
||||||
entityId: vacationId,
|
entityId: vacationId,
|
||||||
entityType: "vacation",
|
entityType: "vacation",
|
||||||
link: "/vacations",
|
link: "/vacations",
|
||||||
channel: "in_app",
|
channel: "in_app",
|
||||||
},
|
|
||||||
});
|
});
|
||||||
emitNotificationCreated(approverUserId, notification.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,22 +138,20 @@ 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`,
|
||||||
body: truncatedBody,
|
body: truncatedBody,
|
||||||
entityId: input.entityId,
|
entityId: input.entityId,
|
||||||
entityType: input.entityType,
|
entityType: input.entityType,
|
||||||
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);
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
userId: recipientUserId,
|
db: ctx.db,
|
||||||
type: `BROADCAST_${input.category}`,
|
userId: recipientUserId,
|
||||||
title: input.title,
|
type: `BROADCAST_${input.category}`,
|
||||||
...(input.body !== undefined ? { body: input.body } : {}),
|
title: input.title,
|
||||||
...(input.link !== undefined ? { link: input.link } : {}),
|
body: input.body,
|
||||||
category: input.category,
|
link: input.link,
|
||||||
priority: input.priority,
|
category: input.category,
|
||||||
channel: input.channel,
|
priority: input.priority,
|
||||||
sourceId: broadcast.id,
|
channel: input.channel,
|
||||||
senderId,
|
sourceId: broadcast.id,
|
||||||
...(isTask ? { taskStatus: "OPEN" as const } : {}),
|
senderId,
|
||||||
...(input.taskAction !== undefined ? { taskAction: input.taskAction } : {}),
|
taskStatus: isTask ? "OPEN" : undefined,
|
||||||
...(input.dueDate !== undefined ? { dueDate: input.dueDate } : {}),
|
taskAction: input.taskAction,
|
||||||
},
|
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,33 +565,33 @@ 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",
|
||||||
taskStatus: "OPEN",
|
taskStatus: "OPEN",
|
||||||
title: input.title,
|
title: input.title,
|
||||||
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;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -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,25 +232,23 @@ 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",
|
||||||
priority: "NORMAL",
|
priority: "NORMAL",
|
||||||
title: `Vacation approval: ${resourceName}`,
|
title: `Vacation approval: ${resourceName}`,
|
||||||
body: `${resourceName} requests ${input.type} from ${startStr} to ${endStr}`,
|
body: `${resourceName} requests ${input.type} from ${startStr} to ${endStr}`,
|
||||||
taskStatus: "OPEN",
|
taskStatus: "OPEN",
|
||||||
taskAction: buildTaskAction("approve_vacation", vacation.id),
|
taskAction: buildTaskAction("approve_vacation", vacation.id),
|
||||||
entityId: vacation.id,
|
entityId: vacation.id,
|
||||||
entityType: "vacation",
|
entityType: "vacation",
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user