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:
@@ -84,6 +84,16 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro
|
||||
</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>
|
||||
<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} />
|
||||
</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")!,
|
||||
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 {
|
||||
|
||||
@@ -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"
|
||||
| "phasing"
|
||||
| "versions"
|
||||
| "exports";
|
||||
| "exports"
|
||||
| "comments";
|
||||
|
||||
@@ -66,6 +66,11 @@ const ExportsTab = dynamic(
|
||||
{ loading: TabSkeleton },
|
||||
);
|
||||
|
||||
const CommentThread = dynamic(
|
||||
() => import("~/components/comments/CommentThread.js").then((mod) => ({ default: mod.CommentThread })),
|
||||
{ loading: TabSkeleton },
|
||||
);
|
||||
|
||||
const TABS: Array<{ id: WorkspaceTab; label: string }> = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "assumptions", label: "Assumptions" },
|
||||
@@ -75,6 +80,7 @@ const TABS: Array<{ id: WorkspaceTab; label: string }> = [
|
||||
{ id: "phasing", label: "Phasing" },
|
||||
{ id: "versions", label: "Versions" },
|
||||
{ id: "exports", label: "Exports" },
|
||||
{ id: "comments", label: "Comments" },
|
||||
];
|
||||
|
||||
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||
@@ -127,6 +133,12 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
|
||||
const createExportMutation = trpc.estimate.createExport.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 hasWorkingVersion = estimate?.versions.some((version) => version.status === "WORKING") ?? false;
|
||||
const editableTab = tab === "overview" || tab === "assumptions" || tab === "scope" || tab === "staffing";
|
||||
@@ -296,6 +308,11 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
@@ -342,6 +359,14 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
|
||||
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() {
|
||||
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() {
|
||||
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/skill-marketplace", label: "Skill Marketplace", icon: <MarketplaceIcon />, 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"] },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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) ·{" "}
|
||||
{formatMoney(baseline.totalCostCents)} total ·{" "}
|
||||
{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)} ·{" "}
|
||||
{a.hoursPerDay}h/d · {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"} · {d.headcount}x ·{" "}
|
||||
{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"} — 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);
|
||||
}
|
||||
@@ -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 } });
|
||||
}),
|
||||
});
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
getDashboardOverview,
|
||||
getDashboardPeakTimes,
|
||||
getDashboardTopValueResources,
|
||||
getDashboardBudgetForecast,
|
||||
getDashboardSkillGaps,
|
||||
getDashboardProjectHealth,
|
||||
} from "@planarchy/application";
|
||||
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||
import { cacheGet, cacheSet } from "../lib/cache.js";
|
||||
@@ -129,4 +132,34 @@ export const dashboardRouter = createTRPCRouter({
|
||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||
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,6 +6,7 @@ import { blueprintRouter } from "./blueprint.js";
|
||||
import { chargeabilityReportRouter } from "./chargeability-report.js";
|
||||
import { computationGraphRouter } from "./computation-graph.js";
|
||||
import { clientRouter } from "./client.js";
|
||||
import { commentRouter } from "./comment.js";
|
||||
import { countryRouter } from "./country.js";
|
||||
import { dashboardRouter } from "./dashboard.js";
|
||||
import { effortRuleRouter } from "./effort-rule.js";
|
||||
@@ -18,8 +19,10 @@ import { notificationRouter } from "./notification.js";
|
||||
import { orgUnitRouter } from "./org-unit.js";
|
||||
import { projectRouter } from "./project.js";
|
||||
import { rateCardRouter } from "./rate-card.js";
|
||||
import { reportRouter } from "./report.js";
|
||||
import { resourceRouter } from "./resource.js";
|
||||
import { roleRouter } from "./role.js";
|
||||
import { scenarioRouter } from "./scenario.js";
|
||||
import { settingsRouter } from "./settings.js";
|
||||
import { staffingRouter } from "./staffing.js";
|
||||
import { systemRoleConfigRouter } from "./system-role-config.js";
|
||||
@@ -54,7 +57,10 @@ export const appRouter = createTRPCRouter({
|
||||
managementLevel: managementLevelRouter,
|
||||
rateCard: rateCardRouter,
|
||||
chargeabilityReport: chargeabilityReportRouter,
|
||||
report: reportRouter,
|
||||
scenario: scenarioRouter,
|
||||
calculationRule: calculationRuleRouter,
|
||||
comment: commentRouter,
|
||||
computationGraph: computationGraphRouter,
|
||||
systemRoleConfig: systemRoleConfigRouter,
|
||||
});
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -80,6 +80,12 @@ export {
|
||||
type GetDashboardTopValueResourcesInput,
|
||||
type GetDashboardDemandInput,
|
||||
type GetDashboardChargeabilityOverviewInput,
|
||||
getDashboardBudgetForecast,
|
||||
type BudgetForecastRow,
|
||||
getDashboardSkillGaps,
|
||||
type SkillGapRow,
|
||||
getDashboardProjectHealth,
|
||||
type ProjectHealthRow,
|
||||
} from "./use-cases/dashboard/index.js";
|
||||
|
||||
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,
|
||||
type GetDashboardChargeabilityOverviewInput,
|
||||
} 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";
|
||||
|
||||
@@ -190,6 +190,7 @@ model User {
|
||||
tasksAssigned Notification[] @relation("taskAssignee")
|
||||
notificationsSent Notification[] @relation("notificationSender")
|
||||
broadcasts NotificationBroadcast[] @relation("broadcastSender")
|
||||
comments Comment[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -1481,6 +1482,29 @@ model CalculationRule {
|
||||
@@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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
model AuditLog {
|
||||
|
||||
@@ -77,6 +77,9 @@ export const dashboardWidgetConfigSchemas = {
|
||||
"top-value-resources": topValueWidgetConfigSchema,
|
||||
"chargeability-overview": chargeabilityWidgetConfigSchema,
|
||||
"my-projects": myProjectsWidgetConfigSchema,
|
||||
"budget-forecast": z.object({}),
|
||||
"skill-gap": z.object({}),
|
||||
"project-health": z.object({}),
|
||||
} as const;
|
||||
|
||||
type DashboardWidgetConfigSchemaMap = typeof dashboardWidgetConfigSchemas;
|
||||
|
||||
@@ -42,6 +42,12 @@ export interface MyProjectsWidgetConfig {
|
||||
showResponsible?: boolean;
|
||||
}
|
||||
|
||||
export interface BudgetForecastWidgetConfig {}
|
||||
|
||||
export interface SkillGapWidgetConfig {}
|
||||
|
||||
export interface ProjectHealthWidgetConfig {}
|
||||
|
||||
export interface DashboardWidgetConfigMap {
|
||||
"stat-cards": StatCardsWidgetConfig;
|
||||
"resource-table": ResourceTableWidgetConfig;
|
||||
@@ -51,6 +57,9 @@ export interface DashboardWidgetConfigMap {
|
||||
"top-value-resources": TopValueResourcesWidgetConfig;
|
||||
"chargeability-overview": ChargeabilityOverviewWidgetConfig;
|
||||
"my-projects": MyProjectsWidgetConfig;
|
||||
"budget-forecast": BudgetForecastWidgetConfig;
|
||||
"skill-gap": SkillGapWidgetConfig;
|
||||
"project-health": ProjectHealthWidgetConfig;
|
||||
}
|
||||
|
||||
export const DASHBOARD_WIDGET_TYPES = [
|
||||
@@ -62,6 +71,9 @@ export const DASHBOARD_WIDGET_TYPES = [
|
||||
"top-value-resources",
|
||||
"chargeability-overview",
|
||||
"my-projects",
|
||||
"budget-forecast",
|
||||
"skill-gap",
|
||||
"project-health",
|
||||
] as const;
|
||||
|
||||
export type DashboardWidgetType = (typeof DASHBOARD_WIDGET_TYPES)[number];
|
||||
@@ -182,4 +194,31 @@ export const DASHBOARD_WIDGET_CATALOG = [
|
||||
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[];
|
||||
|
||||
Reference in New Issue
Block a user