feat: Sprint 4 — scenario planner, report builder, comments, dashboard widgets

What-If Scenario Planner (G5):
- New /projects/[id]/scenario page with side-by-side baseline vs scenario
- simulate mutation: pure cost/hours/headcount/utilization computation
- apply mutation: creates real PROPOSED assignments from scenario
- Impact cards: cost delta, hours delta, headcount, skill coverage %
- Per-resource utilization impact table with over-allocation warnings
- "What-If" button added to project detail page

Custom Report Builder (G7):
- New /reports/builder page with full config panel
- Entity selector (resource/project/assignment), column picker, filter builder
- Dynamic Prisma query with eq/neq/gt/lt/contains/in operators
- Sortable results table with pagination (50/page)
- CSV export via exportReport mutation
- Sidebar nav link under Analytics

Collaboration Layer (G8):
- Comment model in Prisma (entityType/entityId, replies, @mentions, resolved)
- comment router: list, count, create, resolve, delete
- @mention parsing with notification creation + SSE delivery
- CommentInput with @mention autocomplete (arrow nav, Enter/Tab confirm)
- CommentThread with avatar, timestamp, reply, resolve, delete
- Integrated as "Comments" tab in estimate workspace with count badge

Dashboard Widgets:
- BudgetForecastWidget: progress bars per project, burn rate, exhaustion date
- SkillGapWidget: supply vs demand per skill, shortage/surplus indicators
- ProjectHealthWidget: 3-dimension health circles + composite score
- 3 new application use-cases + dashboard router queries
- All registered in widget-registry with lazy imports

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-19 21:47:47 +01:00
parent 6f34659587
commit e1368c7ef7
27 changed files with 3889 additions and 1 deletions
@@ -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) &middot;{" "}
{formatMoney(baseline.totalCostCents)} total &middot;{" "}
{baseline.totalHours.toFixed(0)}h
</p>
{baseline.assignments.length === 0 ? (
<p className="text-sm text-gray-400 italic">No assignments yet.</p>
) : (
<div className="space-y-2 max-h-[500px] overflow-y-auto">
{baseline.assignments.map((a) => (
<div
key={a.id}
className="flex items-center justify-between rounded-lg border border-gray-100 dark:border-gray-700 px-3 py-2 text-sm"
>
<div className="min-w-0">
<div className="font-medium text-gray-900 dark:text-gray-100 truncate">
{a.resourceName}
{a.roleName && (
<span
className="ml-2 inline-block px-1.5 py-0.5 text-xs rounded"
style={{
backgroundColor: a.roleColor ? `${a.roleColor}20` : "#f3f4f6",
color: a.roleColor ?? "#6b7280",
}}
>
{a.roleName}
</span>
)}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{formatDate(a.startDate)} - {formatDate(a.endDate)} &middot;{" "}
{a.hoursPerDay}h/d &middot; {a.workingDays} days
</div>
</div>
<div className="text-right flex-shrink-0 ml-3">
<div className="font-mono text-sm text-gray-900 dark:text-gray-100">
{formatMoney(a.costCents)}
</div>
<div className="text-xs text-gray-500">{a.totalHours.toFixed(0)}h</div>
</div>
</div>
))}
</div>
)}
{baseline.demands.length > 0 && (
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Open Demands
</h3>
<div className="space-y-1">
{baseline.demands.map((d) => (
<div key={d.id} className="text-xs text-gray-500 flex items-center gap-2">
<span
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: d.roleColor ?? "#9ca3af" }}
/>
{d.roleName || "Unspecified"} &middot; {d.headcount}x &middot;{" "}
{d.hoursPerDay}h/d
</div>
))}
</div>
</div>
)}
</div>
{/* Right: Scenario Editor */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Scenario Editor
</h2>
<p className="text-xs text-gray-500">
{activeRows.length} allocation(s)
{removedRows.length > 0 && (
<span className="text-red-500 ml-1">
({removedRows.length} removed)
</span>
)}
</p>
</div>
<div className="flex items-center gap-2">
{isDirty && (
<button
type="button"
onClick={resetScenario}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Reset
</button>
)}
<button
type="button"
onClick={addRow}
className="inline-flex items-center gap-1 rounded-lg bg-brand-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-brand-700 transition"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Resource
</button>
</div>
</div>
<div className="space-y-3 max-h-[500px] overflow-y-auto">
{rows.map((row) => (
<ScenarioRowEditor
key={row.key}
row={row}
resources={resources}
roles={roles}
resourceMap={resourceMap}
roleMap={roleMap}
onUpdate={updateRow}
onRemove={removeRow}
onRestore={restoreRow}
/>
))}
{rows.length === 0 && (
<p className="text-sm text-gray-400 italic text-center py-6">
Add resources to build your scenario.
</p>
)}
</div>
{/* Action buttons */}
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-700 flex items-center gap-3">
<button
type="button"
onClick={runSimulation}
disabled={!isDirty || simulateMut.isPending}
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{simulateMut.isPending ? (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
)}
Simulate
</button>
{result && (
<button
type="button"
onClick={() => setShowApplyConfirm(true)}
disabled={applyMut.isPending}
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Apply Scenario
</button>
)}
</div>
{simulateMut.error && (
<p className="mt-2 text-sm text-red-600">{simulateMut.error.message}</p>
)}
</div>
</div>
{/* Impact Summary */}
{result && <ImpactSummary result={result} budgetCents={baseline.budgetCents ?? 0} />}
{/* Apply confirmation dialog */}
{showApplyConfirm && (
<ConfirmDialog
title="Apply Scenario"
message="This will create/modify real assignments based on your scenario. Existing assignments may be changed or cancelled. This action cannot be undone."
confirmLabel={applyMut.isPending ? "Applying..." : "Apply"}
onConfirm={applyScenario}
onCancel={() => setShowApplyConfirm(false)}
/>
)}
{applyMut.isSuccess && (
<div className="rounded-lg bg-green-50 border border-green-200 p-4 text-sm text-green-800">
Scenario applied successfully. Redirecting...
</div>
)}
</div>
);
}
// ── ScenarioRowEditor ────────────────────────────────────────────────────────
interface ScenarioRowEditorProps {
row: ScenarioRow;
resources: ResourceOption[];
roles: RoleOption[];
resourceMap: Map<string, ResourceOption>;
roleMap: Map<string, RoleOption>;
onUpdate: (key: string, updates: Partial<ScenarioRow>) => void;
onRemove: (key: string) => void;
onRestore: (key: string) => void;
}
function ScenarioRowEditor({
row,
resources,
roles,
resourceMap,
roleMap,
onUpdate,
onRemove,
onRestore,
}: ScenarioRowEditorProps) {
const isRemoved = row.remove;
const resource = row.resourceId ? resourceMap.get(row.resourceId) : null;
const lcrDisplay = resource ? `${formatMoney(resource.lcrCents)}/h` : "";
return (
<div
className={`rounded-lg border p-3 transition ${
isRemoved
? "border-red-200 bg-red-50/50 dark:border-red-800 dark:bg-red-900/10 opacity-60"
: row.assignmentId
? "border-gray-200 dark:border-gray-700"
: "border-blue-200 bg-blue-50/30 dark:border-blue-800 dark:bg-blue-900/10"
}`}
>
{isRemoved ? (
<div className="flex items-center justify-between">
<span className="text-sm text-red-600 line-through">
{resource?.displayName ?? "Unknown"} &mdash; removed
</span>
<button
type="button"
onClick={() => onRestore(row.key)}
className="text-xs text-blue-600 hover:text-blue-800 underline"
>
Restore
</button>
</div>
) : (
<div className="space-y-2">
{/* Top row: resource + role + remove */}
<div className="flex items-center gap-2">
<select
value={row.resourceId}
onChange={(e) => onUpdate(row.key, { resourceId: e.target.value })}
className="flex-1 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1.5 text-sm text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-brand-500 focus:border-transparent"
>
<option value="">Select resource...</option>
{resources.map((r) => (
<option key={r.id} value={r.id}>
{r.displayName} ({r.eid})
</option>
))}
</select>
<select
value={row.roleId}
onChange={(e) => onUpdate(row.key, { roleId: e.target.value })}
className="w-40 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1.5 text-sm text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-brand-500 focus:border-transparent"
>
<option value="">Role...</option>
{roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
<button
type="button"
onClick={() => onRemove(row.key)}
className="p-1.5 text-gray-400 hover:text-red-500 transition rounded"
title="Remove from scenario"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Bottom row: dates + hours + LCR info */}
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1">
<label className="text-xs text-gray-500 whitespace-nowrap">From</label>
<DateInput
value={row.startDate}
onChange={(v) => onUpdate(row.key, { startDate: v })}
className="w-28 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-gray-900 dark:text-gray-100"
/>
</div>
<div className="flex items-center gap-1">
<label className="text-xs text-gray-500 whitespace-nowrap">To</label>
<DateInput
value={row.endDate}
onChange={(v) => onUpdate(row.key, { endDate: v })}
className="w-28 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-gray-900 dark:text-gray-100"
/>
</div>
<div className="flex items-center gap-1">
<label className="text-xs text-gray-500 whitespace-nowrap">h/day</label>
<input
type="number"
min={0}
max={24}
step={0.5}
value={row.hoursPerDay}
onChange={(e) => onUpdate(row.key, { hoursPerDay: parseFloat(e.target.value) || 0 })}
className="w-16 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-gray-900 dark:text-gray-100 text-center"
/>
</div>
{lcrDisplay && (
<span className="text-xs text-gray-400 ml-auto">{lcrDisplay}</span>
)}
{!row.assignmentId && (
<span className="text-xs text-blue-500 font-medium">NEW</span>
)}
</div>
</div>
)}
</div>
);
}
// ── ImpactSummary ────────────────────────────────────────────────────────────
interface SimulationResult {
baseline: { totalCostCents: number; totalHours: number; headcount: number; skillCount: number };
scenario: { totalCostCents: number; totalHours: number; headcount: number; skillCount: number };
delta: { costCents: number; hours: number; headcount: number; skillCoveragePct: number };
resourceImpacts: Array<{
resourceId: string;
resourceName: string;
chargeabilityTarget: number;
currentUtilization: number;
scenarioUtilization: number;
utilizationDelta: number;
isOverallocated: boolean;
}>;
warnings: string[];
budgetCents: number;
}
function ImpactSummary({ result, budgetCents }: { result: SimulationResult; budgetCents: number }) {
const { baseline, scenario, delta, resourceImpacts, warnings } = result;
const costSign = delta.costCents > 0 ? "+" : "";
const hoursSign = delta.hours > 0 ? "+" : "";
const headcountSign = delta.headcount > 0 ? "+" : "";
const costColor = delta.costCents > 0 ? "text-red-600" : delta.costCents < 0 ? "text-green-600" : "text-gray-500";
const hoursColor = delta.hours > 0 ? "text-amber-600" : delta.hours < 0 ? "text-blue-600" : "text-gray-500";
const budgetUsedPct = budgetCents > 0 ? Math.round((scenario.totalCostCents / budgetCents) * 100) : null;
return (
<div className="space-y-4">
{/* Delta cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<DeltaCard
label="Cost Impact"
value={`${costSign}${formatMoney(delta.costCents)}`}
subtitle={`${formatMoney(scenario.totalCostCents)} total`}
color={costColor}
/>
<DeltaCard
label="Hours Impact"
value={`${hoursSign}${delta.hours.toFixed(0)}h`}
subtitle={`${scenario.totalHours.toFixed(0)}h total`}
color={hoursColor}
/>
<DeltaCard
label="Headcount"
value={`${headcountSign}${delta.headcount}`}
subtitle={`${scenario.headcount} total`}
color={delta.headcount !== 0 ? "text-indigo-600" : "text-gray-500"}
/>
<DeltaCard
label="Skill Coverage"
value={`${delta.skillCoveragePct}%`}
subtitle={`${scenario.skillCount} unique skills`}
color={delta.skillCoveragePct >= 100 ? "text-green-600" : "text-amber-600"}
/>
</div>
{/* Budget progress */}
{budgetUsedPct !== null && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-gray-600 dark:text-gray-400">Budget Usage</span>
<span className={`font-medium ${budgetUsedPct > 100 ? "text-red-600" : "text-gray-900 dark:text-gray-100"}`}>
{budgetUsedPct}% of {formatMoney(budgetCents)}
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
budgetUsedPct > 100 ? "bg-red-500" : budgetUsedPct > 80 ? "bg-amber-500" : "bg-green-500"
}`}
style={{ width: `${Math.min(budgetUsedPct, 100)}%` }}
/>
</div>
</div>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-900/20 p-4">
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">Warnings</h3>
<ul className="space-y-1">
{warnings.map((w, i) => (
<li key={i} className="text-sm text-amber-700 dark:text-amber-300 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
{w}
</li>
))}
</ul>
</div>
)}
{/* Resource utilization impacts */}
{resourceImpacts.length > 0 && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
Resource Utilization Impact
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-100 dark:border-gray-700">
<th className="text-left py-2 pr-4 font-medium">Resource</th>
<th className="text-right py-2 px-3 font-medium">Current</th>
<th className="text-right py-2 px-3 font-medium">Scenario</th>
<th className="text-right py-2 px-3 font-medium">Delta</th>
<th className="text-right py-2 pl-3 font-medium">Target</th>
</tr>
</thead>
<tbody>
{resourceImpacts.map((ri) => (
<tr
key={ri.resourceId}
className={`border-b border-gray-50 dark:border-gray-800 ${
ri.isOverallocated ? "bg-red-50/50 dark:bg-red-900/10" : ""
}`}
>
<td className="py-2 pr-4 font-medium text-gray-900 dark:text-gray-100">
{ri.resourceName}
{ri.isOverallocated && (
<span className="ml-2 text-xs text-red-500 font-normal">over-allocated</span>
)}
</td>
<td className="text-right py-2 px-3 text-gray-600 dark:text-gray-400">
{ri.currentUtilization.toFixed(1)}%
</td>
<td className={`text-right py-2 px-3 font-medium ${ri.isOverallocated ? "text-red-600" : "text-gray-900 dark:text-gray-100"}`}>
{ri.scenarioUtilization.toFixed(1)}%
</td>
<td className={`text-right py-2 px-3 ${
ri.utilizationDelta > 0 ? "text-amber-600" : ri.utilizationDelta < 0 ? "text-blue-600" : "text-gray-500"
}`}>
{ri.utilizationDelta > 0 ? "+" : ""}{ri.utilizationDelta.toFixed(1)}%
</td>
<td className="text-right py-2 pl-3 text-gray-500">
{ri.chargeabilityTarget}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
// ── DeltaCard ────────────────────────────────────────────────────────────────
function DeltaCard({
label,
value,
subtitle,
color,
}: {
label: string;
value: string;
subtitle: string;
color: string;
}) {
return (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<p className="text-xs text-gray-500 mb-1">{label}</p>
<p className={`text-xl font-bold ${color}`}>{value}</p>
<p className="text-xs text-gray-400 mt-1">{subtitle}</p>
</div>
);
}
@@ -0,0 +1,564 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { keepPreviousData } from "@tanstack/react-query";
import { trpc } from "~/lib/trpc/client.js";
import { clsx } from "clsx";
// ─── Types ──────────────────────────────────────────────────────────────────
type EntityType = "resource" | "project" | "assignment";
type FilterOp = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in";
interface FilterRow {
id: string;
field: string;
op: FilterOp;
value: string;
}
const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [
{ value: "resource", label: "Resources" },
{ value: "project", label: "Projects" },
{ value: "assignment", label: "Assignments" },
];
const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
{ value: "eq", label: "equals" },
{ value: "neq", label: "not equals" },
{ value: "gt", label: "greater than" },
{ value: "lt", label: "less than" },
{ value: "gte", label: ">= (gte)" },
{ value: "lte", label: "<= (lte)" },
{ value: "contains", label: "contains" },
{ value: "in", label: "in (comma-sep)" },
];
const PAGE_SIZE = 50;
function generateId(): string {
return Math.random().toString(36).slice(2, 10);
}
// ─── Component ──────────────────────────────────────────────────────────────
export function ReportBuilder() {
// Config state
const [entity, setEntity] = useState<EntityType>("resource");
const [selectedColumns, setSelectedColumns] = useState<Set<string>>(new Set());
const [filters, setFilters] = useState<FilterRow[]>([]);
const [groupBy, setGroupBy] = useState<string>("");
const [sortBy, setSortBy] = useState<string>("");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [page, setPage] = useState(0);
const [runQuery, setRunQuery] = useState(false);
// Fetch available columns when entity changes
const columnsQuery = trpc.report.getAvailableColumns.useQuery(
{ entity },
{ placeholderData: keepPreviousData },
);
const availableColumns = columnsQuery.data ?? [];
// Scalar columns (for filter/sort/group — only non-relation columns)
const scalarColumns = useMemo(
() => availableColumns.filter((c) => !c.key.includes(".")),
[availableColumns],
);
// Build query input
const queryInput = useMemo(() => {
if (!runQuery || selectedColumns.size === 0) return null;
return {
entity,
columns: Array.from(selectedColumns),
filters: filters
.filter((f) => f.field && f.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
};
}, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page]);
// Fetch report data
const reportQuery = trpc.report.getReportData.useQuery(
queryInput!,
{ enabled: queryInput !== null, placeholderData: keepPreviousData },
);
const exportMutation = trpc.report.exportReport.useMutation();
// ─── Handlers ───────────────────────────────────────────────────────────
const handleEntityChange = useCallback((newEntity: EntityType) => {
setEntity(newEntity);
setSelectedColumns(new Set());
setFilters([]);
setGroupBy("");
setSortBy("");
setRunQuery(false);
setPage(0);
}, []);
const toggleColumn = useCallback((key: string) => {
setSelectedColumns((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
const selectAllColumns = useCallback(() => {
setSelectedColumns(new Set(availableColumns.map((c) => c.key)));
}, [availableColumns]);
const clearAllColumns = useCallback(() => {
setSelectedColumns(new Set());
}, []);
const addFilter = useCallback(() => {
const firstField = scalarColumns[0]?.key ?? "";
setFilters((prev) => [...prev, { id: generateId(), field: firstField, op: "eq", value: "" }]);
}, [scalarColumns]);
const updateFilter = useCallback((id: string, patch: Partial<FilterRow>) => {
setFilters((prev) => prev.map((f) => (f.id === id ? { ...f, ...patch } : f)));
}, []);
const removeFilter = useCallback((id: string) => {
setFilters((prev) => prev.filter((f) => f.id !== id));
}, []);
const handleRun = useCallback(() => {
setPage(0);
setRunQuery(true);
}, []);
const handleSort = useCallback((column: string) => {
if (!column.includes(".")) {
if (sortBy === column) {
setSortDir((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortBy(column);
setSortDir("asc");
}
// Re-run with new sort
setRunQuery(true);
}
}, [sortBy]);
const handleExport = useCallback(async () => {
if (selectedColumns.size === 0) return;
try {
const result = await exportMutation.mutateAsync({
entity,
columns: Array.from(selectedColumns),
filters: filters
.filter((f) => f.field && f.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
limit: 5000,
});
// Download CSV
const blob = new Blob([result.csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `report-${entity}-${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
} catch {
// Error handled by tRPC
}
}, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation]);
// ─── Derived ──────────────────────────────────────────────────────────
const rows = reportQuery.data?.rows ?? [];
const totalCount = reportQuery.data?.totalCount ?? 0;
const outputColumns = reportQuery.data?.columns ?? [];
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
const isLoading = reportQuery.isFetching;
// Column label lookup
const columnLabelMap = useMemo(() => {
const map = new Map<string, string>();
for (const col of availableColumns) {
map.set(col.key, col.label);
}
return map;
}, [availableColumns]);
// ─── Render ───────────────────────────────────────────────────────────
return (
<div className="mx-auto max-w-[1600px] space-y-6 p-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Report Builder</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Build custom reports by selecting an entity, columns, and filters.
</p>
</div>
{/* Config Panel */}
<div className="space-y-5 rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-950">
{/* Entity Selector */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Entity
</label>
<div className="flex gap-3">
{ENTITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleEntityChange(opt.value)}
className={clsx(
"rounded-xl px-4 py-2 text-sm font-medium transition-colors",
entity === opt.value
? "bg-brand-600 text-white shadow-sm"
: "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-slate-800 dark:text-gray-300 dark:hover:bg-slate-700",
)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Column Picker */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Columns ({selectedColumns.size} selected)
</label>
<div className="flex gap-2">
<button
type="button"
onClick={selectAllColumns}
className="text-xs font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400"
>
Select all
</button>
<span className="text-xs text-gray-300 dark:text-gray-600">|</span>
<button
type="button"
onClick={clearAllColumns}
className="text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear
</button>
</div>
</div>
{columnsQuery.isLoading ? (
<div className="py-3 text-sm text-gray-400">Loading columns...</div>
) : (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{availableColumns.map((col) => (
<label
key={col.key}
className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors hover:bg-gray-50 dark:hover:bg-slate-900"
>
<input
type="checkbox"
checked={selectedColumns.has(col.key)}
onChange={() => toggleColumn(col.key)}
className="h-3.5 w-3.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-slate-600"
/>
<span className="text-gray-700 dark:text-gray-300">{col.label}</span>
<span className="ml-auto text-[10px] uppercase tracking-wider text-gray-400 dark:text-gray-600">
{col.dataType}
</span>
</label>
))}
</div>
)}
</div>
{/* Filter Builder */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Filters
</label>
<button
type="button"
onClick={addFilter}
className="flex items-center gap-1 text-xs font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add filter
</button>
</div>
{filters.length === 0 ? (
<p className="text-sm text-gray-400 dark:text-gray-500">No filters applied.</p>
) : (
<div className="space-y-2">
{filters.map((filter) => (
<div key={filter.id} className="flex items-center gap-2">
{/* Field */}
<select
value={filter.field}
onChange={(e) => updateFilter(filter.id, { field: e.target.value })}
className="w-44 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
>
{scalarColumns.map((col) => (
<option key={col.key} value={col.key}>{col.label}</option>
))}
</select>
{/* Operator */}
<select
value={filter.op}
onChange={(e) => updateFilter(filter.id, { op: e.target.value as FilterOp })}
className="w-36 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
>
{OPERATOR_OPTIONS.map((op) => (
<option key={op.value} value={op.value}>{op.label}</option>
))}
</select>
{/* Value */}
<input
type="text"
value={filter.value}
onChange={(e) => updateFilter(filter.id, { value: e.target.value })}
placeholder="Value..."
className="min-w-0 flex-1 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 placeholder:text-gray-400 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300 dark:placeholder:text-gray-600"
/>
{/* Remove */}
<button
type="button"
onClick={() => removeFilter(filter.id)}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950 dark:hover:text-red-400"
title="Remove filter"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
{/* Sort & Group */}
<div className="flex flex-wrap gap-4">
<div className="min-w-[160px]">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Group by
</label>
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
>
<option value="">None</option>
{scalarColumns.map((col) => (
<option key={col.key} value={col.key}>{col.label}</option>
))}
</select>
</div>
<div className="min-w-[160px]">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Sort by
</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
>
<option value="">Default</option>
{scalarColumns.map((col) => (
<option key={col.key} value={col.key}>{col.label}</option>
))}
</select>
</div>
<div className="min-w-[120px]">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Direction
</label>
<select
value={sortDir}
onChange={(e) => setSortDir(e.target.value as "asc" | "desc")}
className="w-full rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
</div>
</div>
{/* Run button */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleRun}
disabled={selectedColumns.size === 0}
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Run Report
</button>
{selectedColumns.size === 0 && (
<span className="text-sm text-gray-400 dark:text-gray-500">Select at least one column</span>
)}
</div>
</div>
{/* Results */}
{runQuery && (
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
{/* Results Header */}
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-slate-800">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Results</h2>
{!isLoading && (
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-slate-800 dark:text-gray-400">
{totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""}
</span>
)}
</div>
<button
type="button"
onClick={() => void handleExport()}
disabled={exportMutation.isPending || totalCount === 0}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300 dark:hover:bg-slate-800"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{exportMutation.isPending ? "Exporting..." : "Export CSV"}
</button>
</div>
{/* Table */}
<div className="overflow-x-auto">
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-600 border-t-transparent" />
</div>
) : rows.length === 0 ? (
<div className="py-16 text-center text-sm text-gray-400 dark:text-gray-500">
No data found. Try adjusting your filters.
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50/80 dark:border-slate-800 dark:bg-slate-900/50">
{outputColumns.map((col) => {
const isSortable = !col.includes(".");
const isSorted = sortBy === col;
return (
<th
key={col}
onClick={isSortable ? () => handleSort(col) : undefined}
className={clsx(
"whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400",
isSortable && "cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200",
)}
>
<span className="inline-flex items-center gap-1">
{columnLabelMap.get(col) ?? col}
{isSorted && (
<svg className={clsx("h-3 w-3", sortDir === "desc" && "rotate-180")} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
)}
</span>
</th>
);
})}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-800/60">
{rows.map((row, idx) => (
<tr
key={(row.id as string) ?? idx}
className="transition-colors hover:bg-gray-50/60 dark:hover:bg-slate-900/40"
>
{outputColumns.map((col) => (
<td
key={col}
className="whitespace-nowrap px-4 py-2.5 text-gray-700 dark:text-gray-300"
>
{formatCellValue(row[col])}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-gray-200 px-6 py-3 dark:border-slate-800">
<span className="text-sm text-gray-500 dark:text-gray-400">
Page {page + 1} of {totalPages}
</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-gray-300 dark:hover:bg-slate-800"
>
Previous
</button>
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-gray-300 dark:hover:bg-slate-800"
>
Next
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatCellValue(value: unknown): string {
if (value === null || value === undefined) return "--";
if (typeof value === "boolean") return value ? "Yes" : "No";
if (typeof value === "string") {
// ISO date detection
if (/^\d{4}-\d{2}-\d{2}T/.test(value)) {
return new Date(value).toLocaleDateString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
return value;
}
if (typeof value === "number") {
return value.toLocaleString("de-DE");
}
return String(value);
}