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
@@ -84,6 +84,16 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro
</div> </div>
<div className="mt-0.5 flex items-center">Win probability: {project.winProbability}%<InfoTooltip content="Likelihood of winning this project (0-100%). Used to calculate weighted pipeline value (budget x probability)." /></div> <div className="mt-0.5 flex items-center">Win probability: {project.winProbability}%<InfoTooltip content="Likelihood of winning this project (0-100%). Used to calculate weighted pipeline value (budget x probability)." /></div>
</div> </div>
<Link
href={`/projects/${id}/scenario`}
className="inline-flex items-center gap-2 rounded-lg border border-indigo-300 bg-white px-3 py-2 text-sm font-medium text-indigo-700 shadow-sm hover:bg-indigo-50 transition dark:border-indigo-600 dark:bg-gray-800 dark:text-indigo-300 dark:hover:bg-indigo-900/20"
title="Open What-If Scenario Planner"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
What-If
</Link>
<ProjectDetailActions project={project as never} /> <ProjectDetailActions project={project as never} />
</div> </div>
</div> </div>
@@ -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<ReturnType<typeof trpc.scenario.getProjectBaseline>>;
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 (
<div className="p-6 max-w-7xl mx-auto space-y-6">
<Link
href={`/projects/${id}`}
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-800 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to {baseline.project.name}
</Link>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
What-If Scenario Planner
</h1>
<p className="mt-1 text-sm text-gray-500">
Explore alternate staffing configurations for{" "}
<span className="font-medium text-gray-700 dark:text-gray-300">{baseline.project.name}</span>{" "}
and see instant cost/schedule impact.
</p>
</div>
<ScenarioPlanner
projectId={id}
baseline={baseline}
resources={resources as never}
roles={roles as never}
/>
</div>
);
}
@@ -0,0 +1,5 @@
import { ReportBuilder } from "~/components/reports/ReportBuilder.js";
export default function ReportBuilderPage() {
return <ReportBuilder />;
}
@@ -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>
);
}
@@ -53,6 +53,18 @@ export const WIDGET_REGISTRY: Record<DashboardWidgetType, WidgetDefinition> = {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "my-projects")!, ...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "my-projects")!,
component: lazy(() => import("./widgets/MyProjectsWidget.js").then((m) => ({ default: m.MyProjectsWidget }))), component: lazy(() => import("./widgets/MyProjectsWidget.js").then((m) => ({ default: m.MyProjectsWidget }))),
}, },
"budget-forecast": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "budget-forecast")!,
component: lazy(() => import("./widgets/BudgetForecastWidget.js").then((m) => ({ default: m.BudgetForecastWidget }))),
},
"skill-gap": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "skill-gap")!,
component: lazy(() => import("./widgets/SkillGapWidget.js").then((m) => ({ default: m.SkillGapWidget }))),
},
"project-health": {
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "project-health")!,
component: lazy(() => import("./widgets/ProjectHealthWidget.js").then((m) => ({ default: m.ProjectHealthWidget }))),
},
}; };
export function getWidget(type: DashboardWidgetType): WidgetDefinition { export function getWidget(type: DashboardWidgetType): WidgetDefinition {
@@ -0,0 +1,93 @@
"use client";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
function colorClass(pct: number): string {
if (pct > 90) return "bg-red-500";
if (pct > 70) return "bg-amber-400";
return "bg-green-500";
}
function textColorClass(pct: number): string {
if (pct > 90) return "text-red-700";
if (pct > 70) return "text-amber-700";
return "text-green-700";
}
export function BudgetForecastWidget(_props: WidgetProps) {
const { data, isLoading } = trpc.dashboard.getBudgetForecast.useQuery(
undefined,
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
if (isLoading && !data) {
return (
<div className="flex flex-col gap-1 pt-1">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2">
<div className="h-3 w-16 shimmer-skeleton rounded" />
<div className="h-3 flex-1 shimmer-skeleton rounded" />
<div className="h-3 w-12 shimmer-skeleton rounded" />
</div>
))}
</div>
);
}
const rows = data ?? [];
if (rows.length === 0) {
return (
<div className="flex items-center justify-center h-full text-sm text-gray-400">
No active projects with budgets.
</div>
);
}
return (
<div className="overflow-auto h-full">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">Project</th>
<th className="px-3 py-2 text-left font-medium text-gray-500">Budget Usage</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">Burn/mo</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">Exhaustion</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row) => (
<tr key={row.shortCode} className="hover:bg-gray-50">
<td className="px-3 py-2 font-medium text-gray-900 max-w-[140px] truncate">
<span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>
{row.projectName}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${colorClass(row.pctUsed)}`}
style={{ width: `${Math.min(row.pctUsed, 100)}%` }}
/>
</div>
<span className={`text-[11px] font-semibold tabular-nums w-10 text-right ${textColorClass(row.pctUsed)}`}>
{row.pctUsed}%
</span>
</div>
</td>
<td className="px-3 py-2 text-right text-gray-700 tabular-nums">
{row.burnRate > 0
? `${(row.burnRate / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} \u20AC`
: "\u2014"}
</td>
<td className="px-3 py-2 text-right text-gray-500 tabular-nums">
{row.estimatedExhaustionDate ?? "\u2014"}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,101 @@
"use client";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
function healthDot(value: number): string {
if (value >= 70) return "bg-green-500";
if (value >= 40) return "bg-amber-400";
return "bg-red-500";
}
function scoreBadge(score: number): string {
if (score >= 70) return "bg-green-100 text-green-700";
if (score >= 40) return "bg-amber-100 text-amber-700";
return "bg-red-100 text-red-700";
}
export function ProjectHealthWidget(_props: WidgetProps) {
const { data, isLoading } = trpc.dashboard.getProjectHealth.useQuery(
undefined,
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
if (isLoading && !data) {
return (
<div className="flex flex-col gap-1 pt-1">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2">
<div className="h-3 w-16 shimmer-skeleton rounded" />
<div className="h-3 flex-1 shimmer-skeleton rounded" />
<div className="flex gap-1">
<div className="h-4 w-4 shimmer-skeleton rounded-full" />
<div className="h-4 w-4 shimmer-skeleton rounded-full" />
<div className="h-4 w-4 shimmer-skeleton rounded-full" />
</div>
<div className="h-5 w-10 shimmer-skeleton rounded-full" />
</div>
))}
</div>
);
}
const rows = data ?? [];
if (rows.length === 0) {
return (
<div className="flex items-center justify-center h-full text-sm text-gray-400">
No active projects found.
</div>
);
}
return (
<div className="overflow-auto h-full">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">Project</th>
<th className="px-3 py-2 text-center font-medium text-gray-500" title="Budget / Staffing / Timeline">
B / S / T
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">Score</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row) => (
<tr key={row.shortCode} className="hover:bg-gray-50">
<td className="px-3 py-2 font-medium text-gray-900 max-w-[160px] truncate">
<span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>
{row.projectName}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-center gap-2">
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`}
title={`Budget: ${row.budgetHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.staffingHealth)}`}
title={`Staffing: ${row.staffingHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.timelineHealth)}`}
title={`Timeline: ${row.timelineHealth}%`}
/>
</div>
</td>
<td className="px-3 py-2 text-right">
<span
className={`inline-block px-2 py-0.5 rounded-full font-semibold tabular-nums ${scoreBadge(row.compositeScore)}`}
>
{row.compositeScore}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,94 @@
"use client";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
export function SkillGapWidget(_props: WidgetProps) {
const { data, isLoading } = trpc.dashboard.getSkillGaps.useQuery(
undefined,
{ staleTime: 60_000, placeholderData: (prev) => prev },
);
if (isLoading && !data) {
return (
<div className="flex flex-col gap-1 pt-1">
{[...Array(6)].map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2">
<div className="h-3 flex-1 shimmer-skeleton rounded" />
<div className="h-3 w-10 shimmer-skeleton rounded" />
<div className="h-3 w-10 shimmer-skeleton rounded" />
<div className="h-3 w-6 shimmer-skeleton rounded-full" />
</div>
))}
</div>
);
}
const rows = data ?? [];
if (rows.length === 0) {
return (
<div className="flex items-center justify-center h-full text-sm text-gray-400">
No skill gaps detected.
</div>
);
}
return (
<div className="overflow-auto h-full">
<table className="w-full text-xs">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500">Skill</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">Demand</th>
<th className="px-3 py-2 text-right font-medium text-gray-500">Supply</th>
<th className="px-3 py-2 text-center font-medium text-gray-500">Gap</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row) => {
const isShortage = row.gap < 0;
const isSurplus = row.gap > 0;
return (
<tr key={row.skill} className="hover:bg-gray-50">
<td className="px-3 py-2 font-medium text-gray-900 max-w-[180px] truncate">
{row.skill}
</td>
<td className="px-3 py-2 text-right text-gray-700 tabular-nums">
{row.demand}
</td>
<td className="px-3 py-2 text-right text-gray-700 tabular-nums">
{row.supply}
</td>
<td className="px-3 py-2 text-center">
<span className="inline-flex items-center gap-1.5">
<span
className={`inline-block w-2 h-2 rounded-full ${
isShortage
? "bg-red-500"
: isSurplus
? "bg-green-500"
: "bg-gray-400"
}`}
/>
<span
className={`font-semibold tabular-nums ${
isShortage
? "text-red-700"
: isSurplus
? "text-green-700"
: "text-gray-500"
}`}
>
{row.gap > 0 ? `+${row.gap}` : row.gap}
</span>
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
@@ -128,4 +128,5 @@ export type WorkspaceTab =
| "financials" | "financials"
| "phasing" | "phasing"
| "versions" | "versions"
| "exports"; | "exports"
| "comments";
@@ -66,6 +66,11 @@ const ExportsTab = dynamic(
{ loading: TabSkeleton }, { loading: TabSkeleton },
); );
const CommentThread = dynamic(
() => import("~/components/comments/CommentThread.js").then((mod) => ({ default: mod.CommentThread })),
{ loading: TabSkeleton },
);
const TABS: Array<{ id: WorkspaceTab; label: string }> = [ const TABS: Array<{ id: WorkspaceTab; label: string }> = [
{ id: "overview", label: "Overview" }, { id: "overview", label: "Overview" },
{ id: "assumptions", label: "Assumptions" }, { id: "assumptions", label: "Assumptions" },
@@ -75,6 +80,7 @@ const TABS: Array<{ id: WorkspaceTab; label: string }> = [
{ id: "phasing", label: "Phasing" }, { id: "phasing", label: "Phasing" },
{ id: "versions", label: "Versions" }, { id: "versions", label: "Versions" },
{ id: "exports", label: "Exports" }, { id: "exports", label: "Exports" },
{ id: "comments", label: "Comments" },
]; ];
function EmptyState({ children }: { children: React.ReactNode }) { function EmptyState({ children }: { children: React.ReactNode }) {
@@ -127,6 +133,12 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
const createExportMutation = trpc.estimate.createExport.useMutation(); const createExportMutation = trpc.estimate.createExport.useMutation();
const createPlanningHandoffMutation = trpc.estimate.createPlanningHandoff.useMutation(); const createPlanningHandoffMutation = trpc.estimate.createPlanningHandoff.useMutation();
const commentCountQuery = trpc.comment.count.useQuery(
{ entityType: "estimate", entityId: estimateId },
{ staleTime: 30_000 },
);
const commentCount = commentCountQuery.data ?? 0;
const estimate = (detailQuery.data as EstimateWorkspaceView | undefined) ?? null; const estimate = (detailQuery.data as EstimateWorkspaceView | undefined) ?? null;
const hasWorkingVersion = estimate?.versions.some((version) => version.status === "WORKING") ?? false; const hasWorkingVersion = estimate?.versions.some((version) => version.status === "WORKING") ?? false;
const editableTab = tab === "overview" || tab === "assumptions" || tab === "scope" || tab === "staffing"; const editableTab = tab === "overview" || tab === "assumptions" || tab === "scope" || tab === "staffing";
@@ -296,6 +308,11 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
)} )}
> >
{item.label} {item.label}
{item.id === "comments" && commentCount > 0 && (
<span className="ml-1.5 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-brand-100 dark:bg-sky-800 px-1.5 text-xs font-semibold text-brand-700 dark:text-sky-200">
{commentCount}
</span>
)}
</button> </button>
))} ))}
</div> </div>
@@ -342,6 +359,14 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
isCreatingExport={createExportMutation.isPending} isCreatingExport={createExportMutation.isPending}
/> />
)} )}
{tab === "comments" && (
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-50">
Comments
</h2>
<CommentThread entityType="estimate" entityId={estimate.id} />
</div>
)}
</> </>
)} )}
</> </>
@@ -61,6 +61,9 @@ function MarketplaceIcon() {
function ChargeabilityIcon() { function ChargeabilityIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M5 17l4-4 3 3 7-8M19 19H5V5" /></svg>; return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M5 17l4-4 3 3 7-8M19 19H5V5" /></svg>;
} }
function ReportBuilderIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>;
}
function GraphIcon() { function GraphIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="18" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="12" cy="18" r="2.5" strokeWidth={1.8} /><path strokeLinecap="round" strokeWidth={1.8} d="M8.5 7.5l2 7M15.5 7.5l-2 7M8.5 6h7" /></svg>; return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="18" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="12" cy="18" r="2.5" strokeWidth={1.8} /><path strokeLinecap="round" strokeWidth={1.8} d="M8.5 7.5l2 7M15.5 7.5l-2 7M8.5 6h7" /></svg>;
} }
@@ -141,6 +144,7 @@ const navSections: NavSection[] = [
{ href: "/analytics/skills", label: "Skills Analytics", icon: <SkillsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] }, { href: "/analytics/skills", label: "Skills Analytics", icon: <SkillsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/analytics/skill-marketplace", label: "Skill Marketplace", icon: <MarketplaceIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/analytics/skill-marketplace", label: "Skill Marketplace", icon: <MarketplaceIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/reports/builder", label: "Report Builder", icon: <ReportBuilderIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/analytics/computation-graph", label: "Computation Graph", icon: <GraphIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/analytics/computation-graph", label: "Computation Graph", icon: <GraphIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
], ],
}, },
@@ -0,0 +1,772 @@
"use client";
import { useState, useCallback, useMemo } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { formatMoney, formatDate } from "~/lib/format.js";
// ── Types ────────────────────────────────────────────────────────────────────
interface BaselineAssignment {
id: string;
resourceId: string | null;
resourceName: string;
resourceEid: string;
lcrCents: number;
roleId: string | null;
roleName: string;
roleColor: string | null;
startDate: string;
endDate: string;
hoursPerDay: number;
status: string;
costCents: number;
totalHours: number;
workingDays: number;
}
interface BaselineDemand {
id: string;
roleId: string | null;
roleName: string;
roleColor: string | null;
startDate: string;
endDate: string;
hoursPerDay: number;
headcount: number;
status: string;
}
interface Baseline {
project: {
id: string;
name: string;
shortCode: string;
startDate: Date | string;
endDate: Date | string;
budgetCents: number | null;
};
assignments: BaselineAssignment[];
demands: BaselineDemand[];
totalCostCents: number;
totalHours: number;
budgetCents: number | null;
}
interface ResourceOption {
id: string;
displayName: string;
eid: string;
lcrCents: number;
}
interface RoleOption {
id: string;
name: string;
color: string | null;
}
interface ScenarioRow {
key: string;
assignmentId?: string;
resourceId: string;
roleId: string;
startDate: string;
endDate: string;
hoursPerDay: number;
remove?: boolean;
}
interface ScenarioPlannerProps {
projectId: string;
baseline: Baseline;
resources: ResourceOption[];
roles: RoleOption[];
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function toISODate(d: Date | string): string {
if (typeof d === "string") return d.split("T")[0] ?? d;
return d.toISOString().split("T")[0] ?? "";
}
let nextKey = 1;
function genKey(): string {
return `new-${nextKey++}`;
}
// ── Component ────────────────────────────────────────────────────────────────
export function ScenarioPlanner({ projectId, baseline, resources, roles }: ScenarioPlannerProps) {
const projectStart = toISODate(baseline.project.startDate);
const projectEnd = toISODate(baseline.project.endDate);
// Initialize scenario rows from baseline assignments
const initialRows: ScenarioRow[] = baseline.assignments.map((a) => ({
key: a.id,
assignmentId: a.id,
resourceId: a.resourceId ?? "",
roleId: a.roleId ?? "",
startDate: toISODate(a.startDate),
endDate: toISODate(a.endDate),
hoursPerDay: a.hoursPerDay,
}));
const [rows, setRows] = useState<ScenarioRow[]>(initialRows);
const [showApplyConfirm, setShowApplyConfirm] = useState(false);
// Simulation mutation
const simulateMut = trpc.scenario.simulate.useMutation();
const applyMut = trpc.scenario.apply.useMutation();
// Derived: has the scenario diverged from baseline?
const isDirty = useMemo(() => {
if (rows.length !== initialRows.length) return true;
return rows.some((r, i) => {
const init = initialRows[i];
if (!init) return true;
return (
r.resourceId !== init.resourceId ||
r.roleId !== init.roleId ||
r.startDate !== init.startDate ||
r.endDate !== init.endDate ||
r.hoursPerDay !== init.hoursPerDay ||
r.remove
);
});
}, [rows, initialRows]);
// Resource lookup map
const resourceMap = useMemo(() => new Map(resources.map((r) => [r.id, r])), [resources]);
const roleMap = useMemo(() => new Map(roles.map((r) => [r.id, r])), [roles]);
// ── Row Handlers ─────────────────────────────────────────────────────────
const updateRow = useCallback((key: string, updates: Partial<ScenarioRow>) => {
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...updates } : r)));
}, []);
const removeRow = useCallback((key: string) => {
setRows((prev) => {
const row = prev.find((r) => r.key === key);
if (!row) return prev;
// If it's an existing assignment, mark for removal instead of deleting
if (row.assignmentId) {
return prev.map((r) => (r.key === key ? { ...r, remove: true } : r));
}
// New row — just remove it
return prev.filter((r) => r.key !== key);
});
}, []);
const restoreRow = useCallback((key: string) => {
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, remove: false } : r)));
}, []);
const addRow = useCallback(() => {
setRows((prev) => [
...prev,
{
key: genKey(),
resourceId: "",
roleId: "",
startDate: projectStart,
endDate: projectEnd,
hoursPerDay: 8,
},
]);
}, [projectStart, projectEnd]);
const resetScenario = useCallback(() => {
setRows(initialRows);
simulateMut.reset();
}, [initialRows, simulateMut]);
// ── Simulate ─────────────────────────────────────────────────────────────
const runSimulation = useCallback(() => {
// Build changes array: only rows that differ from baseline or are new
const changes = rows
.filter((r) => {
if (r.remove) return true;
if (!r.assignmentId) return true; // new row
// Check if modified
const orig = initialRows.find((ir) => ir.key === r.key);
if (!orig) return true;
return (
r.resourceId !== orig.resourceId ||
r.roleId !== orig.roleId ||
r.startDate !== orig.startDate ||
r.endDate !== orig.endDate ||
r.hoursPerDay !== orig.hoursPerDay
);
})
.map((r) => ({
assignmentId: r.assignmentId,
resourceId: r.resourceId || undefined,
roleId: r.roleId || undefined,
startDate: new Date(r.startDate),
endDate: new Date(r.endDate),
hoursPerDay: r.hoursPerDay,
remove: r.remove,
}));
if (changes.length === 0) return;
simulateMut.mutate({ projectId, changes });
}, [rows, initialRows, projectId, simulateMut]);
// ── Apply ────────────────────────────────────────────────────────────────
const applyScenario = useCallback(() => {
const changes = rows
.filter((r) => {
if (r.remove) return true;
if (!r.assignmentId) return true;
const orig = initialRows.find((ir) => ir.key === r.key);
if (!orig) return true;
return (
r.resourceId !== orig.resourceId ||
r.roleId !== orig.roleId ||
r.startDate !== orig.startDate ||
r.endDate !== orig.endDate ||
r.hoursPerDay !== orig.hoursPerDay
);
})
.map((r) => ({
assignmentId: r.assignmentId,
resourceId: r.resourceId || undefined,
roleId: r.roleId || undefined,
startDate: new Date(r.startDate),
endDate: new Date(r.endDate),
hoursPerDay: r.hoursPerDay,
remove: r.remove,
}));
applyMut.mutate(
{ projectId, changes },
{
onSuccess: () => {
setShowApplyConfirm(false);
// Reload page to see updated state
window.location.href = `/projects/${projectId}`;
},
},
);
}, [rows, initialRows, projectId, applyMut]);
const result = simulateMut.data;
const activeRows = rows.filter((r) => !r.remove);
const removedRows = rows.filter((r) => r.remove);
return (
<div className="space-y-6">
{/* Two-panel layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left: Baseline (read-only) */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">
Current Baseline
</h2>
<p className="text-xs text-gray-500 mb-4">
{baseline.assignments.length} assignment(s) &middot;{" "}
{formatMoney(baseline.totalCostCents)} total &middot;{" "}
{baseline.totalHours.toFixed(0)}h
</p>
{baseline.assignments.length === 0 ? (
<p className="text-sm text-gray-400 italic">No assignments yet.</p>
) : (
<div className="space-y-2 max-h-[500px] overflow-y-auto">
{baseline.assignments.map((a) => (
<div
key={a.id}
className="flex items-center justify-between rounded-lg border border-gray-100 dark:border-gray-700 px-3 py-2 text-sm"
>
<div className="min-w-0">
<div className="font-medium text-gray-900 dark:text-gray-100 truncate">
{a.resourceName}
{a.roleName && (
<span
className="ml-2 inline-block px-1.5 py-0.5 text-xs rounded"
style={{
backgroundColor: a.roleColor ? `${a.roleColor}20` : "#f3f4f6",
color: a.roleColor ?? "#6b7280",
}}
>
{a.roleName}
</span>
)}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{formatDate(a.startDate)} - {formatDate(a.endDate)} &middot;{" "}
{a.hoursPerDay}h/d &middot; {a.workingDays} days
</div>
</div>
<div className="text-right flex-shrink-0 ml-3">
<div className="font-mono text-sm text-gray-900 dark:text-gray-100">
{formatMoney(a.costCents)}
</div>
<div className="text-xs text-gray-500">{a.totalHours.toFixed(0)}h</div>
</div>
</div>
))}
</div>
)}
{baseline.demands.length > 0 && (
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Open Demands
</h3>
<div className="space-y-1">
{baseline.demands.map((d) => (
<div key={d.id} className="text-xs text-gray-500 flex items-center gap-2">
<span
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: d.roleColor ?? "#9ca3af" }}
/>
{d.roleName || "Unspecified"} &middot; {d.headcount}x &middot;{" "}
{d.hoursPerDay}h/d
</div>
))}
</div>
</div>
)}
</div>
{/* Right: Scenario Editor */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Scenario Editor
</h2>
<p className="text-xs text-gray-500">
{activeRows.length} allocation(s)
{removedRows.length > 0 && (
<span className="text-red-500 ml-1">
({removedRows.length} removed)
</span>
)}
</p>
</div>
<div className="flex items-center gap-2">
{isDirty && (
<button
type="button"
onClick={resetScenario}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Reset
</button>
)}
<button
type="button"
onClick={addRow}
className="inline-flex items-center gap-1 rounded-lg bg-brand-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-brand-700 transition"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Resource
</button>
</div>
</div>
<div className="space-y-3 max-h-[500px] overflow-y-auto">
{rows.map((row) => (
<ScenarioRowEditor
key={row.key}
row={row}
resources={resources}
roles={roles}
resourceMap={resourceMap}
roleMap={roleMap}
onUpdate={updateRow}
onRemove={removeRow}
onRestore={restoreRow}
/>
))}
{rows.length === 0 && (
<p className="text-sm text-gray-400 italic text-center py-6">
Add resources to build your scenario.
</p>
)}
</div>
{/* Action buttons */}
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-700 flex items-center gap-3">
<button
type="button"
onClick={runSimulation}
disabled={!isDirty || simulateMut.isPending}
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{simulateMut.isPending ? (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
)}
Simulate
</button>
{result && (
<button
type="button"
onClick={() => setShowApplyConfirm(true)}
disabled={applyMut.isPending}
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Apply Scenario
</button>
)}
</div>
{simulateMut.error && (
<p className="mt-2 text-sm text-red-600">{simulateMut.error.message}</p>
)}
</div>
</div>
{/* Impact Summary */}
{result && <ImpactSummary result={result} budgetCents={baseline.budgetCents ?? 0} />}
{/* Apply confirmation dialog */}
{showApplyConfirm && (
<ConfirmDialog
title="Apply Scenario"
message="This will create/modify real assignments based on your scenario. Existing assignments may be changed or cancelled. This action cannot be undone."
confirmLabel={applyMut.isPending ? "Applying..." : "Apply"}
onConfirm={applyScenario}
onCancel={() => setShowApplyConfirm(false)}
/>
)}
{applyMut.isSuccess && (
<div className="rounded-lg bg-green-50 border border-green-200 p-4 text-sm text-green-800">
Scenario applied successfully. Redirecting...
</div>
)}
</div>
);
}
// ── ScenarioRowEditor ────────────────────────────────────────────────────────
interface ScenarioRowEditorProps {
row: ScenarioRow;
resources: ResourceOption[];
roles: RoleOption[];
resourceMap: Map<string, ResourceOption>;
roleMap: Map<string, RoleOption>;
onUpdate: (key: string, updates: Partial<ScenarioRow>) => void;
onRemove: (key: string) => void;
onRestore: (key: string) => void;
}
function ScenarioRowEditor({
row,
resources,
roles,
resourceMap,
roleMap,
onUpdate,
onRemove,
onRestore,
}: ScenarioRowEditorProps) {
const isRemoved = row.remove;
const resource = row.resourceId ? resourceMap.get(row.resourceId) : null;
const lcrDisplay = resource ? `${formatMoney(resource.lcrCents)}/h` : "";
return (
<div
className={`rounded-lg border p-3 transition ${
isRemoved
? "border-red-200 bg-red-50/50 dark:border-red-800 dark:bg-red-900/10 opacity-60"
: row.assignmentId
? "border-gray-200 dark:border-gray-700"
: "border-blue-200 bg-blue-50/30 dark:border-blue-800 dark:bg-blue-900/10"
}`}
>
{isRemoved ? (
<div className="flex items-center justify-between">
<span className="text-sm text-red-600 line-through">
{resource?.displayName ?? "Unknown"} &mdash; removed
</span>
<button
type="button"
onClick={() => onRestore(row.key)}
className="text-xs text-blue-600 hover:text-blue-800 underline"
>
Restore
</button>
</div>
) : (
<div className="space-y-2">
{/* Top row: resource + role + remove */}
<div className="flex items-center gap-2">
<select
value={row.resourceId}
onChange={(e) => onUpdate(row.key, { resourceId: e.target.value })}
className="flex-1 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1.5 text-sm text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-brand-500 focus:border-transparent"
>
<option value="">Select resource...</option>
{resources.map((r) => (
<option key={r.id} value={r.id}>
{r.displayName} ({r.eid})
</option>
))}
</select>
<select
value={row.roleId}
onChange={(e) => onUpdate(row.key, { roleId: e.target.value })}
className="w-40 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1.5 text-sm text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-brand-500 focus:border-transparent"
>
<option value="">Role...</option>
{roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
<button
type="button"
onClick={() => onRemove(row.key)}
className="p-1.5 text-gray-400 hover:text-red-500 transition rounded"
title="Remove from scenario"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Bottom row: dates + hours + LCR info */}
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1">
<label className="text-xs text-gray-500 whitespace-nowrap">From</label>
<DateInput
value={row.startDate}
onChange={(v) => onUpdate(row.key, { startDate: v })}
className="w-28 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-gray-900 dark:text-gray-100"
/>
</div>
<div className="flex items-center gap-1">
<label className="text-xs text-gray-500 whitespace-nowrap">To</label>
<DateInput
value={row.endDate}
onChange={(v) => onUpdate(row.key, { endDate: v })}
className="w-28 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-gray-900 dark:text-gray-100"
/>
</div>
<div className="flex items-center gap-1">
<label className="text-xs text-gray-500 whitespace-nowrap">h/day</label>
<input
type="number"
min={0}
max={24}
step={0.5}
value={row.hoursPerDay}
onChange={(e) => onUpdate(row.key, { hoursPerDay: parseFloat(e.target.value) || 0 })}
className="w-16 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-gray-900 dark:text-gray-100 text-center"
/>
</div>
{lcrDisplay && (
<span className="text-xs text-gray-400 ml-auto">{lcrDisplay}</span>
)}
{!row.assignmentId && (
<span className="text-xs text-blue-500 font-medium">NEW</span>
)}
</div>
</div>
)}
</div>
);
}
// ── ImpactSummary ────────────────────────────────────────────────────────────
interface SimulationResult {
baseline: { totalCostCents: number; totalHours: number; headcount: number; skillCount: number };
scenario: { totalCostCents: number; totalHours: number; headcount: number; skillCount: number };
delta: { costCents: number; hours: number; headcount: number; skillCoveragePct: number };
resourceImpacts: Array<{
resourceId: string;
resourceName: string;
chargeabilityTarget: number;
currentUtilization: number;
scenarioUtilization: number;
utilizationDelta: number;
isOverallocated: boolean;
}>;
warnings: string[];
budgetCents: number;
}
function ImpactSummary({ result, budgetCents }: { result: SimulationResult; budgetCents: number }) {
const { baseline, scenario, delta, resourceImpacts, warnings } = result;
const costSign = delta.costCents > 0 ? "+" : "";
const hoursSign = delta.hours > 0 ? "+" : "";
const headcountSign = delta.headcount > 0 ? "+" : "";
const costColor = delta.costCents > 0 ? "text-red-600" : delta.costCents < 0 ? "text-green-600" : "text-gray-500";
const hoursColor = delta.hours > 0 ? "text-amber-600" : delta.hours < 0 ? "text-blue-600" : "text-gray-500";
const budgetUsedPct = budgetCents > 0 ? Math.round((scenario.totalCostCents / budgetCents) * 100) : null;
return (
<div className="space-y-4">
{/* Delta cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<DeltaCard
label="Cost Impact"
value={`${costSign}${formatMoney(delta.costCents)}`}
subtitle={`${formatMoney(scenario.totalCostCents)} total`}
color={costColor}
/>
<DeltaCard
label="Hours Impact"
value={`${hoursSign}${delta.hours.toFixed(0)}h`}
subtitle={`${scenario.totalHours.toFixed(0)}h total`}
color={hoursColor}
/>
<DeltaCard
label="Headcount"
value={`${headcountSign}${delta.headcount}`}
subtitle={`${scenario.headcount} total`}
color={delta.headcount !== 0 ? "text-indigo-600" : "text-gray-500"}
/>
<DeltaCard
label="Skill Coverage"
value={`${delta.skillCoveragePct}%`}
subtitle={`${scenario.skillCount} unique skills`}
color={delta.skillCoveragePct >= 100 ? "text-green-600" : "text-amber-600"}
/>
</div>
{/* Budget progress */}
{budgetUsedPct !== null && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-gray-600 dark:text-gray-400">Budget Usage</span>
<span className={`font-medium ${budgetUsedPct > 100 ? "text-red-600" : "text-gray-900 dark:text-gray-100"}`}>
{budgetUsedPct}% of {formatMoney(budgetCents)}
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
budgetUsedPct > 100 ? "bg-red-500" : budgetUsedPct > 80 ? "bg-amber-500" : "bg-green-500"
}`}
style={{ width: `${Math.min(budgetUsedPct, 100)}%` }}
/>
</div>
</div>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-900/20 p-4">
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">Warnings</h3>
<ul className="space-y-1">
{warnings.map((w, i) => (
<li key={i} className="text-sm text-amber-700 dark:text-amber-300 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
{w}
</li>
))}
</ul>
</div>
)}
{/* Resource utilization impacts */}
{resourceImpacts.length > 0 && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
Resource Utilization Impact
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-100 dark:border-gray-700">
<th className="text-left py-2 pr-4 font-medium">Resource</th>
<th className="text-right py-2 px-3 font-medium">Current</th>
<th className="text-right py-2 px-3 font-medium">Scenario</th>
<th className="text-right py-2 px-3 font-medium">Delta</th>
<th className="text-right py-2 pl-3 font-medium">Target</th>
</tr>
</thead>
<tbody>
{resourceImpacts.map((ri) => (
<tr
key={ri.resourceId}
className={`border-b border-gray-50 dark:border-gray-800 ${
ri.isOverallocated ? "bg-red-50/50 dark:bg-red-900/10" : ""
}`}
>
<td className="py-2 pr-4 font-medium text-gray-900 dark:text-gray-100">
{ri.resourceName}
{ri.isOverallocated && (
<span className="ml-2 text-xs text-red-500 font-normal">over-allocated</span>
)}
</td>
<td className="text-right py-2 px-3 text-gray-600 dark:text-gray-400">
{ri.currentUtilization.toFixed(1)}%
</td>
<td className={`text-right py-2 px-3 font-medium ${ri.isOverallocated ? "text-red-600" : "text-gray-900 dark:text-gray-100"}`}>
{ri.scenarioUtilization.toFixed(1)}%
</td>
<td className={`text-right py-2 px-3 ${
ri.utilizationDelta > 0 ? "text-amber-600" : ri.utilizationDelta < 0 ? "text-blue-600" : "text-gray-500"
}`}>
{ri.utilizationDelta > 0 ? "+" : ""}{ri.utilizationDelta.toFixed(1)}%
</td>
<td className="text-right py-2 pl-3 text-gray-500">
{ri.chargeabilityTarget}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
// ── DeltaCard ────────────────────────────────────────────────────────────────
function DeltaCard({
label,
value,
subtitle,
color,
}: {
label: string;
value: string;
subtitle: string;
color: string;
}) {
return (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<p className="text-xs text-gray-500 mb-1">{label}</p>
<p className={`text-xl font-bold ${color}`}>{value}</p>
<p className="text-xs text-gray-400 mt-1">{subtitle}</p>
</div>
);
}
@@ -0,0 +1,564 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { keepPreviousData } from "@tanstack/react-query";
import { trpc } from "~/lib/trpc/client.js";
import { clsx } from "clsx";
// ─── Types ──────────────────────────────────────────────────────────────────
type EntityType = "resource" | "project" | "assignment";
type FilterOp = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in";
interface FilterRow {
id: string;
field: string;
op: FilterOp;
value: string;
}
const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [
{ value: "resource", label: "Resources" },
{ value: "project", label: "Projects" },
{ value: "assignment", label: "Assignments" },
];
const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
{ value: "eq", label: "equals" },
{ value: "neq", label: "not equals" },
{ value: "gt", label: "greater than" },
{ value: "lt", label: "less than" },
{ value: "gte", label: ">= (gte)" },
{ value: "lte", label: "<= (lte)" },
{ value: "contains", label: "contains" },
{ value: "in", label: "in (comma-sep)" },
];
const PAGE_SIZE = 50;
function generateId(): string {
return Math.random().toString(36).slice(2, 10);
}
// ─── Component ──────────────────────────────────────────────────────────────
export function ReportBuilder() {
// Config state
const [entity, setEntity] = useState<EntityType>("resource");
const [selectedColumns, setSelectedColumns] = useState<Set<string>>(new Set());
const [filters, setFilters] = useState<FilterRow[]>([]);
const [groupBy, setGroupBy] = useState<string>("");
const [sortBy, setSortBy] = useState<string>("");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [page, setPage] = useState(0);
const [runQuery, setRunQuery] = useState(false);
// Fetch available columns when entity changes
const columnsQuery = trpc.report.getAvailableColumns.useQuery(
{ entity },
{ placeholderData: keepPreviousData },
);
const availableColumns = columnsQuery.data ?? [];
// Scalar columns (for filter/sort/group — only non-relation columns)
const scalarColumns = useMemo(
() => availableColumns.filter((c) => !c.key.includes(".")),
[availableColumns],
);
// Build query input
const queryInput = useMemo(() => {
if (!runQuery || selectedColumns.size === 0) return null;
return {
entity,
columns: Array.from(selectedColumns),
filters: filters
.filter((f) => f.field && f.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
};
}, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page]);
// Fetch report data
const reportQuery = trpc.report.getReportData.useQuery(
queryInput!,
{ enabled: queryInput !== null, placeholderData: keepPreviousData },
);
const exportMutation = trpc.report.exportReport.useMutation();
// ─── Handlers ───────────────────────────────────────────────────────────
const handleEntityChange = useCallback((newEntity: EntityType) => {
setEntity(newEntity);
setSelectedColumns(new Set());
setFilters([]);
setGroupBy("");
setSortBy("");
setRunQuery(false);
setPage(0);
}, []);
const toggleColumn = useCallback((key: string) => {
setSelectedColumns((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
const selectAllColumns = useCallback(() => {
setSelectedColumns(new Set(availableColumns.map((c) => c.key)));
}, [availableColumns]);
const clearAllColumns = useCallback(() => {
setSelectedColumns(new Set());
}, []);
const addFilter = useCallback(() => {
const firstField = scalarColumns[0]?.key ?? "";
setFilters((prev) => [...prev, { id: generateId(), field: firstField, op: "eq", value: "" }]);
}, [scalarColumns]);
const updateFilter = useCallback((id: string, patch: Partial<FilterRow>) => {
setFilters((prev) => prev.map((f) => (f.id === id ? { ...f, ...patch } : f)));
}, []);
const removeFilter = useCallback((id: string) => {
setFilters((prev) => prev.filter((f) => f.id !== id));
}, []);
const handleRun = useCallback(() => {
setPage(0);
setRunQuery(true);
}, []);
const handleSort = useCallback((column: string) => {
if (!column.includes(".")) {
if (sortBy === column) {
setSortDir((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortBy(column);
setSortDir("asc");
}
// Re-run with new sort
setRunQuery(true);
}
}, [sortBy]);
const handleExport = useCallback(async () => {
if (selectedColumns.size === 0) return;
try {
const result = await exportMutation.mutateAsync({
entity,
columns: Array.from(selectedColumns),
filters: filters
.filter((f) => f.field && f.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
limit: 5000,
});
// Download CSV
const blob = new Blob([result.csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `report-${entity}-${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
} catch {
// Error handled by tRPC
}
}, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation]);
// ─── Derived ──────────────────────────────────────────────────────────
const rows = reportQuery.data?.rows ?? [];
const totalCount = reportQuery.data?.totalCount ?? 0;
const outputColumns = reportQuery.data?.columns ?? [];
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
const isLoading = reportQuery.isFetching;
// Column label lookup
const columnLabelMap = useMemo(() => {
const map = new Map<string, string>();
for (const col of availableColumns) {
map.set(col.key, col.label);
}
return map;
}, [availableColumns]);
// ─── Render ───────────────────────────────────────────────────────────
return (
<div className="mx-auto max-w-[1600px] space-y-6 p-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Report Builder</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Build custom reports by selecting an entity, columns, and filters.
</p>
</div>
{/* Config Panel */}
<div className="space-y-5 rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-950">
{/* Entity Selector */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Entity
</label>
<div className="flex gap-3">
{ENTITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleEntityChange(opt.value)}
className={clsx(
"rounded-xl px-4 py-2 text-sm font-medium transition-colors",
entity === opt.value
? "bg-brand-600 text-white shadow-sm"
: "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-slate-800 dark:text-gray-300 dark:hover:bg-slate-700",
)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Column Picker */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Columns ({selectedColumns.size} selected)
</label>
<div className="flex gap-2">
<button
type="button"
onClick={selectAllColumns}
className="text-xs font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400"
>
Select all
</button>
<span className="text-xs text-gray-300 dark:text-gray-600">|</span>
<button
type="button"
onClick={clearAllColumns}
className="text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear
</button>
</div>
</div>
{columnsQuery.isLoading ? (
<div className="py-3 text-sm text-gray-400">Loading columns...</div>
) : (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{availableColumns.map((col) => (
<label
key={col.key}
className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors hover:bg-gray-50 dark:hover:bg-slate-900"
>
<input
type="checkbox"
checked={selectedColumns.has(col.key)}
onChange={() => toggleColumn(col.key)}
className="h-3.5 w-3.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-slate-600"
/>
<span className="text-gray-700 dark:text-gray-300">{col.label}</span>
<span className="ml-auto text-[10px] uppercase tracking-wider text-gray-400 dark:text-gray-600">
{col.dataType}
</span>
</label>
))}
</div>
)}
</div>
{/* Filter Builder */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Filters
</label>
<button
type="button"
onClick={addFilter}
className="flex items-center gap-1 text-xs font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add filter
</button>
</div>
{filters.length === 0 ? (
<p className="text-sm text-gray-400 dark:text-gray-500">No filters applied.</p>
) : (
<div className="space-y-2">
{filters.map((filter) => (
<div key={filter.id} className="flex items-center gap-2">
{/* Field */}
<select
value={filter.field}
onChange={(e) => updateFilter(filter.id, { field: e.target.value })}
className="w-44 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
>
{scalarColumns.map((col) => (
<option key={col.key} value={col.key}>{col.label}</option>
))}
</select>
{/* Operator */}
<select
value={filter.op}
onChange={(e) => updateFilter(filter.id, { op: e.target.value as FilterOp })}
className="w-36 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
>
{OPERATOR_OPTIONS.map((op) => (
<option key={op.value} value={op.value}>{op.label}</option>
))}
</select>
{/* Value */}
<input
type="text"
value={filter.value}
onChange={(e) => updateFilter(filter.id, { value: e.target.value })}
placeholder="Value..."
className="min-w-0 flex-1 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 placeholder:text-gray-400 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300 dark:placeholder:text-gray-600"
/>
{/* Remove */}
<button
type="button"
onClick={() => removeFilter(filter.id)}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950 dark:hover:text-red-400"
title="Remove filter"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
{/* Sort & Group */}
<div className="flex flex-wrap gap-4">
<div className="min-w-[160px]">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Group by
</label>
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
>
<option value="">None</option>
{scalarColumns.map((col) => (
<option key={col.key} value={col.key}>{col.label}</option>
))}
</select>
</div>
<div className="min-w-[160px]">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Sort by
</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
>
<option value="">Default</option>
{scalarColumns.map((col) => (
<option key={col.key} value={col.key}>{col.label}</option>
))}
</select>
</div>
<div className="min-w-[120px]">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Direction
</label>
<select
value={sortDir}
onChange={(e) => setSortDir(e.target.value as "asc" | "desc")}
className="w-full rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
</div>
</div>
{/* Run button */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleRun}
disabled={selectedColumns.size === 0}
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Run Report
</button>
{selectedColumns.size === 0 && (
<span className="text-sm text-gray-400 dark:text-gray-500">Select at least one column</span>
)}
</div>
</div>
{/* Results */}
{runQuery && (
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
{/* Results Header */}
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-slate-800">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Results</h2>
{!isLoading && (
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-slate-800 dark:text-gray-400">
{totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""}
</span>
)}
</div>
<button
type="button"
onClick={() => void handleExport()}
disabled={exportMutation.isPending || totalCount === 0}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300 dark:hover:bg-slate-800"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{exportMutation.isPending ? "Exporting..." : "Export CSV"}
</button>
</div>
{/* Table */}
<div className="overflow-x-auto">
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-600 border-t-transparent" />
</div>
) : rows.length === 0 ? (
<div className="py-16 text-center text-sm text-gray-400 dark:text-gray-500">
No data found. Try adjusting your filters.
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50/80 dark:border-slate-800 dark:bg-slate-900/50">
{outputColumns.map((col) => {
const isSortable = !col.includes(".");
const isSorted = sortBy === col;
return (
<th
key={col}
onClick={isSortable ? () => handleSort(col) : undefined}
className={clsx(
"whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400",
isSortable && "cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200",
)}
>
<span className="inline-flex items-center gap-1">
{columnLabelMap.get(col) ?? col}
{isSorted && (
<svg className={clsx("h-3 w-3", sortDir === "desc" && "rotate-180")} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
)}
</span>
</th>
);
})}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-800/60">
{rows.map((row, idx) => (
<tr
key={(row.id as string) ?? idx}
className="transition-colors hover:bg-gray-50/60 dark:hover:bg-slate-900/40"
>
{outputColumns.map((col) => (
<td
key={col}
className="whitespace-nowrap px-4 py-2.5 text-gray-700 dark:text-gray-300"
>
{formatCellValue(row[col])}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-gray-200 px-6 py-3 dark:border-slate-800">
<span className="text-sm text-gray-500 dark:text-gray-400">
Page {page + 1} of {totalPages}
</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-gray-300 dark:hover:bg-slate-800"
>
Previous
</button>
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-gray-300 dark:hover:bg-slate-800"
>
Next
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatCellValue(value: unknown): string {
if (value === null || value === undefined) return "--";
if (typeof value === "boolean") return value ? "Yes" : "No";
if (typeof value === "string") {
// ISO date detection
if (/^\d{4}-\d{2}-\d{2}T/.test(value)) {
return new Date(value).toLocaleDateString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
return value;
}
if (typeof value === "number") {
return value.toLocaleString("de-DE");
}
return String(value);
}
+233
View File
@@ -0,0 +1,233 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { SystemRole } from "@planarchy/shared";
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import { emitNotificationCreated } from "../sse/event-bus.js";
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** Resolve the DB user id from the session email. Throws UNAUTHORIZED if not found. */
async function resolveUserId(ctx: {
db: {
user: {
findUnique: (args: {
where: { email: string };
select: { id: true };
}) => Promise<{ id: string } | null>;
};
};
session: { user?: { email?: string | null } | null };
}): Promise<string> {
const email = ctx.session.user?.email;
if (!email) throw new TRPCError({ code: "UNAUTHORIZED" });
const user = await ctx.db.user.findUnique({
where: { email },
select: { id: true },
});
if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
return user.id;
}
/**
* Parse @mentions from comment body.
* Pattern: @[Display Name](userId)
* Returns an array of unique user IDs.
*/
function parseMentions(body: string): string[] {
const regex = /@\[([^\]]+)\]\(([^)]+)\)/g;
const ids = new Set<string>();
let match: RegExpExecArray | null;
while ((match = regex.exec(body)) !== null) {
ids.add(match[2]!);
}
return Array.from(ids);
}
// ─── Router ───────────────────────────────────────────────────────────────────
export const commentRouter = createTRPCRouter({
/** List comments for a given entity, with author info and 1-level nested replies */
list: protectedProcedure
.input(
z.object({
entityType: z.string(),
entityId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
return ctx.db.comment.findMany({
where: {
entityType: input.entityType,
entityId: input.entityId,
parentId: null, // only top-level comments
},
include: {
author: { select: { id: true, name: true, email: true, image: true } },
replies: {
include: {
author: { select: { id: true, name: true, email: true, image: true } },
},
orderBy: { createdAt: "asc" },
},
},
orderBy: { createdAt: "asc" },
});
}),
/** Count comments for a given entity (used for badge) */
count: protectedProcedure
.input(
z.object({
entityType: z.string(),
entityId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
return ctx.db.comment.count({
where: {
entityType: input.entityType,
entityId: input.entityId,
},
});
}),
/** Create a comment, parse @mentions, and notify mentioned users */
create: protectedProcedure
.input(
z.object({
entityType: z.string(),
entityId: z.string(),
parentId: z.string().optional(),
body: z.string().min(1).max(10_000),
}),
)
.mutation(async ({ ctx, input }) => {
const authorId = await resolveUserId(ctx);
const mentions = parseMentions(input.body);
// If replying, verify the parent exists
if (input.parentId) {
const parent = await ctx.db.comment.findUnique({
where: { id: input.parentId },
select: { id: true },
});
if (!parent) {
throw new TRPCError({ code: "NOT_FOUND", message: "Parent comment not found" });
}
}
const comment = await ctx.db.comment.create({
data: {
entityType: input.entityType,
entityId: input.entityId,
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
authorId,
body: input.body,
mentions,
},
include: {
author: { select: { id: true, name: true, email: true, image: true } },
},
});
// Create notifications for mentioned users (excluding the author)
const mentionedUserIds = mentions.filter((id) => id !== authorId);
if (mentionedUserIds.length > 0) {
const authorName = comment.author.name ?? comment.author.email;
const truncatedBody =
input.body.length > 120 ? `${input.body.slice(0, 120)}...` : input.body;
await Promise.all(
mentionedUserIds.map(async (userId) => {
const notification = await ctx.db.notification.create({
data: {
userId,
type: "COMMENT_MENTION",
title: `${authorName} mentioned you in a comment`,
body: truncatedBody,
entityId: input.entityId,
entityType: input.entityType,
senderId: authorId,
link: `/estimates/${input.entityId}?tab=comments`,
channel: "in_app",
},
});
emitNotificationCreated(userId, notification.id);
}),
);
}
return comment;
}),
/** Resolve or unresolve a comment (author or admin only) */
resolve: protectedProcedure
.input(
z.object({
id: z.string(),
resolved: z.boolean().default(true),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = await resolveUserId(ctx);
const dbUser = ctx.dbUser;
const existing = await ctx.db.comment.findUnique({
where: { id: input.id },
select: { id: true, authorId: true },
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Comment not found" });
}
// Only the author or an admin can resolve
const isAdmin = dbUser?.systemRole === SystemRole.ADMIN;
if (existing.authorId !== userId && !isAdmin) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the comment author or an admin can resolve comments",
});
}
return ctx.db.comment.update({
where: { id: input.id },
data: { resolved: input.resolved },
include: {
author: { select: { id: true, name: true, email: true, image: true } },
},
});
}),
/** Delete a comment (author or admin only). Hard-deletes, including all replies. */
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const userId = await resolveUserId(ctx);
const dbUser = ctx.dbUser;
const existing = await ctx.db.comment.findUnique({
where: { id: input.id },
select: { id: true, authorId: true },
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Comment not found" });
}
const isAdmin = dbUser?.systemRole === SystemRole.ADMIN;
if (existing.authorId !== userId && !isAdmin) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the comment author or an admin can delete comments",
});
}
// Delete all replies first (they reference this comment as parent)
await ctx.db.comment.deleteMany({
where: { parentId: input.id },
});
await ctx.db.comment.delete({ where: { id: input.id } });
}),
});
+33
View File
@@ -6,6 +6,9 @@ import {
getDashboardOverview, getDashboardOverview,
getDashboardPeakTimes, getDashboardPeakTimes,
getDashboardTopValueResources, getDashboardTopValueResources,
getDashboardBudgetForecast,
getDashboardSkillGaps,
getDashboardProjectHealth,
} from "@planarchy/application"; } from "@planarchy/application";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js"; import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
import { cacheGet, cacheSet } from "../lib/cache.js"; import { cacheGet, cacheSet } from "../lib/cache.js";
@@ -129,4 +132,34 @@ export const dashboardRouter = createTRPCRouter({
await cacheSet(cacheKey, result, DEFAULT_TTL); await cacheSet(cacheKey, result, DEFAULT_TTL);
return result; return result;
}), }),
getBudgetForecast: protectedProcedure.query(async ({ ctx }) => {
const cacheKey = "budgetForecast";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardBudgetForecast>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardBudgetForecast(ctx.db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}),
getSkillGaps: protectedProcedure.query(async ({ ctx }) => {
const cacheKey = "skillGaps";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardSkillGaps>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardSkillGaps(ctx.db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}),
getProjectHealth: protectedProcedure.query(async ({ ctx }) => {
const cacheKey = "projectHealth";
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardProjectHealth>>>(cacheKey);
if (cached) return cached;
const result = await getDashboardProjectHealth(ctx.db);
await cacheSet(cacheKey, result, DEFAULT_TTL);
return result;
}),
}); });
+6
View File
@@ -6,6 +6,7 @@ import { blueprintRouter } from "./blueprint.js";
import { chargeabilityReportRouter } from "./chargeability-report.js"; import { chargeabilityReportRouter } from "./chargeability-report.js";
import { computationGraphRouter } from "./computation-graph.js"; import { computationGraphRouter } from "./computation-graph.js";
import { clientRouter } from "./client.js"; import { clientRouter } from "./client.js";
import { commentRouter } from "./comment.js";
import { countryRouter } from "./country.js"; import { countryRouter } from "./country.js";
import { dashboardRouter } from "./dashboard.js"; import { dashboardRouter } from "./dashboard.js";
import { effortRuleRouter } from "./effort-rule.js"; import { effortRuleRouter } from "./effort-rule.js";
@@ -18,8 +19,10 @@ import { notificationRouter } from "./notification.js";
import { orgUnitRouter } from "./org-unit.js"; import { orgUnitRouter } from "./org-unit.js";
import { projectRouter } from "./project.js"; import { projectRouter } from "./project.js";
import { rateCardRouter } from "./rate-card.js"; import { rateCardRouter } from "./rate-card.js";
import { reportRouter } from "./report.js";
import { resourceRouter } from "./resource.js"; import { resourceRouter } from "./resource.js";
import { roleRouter } from "./role.js"; import { roleRouter } from "./role.js";
import { scenarioRouter } from "./scenario.js";
import { settingsRouter } from "./settings.js"; import { settingsRouter } from "./settings.js";
import { staffingRouter } from "./staffing.js"; import { staffingRouter } from "./staffing.js";
import { systemRoleConfigRouter } from "./system-role-config.js"; import { systemRoleConfigRouter } from "./system-role-config.js";
@@ -54,7 +57,10 @@ export const appRouter = createTRPCRouter({
managementLevel: managementLevelRouter, managementLevel: managementLevelRouter,
rateCard: rateCardRouter, rateCard: rateCardRouter,
chargeabilityReport: chargeabilityReportRouter, chargeabilityReport: chargeabilityReportRouter,
report: reportRouter,
scenario: scenarioRouter,
calculationRule: calculationRuleRouter, calculationRule: calculationRuleRouter,
comment: commentRouter,
computationGraph: computationGraphRouter, computationGraph: computationGraphRouter,
systemRoleConfig: systemRoleConfigRouter, systemRoleConfig: systemRoleConfigRouter,
}); });
+387
View File
@@ -0,0 +1,387 @@
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import { TRPCError } from "@trpc/server";
// ─── Column Definitions ──────────────────────────────────────────────────────
interface ColumnDef {
key: string;
label: string;
dataType: "string" | "number" | "date" | "boolean";
/** Prisma select path — nested relations use dot notation */
prismaPath?: string;
}
const RESOURCE_COLUMNS: ColumnDef[] = [
{ key: "id", label: "ID", dataType: "string" },
{ key: "eid", label: "Employee ID", dataType: "string" },
{ key: "displayName", label: "Name", dataType: "string" },
{ key: "email", label: "Email", dataType: "string" },
{ key: "chapter", label: "Chapter", dataType: "string" },
{ key: "resourceType", label: "Resource Type", dataType: "string" },
{ key: "lcrCents", label: "LCR (cents)", dataType: "number" },
{ key: "ucrCents", label: "UCR (cents)", dataType: "number" },
{ key: "currency", label: "Currency", dataType: "string" },
{ key: "chargeabilityTarget", label: "Chargeability Target (%)", dataType: "number" },
{ key: "fte", label: "FTE", dataType: "number" },
{ key: "isActive", label: "Active", dataType: "boolean" },
{ key: "chgResponsibility", label: "Chg Responsibility", dataType: "boolean" },
{ key: "rolledOff", label: "Rolled Off", dataType: "boolean" },
{ key: "departed", label: "Departed", dataType: "boolean" },
{ key: "postalCode", label: "Postal Code", dataType: "string" },
{ key: "federalState", label: "Federal State", dataType: "string" },
{ key: "country.name", label: "Country", dataType: "string", prismaPath: "country" },
{ key: "metroCity.name", label: "Metro City", dataType: "string", prismaPath: "metroCity" },
{ key: "orgUnit.name", label: "Org Unit", dataType: "string", prismaPath: "orgUnit" },
{ key: "managementLevelGroup.name", label: "Mgmt Level Group", dataType: "string", prismaPath: "managementLevelGroup" },
{ key: "managementLevel.name", label: "Mgmt Level", dataType: "string", prismaPath: "managementLevel" },
{ key: "areaRole.name", label: "Area Role", dataType: "string", prismaPath: "areaRole" },
{ key: "createdAt", label: "Created At", dataType: "date" },
{ key: "updatedAt", label: "Updated At", dataType: "date" },
];
const PROJECT_COLUMNS: ColumnDef[] = [
{ key: "id", label: "ID", dataType: "string" },
{ key: "shortCode", label: "Short Code", dataType: "string" },
{ key: "name", label: "Name", dataType: "string" },
{ key: "orderType", label: "Order Type", dataType: "string" },
{ key: "allocationType", label: "Allocation Type", dataType: "string" },
{ key: "status", label: "Status", dataType: "string" },
{ key: "winProbability", label: "Win Probability (%)", dataType: "number" },
{ key: "budgetCents", label: "Budget (cents)", dataType: "number" },
{ key: "startDate", label: "Start Date", dataType: "date" },
{ key: "endDate", label: "End Date", dataType: "date" },
{ key: "responsiblePerson", label: "Responsible Person", dataType: "string" },
{ key: "client.name", label: "Client", dataType: "string", prismaPath: "client" },
{ key: "utilizationCategory.name", label: "Util. Category", dataType: "string", prismaPath: "utilizationCategory" },
{ key: "blueprint.name", label: "Blueprint", dataType: "string", prismaPath: "blueprint" },
{ key: "createdAt", label: "Created At", dataType: "date" },
{ key: "updatedAt", label: "Updated At", dataType: "date" },
];
const ASSIGNMENT_COLUMNS: ColumnDef[] = [
{ key: "id", label: "ID", dataType: "string" },
{ key: "resource.displayName", label: "Resource", dataType: "string", prismaPath: "resource" },
{ key: "resource.eid", label: "Resource EID", dataType: "string", prismaPath: "resource" },
{ key: "project.name", label: "Project", dataType: "string", prismaPath: "project" },
{ key: "project.shortCode", label: "Project Code", dataType: "string", prismaPath: "project" },
{ key: "startDate", label: "Start Date", dataType: "date" },
{ key: "endDate", label: "End Date", dataType: "date" },
{ key: "hoursPerDay", label: "Hours/Day", dataType: "number" },
{ key: "percentage", label: "Percentage", dataType: "number" },
{ key: "role", label: "Role (legacy)", dataType: "string" },
{ key: "roleEntity.name", label: "Role", dataType: "string", prismaPath: "roleEntity" },
{ key: "dailyCostCents", label: "Daily Cost (cents)", dataType: "number" },
{ key: "status", label: "Status", dataType: "string" },
{ key: "createdAt", label: "Created At", dataType: "date" },
{ key: "updatedAt", label: "Updated At", dataType: "date" },
];
const COLUMN_MAP: Record<EntityKey, ColumnDef[]> = {
resource: RESOURCE_COLUMNS,
project: PROJECT_COLUMNS,
assignment: ASSIGNMENT_COLUMNS,
};
// ─── Helpers ────────────────────────────────────────────────────────────────
const ENTITY_MAP = {
resource: "resource",
project: "project",
assignment: "assignment",
} as const;
type EntityKey = keyof typeof ENTITY_MAP;
/** Allowlist of top-level scalar fields per entity that can be filtered/sorted on. */
const ALLOWED_SCALAR_FIELDS: Record<EntityKey, Set<string>> = {
resource: new Set([
"id", "eid", "displayName", "email", "chapter", "resourceType",
"lcrCents", "ucrCents", "currency", "chargeabilityTarget", "fte",
"isActive", "chgResponsibility", "rolledOff", "departed",
"postalCode", "federalState", "createdAt", "updatedAt",
]),
project: new Set([
"id", "shortCode", "name", "orderType", "allocationType", "status",
"winProbability", "budgetCents", "startDate", "endDate",
"responsiblePerson", "createdAt", "updatedAt",
]),
assignment: new Set([
"id", "startDate", "endDate", "hoursPerDay", "percentage",
"role", "dailyCostCents", "status", "createdAt", "updatedAt",
]),
};
function getValidScalarField(entity: EntityKey, field: string): string | null {
// Only allow top-level scalar fields for filter/sort (no relation traversal in where/orderBy)
if (ALLOWED_SCALAR_FIELDS[entity].has(field)) return field;
return null;
}
/**
* Build a Prisma `select` object from the requested columns.
* Always includes `id`. For relation columns like "country.name",
* we include the relation with `select: { name: true }`.
*/
function buildSelect(entity: EntityKey, columns: string[]): Record<string, unknown> {
const entityColumns = COLUMN_MAP[entity];
const select: Record<string, unknown> = { id: true };
for (const colKey of columns) {
const def = entityColumns.find((c) => c.key === colKey);
if (!def) continue;
if (colKey.includes(".")) {
// Relation column, e.g. "country.name" => select: { country: { select: { name: true } } }
const relationName = def.prismaPath ?? colKey.split(".")[0]!;
const fieldName = colKey.split(".").slice(1).join(".");
const existing = select[relationName];
if (existing && typeof existing === "object" && existing !== null && "select" in existing) {
(existing as { select: Record<string, boolean> }).select[fieldName] = true;
} else {
select[relationName] = { select: { [fieldName]: true } };
}
} else {
select[colKey] = true;
}
}
return select;
}
/**
* Build a Prisma `where` from the filter array.
* Only scalar top-level fields are allowed for safety.
*/
function buildWhere(
entity: EntityKey,
filters: Array<{ field: string; op: string; value: string }>,
): Record<string, unknown> {
const where: Record<string, unknown> = {};
for (const filter of filters) {
const field = getValidScalarField(entity, filter.field);
if (!field) continue;
const entityColumns = COLUMN_MAP[entity];
const colDef = entityColumns.find((c) => c.key === field);
const dataType = colDef?.dataType ?? "string";
// Parse value based on data type
let parsedValue: unknown = filter.value;
if (dataType === "number") {
parsedValue = Number(filter.value);
if (Number.isNaN(parsedValue as number)) continue;
} else if (dataType === "boolean") {
parsedValue = filter.value === "true";
} else if (dataType === "date") {
parsedValue = new Date(filter.value);
if (Number.isNaN((parsedValue as Date).getTime())) continue;
}
switch (filter.op) {
case "eq":
where[field] = parsedValue;
break;
case "neq":
where[field] = { not: parsedValue };
break;
case "gt":
where[field] = { gt: parsedValue };
break;
case "lt":
where[field] = { lt: parsedValue };
break;
case "gte":
where[field] = { gte: parsedValue };
break;
case "lte":
where[field] = { lte: parsedValue };
break;
case "contains":
if (dataType === "string") {
where[field] = { contains: filter.value, mode: "insensitive" };
}
break;
case "in":
if (dataType === "string") {
where[field] = { in: filter.value.split(",").map((v) => v.trim()) };
}
break;
}
}
return where;
}
/**
* Flatten a Prisma result row so nested relations become dot-notation keys.
* E.g. { country: { name: "DE" } } => { "country.name": "DE" }
*/
function flattenRow(row: Record<string, unknown>, prefix = ""): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(row)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === "object" && !(value instanceof Date) && !Array.isArray(value)) {
Object.assign(result, flattenRow(value as Record<string, unknown>, fullKey));
} else {
result[fullKey] = value;
}
}
return result;
}
/**
* Format a value for CSV output.
*/
function csvEscape(value: unknown): string {
if (value === null || value === undefined) return "";
if (value instanceof Date) return value.toISOString();
const str = String(value);
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
// ─── Input Schema ───────────────────────────────────────────────────────────
const FilterSchema = z.object({
field: z.string().min(1),
op: z.enum(["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"]),
value: z.string(),
});
const ReportInputSchema = z.object({
entity: z.enum(["resource", "project", "assignment"]),
columns: z.array(z.string()).min(1),
filters: z.array(FilterSchema).default([]),
groupBy: z.string().optional(),
sortBy: z.string().optional(),
sortDir: z.enum(["asc", "desc"]).default("asc"),
limit: z.number().int().min(1).max(5000).default(50),
offset: z.number().int().min(0).default(0),
});
// ─── Router ──────────────────────────────────────────────────────────────────
export const reportRouter = createTRPCRouter({
/**
* Return available columns for a given entity type.
*/
getAvailableColumns: controllerProcedure
.input(z.object({ entity: z.enum(["resource", "project", "assignment"]) }))
.query(({ input }) => {
const columns = COLUMN_MAP[input.entity];
if (!columns) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${input.entity}` });
}
return columns.map(({ key, label, dataType }) => ({ key, label, dataType }));
}),
/**
* Fetch report data with dynamic columns, filters, sorting and pagination.
*/
getReportData: controllerProcedure
.input(ReportInputSchema)
.query(async ({ ctx, input }) => {
const { entity, columns, filters, sortBy, sortDir, limit, offset } = input;
const select = buildSelect(entity, columns);
const where = buildWhere(entity, filters);
// Build orderBy (only scalar fields)
let orderBy: Record<string, string> | undefined;
if (sortBy) {
const validField = getValidScalarField(entity, sortBy);
if (validField) {
orderBy = { [validField]: sortDir };
}
}
const modelDelegate = getModelDelegate(ctx.db, entity);
const [rawRows, totalCount] = await Promise.all([
(modelDelegate as any).findMany({
select,
where,
...(orderBy ? { orderBy } : {}),
take: limit,
skip: offset,
}),
(modelDelegate as any).count({ where }),
]);
// Flatten nested relations into dot-notation keys
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
// Ensure column order matches request (plus id)
const outputColumns = ["id", ...columns.filter((c) => c !== "id")];
return { rows, columns: outputColumns, totalCount };
}),
/**
* Same as getReportData but returns a CSV string for download.
*/
exportReport: controllerProcedure
.input(ReportInputSchema.omit({ offset: true }).extend({
limit: z.number().int().min(1).max(50000).default(5000),
}))
.mutation(async ({ ctx, input }) => {
const { entity, columns, filters, sortBy, sortDir, limit } = input;
const select = buildSelect(entity, columns);
const where = buildWhere(entity, filters);
let orderBy: Record<string, string> | undefined;
if (sortBy) {
const validField = getValidScalarField(entity, sortBy);
if (validField) {
orderBy = { [validField]: sortDir };
}
}
const modelDelegate = getModelDelegate(ctx.db, entity);
const rawRows = await (modelDelegate as any).findMany({
select,
where,
...(orderBy ? { orderBy } : {}),
take: limit,
});
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
const outputColumns = ["id", ...columns.filter((c) => c !== "id")];
// Build CSV
const entityColumns = COLUMN_MAP[entity];
const headerLabels = outputColumns.map((key) => {
const def = entityColumns.find((c) => c.key === key);
return def?.label ?? key;
});
const csvLines = [
headerLabels.map(csvEscape).join(","),
...rows.map((row) =>
outputColumns.map((col) => csvEscape(row[col])).join(","),
),
];
return { csv: csvLines.join("\n"), rowCount: rows.length };
}),
});
/** Resolve the Prisma model delegate from entity key. */
function getModelDelegate(db: any, entity: EntityKey) {
switch (entity) {
case "resource":
return db.resource;
case "project":
return db.project;
case "assignment":
return db.assignment;
default:
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` });
}
}
+553
View File
@@ -0,0 +1,553 @@
import { calculateAllocation, countWorkingDays } from "@planarchy/engine/allocation";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, controllerProcedure, protectedProcedure } from "../trpc.js";
const DEFAULT_AVAILABILITY = {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
} as const;
const ScenarioChangeSchema = z.object({
/** Existing assignment to modify — omit to add a new allocation */
assignmentId: z.string().optional(),
resourceId: z.string().optional(),
roleId: z.string().optional(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0).max(24),
/** Set to true to mark an existing assignment for removal */
remove: z.boolean().optional(),
});
const SimulateInputSchema = z.object({
projectId: z.string(),
changes: z.array(ScenarioChangeSchema).min(1),
});
export const scenarioRouter = createTRPCRouter({
/**
* Returns current allocations/costs for a project — the baseline for comparison.
*/
getProjectBaseline: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const project = await ctx.db.project.findUnique({
where: { id: input.projectId },
select: {
id: true,
name: true,
shortCode: true,
startDate: true,
endDate: true,
budgetCents: true,
orderType: true,
},
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const assignments = await ctx.db.assignment.findMany({
where: {
projectId: input.projectId,
status: { not: "CANCELLED" },
},
include: {
resource: {
select: {
id: true,
displayName: true,
eid: true,
lcrCents: true,
availability: true,
chargeabilityTarget: true,
skills: true,
},
},
roleEntity: { select: { id: true, name: true, color: true } },
},
});
const demands = await ctx.db.demandRequirement.findMany({
where: {
projectId: input.projectId,
status: { not: "CANCELLED" },
},
include: {
roleEntity: { select: { id: true, name: true, color: true } },
},
});
// Calculate baseline totals
let totalCostCents = 0;
let totalHours = 0;
const baselineAllocations = assignments.map((a) => {
const availability = (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
const lcrCents = a.resource?.lcrCents ?? 0;
const result = calculateAllocation({
lcrCents,
hoursPerDay: a.hoursPerDay,
startDate: a.startDate,
endDate: a.endDate,
availability,
});
totalCostCents += result.totalCostCents;
totalHours += result.totalHours;
return {
id: a.id,
resourceId: a.resourceId,
resourceName: a.resource?.displayName ?? "Unknown",
resourceEid: a.resource?.eid ?? "",
lcrCents,
roleId: a.roleId,
roleName: a.roleEntity?.name ?? a.role ?? "",
roleColor: a.roleEntity?.color ?? null,
startDate: a.startDate.toISOString(),
endDate: a.endDate.toISOString(),
hoursPerDay: a.hoursPerDay,
status: a.status,
costCents: result.totalCostCents,
totalHours: result.totalHours,
workingDays: result.workingDays,
};
});
const baselineDemands = demands.map((d) => ({
id: d.id,
roleId: d.roleId,
roleName: d.roleEntity?.name ?? d.role ?? "",
roleColor: d.roleEntity?.color ?? null,
startDate: d.startDate.toISOString(),
endDate: d.endDate.toISOString(),
hoursPerDay: d.hoursPerDay,
headcount: d.headcount,
status: d.status,
}));
return {
project,
assignments: baselineAllocations,
demands: baselineDemands,
totalCostCents,
totalHours,
budgetCents: project.budgetCents,
};
}),
/**
* Pure simulation: computes cost/hours/utilization impact of scenario changes
* without persisting anything.
*/
simulate: controllerProcedure
.input(SimulateInputSchema)
.mutation(async ({ ctx, input }) => {
const { projectId, changes } = input;
// Load project
const project = await ctx.db.project.findUnique({
where: { id: projectId },
select: { id: true, name: true, budgetCents: true, orderType: true, startDate: true, endDate: true },
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
// Load current assignments for baseline
const currentAssignments = await ctx.db.assignment.findMany({
where: { projectId, status: { not: "CANCELLED" } },
include: {
resource: {
select: {
id: true,
displayName: true,
eid: true,
lcrCents: true,
availability: true,
chargeabilityTarget: true,
skills: true,
},
},
},
});
// Compute baseline totals
let baselineCostCents = 0;
let baselineHours = 0;
for (const a of currentAssignments) {
const availability = (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
const result = calculateAllocation({
lcrCents: a.resource?.lcrCents ?? 0,
hoursPerDay: a.hoursPerDay,
startDate: a.startDate,
endDate: a.endDate,
availability,
});
baselineCostCents += result.totalCostCents;
baselineHours += result.totalHours;
}
// Collect all resource IDs we need to look up (from changes)
const resourceIds = new Set<string>();
for (const c of changes) {
if (c.resourceId) resourceIds.add(c.resourceId);
}
// Also add resources from existing assignments
for (const a of currentAssignments) {
if (a.resourceId) resourceIds.add(a.resourceId);
}
// Load resources
const resources = await ctx.db.resource.findMany({
where: { id: { in: [...resourceIds] } },
select: {
id: true,
displayName: true,
eid: true,
lcrCents: true,
availability: true,
chargeabilityTarget: true,
skills: true,
},
});
const resourceMap = new Map(resources.map((r) => [r.id, r]));
// Load roles referenced in changes
const roleIds = new Set<string>();
for (const c of changes) {
if (c.roleId) roleIds.add(c.roleId);
}
const roles = roleIds.size > 0
? await ctx.db.role.findMany({
where: { id: { in: [...roleIds] } },
select: { id: true, name: true, color: true },
})
: [];
const roleMap = new Map(roles.map((r) => [r.id, r]));
// Build scenario: start from current assignments, apply changes
const removedAssignmentIds = new Set(
changes.filter((c) => c.remove && c.assignmentId).map((c) => c.assignmentId!),
);
const modifiedAssignmentIds = new Set(
changes.filter((c) => !c.remove && c.assignmentId).map((c) => c.assignmentId!),
);
// Keep untouched assignments
const scenarioEntries: Array<{
resourceId: string | null;
lcrCents: number;
hoursPerDay: number;
startDate: Date;
endDate: Date;
availability: typeof DEFAULT_AVAILABILITY;
isNew: boolean;
}> = [];
for (const a of currentAssignments) {
if (removedAssignmentIds.has(a.id)) continue;
if (modifiedAssignmentIds.has(a.id)) continue;
scenarioEntries.push({
resourceId: a.resourceId,
lcrCents: a.resource?.lcrCents ?? 0,
hoursPerDay: a.hoursPerDay,
startDate: a.startDate,
endDate: a.endDate,
availability: (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY,
isNew: false,
});
}
// Add modified and new entries from changes
for (const c of changes) {
if (c.remove) continue;
const resource = c.resourceId ? resourceMap.get(c.resourceId) : null;
const lcrCents = resource?.lcrCents ?? 0;
const availability = (resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
scenarioEntries.push({
resourceId: c.resourceId ?? null,
lcrCents,
hoursPerDay: c.hoursPerDay,
startDate: c.startDate,
endDate: c.endDate,
availability,
isNew: !c.assignmentId,
});
}
// Compute scenario totals
let scenarioCostCents = 0;
let scenarioHours = 0;
for (const entry of scenarioEntries) {
const result = calculateAllocation({
lcrCents: entry.lcrCents,
hoursPerDay: entry.hoursPerDay,
startDate: entry.startDate,
endDate: entry.endDate,
availability: entry.availability,
});
scenarioCostCents += result.totalCostCents;
scenarioHours += result.totalHours;
}
// Compute per-resource utilization impact
// Load ALL assignments for affected resources (across all projects) to measure total utilization
const affectedResourceIds = [...new Set(scenarioEntries.map((e) => e.resourceId).filter(Boolean))] as string[];
const allAssignmentsForResources = affectedResourceIds.length > 0
? await ctx.db.assignment.findMany({
where: {
resourceId: { in: affectedResourceIds },
status: { not: "CANCELLED" },
},
select: {
id: true,
resourceId: true,
projectId: true,
hoursPerDay: true,
startDate: true,
endDate: true,
},
})
: [];
// Group by resource
const assignmentsByResource = new Map<string, typeof allAssignmentsForResources>();
for (const a of allAssignmentsForResources) {
if (!a.resourceId) continue;
const list = assignmentsByResource.get(a.resourceId) ?? [];
list.push(a);
assignmentsByResource.set(a.resourceId, list);
}
// Determine analysis window (the widest date range from scenario changes)
let windowStart = project.startDate;
let windowEnd = project.endDate;
for (const e of scenarioEntries) {
if (e.startDate < windowStart) windowStart = e.startDate;
if (e.endDate > windowEnd) windowEnd = e.endDate;
}
const resourceImpacts = affectedResourceIds.map((resId) => {
const resource = resourceMap.get(resId);
if (!resource) return null;
const availability = (resource.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
const totalWorkDays = countWorkingDays(windowStart, windowEnd, availability);
const totalAvailableHours = totalWorkDays * (availability.monday ?? 8);
// Current utilization on this project
const currentProjectAssignments = (assignmentsByResource.get(resId) ?? []).filter(
(a) => a.projectId === projectId,
);
let currentProjectHours = 0;
for (const a of currentProjectAssignments) {
const r = calculateAllocation({
lcrCents: 0,
hoursPerDay: a.hoursPerDay,
startDate: a.startDate,
endDate: a.endDate,
availability,
});
currentProjectHours += r.totalHours;
}
// Scenario hours for this resource on this project
const scenarioResourceEntries = scenarioEntries.filter((e) => e.resourceId === resId);
let scenarioProjectHours = 0;
for (const e of scenarioResourceEntries) {
const r = calculateAllocation({
lcrCents: 0,
hoursPerDay: e.hoursPerDay,
startDate: e.startDate,
endDate: e.endDate,
availability,
});
scenarioProjectHours += r.totalHours;
}
// Total hours across all projects (excluding this project's current, adding scenario)
const otherProjectAssignments = (assignmentsByResource.get(resId) ?? []).filter(
(a) => a.projectId !== projectId,
);
let otherProjectsHours = 0;
for (const a of otherProjectAssignments) {
const r = calculateAllocation({
lcrCents: 0,
hoursPerDay: a.hoursPerDay,
startDate: a.startDate,
endDate: a.endDate,
availability,
});
otherProjectsHours += r.totalHours;
}
const currentTotalHours = otherProjectsHours + currentProjectHours;
const scenarioTotalHours = otherProjectsHours + scenarioProjectHours;
const currentUtilization = totalAvailableHours > 0 ? (currentTotalHours / totalAvailableHours) * 100 : 0;
const scenarioUtilization = totalAvailableHours > 0 ? (scenarioTotalHours / totalAvailableHours) * 100 : 0;
return {
resourceId: resId,
resourceName: resource.displayName,
chargeabilityTarget: resource.chargeabilityTarget,
currentUtilization: Math.round(currentUtilization * 10) / 10,
scenarioUtilization: Math.round(scenarioUtilization * 10) / 10,
utilizationDelta: Math.round((scenarioUtilization - currentUtilization) * 10) / 10,
isOverallocated: scenarioUtilization > 100,
};
}).filter((x): x is NonNullable<typeof x> => x !== null);
// Build warnings
const warnings: string[] = [];
for (const impact of resourceImpacts) {
if (impact && impact.isOverallocated) {
warnings.push(
`${impact.resourceName} would be at ${impact.scenarioUtilization.toFixed(1)}% utilization (over-allocated)`,
);
}
}
const budgetCents = project.budgetCents ?? 0;
if (budgetCents > 0 && scenarioCostCents > budgetCents) {
const overBudgetPct = Math.round(((scenarioCostCents - budgetCents) / budgetCents) * 100);
warnings.push(`Scenario exceeds budget by ${overBudgetPct}%`);
}
// Skill coverage: how many unique skills does the scenario team bring vs. current?
const currentSkills = new Set<string>();
const scenarioSkills = new Set<string>();
for (const a of currentAssignments) {
const skills = (a.resource?.skills ?? []) as Array<{ skill: string }>;
for (const s of skills) currentSkills.add(s.skill.toLowerCase());
}
for (const entry of scenarioEntries) {
if (!entry.resourceId) continue;
const resource = resourceMap.get(entry.resourceId);
const skills = (resource?.skills ?? []) as Array<{ skill: string }>;
for (const s of skills) scenarioSkills.add(s.skill.toLowerCase());
}
const baselineSkillCount = currentSkills.size;
const scenarioSkillCount = scenarioSkills.size;
const skillCoveragePct = baselineSkillCount > 0
? Math.round((scenarioSkillCount / baselineSkillCount) * 100)
: scenarioSkillCount > 0 ? 100 : 0;
return {
baseline: {
totalCostCents: baselineCostCents,
totalHours: baselineHours,
headcount: currentAssignments.length,
skillCount: baselineSkillCount,
},
scenario: {
totalCostCents: scenarioCostCents,
totalHours: scenarioHours,
headcount: scenarioEntries.length,
skillCount: scenarioSkillCount,
},
delta: {
costCents: scenarioCostCents - baselineCostCents,
hours: scenarioHours - baselineHours,
headcount: scenarioEntries.length - currentAssignments.length,
skillCoveragePct,
},
resourceImpacts,
warnings,
budgetCents,
};
}),
/**
* Applies a scenario: creates real assignments from scenario changes.
* Manager+ access required.
*/
apply: controllerProcedure
.input(SimulateInputSchema)
.mutation(async ({ ctx, input }) => {
const { projectId, changes } = input;
const project = await ctx.db.project.findUnique({
where: { id: projectId },
select: { id: true },
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const created: string[] = [];
for (const change of changes) {
if (change.remove && change.assignmentId) {
// Cancel the existing assignment
await ctx.db.assignment.update({
where: { id: change.assignmentId },
data: { status: "CANCELLED" },
});
continue;
}
if (change.assignmentId) {
// Modify existing assignment
await ctx.db.assignment.update({
where: { id: change.assignmentId },
data: {
startDate: change.startDate,
endDate: change.endDate,
hoursPerDay: change.hoursPerDay,
...(change.resourceId ? { resourceId: change.resourceId } : {}),
...(change.roleId ? { roleId: change.roleId } : {}),
},
});
created.push(change.assignmentId);
continue;
}
if (!change.resourceId) {
// Skip entries without a resource — cannot create an assignment
continue;
}
// Look up the resource LCR for dailyCostCents
const resource = await ctx.db.resource.findUnique({
where: { id: change.resourceId },
select: { lcrCents: true },
});
const dailyCostCents = Math.round((resource?.lcrCents ?? 0) * change.hoursPerDay);
const newAssignment = await ctx.db.assignment.create({
data: {
projectId,
resourceId: change.resourceId,
...(change.roleId ? { roleId: change.roleId } : {}),
startDate: change.startDate,
endDate: change.endDate,
hoursPerDay: change.hoursPerDay,
percentage: 100,
dailyCostCents,
status: "PROPOSED",
metadata: {},
},
});
created.push(newAssignment.id);
}
return { appliedCount: created.length };
}),
});
+6
View File
@@ -80,6 +80,12 @@ export {
type GetDashboardTopValueResourcesInput, type GetDashboardTopValueResourcesInput,
type GetDashboardDemandInput, type GetDashboardDemandInput,
type GetDashboardChargeabilityOverviewInput, type GetDashboardChargeabilityOverviewInput,
getDashboardBudgetForecast,
type BudgetForecastRow,
getDashboardSkillGaps,
type SkillGapRow,
getDashboardProjectHealth,
type ProjectHealthRow,
} from "./use-cases/dashboard/index.js"; } from "./use-cases/dashboard/index.js";
export { export {
@@ -0,0 +1,99 @@
import type { PrismaClient } from "@planarchy/db";
import { calculateInclusiveDays, MILLISECONDS_PER_DAY } from "./shared.js";
export interface BudgetForecastRow {
projectName: string;
shortCode: string;
budgetCents: number;
spentCents: number;
burnRate: number;
estimatedExhaustionDate: string | null;
pctUsed: number;
}
export async function getDashboardBudgetForecast(
db: PrismaClient,
): Promise<BudgetForecastRow[]> {
const projects = await db.project.findMany({
where: { status: "ACTIVE" },
select: {
id: true,
name: true,
shortCode: true,
budgetCents: true,
startDate: true,
endDate: true,
},
});
if (projects.length === 0) return [];
const projectIds = projects.map((p) => p.id);
const assignments = await db.assignment.findMany({
where: {
projectId: { in: projectIds },
status: { not: "CANCELLED" },
},
select: {
projectId: true,
startDate: true,
endDate: true,
dailyCostCents: true,
},
});
const now = new Date();
const spentByProject = new Map<string, number>();
const monthlyBurnByProject = new Map<string, number>();
for (const a of assignments) {
const days = calculateInclusiveDays(a.startDate, a.endDate);
const totalCost = (a.dailyCostCents ?? 0) * days;
spentByProject.set(
a.projectId,
(spentByProject.get(a.projectId) ?? 0) + totalCost,
);
// Approximate monthly burn from active assignments that overlap today
if (a.startDate <= now && a.endDate >= now) {
// ~22 working days per month
const monthlyContribution = (a.dailyCostCents ?? 0) * 22;
monthlyBurnByProject.set(
a.projectId,
(monthlyBurnByProject.get(a.projectId) ?? 0) + monthlyContribution,
);
}
}
const rows: BudgetForecastRow[] = projects.map((p) => {
const spentCents = spentByProject.get(p.id) ?? 0;
const burnRate = monthlyBurnByProject.get(p.id) ?? 0;
const pctUsed =
p.budgetCents > 0 ? Math.round((spentCents / p.budgetCents) * 100) : 0;
let estimatedExhaustionDate: string | null = null;
if (burnRate > 0 && p.budgetCents > spentCents) {
const remainingCents = p.budgetCents - spentCents;
const monthsRemaining = remainingCents / burnRate;
const exhaustionDate = new Date(
now.getTime() + monthsRemaining * 30 * MILLISECONDS_PER_DAY,
);
estimatedExhaustionDate = exhaustionDate.toISOString().slice(0, 10);
}
return {
projectName: p.name,
shortCode: p.shortCode,
budgetCents: p.budgetCents,
spentCents,
burnRate,
estimatedExhaustionDate,
pctUsed,
};
});
rows.sort((a, b) => b.pctUsed - a.pctUsed);
return rows;
}
@@ -0,0 +1,104 @@
import type { PrismaClient } from "@planarchy/db";
import { calculateInclusiveDays } from "./shared.js";
export interface ProjectHealthRow {
projectName: string;
shortCode: string;
budgetHealth: number;
staffingHealth: number;
timelineHealth: number;
compositeScore: number;
}
export async function getDashboardProjectHealth(
db: PrismaClient,
): Promise<ProjectHealthRow[]> {
const projects = await db.project.findMany({
where: { status: "ACTIVE" },
select: {
id: true,
name: true,
shortCode: true,
budgetCents: true,
endDate: true,
demandRequirements: {
select: {
id: true,
headcount: true,
status: true,
assignments: {
where: { status: { not: "CANCELLED" } },
select: { id: true },
},
},
},
},
});
if (projects.length === 0) return [];
const projectIds = projects.map((p) => p.id);
// Fetch assignments for budget calculation
const assignments = await db.assignment.findMany({
where: {
projectId: { in: projectIds },
status: { not: "CANCELLED" },
},
select: {
projectId: true,
startDate: true,
endDate: true,
dailyCostCents: true,
},
});
const spentByProject = new Map<string, number>();
for (const a of assignments) {
const days = calculateInclusiveDays(a.startDate, a.endDate);
const cost = (a.dailyCostCents ?? 0) * days;
spentByProject.set(a.projectId, (spentByProject.get(a.projectId) ?? 0) + cost);
}
const now = new Date();
const rows: ProjectHealthRow[] = projects.map((p) => {
// Budget health: 100 - pctUsed (capped at 100)
const spentCents = spentByProject.get(p.id) ?? 0;
const pctUsed =
p.budgetCents > 0
? Math.round((spentCents / p.budgetCents) * 100)
: 0;
const budgetHealth = Math.max(0, 100 - Math.min(pctUsed, 100));
// Staffing health: filledDemands / totalDemands * 100
let totalDemands = 0;
let filledDemands = 0;
for (const dr of p.demandRequirements) {
totalDemands += dr.headcount;
filledDemands += Math.min(dr.assignments.length, dr.headcount);
}
const staffingHealth =
totalDemands > 0 ? Math.round((filledDemands / totalDemands) * 100) : 100;
// Timeline health: 100 if end date > today, else 0
const timelineHealth = p.endDate > now ? 100 : 0;
// Composite = average of 3 dimensions
const compositeScore = Math.round(
(budgetHealth + staffingHealth + timelineHealth) / 3,
);
return {
projectName: p.name,
shortCode: p.shortCode,
budgetHealth,
staffingHealth,
timelineHealth,
compositeScore,
};
});
rows.sort((a, b) => a.compositeScore - b.compositeScore);
return rows;
}
@@ -0,0 +1,89 @@
import type { PrismaClient } from "@planarchy/db";
export interface SkillGapRow {
skill: string;
demand: number;
supply: number;
gap: number;
}
interface SkillEntry {
name: string;
level?: number;
}
export async function getDashboardSkillGaps(
db: PrismaClient,
): Promise<SkillGapRow[]> {
// Count open demand requirements grouped by required skill (from role name)
const openDemands = await db.demandRequirement.findMany({
where: {
status: { in: ["PROPOSED", "CONFIRMED"] },
project: { status: "ACTIVE" },
},
select: {
role: true,
roleId: true,
roleEntity: { select: { name: true } },
headcount: true,
metadata: true,
},
});
// Build demand map by skill/role name
const demandMap = new Map<string, number>();
for (const d of openDemands) {
// Try to extract required skills from metadata
const meta = d.metadata as Record<string, unknown> | null;
const requiredSkills = Array.isArray(meta?.requiredSkills)
? (meta.requiredSkills as string[])
: [];
if (requiredSkills.length > 0) {
for (const skill of requiredSkills) {
const normalized = skill.trim();
if (normalized) {
demandMap.set(normalized, (demandMap.get(normalized) ?? 0) + d.headcount);
}
}
} else {
// Fall back to role name as the "skill"
const roleName = d.roleEntity?.name ?? d.role;
if (roleName) {
demandMap.set(roleName, (demandMap.get(roleName) ?? 0) + d.headcount);
}
}
}
if (demandMap.size === 0) return [];
// Count active resources with each skill at proficiency >= 3
const resources = await db.resource.findMany({
where: { isActive: true },
select: { skills: true },
});
const supplyMap = new Map<string, number>();
for (const r of resources) {
const skills = (r.skills ?? []) as unknown as SkillEntry[];
if (!Array.isArray(skills)) continue;
for (const skill of skills) {
if (!skill.name) continue;
if ((skill.level ?? 0) >= 3) {
supplyMap.set(skill.name, (supplyMap.get(skill.name) ?? 0) + 1);
}
}
}
// Build gap rows for demanded skills
const rows: SkillGapRow[] = [];
for (const [skill, demand] of demandMap) {
const supply = supplyMap.get(skill) ?? 0;
rows.push({ skill, demand, supply, gap: supply - demand });
}
// Sort by largest shortage first (most negative gap), take top 10
rows.sort((a, b) => a.gap - b.gap);
return rows.slice(0, 10);
}
@@ -21,3 +21,18 @@ export {
getDashboardChargeabilityOverview, getDashboardChargeabilityOverview,
type GetDashboardChargeabilityOverviewInput, type GetDashboardChargeabilityOverviewInput,
} from "./get-chargeability-overview.js"; } from "./get-chargeability-overview.js";
export {
getDashboardBudgetForecast,
type BudgetForecastRow,
} from "./get-budget-forecast.js";
export {
getDashboardSkillGaps,
type SkillGapRow,
} from "./get-skill-gaps.js";
export {
getDashboardProjectHealth,
type ProjectHealthRow,
} from "./get-project-health.js";
+24
View File
@@ -190,6 +190,7 @@ model User {
tasksAssigned Notification[] @relation("taskAssignee") tasksAssigned Notification[] @relation("taskAssignee")
notificationsSent Notification[] @relation("notificationSender") notificationsSent Notification[] @relation("notificationSender")
broadcasts NotificationBroadcast[] @relation("broadcastSender") broadcasts NotificationBroadcast[] @relation("broadcastSender")
comments Comment[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -1481,6 +1482,29 @@ model CalculationRule {
@@map("calculation_rules") @@map("calculation_rules")
} }
// ─── Comment ─────────────────────────────────────────────────────────────────
model Comment {
id String @id @default(cuid())
entityType String // "estimate", "estimate_version", "scope_item", "demand_line"
entityId String
parentId String? // for replies
authorId String
body String @db.Text
mentions String[] // user IDs mentioned
resolved Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
author User @relation(fields: [authorId], references: [id])
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id])
replies Comment[] @relation("CommentReplies")
@@index([entityType, entityId])
@@index([authorId])
@@map("comments")
}
// ─── Audit Log ──────────────────────────────────────────────────────────────── // ─── Audit Log ────────────────────────────────────────────────────────────────
model AuditLog { model AuditLog {
@@ -77,6 +77,9 @@ export const dashboardWidgetConfigSchemas = {
"top-value-resources": topValueWidgetConfigSchema, "top-value-resources": topValueWidgetConfigSchema,
"chargeability-overview": chargeabilityWidgetConfigSchema, "chargeability-overview": chargeabilityWidgetConfigSchema,
"my-projects": myProjectsWidgetConfigSchema, "my-projects": myProjectsWidgetConfigSchema,
"budget-forecast": z.object({}),
"skill-gap": z.object({}),
"project-health": z.object({}),
} as const; } as const;
type DashboardWidgetConfigSchemaMap = typeof dashboardWidgetConfigSchemas; type DashboardWidgetConfigSchemaMap = typeof dashboardWidgetConfigSchemas;
+39
View File
@@ -42,6 +42,12 @@ export interface MyProjectsWidgetConfig {
showResponsible?: boolean; showResponsible?: boolean;
} }
export interface BudgetForecastWidgetConfig {}
export interface SkillGapWidgetConfig {}
export interface ProjectHealthWidgetConfig {}
export interface DashboardWidgetConfigMap { export interface DashboardWidgetConfigMap {
"stat-cards": StatCardsWidgetConfig; "stat-cards": StatCardsWidgetConfig;
"resource-table": ResourceTableWidgetConfig; "resource-table": ResourceTableWidgetConfig;
@@ -51,6 +57,9 @@ export interface DashboardWidgetConfigMap {
"top-value-resources": TopValueResourcesWidgetConfig; "top-value-resources": TopValueResourcesWidgetConfig;
"chargeability-overview": ChargeabilityOverviewWidgetConfig; "chargeability-overview": ChargeabilityOverviewWidgetConfig;
"my-projects": MyProjectsWidgetConfig; "my-projects": MyProjectsWidgetConfig;
"budget-forecast": BudgetForecastWidgetConfig;
"skill-gap": SkillGapWidgetConfig;
"project-health": ProjectHealthWidgetConfig;
} }
export const DASHBOARD_WIDGET_TYPES = [ export const DASHBOARD_WIDGET_TYPES = [
@@ -62,6 +71,9 @@ export const DASHBOARD_WIDGET_TYPES = [
"top-value-resources", "top-value-resources",
"chargeability-overview", "chargeability-overview",
"my-projects", "my-projects",
"budget-forecast",
"skill-gap",
"project-health",
] as const; ] as const;
export type DashboardWidgetType = (typeof DASHBOARD_WIDGET_TYPES)[number]; export type DashboardWidgetType = (typeof DASHBOARD_WIDGET_TYPES)[number];
@@ -182,4 +194,31 @@ export const DASHBOARD_WIDGET_CATALOG = [
showResponsible: true, showResponsible: true,
}, },
}, },
{
type: "budget-forecast",
label: "Budget Forecast",
description: "Budget burn rate and projected exhaustion per active project",
icon: "💰",
defaultSize: { w: 6, h: 5 },
minSize: { w: 4, h: 4 },
defaultConfig: {},
},
{
type: "skill-gap",
label: "Skill Gap Analysis",
description: "Top skill shortages: open demand vs available supply",
icon: "🎯",
defaultSize: { w: 6, h: 5 },
minSize: { w: 4, h: 4 },
defaultConfig: {},
},
{
type: "project-health",
label: "Project Health",
description: "Composite health score per project: budget, staffing, timeline",
icon: "🏥",
defaultSize: { w: 6, h: 5 },
minSize: { w: 4, h: 4 },
defaultConfig: {},
},
] as const satisfies readonly DashboardWidgetCatalogEntry[]; ] as const satisfies readonly DashboardWidgetCatalogEntry[];