refactor(staffing): decompose 735-line StaffingPanel into focused components
Splits StaffingPanel.tsx into: - StaffingSearchForm: skill tags, dates, hours input, submit button - ScoringExplanation: the 3-column scoring breakdown card - StaffingResultCard: individual suggestion card with details and assign form - StaffingResultsList: list orchestration with loading/empty states - StaffingPanel: thin orchestrator (~100 lines) managing state and tRPC query Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
export function ScoringExplanation() {
|
||||||
|
return (
|
||||||
|
<div className="app-surface p-5">
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">Skills</div>
|
||||||
|
<div className="mt-1 text-gray-600 dark:text-gray-300">
|
||||||
|
Quality of skill overlap with the requested stack.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">Availability</div>
|
||||||
|
<div className="mt-1 text-gray-600 dark:text-gray-300">
|
||||||
|
Conflicts and free capacity during the selected period.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">Cost + Load</div>
|
||||||
|
<div className="mt-1 text-gray-600 dark:text-gray-300">
|
||||||
|
Cost efficiency and current utilization weighting.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,19 +2,10 @@
|
|||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
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 { SuccessToast } from "~/components/ui/SuccessToast.js";
|
||||||
import { Button } from "@capakraken/ui";
|
import { StaffingSearchForm } from "./StaffingSearchForm.js";
|
||||||
|
import { ScoringExplanation } from "./ScoringExplanation.js";
|
||||||
interface SearchCriteria {
|
import { StaffingResultsList } from "./StaffingResultsList.js";
|
||||||
startDate: string;
|
|
||||||
endDate: string;
|
|
||||||
hoursPerDay: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StaffingPanel() {
|
export function StaffingPanel() {
|
||||||
const [requiredSkills, setRequiredSkills] = useState<string[]>(["TypeScript", "React"]);
|
const [requiredSkills, setRequiredSkills] = useState<string[]>(["TypeScript", "React"]);
|
||||||
@@ -39,7 +30,7 @@ export function StaffingPanel() {
|
|||||||
|
|
||||||
const { data: suggestions, isLoading } = trpc.staffing.getSuggestions.useQuery(
|
const { data: suggestions, isLoading } = trpc.staffing.getSuggestions.useQuery(
|
||||||
{
|
{
|
||||||
requiredSkills: requiredSkills,
|
requiredSkills,
|
||||||
startDate: new Date(startDate),
|
startDate: new Date(startDate),
|
||||||
endDate: new Date(endDate),
|
endDate: new Date(endDate),
|
||||||
hoursPerDay,
|
hoursPerDay,
|
||||||
@@ -47,14 +38,12 @@ export function StaffingPanel() {
|
|||||||
{ enabled: submitted },
|
{ enabled: submitted },
|
||||||
);
|
);
|
||||||
|
|
||||||
const visibleSuggestions = suggestions?.filter((s) => !assignedIds.has(s.resourceId));
|
|
||||||
|
|
||||||
const handleAssigned = useCallback((resourceId: string, resourceName: string) => {
|
const handleAssigned = useCallback((resourceId: string, resourceName: string) => {
|
||||||
setAssignedIds((prev) => new Set(prev).add(resourceId));
|
setAssignedIds((prev) => new Set(prev).add(resourceId));
|
||||||
setToast({ show: true, message: `${resourceName} assigned successfully`, variant: "success" });
|
setToast({ show: true, message: `${resourceName} assigned successfully`, variant: "success" });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const searchCriteria: SearchCriteria = { startDate, endDate, hoursPerDay };
|
const searchCriteria = { startDate, endDate, hoursPerDay };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page space-y-6">
|
<div className="app-page space-y-6">
|
||||||
@@ -82,654 +71,30 @@ export function StaffingPanel() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="app-surface-strong p-6">
|
<StaffingSearchForm
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Search Criteria</h2>
|
requiredSkills={requiredSkills}
|
||||||
<p className="mt-1 text-sm text-gray-500">Define the role needs and let the matching engine rank the best candidates.</p>
|
onSkillsChange={setRequiredSkills}
|
||||||
|
startDate={startDate}
|
||||||
<div className="mt-6 space-y-5">
|
onStartDateChange={setStartDate}
|
||||||
<div>
|
endDate={endDate}
|
||||||
<label className="app-label">Required Skills<InfoTooltip content="Skills the candidate must have. The engine scores overlap and proficiency against this list." /></label>
|
onEndDateChange={setEndDate}
|
||||||
<SkillTagInput
|
hoursPerDay={hoursPerDay}
|
||||||
value={requiredSkills}
|
onHoursPerDayChange={setHoursPerDay}
|
||||||
onChange={setRequiredSkills}
|
onSubmit={() => setSubmitted(true)}
|
||||||
placeholder="Add skill..."
|
/>
|
||||||
/>
|
<ScoringExplanation />
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
|
||||||
<div>
|
|
||||||
<label className="app-label">Start Date</label>
|
|
||||||
<DateInput
|
|
||||||
value={startDate}
|
|
||||||
onChange={setStartDate}
|
|
||||||
className="app-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="app-label">End Date</label>
|
|
||||||
<DateInput
|
|
||||||
value={endDate}
|
|
||||||
onChange={setEndDate}
|
|
||||||
min={startDate}
|
|
||||||
className="app-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="app-label">Hours per Day<InfoTooltip content="Required hours per day for the role. Used to check conflicts and estimate capacity." /></label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={hoursPerDay}
|
|
||||||
onChange={(e) => setHoursPerDay(Number(e.target.value))}
|
|
||||||
min={1}
|
|
||||||
max={24}
|
|
||||||
className="app-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setSubmitted(true)}
|
|
||||||
className="inline-flex w-full items-center justify-center rounded-xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-brand-700"
|
|
||||||
>
|
|
||||||
Find Matches
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="app-surface p-5">
|
|
||||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">Skills</div>
|
|
||||||
<div className="mt-1 text-gray-600 dark:text-gray-300">Quality of skill overlap with the requested stack.</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">Availability</div>
|
|
||||||
<div className="mt-1 text-gray-600 dark:text-gray-300">Conflicts and free capacity during the selected period.</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">Cost + Load</div>
|
|
||||||
<div className="mt-1 text-gray-600 dark:text-gray-300">Cost efficiency and current utilization weighting.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<StaffingResultsList
|
||||||
{isLoading && (
|
suggestions={suggestions}
|
||||||
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
|
isLoading={isLoading}
|
||||||
Finding best matches...
|
submitted={submitted}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{visibleSuggestions && visibleSuggestions.length === 0 && (
|
|
||||||
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
|
|
||||||
{assignedIds.size > 0 ? "All suggestions have been assigned." : "No resources found matching your criteria."}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{visibleSuggestions && visibleSuggestions.length > 0 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{visibleSuggestions.map((suggestion, idx) => (
|
|
||||||
<SuggestionCard
|
|
||||||
key={suggestion.resourceId}
|
|
||||||
suggestion={suggestion}
|
|
||||||
rank={idx + 1}
|
|
||||||
searchCriteria={searchCriteria}
|
|
||||||
onAssigned={handleAssigned}
|
|
||||||
onError={(msg) => setToast({ show: true, message: msg, variant: "warning" })}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!submitted && (
|
|
||||||
<div className="app-surface-strong border-dashed py-20 text-center">
|
|
||||||
<div className="mx-auto max-w-md">
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">No suggestions yet</div>
|
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
|
||||||
Add the required skills and date range, then run the search to see ranked staffing matches.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* Suggestion Card */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
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 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 [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 (
|
|
||||||
<div data-suggestion className="app-surface p-5">
|
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-100 font-display text-lg font-semibold text-brand-700 dark:bg-brand-900/40 dark:text-brand-200">
|
|
||||||
{rank}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">{suggestion.resourceName}</div>
|
|
||||||
<div className="text-sm text-gray-500">{suggestion.eid}</div>
|
|
||||||
<div className="mt-1 text-xs text-gray-500">{locationLabel}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowDetails((prev) => !prev)}
|
|
||||||
>
|
|
||||||
{showDetails ? "Hide Details" : "Details"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowAssignForm((prev) => !prev)}
|
|
||||||
>
|
|
||||||
{showAssignForm ? "Close Assign" : "Assign"}
|
|
||||||
</Button>
|
|
||||||
<div className="rounded-2xl border border-brand-200 bg-brand-50 px-4 py-3 text-right dark:border-brand-900/50 dark:bg-brand-900/20">
|
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:text-brand-200 inline-flex items-center gap-0.5">Match Score<InfoTooltip content="Composite score (0-100) blending skill fit, free capacity, cost efficiency, and current utilization." /></div>
|
|
||||||
<div className="mt-1 text-3xl font-semibold text-brand-700 dark:text-brand-100">{suggestion.score}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
{suggestion.matchedSkills.map((skill) => (
|
|
||||||
<span key={skill} className="rounded-full bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 dark:bg-green-950/30 dark:text-green-300">
|
|
||||||
{skill}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{suggestion.missingSkills.map((skill) => (
|
|
||||||
<span key={skill} className="rounded-full bg-red-50 px-2.5 py-1 text-xs font-medium text-red-600 dark:bg-red-950/30 dark:text-red-300">
|
|
||||||
{skill} missing
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<StatCard
|
|
||||||
label="Free / Workday"
|
|
||||||
value={formatHours(remainingHoursPerDay)}
|
|
||||||
tone={remainingHoursPerDay >= searchCriteria.hoursPerDay ? "good" : "warn"}
|
|
||||||
helper={`${formatHours(remainingHours)} total in window`}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Capacity"
|
|
||||||
value={`${formatHours(effectiveAvailableHours)} effective`}
|
|
||||||
helper={`${formatHours(baseAvailableHours)} base`}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Holiday Deduction"
|
|
||||||
value={holidayHoursDeduction > 0 ? formatHours(holidayHoursDeduction) : "0h"}
|
|
||||||
tone={holidayHoursDeduction > 0 ? "warn" : "neutral"}
|
|
||||||
helper={capacity ? `${capacity.holidayWorkdayCount} affected workdays` : "No local holiday impact"}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Conflicts"
|
|
||||||
value={String(conflictCount)}
|
|
||||||
tone={conflictCount > 0 ? "warn" : "good"}
|
|
||||||
helper={conflictCount > 0 ? `${conflictCount} overloaded day${conflictCount === 1 ? "" : "s"}` : "No day-level overloads"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-gray-500">
|
|
||||||
<span>LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} EUR/h</span>
|
|
||||||
<span>Utilization: {Math.round(suggestion.currentUtilization)}%</span>
|
|
||||||
{suggestion.valueScore != null && (
|
|
||||||
<span>Value Score: {suggestion.valueScore}</span>
|
|
||||||
)}
|
|
||||||
{conflictCount > 0 && (
|
|
||||||
<span className="font-medium text-amber-600 dark:text-amber-300">
|
|
||||||
{conflictCount} scheduling conflict{conflictCount === 1 ? "" : "s"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showDetails && (
|
|
||||||
<div className="mt-5 space-y-4 rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-950/40">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} tooltip="Quality of skill overlap with the requested stack, weighted by proficiency level." />
|
|
||||||
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} tooltip="Free capacity during the selected period, accounting for existing bookings and vacations." />
|
|
||||||
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} tooltip="Cost efficiency based on the resource's LCR relative to the team average." />
|
|
||||||
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} tooltip="Current workload. Higher score means more capacity available (lower utilization)." />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-[1.15fr_1fr]">
|
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
|
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Capacity Basis</div>
|
|
||||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
|
||||||
<MetricLine label="Requested load" value={`${formatHours(capacity?.requestedHoursPerDay ?? searchCriteria.hoursPerDay)} / day`} />
|
|
||||||
<MetricLine label="Requested total" value={formatHours(capacity?.requestedHoursTotal ?? 0)} />
|
|
||||||
<MetricLine label="Base working days" value={String(capacity?.baseWorkingDays ?? 0)} />
|
|
||||||
<MetricLine label="Effective working days" value={String(capacity?.effectiveWorkingDays ?? 0)} />
|
|
||||||
<MetricLine label="Base available hours" value={formatHours(baseAvailableHours)} />
|
|
||||||
<MetricLine label="Effective available hours" value={formatHours(effectiveAvailableHours)} />
|
|
||||||
<MetricLine label="Booked hours" value={formatHours(capacity?.bookedHours ?? 0)} />
|
|
||||||
<MetricLine label="Remaining hours" value={formatHours(remainingHours)} />
|
|
||||||
<MetricLine label="Holiday deduction" value={formatHours(holidayHoursDeduction)} />
|
|
||||||
<MetricLine label="Absence deduction" value={formatHours(capacity?.absenceHoursDeduction ?? 0)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
|
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Ranking Basis</div>
|
|
||||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{suggestion.ranking?.model ?? "Composite ranking across skill fit, availability, cost, and utilization."}
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{(suggestion.ranking?.components ?? []).map((component) => (
|
|
||||||
<MetricLine key={component.key} label={component.label} value={`${component.score}`} />
|
|
||||||
))}
|
|
||||||
{suggestion.ranking?.tieBreakerReason && (
|
|
||||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
|
|
||||||
{suggestion.ranking.tieBreakerReason}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
|
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Location + Calendar</div>
|
|
||||||
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
<MetricLine label="Location" value={locationLabel} />
|
|
||||||
<MetricLine label="Holiday dates" value={String(capacity?.holidayCount ?? 0)} />
|
|
||||||
<MetricLine label="Holiday workdays" value={String(capacity?.holidayWorkdayCount ?? 0)} />
|
|
||||||
<MetricLine label="Absence days" value={String(capacity?.absenceDayEquivalent ?? 0)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Conflict Check</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
Requested {formatHours(searchCriteria.hoursPerDay)} / day between {searchCriteria.startDate} and {searchCriteria.endDate}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{conflictCount === 0 ? (
|
|
||||||
<p className="mt-3 text-sm text-green-700 dark:text-green-300">
|
|
||||||
No overloaded working days in the selected window.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
{(conflicts?.details ?? []).slice(0, 6).map((item) => (
|
|
||||||
<div key={item.date} className="rounded-xl border border-amber-200 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-100">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<span className="font-medium">{item.date}</span>
|
|
||||||
<span>Short by {formatHours(item.shortageHours)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs">
|
|
||||||
Base {formatHours(item.baseHours)} | Effective {formatHours(item.effectiveHours)} | Already booked {formatHours(item.allocatedHours)} | Remaining {formatHours(item.remainingHours)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{conflictCount > 6 && (
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
+{conflictCount - 6} more conflict day{conflictCount - 6 === 1 ? "" : "s"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showAssignForm && (
|
|
||||||
<AssignForm
|
|
||||||
resourceId={suggestion.resourceId}
|
|
||||||
resourceName={suggestion.resourceName}
|
|
||||||
searchCriteria={searchCriteria}
|
searchCriteria={searchCriteria}
|
||||||
onAssigned={() => onAssigned(suggestion.resourceId, suggestion.resourceName)}
|
assignedIds={assignedIds}
|
||||||
onError={onError}
|
onAssigned={handleAssigned}
|
||||||
onCancel={() => setShowAssignForm(false)}
|
onError={(msg) => setToast({ show: true, message: msg, variant: "warning" })}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* 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 (
|
|
||||||
<div className="mt-4 rounded-xl border border-brand-200 bg-brand-50/50 p-4 dark:border-brand-900/40 dark:bg-brand-950/30">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
Assign {resourceName}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<label className="app-label">Project *</label>
|
|
||||||
<select
|
|
||||||
value={projectId}
|
|
||||||
onChange={(e) => setProjectId(e.target.value)}
|
|
||||||
className="app-input"
|
|
||||||
>
|
|
||||||
<option value="">Select project...</option>
|
|
||||||
{projectList.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{p.shortCode ? `[${p.shortCode}] ` : ""}{p.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="app-label">Start Date</label>
|
|
||||||
<DateInput
|
|
||||||
value={assignStartDate}
|
|
||||||
onChange={setAssignStartDate}
|
|
||||||
className="app-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="app-label">End Date</label>
|
|
||||||
<DateInput
|
|
||||||
value={assignEndDate}
|
|
||||||
onChange={setAssignEndDate}
|
|
||||||
min={assignStartDate}
|
|
||||||
className="app-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="app-label">Hours / Day</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={assignHours}
|
|
||||||
onChange={(e) => setAssignHours(Number(e.target.value))}
|
|
||||||
min={0.5}
|
|
||||||
max={24}
|
|
||||||
step={0.5}
|
|
||||||
className="app-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="app-label">Role</label>
|
|
||||||
{rolesList.length > 0 ? (
|
|
||||||
<select
|
|
||||||
value={roleId}
|
|
||||||
onChange={(e) => setRoleId(e.target.value)}
|
|
||||||
className="app-input"
|
|
||||||
>
|
|
||||||
<option value="">No role</option>
|
|
||||||
{rolesList.map((r) => (
|
|
||||||
<option key={r.id} value={r.id}>
|
|
||||||
{r.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={roleFreeText}
|
|
||||||
onChange={(e) => setRoleFreeText(e.target.value)}
|
|
||||||
placeholder="e.g. 3D Artist"
|
|
||||||
className="app-input"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedProject && (
|
|
||||||
<div className="mt-3 text-xs text-gray-500">
|
|
||||||
Assigning to <span className="font-medium text-gray-700 dark:text-gray-300">{selectedProject.name}</span> from {assignStartDate} to {assignEndDate} at {assignHours}h/day
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4 flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
disabled={!canSubmit || createAssignment.isPending}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
>
|
|
||||||
{createAssignment.isPending ? "Assigning..." : "Confirm Assignment"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={createAssignment.isPending}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* Score Bar */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
function ScoreBar({ label, value, tooltip }: { label: string; value: number; tooltip?: string }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 p-3 dark:border-gray-700 dark:bg-gray-900/40">
|
|
||||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500 inline-flex items-center gap-0.5">{label}{tooltip && <InfoTooltip content={tooltip} />}</div>
|
|
||||||
<div className="h-2 rounded-full bg-gray-200/80 dark:bg-gray-800">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-brand-500"
|
|
||||||
style={{ width: `${value}%` }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm font-medium text-gray-700 dark:text-gray-200">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatHours(value: number): string {
|
|
||||||
const rounded = Math.round(value * 10) / 10;
|
|
||||||
return `${rounded}h`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricLine({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-3 border-b border-gray-100 pb-2 text-sm last:border-b-0 last:pb-0 dark:border-gray-800">
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">{label}</span>
|
|
||||||
<span className="text-right font-medium text-gray-900 dark:text-gray-100">{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className={`rounded-2xl border p-3 ${toneClass}`}>
|
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">{label}</div>
|
|
||||||
<div className="mt-2 text-lg font-semibold text-gray-900 dark:text-gray-100">{value}</div>
|
|
||||||
{helper && (
|
|
||||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{helper}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,499 @@
|
|||||||
|
"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 (
|
||||||
|
<div data-suggestion className="app-surface p-5">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-100 font-display text-lg font-semibold text-brand-700 dark:bg-brand-900/40 dark:text-brand-200">
|
||||||
|
{rank}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">{suggestion.resourceName}</div>
|
||||||
|
<div className="text-sm text-gray-500">{suggestion.eid}</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500">{locationLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setShowDetails((prev) => !prev)}>
|
||||||
|
{showDetails ? "Hide Details" : "Details"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="sm" onClick={() => setShowAssignForm((prev) => !prev)}>
|
||||||
|
{showAssignForm ? "Close Assign" : "Assign"}
|
||||||
|
</Button>
|
||||||
|
<div className="rounded-2xl border border-brand-200 bg-brand-50 px-4 py-3 text-right dark:border-brand-900/50 dark:bg-brand-900/20">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:text-brand-200 inline-flex items-center gap-0.5">
|
||||||
|
Match Score
|
||||||
|
<InfoTooltip content="Composite score (0-100) blending skill fit, free capacity, cost efficiency, and current utilization." />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-3xl font-semibold text-brand-700 dark:text-brand-100">{suggestion.score}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{suggestion.matchedSkills.map((skill) => (
|
||||||
|
<span key={skill} className="rounded-full bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 dark:bg-green-950/30 dark:text-green-300">
|
||||||
|
{skill}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{suggestion.missingSkills.map((skill) => (
|
||||||
|
<span key={skill} className="rounded-full bg-red-50 px-2.5 py-1 text-xs font-medium text-red-600 dark:bg-red-950/30 dark:text-red-300">
|
||||||
|
{skill} missing
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
label="Free / Workday"
|
||||||
|
value={formatHours(remainingHoursPerDay)}
|
||||||
|
tone={remainingHoursPerDay >= searchCriteria.hoursPerDay ? "good" : "warn"}
|
||||||
|
helper={`${formatHours(remainingHours)} total in window`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Capacity"
|
||||||
|
value={`${formatHours(effectiveAvailableHours)} effective`}
|
||||||
|
helper={`${formatHours(baseAvailableHours)} base`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Holiday Deduction"
|
||||||
|
value={holidayHoursDeduction > 0 ? formatHours(holidayHoursDeduction) : "0h"}
|
||||||
|
tone={holidayHoursDeduction > 0 ? "warn" : "neutral"}
|
||||||
|
helper={capacity ? `${capacity.holidayWorkdayCount} affected workdays` : "No local holiday impact"}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Conflicts"
|
||||||
|
value={String(conflictCount)}
|
||||||
|
tone={conflictCount > 0 ? "warn" : "good"}
|
||||||
|
helper={conflictCount > 0 ? `${conflictCount} overloaded day${conflictCount === 1 ? "" : "s"}` : "No day-level overloads"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-gray-500">
|
||||||
|
<span>LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} EUR/h</span>
|
||||||
|
<span>Utilization: {Math.round(suggestion.currentUtilization)}%</span>
|
||||||
|
{suggestion.valueScore != null && <span>Value Score: {suggestion.valueScore}</span>}
|
||||||
|
{conflictCount > 0 && (
|
||||||
|
<span className="font-medium text-amber-600 dark:text-amber-300">
|
||||||
|
{conflictCount} scheduling conflict{conflictCount === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDetails && (
|
||||||
|
<div className="mt-5 space-y-4 rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-950/40">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} tooltip="Quality of skill overlap with the requested stack, weighted by proficiency level." />
|
||||||
|
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} tooltip="Free capacity during the selected period, accounting for existing bookings and vacations." />
|
||||||
|
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} tooltip="Cost efficiency based on the resource's LCR relative to the team average." />
|
||||||
|
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} tooltip="Current workload. Higher score means more capacity available (lower utilization)." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[1.15fr_1fr]">
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Capacity Basis</div>
|
||||||
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
|
<MetricLine label="Requested load" value={`${formatHours(capacity?.requestedHoursPerDay ?? searchCriteria.hoursPerDay)} / day`} />
|
||||||
|
<MetricLine label="Requested total" value={formatHours(capacity?.requestedHoursTotal ?? 0)} />
|
||||||
|
<MetricLine label="Base working days" value={String(capacity?.baseWorkingDays ?? 0)} />
|
||||||
|
<MetricLine label="Effective working days" value={String(capacity?.effectiveWorkingDays ?? 0)} />
|
||||||
|
<MetricLine label="Base available hours" value={formatHours(baseAvailableHours)} />
|
||||||
|
<MetricLine label="Effective available hours" value={formatHours(effectiveAvailableHours)} />
|
||||||
|
<MetricLine label="Booked hours" value={formatHours(capacity?.bookedHours ?? 0)} />
|
||||||
|
<MetricLine label="Remaining hours" value={formatHours(remainingHours)} />
|
||||||
|
<MetricLine label="Holiday deduction" value={formatHours(holidayHoursDeduction)} />
|
||||||
|
<MetricLine label="Absence deduction" value={formatHours(capacity?.absenceHoursDeduction ?? 0)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Ranking Basis</div>
|
||||||
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{suggestion.ranking?.model ?? "Composite ranking across skill fit, availability, cost, and utilization."}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{(suggestion.ranking?.components ?? []).map((component) => (
|
||||||
|
<MetricLine key={component.key} label={component.label} value={`${component.score}`} />
|
||||||
|
))}
|
||||||
|
{suggestion.ranking?.tieBreakerReason && (
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
|
{suggestion.ranking.tieBreakerReason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Location + Calendar</div>
|
||||||
|
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<MetricLine label="Location" value={locationLabel} />
|
||||||
|
<MetricLine label="Holiday dates" value={String(capacity?.holidayCount ?? 0)} />
|
||||||
|
<MetricLine label="Holiday workdays" value={String(capacity?.holidayWorkdayCount ?? 0)} />
|
||||||
|
<MetricLine label="Absence days" value={String(capacity?.absenceDayEquivalent ?? 0)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Conflict Check</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
Requested {formatHours(searchCriteria.hoursPerDay)} / day between {searchCriteria.startDate} and {searchCriteria.endDate}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{conflictCount === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-green-700 dark:text-green-300">
|
||||||
|
No overloaded working days in the selected window.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{(conflicts?.details ?? []).slice(0, 6).map((item) => (
|
||||||
|
<div key={item.date} className="rounded-xl border border-amber-200 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-100">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="font-medium">{item.date}</span>
|
||||||
|
<span>Short by {formatHours(item.shortageHours)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs">
|
||||||
|
Base {formatHours(item.baseHours)} | Effective {formatHours(item.effectiveHours)} | Already booked {formatHours(item.allocatedHours)} | Remaining {formatHours(item.remainingHours)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{conflictCount > 6 && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
+{conflictCount - 6} more conflict day{conflictCount - 6 === 1 ? "" : "s"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAssignForm && (
|
||||||
|
<AssignForm
|
||||||
|
resourceId={suggestion.resourceId}
|
||||||
|
resourceName={suggestion.resourceName}
|
||||||
|
searchCriteria={searchCriteria}
|
||||||
|
onAssigned={() => onAssigned(suggestion.resourceId, suggestion.resourceName)}
|
||||||
|
onError={onError}
|
||||||
|
onCancel={() => setShowAssignForm(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* 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 (
|
||||||
|
<div className="mt-4 rounded-xl border border-brand-200 bg-brand-50/50 p-4 dark:border-brand-900/40 dark:bg-brand-950/30">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Assign {resourceName}</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="app-label">Project *</label>
|
||||||
|
<select value={projectId} onChange={(e) => setProjectId(e.target.value)} className="app-input">
|
||||||
|
<option value="">Select project...</option>
|
||||||
|
{projectList.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.shortCode ? `[${p.shortCode}] ` : ""}{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="app-label">Start Date</label>
|
||||||
|
<DateInput value={assignStartDate} onChange={setAssignStartDate} className="app-input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="app-label">End Date</label>
|
||||||
|
<DateInput value={assignEndDate} onChange={setAssignEndDate} min={assignStartDate} className="app-input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="app-label">Hours / Day</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={assignHours}
|
||||||
|
onChange={(e) => setAssignHours(Number(e.target.value))}
|
||||||
|
min={0.5}
|
||||||
|
max={24}
|
||||||
|
step={0.5}
|
||||||
|
className="app-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="app-label">Role</label>
|
||||||
|
{rolesList.length > 0 ? (
|
||||||
|
<select value={roleId} onChange={(e) => setRoleId(e.target.value)} className="app-input">
|
||||||
|
<option value="">No role</option>
|
||||||
|
{rolesList.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={roleFreeText}
|
||||||
|
onChange={(e) => setRoleFreeText(e.target.value)}
|
||||||
|
placeholder="e.g. 3D Artist"
|
||||||
|
className="app-input"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedProject && (
|
||||||
|
<div className="mt-3 text-xs text-gray-500">
|
||||||
|
Assigning to{" "}
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-300">{selectedProject.name}</span>
|
||||||
|
{" "}from {assignStartDate} to {assignEndDate} at {assignHours}h/day
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
<Button variant="primary" size="sm" disabled={!canSubmit || createAssignment.isPending} onClick={handleSubmit}>
|
||||||
|
{createAssignment.isPending ? "Assigning..." : "Confirm Assignment"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onCancel} disabled={createAssignment.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Primitive display components */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
function ScoreBar({ label, value, tooltip }: { label: string; value: number; tooltip?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 p-3 dark:border-gray-700 dark:bg-gray-900/40">
|
||||||
|
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500 inline-flex items-center gap-0.5">
|
||||||
|
{label}
|
||||||
|
{tooltip && <InfoTooltip content={tooltip} />}
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-gray-200/80 dark:bg-gray-800">
|
||||||
|
<div className="h-full rounded-full bg-brand-500" style={{ width: `${value}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm font-medium text-gray-700 dark:text-gray-200">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricLine({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-gray-100 pb-2 text-sm last:border-b-0 last:pb-0 dark:border-gray-800">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">{label}</span>
|
||||||
|
<span className="text-right font-medium text-gray-900 dark:text-gray-100">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={`rounded-2xl border p-3 ${toneClass}`}>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">{label}</div>
|
||||||
|
<div className="mt-2 text-lg font-semibold text-gray-900 dark:text-gray-100">{value}</div>
|
||||||
|
{helper && <div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{helper}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHours(value: number): string {
|
||||||
|
return `${Math.round(value * 10) / 10}h`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { StaffingResultCard, type SuggestionLike } from "./StaffingResultCard.js";
|
||||||
|
import type { SearchCriteria } from "./StaffingSearchForm.js";
|
||||||
|
|
||||||
|
interface StaffingResultsListProps {
|
||||||
|
suggestions: SuggestionLike[] | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
submitted: boolean;
|
||||||
|
searchCriteria: SearchCriteria;
|
||||||
|
assignedIds: Set<string>;
|
||||||
|
onAssigned: (resourceId: string, resourceName: string) => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StaffingResultsList({
|
||||||
|
suggestions,
|
||||||
|
isLoading,
|
||||||
|
submitted,
|
||||||
|
searchCriteria,
|
||||||
|
assignedIds,
|
||||||
|
onAssigned,
|
||||||
|
onError,
|
||||||
|
}: StaffingResultsListProps) {
|
||||||
|
const visible = suggestions?.filter((s) => !assignedIds.has(s.resourceId));
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
|
||||||
|
Finding best matches...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visible && visible.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
|
||||||
|
{assignedIds.size > 0
|
||||||
|
? "All suggestions have been assigned."
|
||||||
|
: "No resources found matching your criteria."}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!submitted) {
|
||||||
|
return (
|
||||||
|
<div className="app-surface-strong border-dashed py-20 text-center">
|
||||||
|
<div className="mx-auto max-w-md">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">No suggestions yet</div>
|
||||||
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
|
Add the required skills and date range, then run the search to see ranked staffing matches.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{visible.map((suggestion, idx) => (
|
||||||
|
<StaffingResultCard
|
||||||
|
key={suggestion.resourceId}
|
||||||
|
suggestion={suggestion}
|
||||||
|
rank={idx + 1}
|
||||||
|
searchCriteria={searchCriteria}
|
||||||
|
onAssigned={onAssigned}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { DateInput } from "~/components/ui/DateInput.js";
|
||||||
|
import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
|
||||||
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
|
||||||
|
export interface SearchCriteria {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
hoursPerDay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StaffingSearchFormProps {
|
||||||
|
requiredSkills: string[];
|
||||||
|
onSkillsChange: (skills: string[]) => void;
|
||||||
|
startDate: string;
|
||||||
|
onStartDateChange: (date: string) => void;
|
||||||
|
endDate: string;
|
||||||
|
onEndDateChange: (date: string) => void;
|
||||||
|
hoursPerDay: number;
|
||||||
|
onHoursPerDayChange: (hours: number) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StaffingSearchForm({
|
||||||
|
requiredSkills,
|
||||||
|
onSkillsChange,
|
||||||
|
startDate,
|
||||||
|
onStartDateChange,
|
||||||
|
endDate,
|
||||||
|
onEndDateChange,
|
||||||
|
hoursPerDay,
|
||||||
|
onHoursPerDayChange,
|
||||||
|
onSubmit,
|
||||||
|
}: StaffingSearchFormProps) {
|
||||||
|
return (
|
||||||
|
<div className="app-surface-strong p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Search Criteria</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Define the role needs and let the matching engine rank the best candidates.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="app-label">
|
||||||
|
Required Skills
|
||||||
|
<InfoTooltip content="Skills the candidate must have. The engine scores overlap and proficiency against this list." />
|
||||||
|
</label>
|
||||||
|
<SkillTagInput value={requiredSkills} onChange={onSkillsChange} placeholder="Add skill..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
||||||
|
<div>
|
||||||
|
<label className="app-label">Start Date</label>
|
||||||
|
<DateInput value={startDate} onChange={onStartDateChange} className="app-input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="app-label">End Date</label>
|
||||||
|
<DateInput value={endDate} onChange={onEndDateChange} min={startDate} className="app-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="app-label">
|
||||||
|
Hours per Day
|
||||||
|
<InfoTooltip content="Required hours per day for the role. Used to check conflicts and estimate capacity." />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={hoursPerDay}
|
||||||
|
onChange={(e) => onHoursPerDayChange(Number(e.target.value))}
|
||||||
|
min={1}
|
||||||
|
max={24}
|
||||||
|
className="app-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onSubmit}
|
||||||
|
className="inline-flex w-full items-center justify-center rounded-xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-brand-700"
|
||||||
|
>
|
||||||
|
Find Matches
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user