rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled

rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
This commit was merged in pull request #61.
This commit is contained in:
2026-05-21 16:28:40 +02:00
committed by Hartmut
parent d9a7ec0338
commit b41c1d2501
943 changed files with 24548 additions and 16832 deletions
@@ -20,7 +20,11 @@ export function StaffingPanel() {
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" }>({
const [toast, setToast] = useState<{
show: boolean;
message: string;
variant: "success" | "warning";
}>({
show: false,
message: "",
variant: "success",
@@ -47,7 +51,12 @@ export function StaffingPanel() {
return (
<div className="app-page space-y-6">
<SuccessToast show={toast.show} message={toast.message} variant={toast.variant} onDone={clearToast} />
<SuccessToast
show={toast.show}
message={toast.message}
variant={toast.variant}
onDone={clearToast}
/>
<div className="app-page-header gap-4">
<div className="space-y-3">
@@ -57,14 +66,16 @@ export function StaffingPanel() {
<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.
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.
Nexus 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>
@@ -3,7 +3,7 @@
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { AllocationStatus } from "@capakraken/shared";
import { AllocationStatus } from "@nexus/shared";
import { DateInput } from "~/components/ui/DateInput.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { Button } from "~/components/ui/Button.js";
@@ -92,12 +92,22 @@ interface StaffingResultCardProps {
onError: (message: string) => void;
}
export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigned, onError }: StaffingResultCardProps) {
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]
[
suggestion.location?.countryCode,
suggestion.location?.federalState,
suggestion.location?.metroCityName,
]
.filter(Boolean)
.join(" / ") ||
"No location";
@@ -105,10 +115,13 @@ export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigne
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 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;
const effectiveAvailableHours =
capacity?.effectiveAvailableHours ?? suggestion.effectiveAvailableHours ?? 0;
const holidayHoursDeduction =
capacity?.holidayHoursDeduction ?? suggestion.holidayHoursDeduction ?? 0;
return (
<div data-suggestion className="app-surface p-5">
@@ -118,7 +131,9 @@ export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigne
{rank}
</div>
<div>
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">{suggestion.resourceName}</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>
@@ -135,19 +150,27 @@ export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigne
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 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">
<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">
<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>
))}
@@ -169,13 +192,21 @@ export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigne
label="Holiday Deduction"
value={holidayHoursDeduction > 0 ? formatHours(holidayHoursDeduction) : "0h"}
tone={holidayHoursDeduction > 0 ? "warn" : "neutral"}
helper={capacity ? `${capacity.holidayWorkdayCount} affected workdays` : "No local holiday impact"}
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"}
helper={
conflictCount > 0
? `${conflictCount} overloaded day${conflictCount === 1 ? "" : "s"}`
: "No day-level overloads"
}
/>
</div>
@@ -193,38 +224,81 @@ export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigne
{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)." />
<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="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="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="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)} />
<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>
<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."}
{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}`} />
<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">
@@ -235,12 +309,20 @@ export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigne
</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="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)} />
<MetricLine
label="Holiday workdays"
value={String(capacity?.holidayWorkdayCount ?? 0)}
/>
<MetricLine
label="Absence days"
value={String(capacity?.absenceDayEquivalent ?? 0)}
/>
</div>
</div>
</div>
@@ -248,9 +330,12 @@ export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigne
<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-[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}
Requested {formatHours(searchCriteria.hoursPerDay)} / day between{" "}
{searchCriteria.startDate} and {searchCriteria.endDate}
</div>
</div>
{conflictCount === 0 ? (
@@ -260,13 +345,19 @@ export function StaffingResultCard({ suggestion, rank, searchCriteria, onAssigne
) : (
<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
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)}
Base {formatHours(item.baseHours)} | Effective{" "}
{formatHours(item.effectiveHours)} | Already booked{" "}
{formatHours(item.allocatedHours)} | Remaining{" "}
{formatHours(item.remainingHours)}
</div>
</div>
))}
@@ -308,7 +399,14 @@ interface AssignFormProps {
onCancel: () => void;
}
function AssignForm({ resourceId, resourceName, searchCriteria, onAssigned, onError, onCancel }: AssignFormProps) {
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);
@@ -350,24 +448,36 @@ function AssignForm({ resourceId, resourceName, searchCriteria, onAssigned, onEr
});
};
const projectList = (projects as { projects?: Array<{ id: string; name: string; shortCode?: string | null }> } | undefined)?.projects ?? [];
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>
<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">
<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}
{p.shortCode ? `[${p.shortCode}] ` : ""}
{p.name}
</option>
))}
</select>
@@ -380,7 +490,12 @@ function AssignForm({ resourceId, resourceName, searchCriteria, onAssigned, onEr
<div>
<label className="app-label">End Date</label>
<DateInput value={assignEndDate} onChange={setAssignEndDate} min={assignStartDate} className="app-input" />
<DateInput
value={assignEndDate}
onChange={setAssignEndDate}
min={assignStartDate}
className="app-input"
/>
</div>
<div>
@@ -399,7 +514,11 @@ function AssignForm({ resourceId, resourceName, searchCriteria, onAssigned, onEr
<div>
<label className="app-label">Role</label>
{rolesList.length > 0 ? (
<select value={roleId} onChange={(e) => setRoleId(e.target.value)} className="app-input">
<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}>
@@ -422,13 +541,20 @@ function AssignForm({ resourceId, resourceName, searchCriteria, onAssigned, onEr
{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
<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}>
<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}>
@@ -487,7 +613,9 @@ function StatCard({
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="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>