"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 && (
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}
)}
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}
{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`;
}