feat: dashboard overhaul, chargeability reports, dispo import enhancements, UI polish
Dashboard: expanded chargeability widget, resource/project table widgets with sorting and filters, stat cards with formatMoney integration. Chargeability: new report client with filtering, chargeability-bookings use case, updated dashboard overview logic. Dispo import: TBD project handling, parse-dispo-matrix improvements, stage-dispo-projects resource value scores, new tests. Estimates: CommercialTermsEditor component, commercial-terms engine module, expanded estimate schemas and types. UI: AppShell navigation updates, timeline filter/toolbar enhancements, role management improvements, signin page redesign, Tailwind/globals polish, SystemSettings SMTP section, anonymization support. Tests: new router tests (anonymization, chargeability, effort-rule, entitlement, estimate, experience-multiplier, notification, resource, staffing, vacation). Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -25,138 +25,181 @@ export function StaffingPanel() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Search Form */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Search Criteria</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Required Skills
|
||||
</label>
|
||||
<SkillTagInput
|
||||
value={requiredSkills}
|
||||
onChange={setRequiredSkills}
|
||||
placeholder="Add skill…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
|
||||
<DateInput
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">End Date</label>
|
||||
<DateInput
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
min={startDate}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Hours per Day</label>
|
||||
<input
|
||||
type="number"
|
||||
value={hoursPerDay}
|
||||
onChange={(e) => setHoursPerDay(Number(e.target.value))}
|
||||
min={1}
|
||||
max={24}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setSubmitted(true)}
|
||||
className="w-full bg-brand-600 hover:bg-brand-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
Find Matches
|
||||
</button>
|
||||
<div className="app-page space-y-6">
|
||||
<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">
|
||||
Planarchy 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>
|
||||
|
||||
{/* Results */}
|
||||
<div className="lg:col-span-2">
|
||||
{isLoading && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center text-gray-400">
|
||||
Finding best matches...
|
||||
</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>
|
||||
|
||||
{suggestions && suggestions.length === 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center text-gray-400">
|
||||
No resources found matching your criteria.
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 space-y-5">
|
||||
<div>
|
||||
<label className="app-label">Required Skills</label>
|
||||
<SkillTagInput
|
||||
value={requiredSkills}
|
||||
onChange={setRequiredSkills}
|
||||
placeholder="Add skill…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{suggestions && suggestions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{suggestions.map((suggestion, idx) => (
|
||||
<div key={suggestion.resourceId} className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-brand-100 flex items-center justify-center font-bold text-brand-700">
|
||||
#{idx + 1}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900">{suggestion.resourceName}</div>
|
||||
<div className="text-xs text-gray-500">{suggestion.eid}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-brand-600">{suggestion.score}</div>
|
||||
<div className="text-xs text-gray-400">score</div>
|
||||
</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 className="mt-4 grid grid-cols-4 gap-3">
|
||||
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} />
|
||||
<ScoreBar label="Avail." value={suggestion.scoreBreakdown.availabilityScore} />
|
||||
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} />
|
||||
<ScoreBar label="Util." value={suggestion.scoreBreakdown.utilizationScore} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{suggestion.matchedSkills.map((skill) => (
|
||||
<span key={skill} className="px-2 py-0.5 bg-green-50 text-green-700 text-xs rounded-full">
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{suggestion.missingSkills.map((skill) => (
|
||||
<span key={skill} className="px-2 py-0.5 bg-red-50 text-red-600 text-xs rounded-full">
|
||||
{skill} (missing)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>
|
||||
LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} €/h
|
||||
</span>
|
||||
<span>Utilization: {Math.round(suggestion.currentUtilization)}%</span>
|
||||
{suggestion.availabilityConflicts.length > 0 && (
|
||||
<span className="text-yellow-600">⚠ {suggestion.availabilityConflicts.length} conflicts</span>
|
||||
)}
|
||||
<div>
|
||||
<label className="app-label">End Date</label>
|
||||
<DateInput
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
min={startDate}
|
||||
className="app-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!submitted && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 border-dashed p-12 text-center text-gray-300">
|
||||
Fill in the criteria and click "Find Matches" to see staffing suggestions.
|
||||
<div>
|
||||
<label className="app-label">Hours per Day</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>
|
||||
)}
|
||||
|
||||
{suggestions && suggestions.length === 0 && (
|
||||
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
|
||||
No resources found matching your criteria.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestions && suggestions.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{suggestions.map((suggestion, idx) => (
|
||||
<div key={suggestion.resourceId} 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">
|
||||
{idx + 1}
|
||||
</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="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">Match Score</div>
|
||||
<div className="mt-1 text-3xl font-semibold text-brand-700 dark:text-brand-100">{suggestion.score}</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} />
|
||||
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} />
|
||||
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} />
|
||||
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} />
|
||||
</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)} €/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>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
@@ -164,15 +207,15 @@ export function StaffingPanel() {
|
||||
|
||||
function ScoreBar({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">{label}</div>
|
||||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<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">{label}</div>
|
||||
<div className="h-2 rounded-full bg-gray-200/80 dark:bg-gray-800">
|
||||
<div
|
||||
className="h-full bg-brand-500 rounded-full"
|
||||
className="h-full rounded-full bg-brand-500"
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-0.5">{value}</div>
|
||||
<div className="mt-2 text-sm font-medium text-gray-700 dark:text-gray-200">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user