-
{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"
+ />
+
+
+
+
+
+ );
+}