"use client"; import { useState } 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 { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { Button } from "~/components/ui/Button.js"; import type { SearchCriteria } from "./StaffingSearchForm.js"; export interface SuggestionLike { resourceId: string; resourceName: string; eid: string; score: number; valueScore?: number; scoreBreakdown: { skillScore: number; availabilityScore: number; costScore: number; utilizationScore: number; total?: number; }; matchedSkills: string[]; missingSkills: string[]; availabilityConflicts: string[]; estimatedDailyCostCents: number; currentUtilization: number; remainingHours?: number; remainingHoursPerDay?: number; baseAvailableHours?: number; effectiveAvailableHours?: number; holidayHoursDeduction?: number; location?: { countryCode: string | null; countryName: string | null; federalState: string | null; metroCityName: string | null; label: string; }; capacity?: { requestedHoursPerDay: number; requestedHoursTotal: number; baseWorkingDays: number; effectiveWorkingDays: number; baseAvailableHours: number; effectiveAvailableHours: number; bookedHours: number; remainingHours: number; remainingHoursPerDay: number; holidayCount: number; holidayWorkdayCount: number; holidayHoursDeduction: number; absenceDayEquivalent: number; absenceHoursDeduction: number; }; conflicts?: { count: number; conflictDays: string[]; details: Array<{ date: string; baseHours: number; effectiveHours: number; allocatedHours: number; remainingHours: number; requestedHours: number; shortageHours: number; absenceFraction: number; isHoliday: boolean; }>; }; ranking?: { rank: number; baseRank: number; tieBreakerApplied: boolean; tieBreakerReason: string | null; model: string; components: Array<{ key: string; label: string; score: number; }>; }; } interface StaffingResultCardProps { suggestion: SuggestionLike; rank: number; searchCriteria: SearchCriteria; onAssigned: (resourceId: string, resourceName: string) => void; onError: (message: string) => void; } export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigned, onError }: StaffingResultCardProps) { const [showDetails, setShowDetails] = useState(false); const [showAssignForm, setShowAssignForm] = useState(false); const locationLabel = suggestion.location?.label || [suggestion.location?.countryCode, suggestion.location?.federalState, suggestion.location?.metroCityName] .filter(Boolean) .join(" / ") || "No location"; const capacity = suggestion.capacity; const conflicts = suggestion.conflicts; const conflictCount = conflicts?.count ?? suggestion.availabilityConflicts.length; const remainingHours = capacity?.remainingHours ?? suggestion.remainingHours ?? 0; const remainingHoursPerDay = capacity?.remainingHoursPerDay ?? suggestion.remainingHoursPerDay ?? 0; const baseAvailableHours = capacity?.baseAvailableHours ?? suggestion.baseAvailableHours ?? 0; const effectiveAvailableHours = capacity?.effectiveAvailableHours ?? suggestion.effectiveAvailableHours ?? 0; const holidayHoursDeduction = capacity?.holidayHoursDeduction ?? suggestion.holidayHoursDeduction ?? 0; return (
{rank}
{suggestion.resourceName}
{suggestion.eid}
{locationLabel}
Match Score
{suggestion.score}
{suggestion.matchedSkills.map((skill) => ( {skill} ))} {suggestion.missingSkills.map((skill) => ( {skill} missing ))}
= searchCriteria.hoursPerDay ? "good" : "warn"} helper={`${formatHours(remainingHours)} total in window`} /> 0 ? formatHours(holidayHoursDeduction) : "0h"} tone={holidayHoursDeduction > 0 ? "warn" : "neutral"} helper={capacity ? `${capacity.holidayWorkdayCount} affected workdays` : "No local holiday impact"} /> 0 ? "warn" : "good"} helper={conflictCount > 0 ? `${conflictCount} overloaded day${conflictCount === 1 ? "" : "s"}` : "No day-level overloads"} />
LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} EUR/h Utilization: {Math.round(suggestion.currentUtilization)}% {suggestion.valueScore != null && Value Score: {suggestion.valueScore}} {conflictCount > 0 && ( {conflictCount} scheduling conflict{conflictCount === 1 ? "" : "s"} )}
{showDetails && (
Capacity Basis
Ranking Basis

{suggestion.ranking?.model ?? "Composite ranking across skill fit, availability, cost, and utilization."}

{(suggestion.ranking?.components ?? []).map((component) => ( ))} {suggestion.ranking?.tieBreakerReason && (
{suggestion.ranking.tieBreakerReason}
)}
Location + Calendar
Conflict Check
Requested {formatHours(searchCriteria.hoursPerDay)} / day between {searchCriteria.startDate} and {searchCriteria.endDate}
{conflictCount === 0 ? (

No overloaded working days in the selected window.

) : (
{(conflicts?.details ?? []).slice(0, 6).map((item) => (
{item.date} Short by {formatHours(item.shortageHours)}
Base {formatHours(item.baseHours)} | Effective {formatHours(item.effectiveHours)} | Already booked {formatHours(item.allocatedHours)} | Remaining {formatHours(item.remainingHours)}
))} {conflictCount > 6 && (

+{conflictCount - 6} more conflict day{conflictCount - 6 === 1 ? "" : "s"}

)}
)}
)} {showAssignForm && ( onAssigned(suggestion.resourceId, suggestion.resourceName)} onError={onError} onCancel={() => setShowAssignForm(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
)}
); } /* -------------------------------------------------------------------------- */ /* Primitive display components */ /* -------------------------------------------------------------------------- */ function ScoreBar({ label, value, tooltip }: { label: string; value: number; tooltip?: string }) { return (
{label} {tooltip && }
{value}
); } function MetricLine({ label, value }: { label: string; value: string }) { return (
{label} {value}
); } function StatCard({ label, value, helper, tone = "neutral", }: { label: string; value: string; helper?: string; tone?: "neutral" | "good" | "warn"; }) { const toneClass = tone === "good" ? "border-green-200 bg-green-50/70 dark:border-green-900/40 dark:bg-green-950/20" : tone === "warn" ? "border-amber-200 bg-amber-50/70 dark:border-amber-900/40 dark:bg-amber-950/20" : "border-gray-200 bg-gray-50/70 dark:border-gray-700 dark:bg-gray-900/40"; return (
{label}
{value}
{helper &&
{helper}
}
); } function formatHours(value: number): string { return `${Math.round(value * 10) / 10}h`; }