feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -209,17 +209,74 @@ interface SuggestionLike {
|
||||
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 {
|
||||
@@ -231,10 +288,24 @@ interface SuggestionCardProps {
|
||||
}
|
||||
|
||||
function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError }: SuggestionCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
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 className="app-surface p-5">
|
||||
<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">
|
||||
@@ -243,15 +314,23 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
|
||||
<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={() => setExpanded((prev) => !prev)}
|
||||
onClick={() => setShowAssignForm((prev) => !prev)}
|
||||
>
|
||||
{expanded ? "Cancel" : "Assign"}
|
||||
{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>
|
||||
@@ -260,13 +339,6 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
|
||||
</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">
|
||||
@@ -280,24 +352,144 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
|
||||
))}
|
||||
</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.availabilityConflicts.length > 0 && (
|
||||
{suggestion.valueScore != null && (
|
||||
<span>Value Score: {suggestion.valueScore}</span>
|
||||
)}
|
||||
{conflictCount > 0 && (
|
||||
<span className="font-medium text-amber-600 dark:text-amber-300">
|
||||
{suggestion.availabilityConflicts.length} scheduling conflict{suggestion.availabilityConflicts.length === 1 ? "" : "s"}
|
||||
{conflictCount} scheduling conflict{conflictCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
{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={() => setExpanded(false)}
|
||||
onCancel={() => setShowAssignForm(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -499,3 +691,45 @@ function ScoreBar({ label, value, tooltip }: { label: string; value: number; too
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user