From e1368c7ef75e4b3467b04ca28673aa53eec99de4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 19 Mar 2026 21:47:47 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=204=20=E2=80=94=20scenario=20pla?= =?UTF-8?q?nner,=20report=20builder,=20comments,=20dashboard=20widgets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/src/app/(app)/projects/[id]/page.tsx | 10 + .../app/(app)/projects/[id]/scenario/page.tsx | 58 ++ .../src/app/(app)/reports/builder/page.tsx | 5 + .../src/components/comments/CommentInput.tsx | 235 ++++++ .../src/components/comments/CommentThread.tsx | 323 ++++++++ .../components/dashboard/widget-registry.ts | 12 + .../widgets/BudgetForecastWidget.tsx | 93 +++ .../dashboard/widgets/ProjectHealthWidget.tsx | 101 +++ .../dashboard/widgets/SkillGapWidget.tsx | 94 +++ .../estimates/EstimateWorkspace.types.ts | 3 +- .../estimates/EstimateWorkspaceClient.tsx | 25 + apps/web/src/components/layout/AppShell.tsx | 4 + .../components/projects/ScenarioPlanner.tsx | 772 ++++++++++++++++++ .../src/components/reports/ReportBuilder.tsx | 564 +++++++++++++ packages/api/src/router/comment.ts | 233 ++++++ packages/api/src/router/dashboard.ts | 33 + packages/api/src/router/index.ts | 6 + packages/api/src/router/report.ts | 387 +++++++++ packages/api/src/router/scenario.ts | 553 +++++++++++++ packages/application/src/index.ts | 6 + .../dashboard/get-budget-forecast.ts | 99 +++ .../use-cases/dashboard/get-project-health.ts | 104 +++ .../src/use-cases/dashboard/get-skill-gaps.ts | 89 ++ .../src/use-cases/dashboard/index.ts | 15 + packages/db/prisma/schema.prisma | 24 + .../shared/src/schemas/dashboard.schema.ts | 3 + packages/shared/src/types/dashboard.ts | 39 + 27 files changed, 3889 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/(app)/projects/[id]/scenario/page.tsx create mode 100644 apps/web/src/app/(app)/reports/builder/page.tsx create mode 100644 apps/web/src/components/comments/CommentInput.tsx create mode 100644 apps/web/src/components/comments/CommentThread.tsx create mode 100644 apps/web/src/components/dashboard/widgets/BudgetForecastWidget.tsx create mode 100644 apps/web/src/components/dashboard/widgets/ProjectHealthWidget.tsx create mode 100644 apps/web/src/components/dashboard/widgets/SkillGapWidget.tsx create mode 100644 apps/web/src/components/projects/ScenarioPlanner.tsx create mode 100644 apps/web/src/components/reports/ReportBuilder.tsx create mode 100644 packages/api/src/router/comment.ts create mode 100644 packages/api/src/router/report.ts create mode 100644 packages/api/src/router/scenario.ts create mode 100644 packages/application/src/use-cases/dashboard/get-budget-forecast.ts create mode 100644 packages/application/src/use-cases/dashboard/get-project-health.ts create mode 100644 packages/application/src/use-cases/dashboard/get-skill-gaps.ts 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 ( +
+