cd78f72f33
Complete rename of all technical identifiers across the codebase: Package names (11 packages): - @planarchy/* → @capakraken/* in all package.json, tsconfig, imports Import statements: 277 files, 548 occurrences replaced Database & Docker: - PostgreSQL user/db: planarchy → capakraken - Docker volumes: planarchy_pgdata → capakraken_pgdata - Connection strings updated in docker-compose, .env, CI CI/CD: - GitHub Actions workflow: all filter commands updated - Test database credentials updated Infrastructure: - Redis channel: planarchy:sse → capakraken:sse - Logger service name: planarchy-api → capakraken-api - Anonymization seed updated - Start/stop/restart scripts updated Test data: - Seed emails: @planarchy.dev → @capakraken.dev - E2E test credentials: all 11 spec files updated - Email defaults: @planarchy.app → @capakraken.app - localStorage keys: planarchy_* → capakraken_* Documentation: 30+ .md files updated Verification: - pnpm install: workspace resolution works - TypeScript: only pre-existing TS2589 (no new errors) - Engine: 310/310 tests pass - Staffing: 37/37 tests pass Co-Authored-By: claude-flow <ruv@ruv.net>
502 lines
20 KiB
TypeScript
502 lines
20 KiB
TypeScript
"use client";
|
|
|
|
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;
|
|
}
|
|
|
|
export function StaffingPanel() {
|
|
const [requiredSkills, setRequiredSkills] = useState<string[]>(["TypeScript", "React"]);
|
|
const [startDate, setStartDate] = useState(() => {
|
|
const d = new Date();
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
});
|
|
const [endDate, setEndDate] = useState(() => {
|
|
const d = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
});
|
|
const [hoursPerDay, setHoursPerDay] = useState(8);
|
|
const [submitted, setSubmitted] = useState(false);
|
|
const [assignedIds, setAssignedIds] = useState<Set<string>>(new Set());
|
|
const [toast, setToast] = useState<{ show: boolean; message: string; variant: "success" | "warning" }>({
|
|
show: false,
|
|
message: "",
|
|
variant: "success",
|
|
});
|
|
|
|
const clearToast = useCallback(() => setToast((t) => ({ ...t, show: false })), []);
|
|
|
|
const { data: suggestions, isLoading } = trpc.staffing.getSuggestions.useQuery(
|
|
{
|
|
requiredSkills: requiredSkills,
|
|
startDate: new Date(startDate),
|
|
endDate: new Date(endDate),
|
|
hoursPerDay,
|
|
},
|
|
{ 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 };
|
|
|
|
return (
|
|
<div className="app-page space-y-6">
|
|
<SuccessToast show={toast.show} message={toast.message} variant={toast.variant} onDone={clearToast} />
|
|
|
|
<div className="app-page-header gap-4">
|
|
<div className="space-y-3">
|
|
<span className="inline-flex items-center rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-brand-700 dark:border-brand-900/60 dark:bg-brand-900/30 dark:text-brand-200">
|
|
Staffing
|
|
</span>
|
|
<div>
|
|
<h1 className="app-page-title">Staffing Suggestions</h1>
|
|
<p className="app-page-subtitle max-w-2xl">
|
|
Match open work with the strongest available people based on skills, availability, utilization, and cost.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="app-surface max-w-xl p-4">
|
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">How scoring works</p>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
CapaKraken blends skill fit, free capacity, cost, and current utilization. Add the must-have skills first, then narrow the date window to get cleaner results.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
|
<div className="space-y-6">
|
|
<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={setRequiredSkills}
|
|
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={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 className="space-y-4">
|
|
{isLoading && (
|
|
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
|
|
Finding best matches...
|
|
</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;
|
|
scoreBreakdown: {
|
|
skillScore: number;
|
|
availabilityScore: number;
|
|
costScore: number;
|
|
utilizationScore: number;
|
|
};
|
|
matchedSkills: string[];
|
|
missingSkills: string[];
|
|
availabilityConflicts: string[];
|
|
estimatedDailyCostCents: number;
|
|
currentUtilization: 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 [expanded, setExpanded] = useState(false);
|
|
|
|
return (
|
|
<div 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>
|
|
</div>
|
|
<div className="flex items-start gap-3">
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => setExpanded((prev) => !prev)}
|
|
>
|
|
{expanded ? "Cancel" : "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-5 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="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 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.availabilityConflicts.length > 0 && (
|
|
<span className="font-medium text-amber-600 dark:text-amber-300">
|
|
{suggestion.availabilityConflicts.length} scheduling conflict{suggestion.availabilityConflicts.length === 1 ? "" : "s"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{expanded && (
|
|
<AssignForm
|
|
resourceId={suggestion.resourceId}
|
|
resourceName={suggestion.resourceName}
|
|
searchCriteria={searchCriteria}
|
|
onAssigned={() => onAssigned(suggestion.resourceId, suggestion.resourceName)}
|
|
onError={onError}
|
|
onCancel={() => setExpanded(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>
|
|
);
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* 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 className="mt-2 text-sm font-medium text-gray-700 dark:text-gray-200">{value}</div>
|
|
</div>
|
|
);
|
|
}
|