feat: Sprint 4 — scenario planner, report builder, comments, dashboard widgets

What-If Scenario Planner (G5):
- New /projects/[id]/scenario page with side-by-side baseline vs scenario
- simulate mutation: pure cost/hours/headcount/utilization computation
- apply mutation: creates real PROPOSED assignments from scenario
- Impact cards: cost delta, hours delta, headcount, skill coverage %
- Per-resource utilization impact table with over-allocation warnings
- "What-If" button added to project detail page

Custom Report Builder (G7):
- New /reports/builder page with full config panel
- Entity selector (resource/project/assignment), column picker, filter builder
- Dynamic Prisma query with eq/neq/gt/lt/contains/in operators
- Sortable results table with pagination (50/page)
- CSV export via exportReport mutation
- Sidebar nav link under Analytics

Collaboration Layer (G8):
- Comment model in Prisma (entityType/entityId, replies, @mentions, resolved)
- comment router: list, count, create, resolve, delete
- @mention parsing with notification creation + SSE delivery
- CommentInput with @mention autocomplete (arrow nav, Enter/Tab confirm)
- CommentThread with avatar, timestamp, reply, resolve, delete
- Integrated as "Comments" tab in estimate workspace with count badge

Dashboard Widgets:
- BudgetForecastWidget: progress bars per project, burn rate, exhaustion date
- SkillGapWidget: supply vs demand per skill, shortage/surplus indicators
- ProjectHealthWidget: 3-dimension health circles + composite score
- 3 new application use-cases + dashboard router queries
- All registered in widget-registry with lazy imports

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-19 21:47:47 +01:00
parent 6f34659587
commit e1368c7ef7
27 changed files with 3889 additions and 1 deletions
@@ -0,0 +1,235 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
interface CommentInputProps {
entityType: string;
entityId: string;
parentId?: string;
onSubmit: (body: string) => void;
onCancel?: () => void;
isSubmitting?: boolean;
placeholder?: string;
autoFocus?: boolean;
}
interface MentionCandidate {
id: string;
name: string | null;
email: string;
}
export function CommentInput({
entityType: _entityType,
entityId: _entityId,
parentId: _parentId,
onSubmit,
onCancel,
isSubmitting = false,
placeholder = "Write a comment... Use @ to mention someone",
autoFocus = false,
}: CommentInputProps) {
const [body, setBody] = useState("");
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const [cursorPosition, setCursorPosition] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Fetch users for mention autocomplete (only when needed)
const usersQuery = trpc.user.listAssignable.useQuery(undefined, {
enabled: mentionQuery !== null,
staleTime: 60_000,
});
const users = usersQuery.data ?? [];
// Filter users based on mention query
const filteredUsers: MentionCandidate[] =
mentionQuery !== null
? users.filter((u) => {
const q = mentionQuery.toLowerCase();
return (
(u.name?.toLowerCase().includes(q) ?? false) ||
u.email.toLowerCase().includes(q)
);
}).slice(0, 8)
: [];
// Reset mention index when filtered list changes
useEffect(() => {
setMentionIndex(0);
}, [mentionQuery]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const cursor = e.target.selectionStart ?? value.length;
setBody(value);
setCursorPosition(cursor);
// Detect if we are in a @mention context
const textBeforeCursor = value.slice(0, cursor);
const atMatch = textBeforeCursor.match(/@([^\s@]*)$/);
if (atMatch) {
setMentionQuery(atMatch[1]!);
} else {
setMentionQuery(null);
}
},
[],
);
const insertMention = useCallback(
(user: MentionCandidate) => {
const textBeforeCursor = body.slice(0, cursorPosition);
const textAfterCursor = body.slice(cursorPosition);
// Find the @ that triggered this mention
const atMatch = textBeforeCursor.match(/@([^\s@]*)$/);
if (!atMatch) return;
const atStart = textBeforeCursor.length - atMatch[0].length;
const displayName = user.name ?? user.email;
const mentionText = `@[${displayName}](${user.id}) `;
const newBody =
textBeforeCursor.slice(0, atStart) + mentionText + textAfterCursor;
setBody(newBody);
setMentionQuery(null);
// Focus and set cursor position
const newCursorPos = atStart + mentionText.length;
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (ta) {
ta.focus();
ta.selectionStart = newCursorPos;
ta.selectionEnd = newCursorPos;
}
});
},
[body, cursorPosition],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Handle mention dropdown navigation
if (mentionQuery !== null && filteredUsers.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setMentionIndex((prev) =>
prev < filteredUsers.length - 1 ? prev + 1 : 0,
);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setMentionIndex((prev) =>
prev > 0 ? prev - 1 : filteredUsers.length - 1,
);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
insertMention(filteredUsers[mentionIndex]!);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setMentionQuery(null);
return;
}
}
// Submit on Ctrl+Enter / Cmd+Enter
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (body.trim().length > 0 && !isSubmitting) {
onSubmit(body.trim());
setBody("");
}
}
},
[mentionQuery, filteredUsers, mentionIndex, insertMention, body, isSubmitting, onSubmit],
);
function handleSubmitClick() {
if (body.trim().length > 0 && !isSubmitting) {
onSubmit(body.trim());
setBody("");
}
}
return (
<div className="relative">
<textarea
ref={textareaRef}
value={body}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
autoFocus={autoFocus}
disabled={isSubmitting}
rows={3}
className="w-full rounded-xl border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 px-4 py-3 text-sm text-gray-900 dark:text-gray-100 placeholder:text-gray-400 focus:border-brand-400 dark:focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-brand-100 dark:focus:ring-sky-900 disabled:opacity-60 resize-y"
/>
{/* Mention autocomplete dropdown */}
{mentionQuery !== null && filteredUsers.length > 0 && (
<div className="absolute left-0 bottom-full mb-1 z-50 w-72 rounded-xl border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 shadow-lg overflow-hidden">
{filteredUsers.map((user, idx) => (
<button
key={user.id}
type="button"
onMouseDown={(e) => {
e.preventDefault(); // prevent textarea blur
insertMention(user);
}}
className={`flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors ${
idx === mentionIndex
? "bg-brand-50 dark:bg-sky-900/40 text-brand-700 dark:text-sky-200"
: "text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-100 dark:bg-sky-800 text-xs font-semibold text-brand-700 dark:text-sky-200">
{(user.name ?? user.email).charAt(0).toUpperCase()}
</span>
<span className="truncate">
<span className="font-medium">{user.name ?? "—"}</span>
<span className="ml-1 text-gray-400 text-xs">{user.email}</span>
</span>
</button>
))}
</div>
)}
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-gray-400">
Ctrl+Enter to submit
</span>
<div className="flex gap-2">
{onCancel && (
<button
type="button"
onClick={onCancel}
disabled={isSubmitting}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 transition-colors hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-60"
>
Cancel
</button>
)}
<button
type="button"
onClick={handleSubmitClick}
disabled={body.trim().length === 0 || isSubmitting}
className="rounded-lg bg-brand-600 dark:bg-sky-600 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-brand-700 dark:hover:bg-sky-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{isSubmitting ? "Sending..." : "Comment"}
</button>
</div>
</div>
</div>
);
}