From 05f6eba5d84f6829435b2b34e2c309e4c4f3013e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 13:17:55 +0200 Subject: [PATCH] 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 --- .../staffing/ScoringExplanation.tsx | 26 + .../src/components/staffing/StaffingPanel.tsx | 683 +----------------- .../staffing/StaffingResultCard.tsx | 499 +++++++++++++ .../staffing/StaffingResultsList.tsx | 74 ++ .../staffing/StaffingSearchForm.tsx | 87 +++ 5 files changed, 710 insertions(+), 659 deletions(-) create mode 100644 apps/web/src/components/staffing/ScoringExplanation.tsx create mode 100644 apps/web/src/components/staffing/StaffingResultCard.tsx create mode 100644 apps/web/src/components/staffing/StaffingResultsList.tsx create mode 100644 apps/web/src/components/staffing/StaffingSearchForm.tsx diff --git a/apps/web/src/components/staffing/ScoringExplanation.tsx b/apps/web/src/components/staffing/ScoringExplanation.tsx new file mode 100644 index 0000000..2d2ff59 --- /dev/null +++ b/apps/web/src/components/staffing/ScoringExplanation.tsx @@ -0,0 +1,26 @@ +export function ScoringExplanation() { + return ( +
+
+
+
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. +
+
+
+
+ ); +} diff --git a/apps/web/src/components/staffing/StaffingPanel.tsx b/apps/web/src/components/staffing/StaffingPanel.tsx index 26ca15c..6b49d15 100644 --- a/apps/web/src/components/staffing/StaffingPanel.tsx +++ b/apps/web/src/components/staffing/StaffingPanel.tsx @@ -2,19 +2,10 @@ 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; -} +import { StaffingSearchForm } from "./StaffingSearchForm.js"; +import { ScoringExplanation } from "./ScoringExplanation.js"; +import { StaffingResultsList } from "./StaffingResultsList.js"; export function StaffingPanel() { const [requiredSkills, setRequiredSkills] = useState(["TypeScript", "React"]); @@ -39,7 +30,7 @@ export function StaffingPanel() { const { data: suggestions, isLoading } = trpc.staffing.getSuggestions.useQuery( { - requiredSkills: requiredSkills, + requiredSkills, startDate: new Date(startDate), endDate: new Date(endDate), hoursPerDay, @@ -47,14 +38,12 @@ export function StaffingPanel() { { 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 }; + const searchCriteria = { startDate, endDate, hoursPerDay }; return (
@@ -82,654 +71,30 @@ export function StaffingPanel() {
-
-

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.
-
-
-
+ setSubmitted(true)} + /> +
-
- {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; - 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 ( -
-
-
-
- {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 -
- )} - -
- - -
-
- ); -} - -/* -------------------------------------------------------------------------- */ -/* Score Bar */ -/* -------------------------------------------------------------------------- */ - -function ScoreBar({ label, value, tooltip }: { label: string; value: number; tooltip?: string }) { - return ( -
-
{label}{tooltip && }
-
-
setToast({ show: true, message: msg, variant: "warning" })} />
-
{value}
-
- ); -} - -function formatHours(value: number): string { - const rounded = Math.round(value * 10) / 10; - return `${rounded}h`; -} - -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}
- )}
); } diff --git a/apps/web/src/components/staffing/StaffingResultCard.tsx b/apps/web/src/components/staffing/StaffingResultCard.tsx new file mode 100644 index 0000000..a08f142 --- /dev/null +++ b/apps/web/src/components/staffing/StaffingResultCard.tsx @@ -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 ( +
+
+
+
+ {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`; +} diff --git a/apps/web/src/components/staffing/StaffingResultsList.tsx b/apps/web/src/components/staffing/StaffingResultsList.tsx new file mode 100644 index 0000000..8598e2c --- /dev/null +++ b/apps/web/src/components/staffing/StaffingResultsList.tsx @@ -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; + 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 ( +
+ Finding best matches... +
+ ); + } + + if (visible && visible.length === 0) { + return ( +
+ {assignedIds.size > 0 + ? "All suggestions have been assigned." + : "No resources found matching your criteria."} +
+ ); + } + + if (!submitted) { + return ( +
+
+
No suggestions yet
+

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

+
+
+ ); + } + + if (!visible) return null; + + return ( +
+ {visible.map((suggestion, idx) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/staffing/StaffingSearchForm.tsx b/apps/web/src/components/staffing/StaffingSearchForm.tsx new file mode 100644 index 0000000..d1238ae --- /dev/null +++ b/apps/web/src/components/staffing/StaffingSearchForm.tsx @@ -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 ( +
+

Search Criteria

+

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

+ +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + onHoursPerDayChange(Number(e.target.value))} + min={1} + max={24} + className="app-input" + /> +
+ + +
+
+ ); +}