feat(timeline): Sprint 2b — AI staffing suggestions in DemandPopover

Shows top 3 resource suggestions (name, utilization, available h/d) below the
demand details using the existing staffing.getProjectStaffingSuggestions query.
Includes a shimmer loading skeleton while fetching. Each "Fill" button opens
the fill demand modal with the demand pre-loaded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 13:11:36 +02:00
parent 16ce6db07e
commit 7435fdc125
@@ -1,10 +1,12 @@
"use client";
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
import type { RefObject } from "react";
import { createPortal } from "react-dom";
import type { TimelineDemandEntry } from "./TimelineContext.js";
import { formatCents, formatDateLong } from "~/lib/format.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
import { trpc } from "~/lib/trpc/client.js";
interface DemandPopoverProps {
demand: TimelineDemandEntry;
@@ -37,10 +39,23 @@ export function DemandPopover({
const roleColor = demand.roleEntity?.color ?? "#f59e0b";
const startDate = new Date(demand.startDate);
const endDate = new Date(demand.endDate);
const days = Math.max(1, Math.round((endDate.getTime() - startDate.getTime()) / 86_400_000) + 1);
const days = Math.max(1, Math.round((endDate.getTime() - startDate.getTime()) / MILLISECONDS_PER_DAY) + 1);
const totalHours = demand.hoursPerDay * days;
const budgetCents = demand.dailyCostCents * days;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: suggestionData, isLoading: loadingSuggestions } = (trpc.staffing.getProjectStaffingSuggestions.useQuery as any)(
{
projectId: demand.projectId,
roleName: demand.role ?? undefined,
startDate,
endDate,
limit: 3,
},
{ staleTime: 60_000, retry: false },
) as { data: { suggestions: Array<{ id: string; name: string; eid: string; availableHoursPerDay: number; utilization: number }> } | undefined; isLoading: boolean };
const suggestions = suggestionData?.suggestions ?? [];
const popover = (
<div
ref={ref}
@@ -156,6 +171,59 @@ export function DemandPopover({
)}
</div>
{/* AI staffing suggestions */}
{(loadingSuggestions || suggestions.length > 0) && (
<div className="pt-2 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-1 mb-2">
<svg className="h-3.5 w-3.5 text-brand-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="text-[11px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
Suggested Resources
</span>
</div>
{loadingSuggestions ? (
<div className="space-y-1.5">
{[0, 1, 2].map((i) => (
<div key={i} className="flex items-center gap-2">
<div className="h-7 w-7 shrink-0 shimmer-skeleton rounded-full" />
<div className="flex-1 space-y-1">
<div className="h-2.5 w-24 shimmer-skeleton rounded" />
<div className="h-2 w-16 shimmer-skeleton rounded" />
</div>
<div className="h-6 w-10 shimmer-skeleton rounded" />
</div>
))}
</div>
) : (
<div className="space-y-1">
{suggestions.map((s) => (
<div key={s.id} className="flex items-center gap-2">
<div className="h-7 w-7 shrink-0 rounded-full bg-brand-100 dark:bg-brand-900/40 flex items-center justify-center">
<span className="text-[10px] font-semibold text-brand-700 dark:text-brand-300">
{s.name.slice(0, 2).toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-800 dark:text-gray-200 truncate">{s.name}</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">
{Math.round(s.utilization)}% utilized · {s.availableHoursPerDay.toFixed(1)}h/d free
</div>
</div>
<button
onClick={() => { onClose(); onFillDemand(demand); }}
className="shrink-0 rounded px-2 py-1 text-[11px] font-medium bg-brand-50 text-brand-700 hover:bg-brand-100 dark:bg-brand-900/30 dark:text-brand-300 dark:hover:bg-brand-900/50 transition-colors"
title={`Assign ${s.name}`}
>
Fill
</button>
</div>
))}
</div>
)}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t border-gray-100 dark:border-gray-700">
{demand.unfilledHeadcount > 0 && (