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:
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import type { TimelineDemandEntry } from "./TimelineContext.js";
|
import type { TimelineDemandEntry } from "./TimelineContext.js";
|
||||||
import { formatCents, formatDateLong } from "~/lib/format.js";
|
import { formatCents, formatDateLong } from "~/lib/format.js";
|
||||||
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
|
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
|
||||||
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
interface DemandPopoverProps {
|
interface DemandPopoverProps {
|
||||||
demand: TimelineDemandEntry;
|
demand: TimelineDemandEntry;
|
||||||
@@ -37,10 +39,23 @@ export function DemandPopover({
|
|||||||
const roleColor = demand.roleEntity?.color ?? "#f59e0b";
|
const roleColor = demand.roleEntity?.color ?? "#f59e0b";
|
||||||
const startDate = new Date(demand.startDate);
|
const startDate = new Date(demand.startDate);
|
||||||
const endDate = new Date(demand.endDate);
|
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 totalHours = demand.hoursPerDay * days;
|
||||||
const budgetCents = demand.dailyCostCents * 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 = (
|
const popover = (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -156,6 +171,59 @@ export function DemandPopover({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2 pt-2 border-t border-gray-100 dark:border-gray-700">
|
<div className="flex items-center gap-2 pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||||
{demand.unfilledHeadcount > 0 && (
|
{demand.unfilledHeadcount > 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user