diff --git a/apps/web/src/app/(app)/projects/[id]/page.tsx b/apps/web/src/app/(app)/projects/[id]/page.tsx index 4d663eb..38175f1 100644 --- a/apps/web/src/app/(app)/projects/[id]/page.tsx +++ b/apps/web/src/app/(app)/projects/[id]/page.tsx @@ -84,6 +84,16 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro
Win probability: {project.winProbability}%
+ + + + + What-If + diff --git a/apps/web/src/app/(app)/projects/[id]/scenario/page.tsx b/apps/web/src/app/(app)/projects/[id]/scenario/page.tsx new file mode 100644 index 0000000..2bc0aeb --- /dev/null +++ b/apps/web/src/app/(app)/projects/[id]/scenario/page.tsx @@ -0,0 +1,58 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { createCaller } from "~/server/trpc.js"; +import { ScenarioPlanner } from "~/components/projects/ScenarioPlanner.js"; + +interface ScenarioPageProps { + params: Promise<{ id: string }>; +} + +export default async function ScenarioPage({ params }: ScenarioPageProps) { + const { id } = await params; + const trpc = await createCaller(); + + let baseline: Awaited>; + try { + baseline = await trpc.scenario.getProjectBaseline({ projectId: id }); + } catch { + notFound(); + } + + // Load resources and roles for the pickers + const [resources, roles] = await Promise.all([ + trpc.resource.list({ isActive: true }), + trpc.role.list({ isActive: true }), + ]); + + return ( +
+ + + + + Back to {baseline.project.name} + + +
+

+ What-If Scenario Planner +

+

+ Explore alternate staffing configurations for{" "} + {baseline.project.name}{" "} + and see instant cost/schedule impact. +

+
+ + +
+ ); +} diff --git a/apps/web/src/app/(app)/reports/builder/page.tsx b/apps/web/src/app/(app)/reports/builder/page.tsx new file mode 100644 index 0000000..77ac0c7 --- /dev/null +++ b/apps/web/src/app/(app)/reports/builder/page.tsx @@ -0,0 +1,5 @@ +import { ReportBuilder } from "~/components/reports/ReportBuilder.js"; + +export default function ReportBuilderPage() { + return ; +} diff --git a/apps/web/src/components/comments/CommentInput.tsx b/apps/web/src/components/comments/CommentInput.tsx new file mode 100644 index 0000000..3b1135b --- /dev/null +++ b/apps/web/src/components/comments/CommentInput.tsx @@ -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(null); + const [mentionIndex, setMentionIndex] = useState(0); + const [cursorPosition, setCursorPosition] = useState(0); + const textareaRef = useRef(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) => { + 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) => { + // 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 ( +
+