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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { CommentInput } from "./CommentInput.js";
|
||||
|
||||
interface CommentAuthor {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
interface CommentReply {
|
||||
id: string;
|
||||
body: string;
|
||||
resolved: boolean;
|
||||
createdAt: Date | string;
|
||||
author: CommentAuthor;
|
||||
}
|
||||
|
||||
interface CommentItem {
|
||||
id: string;
|
||||
body: string;
|
||||
resolved: boolean;
|
||||
createdAt: Date | string;
|
||||
author: CommentAuthor;
|
||||
replies: CommentReply[];
|
||||
}
|
||||
|
||||
interface CommentThreadProps {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
function formatRelativeTime(date: Date | string): string {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
||||
|
||||
if (diffMinutes < 1) return "just now";
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function AuthorAvatar({ author }: { author: CommentAuthor }) {
|
||||
const initials = author.name
|
||||
? author.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase()
|
||||
: author.email.charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<span className="flex h-8 w-8 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">
|
||||
{initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render comment body with @mention highlights.
|
||||
* Transforms @[Name](userId) into styled spans.
|
||||
*/
|
||||
function CommentBody({ body }: { body: string }) {
|
||||
const parts: Array<{ type: "text" | "mention"; value: string }> = [];
|
||||
const regex = /@\[([^\]]+)\]\([^)]+\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(body)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: "text", value: body.slice(lastIndex, match.index) });
|
||||
}
|
||||
parts.push({ type: "mention", value: `@${match[1]}` });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < body.length) {
|
||||
parts.push({ type: "text", value: body.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words">
|
||||
{parts.map((part, i) =>
|
||||
part.type === "mention" ? (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded bg-brand-50 dark:bg-sky-900/50 px-1 py-0.5 text-brand-700 dark:text-sky-300 font-medium text-xs"
|
||||
>
|
||||
{part.value}
|
||||
</span>
|
||||
) : (
|
||||
<span key={i}>{part.value}</span>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleComment({
|
||||
comment,
|
||||
entityType,
|
||||
entityId,
|
||||
isReply = false,
|
||||
}: {
|
||||
comment: CommentItem | CommentReply;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
isReply?: boolean;
|
||||
}) {
|
||||
const [showReplyInput, setShowReplyInput] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const createMutation = trpc.comment.create.useMutation({
|
||||
onSuccess: () => {
|
||||
setShowReplyInput(false);
|
||||
void utils.comment.list.invalidate({ entityType, entityId });
|
||||
void utils.comment.count.invalidate({ entityType, entityId });
|
||||
},
|
||||
});
|
||||
|
||||
const resolveMutation = trpc.comment.resolve.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.comment.list.invalidate({ entityType, entityId });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.comment.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.comment.list.invalidate({ entityType, entityId });
|
||||
void utils.comment.count.invalidate({ entityType, entityId });
|
||||
},
|
||||
});
|
||||
|
||||
const isResolved = comment.resolved;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"group relative",
|
||||
isResolved && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className={clsx("flex gap-3", isReply && "ml-10")}>
|
||||
<AuthorAvatar author={comment.author} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{comment.author.name ?? comment.author.email}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{formatRelativeTime(comment.createdAt)}
|
||||
</span>
|
||||
{isResolved && (
|
||||
<span className="rounded-full bg-emerald-100 dark:bg-emerald-900/50 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
Resolved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={clsx(isResolved && "line-through decoration-gray-300 dark:decoration-gray-600")}>
|
||||
<CommentBody body={comment.body} />
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-1 flex items-center gap-3 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!isReply && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReplyInput((prev) => !prev)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
{!isReply && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
resolveMutation.mutate({
|
||||
id: comment.id,
|
||||
resolved: !isResolved,
|
||||
})
|
||||
}
|
||||
disabled={resolveMutation.isPending}
|
||||
className="text-xs text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400"
|
||||
>
|
||||
{isResolved ? "Unresolve" : "Resolve"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm("Delete this comment?")) {
|
||||
deleteMutation.mutate({ id: comment.id });
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-xs text-gray-400 hover:text-rose-600 dark:hover:text-rose-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline reply input */}
|
||||
{showReplyInput && (
|
||||
<div className="mt-3">
|
||||
<CommentInput
|
||||
entityType={entityType}
|
||||
entityId={entityId}
|
||||
parentId={comment.id}
|
||||
onSubmit={(replyBody) => {
|
||||
createMutation.mutate({
|
||||
entityType,
|
||||
entityId,
|
||||
parentId: comment.id,
|
||||
body: replyBody,
|
||||
});
|
||||
}}
|
||||
onCancel={() => setShowReplyInput(false)}
|
||||
isSubmitting={createMutation.isPending}
|
||||
placeholder="Write a reply..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Render replies */}
|
||||
{"replies" in comment && comment.replies.length > 0 && (
|
||||
<div className="mt-3 space-y-3 border-l-2 border-gray-100 dark:border-gray-700 pl-2">
|
||||
{comment.replies.map((reply) => (
|
||||
<SingleComment
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
entityType={entityType}
|
||||
entityId={entityId}
|
||||
isReply
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommentThread({ entityType, entityId }: CommentThreadProps) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const commentsQuery = trpc.comment.list.useQuery(
|
||||
{ entityType, entityId },
|
||||
{ staleTime: 10_000 },
|
||||
);
|
||||
|
||||
const createMutation = trpc.comment.create.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.comment.list.invalidate({ entityType, entityId });
|
||||
void utils.comment.count.invalidate({ entityType, entityId });
|
||||
},
|
||||
});
|
||||
|
||||
const comments = (commentsQuery.data ?? []) as CommentItem[];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Comment list */}
|
||||
{commentsQuery.isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<div className="h-8 w-8 shimmer-skeleton rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-32 shimmer-skeleton rounded" />
|
||||
<div className="h-12 shimmer-skeleton rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-400 py-6">
|
||||
No comments yet. Start the conversation below.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{comments.map((comment) => (
|
||||
<SingleComment
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
entityType={entityType}
|
||||
entityId={entityId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New comment input */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<CommentInput
|
||||
entityType={entityType}
|
||||
entityId={entityId}
|
||||
onSubmit={(body) => {
|
||||
createMutation.mutate({
|
||||
entityType,
|
||||
entityId,
|
||||
body,
|
||||
});
|
||||
}}
|
||||
isSubmitting={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user