"use client"; import { useState, useCallback } from "react"; import { trpc } from "~/lib/trpc/client.js"; import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js"; import { AllocationStatus } from "@capakraken/shared"; import { DateInput } from "~/components/ui/DateInput.js"; import { SkillTagInput } from "~/components/ui/SkillTagInput.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { SuccessToast } from "~/components/ui/SuccessToast.js"; import { Button } from "@capakraken/ui"; interface SearchCriteria { startDate: string; endDate: string; hoursPerDay: number; } export function StaffingPanel() { const [requiredSkills, setRequiredSkills] = useState(["TypeScript", "React"]); const [startDate, setStartDate] = useState(() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; }); const [endDate, setEndDate] = useState(() => { const d = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; }); const [hoursPerDay, setHoursPerDay] = useState(8); const [submitted, setSubmitted] = useState(false); const [assignedIds, setAssignedIds] = useState>(new Set()); const [toast, setToast] = useState<{ show: boolean; message: string; variant: "success" | "warning" }>({ show: false, message: "", variant: "success", }); const clearToast = useCallback(() => setToast((t) => ({ ...t, show: false })), []); const { data: suggestions, isLoading } = trpc.staffing.getSuggestions.useQuery( { requiredSkills: requiredSkills, startDate: new Date(startDate), endDate: new Date(endDate), hoursPerDay, }, { enabled: submitted }, ); const visibleSuggestions = suggestions?.filter((s) => !assignedIds.has(s.resourceId)); const handleAssigned = useCallback((resourceId: string, resourceName: string) => { setAssignedIds((prev) => new Set(prev).add(resourceId)); setToast({ show: true, message: `${resourceName} assigned successfully`, variant: "success" }); }, []); const searchCriteria: SearchCriteria = { startDate, endDate, hoursPerDay }; return (
Staffing

Staffing Suggestions

Match open work with the strongest available people based on skills, availability, utilization, and cost.

How scoring works

CapaKraken blends skill fit, free capacity, cost, and current utilization. Add the must-have skills first, then narrow the date window to get cleaner results.

Search Criteria

Define the role needs and let the matching engine rank the best candidates.

setHoursPerDay(Number(e.target.value))} min={1} max={24} className="app-input" />
Skills
Quality of skill overlap with the requested stack.
Availability
Conflicts and free capacity during the selected period.
Cost + Load
Cost efficiency and current utilization weighting.
{isLoading && (
Finding best matches...
)} {visibleSuggestions && visibleSuggestions.length === 0 && (
{assignedIds.size > 0 ? "All suggestions have been assigned." : "No resources found matching your criteria."}
)} {visibleSuggestions && visibleSuggestions.length > 0 && (
{visibleSuggestions.map((suggestion, idx) => ( setToast({ show: true, message: msg, variant: "warning" })} /> ))}
)} {!submitted && (
No suggestions yet

Add the required skills and date range, then run the search to see ranked staffing matches.

)}
); } /* -------------------------------------------------------------------------- */ /* Suggestion Card */ /* -------------------------------------------------------------------------- */ interface SuggestionLike { resourceId: string; resourceName: string; eid: string; score: number; scoreBreakdown: { skillScore: number; availabilityScore: number; costScore: number; utilizationScore: number; }; matchedSkills: string[]; missingSkills: string[]; availabilityConflicts: string[]; estimatedDailyCostCents: number; currentUtilization: number; } interface SuggestionCardProps { suggestion: SuggestionLike; rank: number; searchCriteria: SearchCriteria; onAssigned: (resourceId: string, resourceName: string) => void; onError: (message: string) => void; } function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError }: SuggestionCardProps) { const [expanded, setExpanded] = useState(false); return (
{rank}
{suggestion.resourceName}
{suggestion.eid}
Match Score
{suggestion.score}
{suggestion.matchedSkills.map((skill) => ( {skill} ))} {suggestion.missingSkills.map((skill) => ( {skill} missing ))}
LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} EUR/h Utilization: {Math.round(suggestion.currentUtilization)}% {suggestion.availabilityConflicts.length > 0 && ( {suggestion.availabilityConflicts.length} scheduling conflict{suggestion.availabilityConflicts.length === 1 ? "" : "s"} )}
{expanded && ( onAssigned(suggestion.resourceId, suggestion.resourceName)} onError={onError} onCancel={() => setExpanded(false)} /> )}
); } /* -------------------------------------------------------------------------- */ /* Inline Assign Form */ /* -------------------------------------------------------------------------- */ interface AssignFormProps { resourceId: string; resourceName: string; searchCriteria: SearchCriteria; onAssigned: () => void; onError: (message: string) => void; onCancel: () => void; } function AssignForm({ resourceId, resourceName, searchCriteria, onAssigned, onError, onCancel }: AssignFormProps) { const [projectId, setProjectId] = useState(""); const [assignStartDate, setAssignStartDate] = useState(searchCriteria.startDate); const [assignEndDate, setAssignEndDate] = useState(searchCriteria.endDate); const [assignHours, setAssignHours] = useState(searchCriteria.hoursPerDay); const [roleId, setRoleId] = useState(""); const [roleFreeText, setRoleFreeText] = useState(""); const invalidatePlanningViews = useInvalidatePlanningViews(); const { data: projects } = trpc.project.list.useQuery( { limit: 500 }, { staleTime: 60_000 }, ); const { data: roles } = trpc.role.list.useQuery( { isActive: true }, { staleTime: 60_000 }, ); // eslint-disable-next-line @typescript-eslint/no-explicit-any const createAssignment = (trpc.allocation.createAssignment.useMutation as any)({ onSuccess: () => { invalidatePlanningViews(); onAssigned(); }, onError: (err: { message: string }) => { onError(err.message || "Failed to create assignment"); }, }) as { mutate: (input: unknown) => void; isPending: boolean }; const canSubmit = projectId && assignStartDate && assignEndDate && assignHours > 0; const handleSubmit = () => { if (!canSubmit) return; createAssignment.mutate({ resourceId, projectId, startDate: new Date(assignStartDate), endDate: new Date(assignEndDate), hoursPerDay: assignHours, percentage: 100, ...(roleId ? { roleId } : {}), ...(roleFreeText ? { role: roleFreeText } : {}), status: AllocationStatus.PROPOSED, metadata: {}, }); }; const projectList = (projects as { projects?: Array<{ id: string; name: string; shortCode?: string | null }> } | undefined)?.projects ?? []; const rolesList = (roles ?? []) as Array<{ id: string; name: string }>; const selectedProject = projectList.find((p) => p.id === projectId); return (

Assign {resourceName}

setAssignHours(Number(e.target.value))} min={0.5} max={24} step={0.5} className="app-input" />
{rolesList.length > 0 ? ( ) : ( setRoleFreeText(e.target.value)} placeholder="e.g. 3D Artist" className="app-input" /> )}
{selectedProject && (
Assigning to {selectedProject.name} from {assignStartDate} to {assignEndDate} at {assignHours}h/day
)}
); } /* -------------------------------------------------------------------------- */ /* Score Bar */ /* -------------------------------------------------------------------------- */ function ScoreBar({ label, value, tooltip }: { label: string; value: number; tooltip?: string }) { return (
{label}{tooltip && }
{value}
); }